From 6d94e8049e42183d2aab09194425cc41a59acc88 Mon Sep 17 00:00:00 2001 From: Ashesh Goplani Date: Wed, 18 Mar 2026 18:28:48 +0700 Subject: [PATCH 01/10] fix: prevent terminal-features spam on repeated session starts (#366) Check existing terminal-features before appending to avoid duplicates that balloon the list to 260+ entries over multiple session starts. Committed by Ashesh Goplani --- internal/tmux/tmux.go | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index e7ed52da5..256b5883b 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1238,8 +1238,10 @@ func (s *Session) Start(command string) error { "set-option", "-t", s.Name, "set-clipboard", "on", ";", "set-option", "-t", s.Name, "history-limit", "10000", ";", "set-option", "-t", s.Name, "escape-time", "10", ";", - "set", "-sq", "extended-keys", "on", ";", - "set", "-asq", "terminal-features", ",*:hyperlinks:extkeys").Run() + "set", "-sq", "extended-keys", "on").Run() + + // Idempotent: only append terminal-features if not already present + ensureTerminalFeatures("hyperlinks", "extkeys") // Bind Ctrl+Q to detach at the tmux level as fallback for terminals where // XON/XOFF flow control intercepts the key before it reaches the PTY stdin @@ -1408,6 +1410,31 @@ func (s *Session) ConfigureStatusBar() { _ = exec.Command("tmux", args...).Run() } +// ensureTerminalFeatures appends terminal features only if not already present. +// This prevents the terminal-features list from growing on every session start (#366). +func ensureTerminalFeatures(features ...string) { + out, err := exec.Command("tmux", "show", "-sv", "terminal-features").Output() + if err != nil { + // tmux too old or server not running — append unconditionally as best-effort + if len(features) > 0 { + val := ",*:" + strings.Join(features, ":") + _ = exec.Command("tmux", "set", "-asq", "terminal-features", val).Run() + } + return + } + existing := string(out) + var missing []string + for _, f := range features { + if !strings.Contains(existing, f) { + missing = append(missing, f) + } + } + if len(missing) > 0 { + val := ",*:" + strings.Join(missing, ":") + _ = exec.Command("tmux", "set", "-asq", "terminal-features", val).Run() + } +} + // EnableMouseMode enables mouse scrolling, clipboard integration, and optimal settings // Safe to call multiple times - just sets the options again // @@ -1452,12 +1479,14 @@ func (s *Session) EnableMouseMode() error { "set-option", "-t", s.Name, "-q", "allow-passthrough", "on", ";", "set-option", "-t", s.Name, "history-limit", "10000", ";", "set-option", "-t", s.Name, "escape-time", "10", ";", - "set", "-sq", "extended-keys", "on", ";", - "set", "-asq", "terminal-features", ",*:hyperlinks:extkeys") + "set", "-sq", "extended-keys", "on") // Ignore errors - all these are non-fatal enhancements // Older tmux versions may not support some options _ = enhanceCmd.Run() + // Idempotent: only append terminal-features if not already present + ensureTerminalFeatures("hyperlinks", "extkeys") + return nil } From 1e6a523fef4fe7b0d768e2d4303ee8874bb6dcdd Mon Sep 17 00:00:00 2001 From: naps62 Date: Wed, 18 Mar 2026 12:11:15 +0000 Subject: [PATCH 02/10] feat: auto-generate session names as placeholders in new session dialog When the name field is left empty, a random adjective-noun name (e.g., "golden-eagle") is shown as a dimmed placeholder and used on submit. The worktree branch placeholder also reflects the generated name. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/newdialog.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 31e8cd22f..f615b3fa3 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -40,6 +40,7 @@ type settingDisplay struct { // NewDialog represents the new session creation dialog. type NewDialog struct { nameInput textinput.Model + generatedName string // auto-generated name shown as placeholder, used when user leaves name empty pathInput textinput.Model commandInput textinput.Model claudeOptions *ClaudeOptionsPanel // Claude-specific options (concrete for value extraction). @@ -208,6 +209,8 @@ func (d *NewDialog) ShowInGroup(groupPath, groupName, defaultPath string) { d.focusIndex = 0 d.validationErr = "" d.nameInput.SetValue("") + d.generatedName = session.GenerateSessionName() + d.nameInput.Placeholder = d.generatedName d.nameInput.Focus() d.suggestionNavigated = false // reset on show d.pathSuggestionCursor = 0 // reset cursor too @@ -250,7 +253,7 @@ func (d *NewDialog) ShowInGroup(groupPath, groupName, defaultPath string) { d.inheritedSettings = buildInheritedSettings(userConfig.Docker) d.branchPrefix = userConfig.Worktree.Prefix() } - d.branchInput.Placeholder = d.branchPrefix + "branch-name" + d.branchInput.Placeholder = d.branchPrefix + d.generatedName d.rebuildFocusTargets() } @@ -462,6 +465,9 @@ func (d *NewDialog) IsVisible() bool { // GetValues returns the current dialog values with expanded paths func (d *NewDialog) GetValues() (name, path, command string) { name = strings.TrimSpace(d.nameInput.Value()) + if name == "" { + name = d.generatedName + } // Fix: sanitize input to remove surrounding quotes that cause path issues path = strings.Trim(strings.TrimSpace(d.pathInput.Value()), "'\"") @@ -497,9 +503,16 @@ func (d *NewDialog) ToggleWorktree() { // autoBranchFromName sets the branch input to "" if the // name field is non-empty and the branch hasn't been manually edited. +// When the name is empty but a generated name exists, it updates the placeholder instead. func (d *NewDialog) autoBranchFromName() { name := strings.TrimSpace(d.nameInput.Value()) if name == "" { + // No user-typed name — show generated branch as placeholder only + if d.generatedName != "" { + d.branchInput.Placeholder = d.branchPrefix + d.generatedName + } + d.branchInput.SetValue("") + d.branchAutoSet = true return } branch := d.branchPrefix + name @@ -516,6 +529,9 @@ func (d *NewDialog) IsWorktreeEnabled() bool { func (d *NewDialog) GetValuesWithWorktree() (name, path, command, branch string, worktreeEnabled bool) { name, path, command = d.GetValues() branch = strings.TrimSpace(d.branchInput.Value()) + if branch == "" && d.worktreeEnabled && d.generatedName != "" { + branch = d.branchPrefix + name + } worktreeEnabled = d.worktreeEnabled return } @@ -624,7 +640,10 @@ func (d *NewDialog) Validate() string { // Fix: sanitize input to remove surrounding quotes that cause path issues path := strings.Trim(strings.TrimSpace(d.pathInput.Value()), "'\"") - // Check for empty name + // Fall back to auto-generated name if user left it empty + if name == "" { + name = d.generatedName + } if name == "" { return "Session name cannot be empty" } @@ -663,6 +682,9 @@ func (d *NewDialog) Validate() string { // Validate worktree branch if enabled if d.worktreeEnabled { branch := strings.TrimSpace(d.branchInput.Value()) + if branch == "" && name != "" { + branch = d.branchPrefix + name + } if branch == "" { return "Branch name required for worktree" } From b3c53a469e3a42ab4317ee1db9790ef39df87446 Mon Sep 17 00:00:00 2001 From: naps62 Date: Wed, 18 Mar 2026 12:15:45 +0000 Subject: [PATCH 03/10] chore: remove redundant comment on generatedName field Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/newdialog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index f615b3fa3..216dca0f0 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -40,7 +40,7 @@ type settingDisplay struct { // NewDialog represents the new session creation dialog. type NewDialog struct { nameInput textinput.Model - generatedName string // auto-generated name shown as placeholder, used when user leaves name empty + generatedName string pathInput textinput.Model commandInput textinput.Model claudeOptions *ClaudeOptionsPanel // Claude-specific options (concrete for value extraction). From 9942ee8af48565fe0b0f31e819591ff65ff242c6 Mon Sep 17 00:00:00 2001 From: naps62 Date: Wed, 18 Mar 2026 12:33:48 +0000 Subject: [PATCH 04/10] fix: use placeholder for worktree branch with generated name, add tests - Branch shows as dimmed placeholder (not filled input) when using generated name; only fills when user types a custom name - Align Validate() and GetValuesWithWorktree() branch derivation logic - Add tests for generated name fallback, branch placeholder behavior, and worktree branch derivation Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/newdialog.go | 2 +- internal/ui/newdialog_test.go | 111 ++++++++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 216dca0f0..3965b1bd8 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -529,7 +529,7 @@ func (d *NewDialog) IsWorktreeEnabled() bool { func (d *NewDialog) GetValuesWithWorktree() (name, path, command, branch string, worktreeEnabled bool) { name, path, command = d.GetValues() branch = strings.TrimSpace(d.branchInput.Value()) - if branch == "" && d.worktreeEnabled && d.generatedName != "" { + if branch == "" && d.worktreeEnabled && name != "" { branch = d.branchPrefix + name } worktreeEnabled = d.worktreeEnabled diff --git a/internal/ui/newdialog_test.go b/internal/ui/newdialog_test.go index d1fb4b628..95396e8fe 100644 --- a/internal/ui/newdialog_test.go +++ b/internal/ui/newdialog_test.go @@ -506,19 +506,31 @@ func TestNewDialog_GetValuesWithWorktree_Disabled(t *testing.T) { } } -func TestNewDialog_Validate_WorktreeEnabled_EmptyBranch(t *testing.T) { +func TestNewDialog_Validate_WorktreeEnabled_EmptyBranch_WithName(t *testing.T) { dialog := NewNewDialog() dialog.nameInput.SetValue("test-session") dialog.pathInput.SetValue("/tmp/project") dialog.worktreeEnabled = true dialog.branchInput.SetValue("") + // With a name set, empty branch is derived from name — validation passes err := dialog.Validate() - if err == "" { - t.Error("Validation should fail when worktree enabled but branch is empty") + if err != "" { + t.Errorf("Validation should pass when branch is empty but name is set (derives branch), got: %q", err) } - if err != "Branch name required for worktree" { - t.Errorf("Unexpected error message: %q", err) +} + +func TestNewDialog_Validate_WorktreeEnabled_EmptyBranch_NoName(t *testing.T) { + dialog := NewNewDialog() + dialog.nameInput.SetValue("") + dialog.generatedName = "" // no fallback + dialog.pathInput.SetValue("/tmp/project") + dialog.worktreeEnabled = true + dialog.branchInput.SetValue("") + + err := dialog.Validate() + if err == "" { + t.Error("Validation should fail when worktree enabled, branch empty, and no name") } } @@ -1234,6 +1246,95 @@ func TestNewDialog_FilterPaths_EmptyInput(t *testing.T) { } } +// ===== Generated Name Fallback Tests ===== + +func TestNewDialog_EmptyName_UsesGeneratedName(t *testing.T) { + d := NewNewDialog() + d.pathInput.SetValue("/tmp/project") + d.nameInput.SetValue("") + d.generatedName = "golden-eagle" + + name, _, _ := d.GetValues() + if name != "golden-eagle" { + t.Errorf("GetValues() name = %q, want %q", name, "golden-eagle") + } +} + +func TestNewDialog_Validate_EmptyName_UsesGeneratedName(t *testing.T) { + d := NewNewDialog() + d.pathInput.SetValue("/tmp/project") + d.nameInput.SetValue("") + d.generatedName = "swift-fox" + + err := d.Validate() + if err != "" { + t.Errorf("Validate() should pass with generatedName fallback, got: %q", err) + } +} + +func TestNewDialog_ShowInGroup_SetsGeneratedName(t *testing.T) { + d := NewNewDialog() + d.ShowInGroup("default", "default", "") + + if d.generatedName == "" { + t.Error("generatedName should be set after ShowInGroup") + } + if d.nameInput.Placeholder != d.generatedName { + t.Errorf("nameInput.Placeholder = %q, want %q", d.nameInput.Placeholder, d.generatedName) + } +} + +func TestNewDialog_WorktreeBranch_PlaceholderWhenNameEmpty(t *testing.T) { + d := NewNewDialog() + d.generatedName = "calm-river" + d.branchPrefix = "feature/" + d.nameInput.SetValue("") + + d.autoBranchFromName() + + // Branch input should remain empty (placeholder only) + if d.branchInput.Value() != "" { + t.Errorf("branch value should be empty when using generated name, got %q", d.branchInput.Value()) + } + if d.branchInput.Placeholder != "feature/calm-river" { + t.Errorf("branch placeholder = %q, want %q", d.branchInput.Placeholder, "feature/calm-river") + } + if !d.branchAutoSet { + t.Error("branchAutoSet should be true") + } +} + +func TestNewDialog_WorktreeBranch_FilledWhenNameProvided(t *testing.T) { + d := NewNewDialog() + d.generatedName = "calm-river" + d.branchPrefix = "feature/" + d.nameInput.SetValue("my-feature") + + d.autoBranchFromName() + + if d.branchInput.Value() != "feature/my-feature" { + t.Errorf("branch value = %q, want %q", d.branchInput.Value(), "feature/my-feature") + } +} + +func TestNewDialog_GetValuesWithWorktree_EmptyBranch_DerivedFromName(t *testing.T) { + d := NewNewDialog() + d.worktreeEnabled = true + d.branchPrefix = "feature/" + d.generatedName = "bold-crane" + d.nameInput.SetValue("") + d.pathInput.SetValue("/tmp/project") + d.branchInput.SetValue("") + + name, _, _, branch, _ := d.GetValuesWithWorktree() + if name != "bold-crane" { + t.Errorf("name = %q, want %q", name, "bold-crane") + } + if branch != "feature/bold-crane" { + t.Errorf("branch = %q, want %q", branch, "feature/bold-crane") + } +} + func TestNewDialog_BranchPrefix_Default(t *testing.T) { d := NewNewDialog() if d.branchPrefix != "feature/" { From af1178de99ec9afdd28e89d600af4eca986f62b1 Mon Sep 17 00:00:00 2001 From: chenwl Date: Wed, 18 Mar 2026 22:51:23 +0800 Subject: [PATCH 05/10] feat(worktree): reuse existing branches and add fzf picker --- cmd/agent-deck/launch_cmd.go | 12 +-- cmd/agent-deck/main.go | 17 +-- cmd/agent-deck/session_cmd.go | 10 +- internal/git/git.go | 184 ++++++++++++++++++++++++++++++++- internal/git/git_test.go | 119 +++++++++++++++++++++ internal/ui/branch_picker.go | 116 +++++++++++++++++++++ internal/ui/forkdialog.go | 33 +++++- internal/ui/forkdialog_test.go | 52 ++++++++++ internal/ui/home.go | 13 +++ internal/ui/newdialog.go | 41 ++++++++ internal/ui/newdialog_test.go | 55 ++++++++++ 11 files changed, 618 insertions(+), 34 deletions(-) create mode 100644 internal/ui/branch_picker.go diff --git a/cmd/agent-deck/launch_cmd.go b/cmd/agent-deck/launch_cmd.go index d0aa10fa2..13d58b746 100644 --- a/cmd/agent-deck/launch_cmd.go +++ b/cmd/agent-deck/launch_cmd.go @@ -36,8 +36,8 @@ func handleLaunch(profile string, args []string) { // Worktree flags worktreeBranch := fs.String("w", "", "Create session in git worktree for branch") worktreeBranchLong := fs.String("worktree", "", "Create session in git worktree for branch") - newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)") - newBranchLong := fs.Bool("new-branch", false, "Create new branch") + newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)") + newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)") worktreeLocation := fs.String("location", "", "Worktree location: sibling, subdirectory, or custom path") // MCP flag @@ -129,7 +129,7 @@ func handleLaunch(profile string, args []string) { if *worktreeBranchLong != "" { wtBranch = *worktreeBranchLong } - createNewBranch := *newBranch || *newBranchLong + _ = *newBranch || *newBranchLong // Validate --resume-session requires Claude if *resumeSession != "" { @@ -159,12 +159,6 @@ func handleLaunch(profile string, args []string) { os.Exit(1) } - branchExists := git.BranchExists(repoRoot, wtBranch) - if createNewBranch && branchExists { - out.Error(fmt.Sprintf("branch '%s' already exists (remove -b flag to use existing branch)", wtBranch), ErrCodeInvalidOperation) - os.Exit(1) - } - wtSettings := session.GetWorktreeSettings() location := wtSettings.DefaultLocation if *worktreeLocation != "" { diff --git a/cmd/agent-deck/main.go b/cmd/agent-deck/main.go index ac4fd77ab..e993c5504 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -815,8 +815,8 @@ func handleAdd(profile string, args []string) { // Worktree flags worktreeBranch := fs.String("w", "", "Create session in git worktree for branch") worktreeBranchLong := fs.String("worktree", "", "Create session in git worktree for branch") - newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)") - newBranchLong := fs.Bool("new-branch", false, "Create new branch") + newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)") + newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)") worktreeLocation := fs.String("location", "", "Worktree location: sibling, subdirectory, or custom path") // MCP flag - can be specified multiple times @@ -895,7 +895,7 @@ func handleAdd(profile string, args []string) { if *worktreeBranchLong != "" { wtBranch = *worktreeBranchLong } - createNewBranch := *newBranch || *newBranchLong + _ = *newBranch || *newBranchLong // Merge short and long flags sessionTitle := mergeFlags(*title, *titleShort) @@ -1035,17 +1035,6 @@ func handleAdd(profile string, args []string) { os.Exit(1) } - // Check -b flag logic: if -b is passed, branch must NOT exist (user wants new branch) - branchExists := git.BranchExists(repoRoot, wtBranch) - if createNewBranch && branchExists { - fmt.Fprintf( - os.Stderr, - "Error: branch '%s' already exists (remove -b flag to use existing branch)\n", - wtBranch, - ) - os.Exit(1) - } - // Determine worktree location: CLI flag overrides config wtSettings := session.GetWorktreeSettings() location := wtSettings.DefaultLocation diff --git a/cmd/agent-deck/session_cmd.go b/cmd/agent-deck/session_cmd.go index 17c6f8ada..b76e28be8 100644 --- a/cmd/agent-deck/session_cmd.go +++ b/cmd/agent-deck/session_cmd.go @@ -383,8 +383,8 @@ func handleSessionFork(profile string, args []string) { groupShort := fs.String("g", "", "Group for forked session (short)") worktreeBranch := fs.String("w", "", "Create fork in git worktree for branch") worktreeBranchLong := fs.String("worktree", "", "Create fork in git worktree for branch") - newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)") - newBranchLong := fs.Bool("new-branch", false, "Create new branch") + newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)") + newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)") sandbox := fs.Bool("sandbox", false, "Run forked session in Docker sandbox") sandboxImage := fs.String("sandbox-image", "", "Docker image for sandbox (overrides config default)") @@ -472,7 +472,7 @@ func handleSessionFork(profile string, args []string) { if *worktreeBranchLong != "" { wtBranch = *worktreeBranchLong } - createNewBranch := *newBranch || *newBranchLong + _ = *newBranch || *newBranchLong // Handle worktree creation var opts *session.ClaudeOptions @@ -487,8 +487,8 @@ func handleSessionFork(profile string, args []string) { os.Exit(1) } - if !createNewBranch && !git.BranchExists(repoRoot, wtBranch) { - out.Error(fmt.Sprintf("branch '%s' does not exist (use -b to create)", wtBranch), ErrCodeInvalidOperation) + if err := git.ValidateBranchName(wtBranch); err != nil { + out.Error(fmt.Sprintf("invalid branch name: %v", err), ErrCodeInvalidOperation) os.Exit(1) } diff --git a/internal/git/git.go b/internal/git/git.go index ccec311ae..4f5c891a1 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strings" ) @@ -56,6 +57,26 @@ func BranchExists(repoDir, branchName string) bool { return err == nil } +func remoteBranchExists(repoDir, remoteName, branchName string) bool { + cmd := exec.Command("git", "-C", repoDir, "show-ref", "--verify", "--quiet", "refs/remotes/"+remoteName+"/"+branchName) + err := cmd.Run() + return err == nil +} + +type worktreeBranchMode int + +const ( + worktreeBranchNew worktreeBranchMode = iota + worktreeBranchLocal + worktreeBranchRemote +) + +type worktreeBranchResolution struct { + Branch string + Mode worktreeBranchMode + Remote string +} + // ValidateBranchName validates that a branch name follows git's naming rules func ValidateBranchName(name string) error { if name == "" { @@ -151,13 +172,22 @@ func CreateWorktree(repoDir, worktreePath, branchName string) error { return errors.New("not a git repository") } - var cmd *exec.Cmd + resolution, err := resolveWorktreeBranch(repoDir, branchName) + if err != nil { + return err + } - if BranchExists(repoDir, branchName) { - // Use existing branch + var cmd *exec.Cmd + switch resolution.Mode { + case worktreeBranchLocal: + // Reuse an existing local branch. cmd = exec.Command("git", "-C", repoDir, "worktree", "add", worktreePath, branchName) - } else { - // Create new branch with -b flag + case worktreeBranchRemote: + // Create a local tracking branch from the default remote. + remoteRef := resolution.Remote + "/" + branchName + cmd = exec.Command("git", "-C", repoDir, "worktree", "add", "--track", "-b", branchName, worktreePath, remoteRef) + default: + // Create a new local branch. cmd = exec.Command("git", "-C", repoDir, "worktree", "add", "-b", branchName, worktreePath) } @@ -360,6 +390,150 @@ func SanitizeBranchName(name string) string { return sanitized } +func resolveWorktreeBranch(repoDir, branchName string) (worktreeBranchResolution, error) { + if !IsGitRepo(repoDir) { + return worktreeBranchResolution{}, errors.New("not a git repository") + } + + resolution := worktreeBranchResolution{ + Branch: branchName, + Mode: worktreeBranchNew, + } + + if BranchExists(repoDir, branchName) { + resolution.Mode = worktreeBranchLocal + return resolution, nil + } + + defaultRemote, err := getDefaultRemote(repoDir) + if err == nil && defaultRemote != "" && remoteBranchExists(repoDir, defaultRemote, branchName) { + resolution.Mode = worktreeBranchRemote + resolution.Remote = defaultRemote + } + + return resolution, nil +} + +func getDefaultRemote(repoDir string) (string, error) { + remotes, err := listRemotes(repoDir) + if err != nil { + return "", err + } + if len(remotes) == 0 { + return "", errors.New("no git remotes configured") + } + + currentBranch, err := GetCurrentBranch(repoDir) + if err == nil && currentBranch != "" && currentBranch != "HEAD" { + cmd := exec.Command("git", "-C", repoDir, "config", "--get", "branch."+currentBranch+".remote") + output, err := cmd.Output() + if err == nil { + remote := strings.TrimSpace(string(output)) + if remote != "" { + return remote, nil + } + } + } + + for _, remote := range remotes { + if remote == "origin" { + return remote, nil + } + } + + if len(remotes) == 1 { + return remotes[0], nil + } + + return "", fmt.Errorf("could not determine default remote from %d remotes", len(remotes)) +} + +func listRemotes(repoDir string) ([]string, error) { + cmd := exec.Command("git", "-C", repoDir, "remote") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list remotes: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var remotes []string + for _, line := range lines { + remote := strings.TrimSpace(line) + if remote != "" { + remotes = append(remotes, remote) + } + } + return remotes, nil +} + +func listRefShortNames(repoDir string, refs ...string) ([]string, error) { + args := []string{"-C", repoDir, "for-each-ref", "--format=%(refname:short)"} + args = append(args, refs...) + cmd := exec.Command("git", args...) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list refs: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var names []string + for _, line := range lines { + name := strings.TrimSpace(line) + if name != "" { + names = append(names, name) + } + } + return names, nil +} + +// ListBranchCandidates returns unique branch names from local branches and the +// default remote, normalized to plain branch names without a remote prefix. +func ListBranchCandidates(repoDir string) ([]string, error) { + if !IsGitRepo(repoDir) { + return nil, errors.New("not a git repository") + } + + repoRoot, err := GetWorktreeBaseRoot(repoDir) + if err == nil && repoRoot != "" { + repoDir = repoRoot + } + + branches, err := listRefShortNames(repoDir, "refs/heads") + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(branches)) + for _, branch := range branches { + seen[branch] = struct{}{} + } + + if defaultRemote, err := getDefaultRemote(repoDir); err == nil && defaultRemote != "" { + remoteBranches, err := listRefShortNames(repoDir, "refs/remotes/"+defaultRemote) + if err != nil { + return nil, err + } + prefix := defaultRemote + "/" + for _, branch := range remoteBranches { + if branch == defaultRemote+"/HEAD" { + continue + } + branch = strings.TrimPrefix(branch, prefix) + if branch == "" { + continue + } + seen[branch] = struct{}{} + } + } + + branches = branches[:0] + for branch := range seen { + branches = append(branches, branch) + } + sort.Strings(branches) + return branches, nil +} + // HasUncommittedChanges checks if the repository at dir has uncommitted changes func HasUncommittedChanges(dir string) (bool, error) { cmd := exec.Command("git", "-C", dir, "status", "--porcelain") diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 4ec1b871c..c6cf528e7 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -61,6 +61,17 @@ func createBranch(t *testing.T, dir, branchName string) { } } +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, strings.TrimSpace(string(output))) + } + return strings.TrimSpace(string(output)) +} + func TestIsGitRepo(t *testing.T) { t.Run("returns true for git repo", func(t *testing.T) { dir := t.TempDir() @@ -491,6 +502,45 @@ func TestCreateWorktree(t *testing.T) { } }) + t.Run("creates worktree from default remote branch", func(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + remoteDir := filepath.Join(t.TempDir(), "origin.git") + if err := os.MkdirAll(remoteDir, 0o755); err != nil { + t.Fatalf("failed to create remote dir: %v", err) + } + runGit(t, remoteDir, "init", "--bare") + runGit(t, dir, "remote", "add", "origin", remoteDir) + runGit(t, dir, "push", "-u", "origin", "main") + runGit(t, dir, "checkout", "-b", "remote-only") + runGit(t, dir, "push", "-u", "origin", "remote-only") + runGit(t, dir, "checkout", "main") + runGit(t, dir, "branch", "-D", "remote-only") + + worktreePath := filepath.Join(t.TempDir(), "worktree") + if err := CreateWorktree(dir, worktreePath, "remote-only"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !BranchExists(dir, "remote-only") { + t.Fatal("expected CreateWorktree to create a local tracking branch") + } + + branch, err := GetCurrentBranch(worktreePath) + if err != nil { + t.Fatalf("failed to get branch: %v", err) + } + if branch != "remote-only" { + t.Fatalf("expected remote-only branch, got %s", branch) + } + + upstream := runGit(t, worktreePath, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") + if upstream != "origin/remote-only" { + t.Fatalf("expected upstream origin/remote-only, got %s", upstream) + } + }) + t.Run("returns error for invalid branch name", func(t *testing.T) { dir := t.TempDir() createTestRepo(t, dir) @@ -514,6 +564,75 @@ func TestCreateWorktree(t *testing.T) { }) } +func TestResolveWorktreeBranch(t *testing.T) { + t.Run("prefers local branch over default remote branch", func(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + remoteDir := filepath.Join(t.TempDir(), "origin.git") + if err := os.MkdirAll(remoteDir, 0o755); err != nil { + t.Fatalf("failed to create remote dir: %v", err) + } + runGit(t, remoteDir, "init", "--bare") + runGit(t, dir, "remote", "add", "origin", remoteDir) + runGit(t, dir, "push", "-u", "origin", "main") + runGit(t, dir, "checkout", "-b", "shared-branch") + runGit(t, dir, "push", "-u", "origin", "shared-branch") + runGit(t, dir, "checkout", "main") + + resolution, err := resolveWorktreeBranch(dir, "shared-branch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolution.Mode != worktreeBranchLocal { + t.Fatalf("expected local branch resolution, got mode %d", resolution.Mode) + } + if resolution.Remote != "" { + t.Fatalf("expected no remote for local resolution, got %q", resolution.Remote) + } + }) +} + +func TestListBranchCandidates(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + remoteDir := filepath.Join(t.TempDir(), "origin.git") + if err := os.MkdirAll(remoteDir, 0o755); err != nil { + t.Fatalf("failed to create remote dir: %v", err) + } + runGit(t, remoteDir, "init", "--bare") + runGit(t, dir, "remote", "add", "origin", remoteDir) + runGit(t, dir, "push", "-u", "origin", "main") + runGit(t, dir, "checkout", "-b", "feature/local-only") + runGit(t, dir, "checkout", "main") + runGit(t, dir, "checkout", "-b", "feature/remote-only") + runGit(t, dir, "push", "-u", "origin", "feature/remote-only") + runGit(t, dir, "checkout", "main") + runGit(t, dir, "branch", "-D", "feature/remote-only") + + branches, err := ListBranchCandidates(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !containsString(branches, "feature/local-only") { + t.Fatalf("expected local branch in candidates: %v", branches) + } + if !containsString(branches, "feature/remote-only") { + t.Fatalf("expected remote-only branch in candidates: %v", branches) + } +} + +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + func TestListWorktrees(t *testing.T) { t.Run("lists worktrees in repo", func(t *testing.T) { dir := t.TempDir() diff --git a/internal/ui/branch_picker.go b/internal/ui/branch_picker.go new file mode 100644 index 000000000..d5365d407 --- /dev/null +++ b/internal/ui/branch_picker.go @@ -0,0 +1,116 @@ +package ui + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/asheshgoplani/agent-deck/internal/git" + "github.com/asheshgoplani/agent-deck/internal/session" +) + +var openBranchPicker = branchPickerCmd + +type branchPickerResultMsg struct { + branch string + canceled bool + err error +} + +func branchPickerCmd(projectPath string) tea.Cmd { + selected := "" + canceled := false + + cmd := &branchPickerExecCmd{ + projectPath: projectPath, + selected: &selected, + canceled: &canceled, + } + + return tea.Exec(cmd, func(err error) tea.Msg { + return branchPickerResultMsg{ + branch: selected, + canceled: canceled, + err: err, + } + }) +} + +type branchPickerExecCmd struct { + projectPath string + selected *string + canceled *bool + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +func (c *branchPickerExecCmd) Run() error { + if _, err := exec.LookPath("fzf"); err != nil { + return errors.New("fzf not found; install fzf or type branch manually") + } + + projectPath := session.ExpandPath(strings.Trim(strings.TrimSpace(c.projectPath), "'\"")) + if projectPath == "" { + return errors.New("project path is empty") + } + + repoRoot, err := git.GetWorktreeBaseRoot(projectPath) + if err != nil { + return errors.New("path is not a git repository") + } + + branches, err := git.ListBranchCandidates(repoRoot) + if err != nil { + return err + } + if len(branches) == 0 { + return errors.New("no branches found in repository") + } + + var output bytes.Buffer + fzf := exec.Command("fzf", "--prompt", "Branch> ", "--height", "40%", "--reverse") + fzf.Stdin = strings.NewReader(strings.Join(branches, "\n") + "\n") + fzf.Stdout = &output + if c.stderr != nil { + fzf.Stderr = c.stderr + } else { + fzf.Stderr = os.Stderr + } + + if err := fzf.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + switch exitErr.ExitCode() { + case 1, 130: + if c.canceled != nil { + *c.canceled = true + } + return nil + } + } + return fmt.Errorf("fzf failed: %w", err) + } + + selected := strings.TrimSpace(output.String()) + if selected == "" { + if c.canceled != nil { + *c.canceled = true + } + return nil + } + if c.selected != nil { + *c.selected = selected + } + return nil +} + +func (c *branchPickerExecCmd) SetStdin(r io.Reader) { c.stdin = r } +func (c *branchPickerExecCmd) SetStdout(w io.Writer) { c.stdout = w } +func (c *branchPickerExecCmd) SetStderr(w io.Writer) { c.stderr = w } diff --git a/internal/ui/forkdialog.go b/internal/ui/forkdialog.go index e5a169a03..5c2969455 100644 --- a/internal/ui/forkdialog.go +++ b/internal/ui/forkdialog.go @@ -186,6 +186,23 @@ func (d *ForkDialog) ClearError() { d.validationErr = "" } +func (d *ForkDialog) applyBranchPickerResult(msg branchPickerResultMsg) { + if msg.err != nil { + d.SetError(msg.err.Error()) + return + } + if msg.canceled { + return + } + if msg.branch == "" { + return + } + + d.branchInput.SetValue(msg.branch) + d.branchInput.SetCursor(len(msg.branch)) + d.ClearError() +} + // Update handles input events func (d *ForkDialog) Update(msg tea.Msg) (*ForkDialog, tea.Cmd) { if !d.visible { @@ -195,6 +212,10 @@ func (d *ForkDialog) Update(msg tea.Msg) (*ForkDialog, tea.Cmd) { optStart := d.optionsStartIndex() switch msg := msg.(type) { + case branchPickerResultMsg: + d.applyBranchPickerResult(msg) + return d, nil + case tea.KeyMsg: switch msg.String() { case "tab", "down": @@ -257,6 +278,11 @@ func (d *ForkDialog) Update(msg tea.Msg) (*ForkDialog, tea.Cmd) { return d, nil } + case "ctrl+f": + if d.focusIndex == 2 && d.worktreeEnabled { + return d, openBranchPicker(d.projectPath) + } + case "s": // Toggle sandbox when on group field. if d.focusIndex == 1 { @@ -414,6 +440,11 @@ func (d *ForkDialog) View() string { errLine = "\n" + errStyle.Render(" ⚠ "+d.validationErr) + "\n" } + helpText := "Enter create │ Esc cancel │ Tab next │ s sandbox │ Space toggle" + if d.focusIndex == 2 && d.worktreeEnabled { + helpText = "^F fzf pick │ Enter create │ Esc cancel │ Tab next" + } + content := titleStyle.Render("Fork Session") + "\n\n" + nameLabel + "\n" + " " + d.nameInput.View() + "\n\n" + @@ -424,7 +455,7 @@ func (d *ForkDialog) View() string { d.optionsPanel.View() + errLine + "\n" + lipgloss.NewStyle().Foreground(ColorComment). - Render("Enter create │ Esc cancel │ Tab next │ s sandbox │ Space toggle") + Render(helpText) dialog := boxStyle.Render(content) diff --git a/internal/ui/forkdialog_test.go b/internal/ui/forkdialog_test.go index a33272262..59c7f8541 100644 --- a/internal/ui/forkdialog_test.go +++ b/internal/ui/forkdialog_test.go @@ -1,8 +1,11 @@ package ui import ( + "os" "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestNewForkDialog(t *testing.T) { @@ -175,3 +178,52 @@ func TestForkDialog_Show_ClearsError(t *testing.T) { t.Error("Show() should clear validationErr") } } + +func TestForkDialog_CtrlFBranchPickerAppliesSelection(t *testing.T) { + d := NewForkDialog() + d.Show("Test", "/tmp/project", "group") + d.worktreeEnabled = true + d.focusIndex = 2 + d.updateFocus() + + origPicker := openBranchPicker + defer func() { openBranchPicker = origPicker }() + + called := false + openBranchPicker = func(path string) tea.Cmd { + called = true + if path != "/tmp/project" { + t.Fatalf("picker path = %q, want %q", path, "/tmp/project") + } + return func() tea.Msg { + return branchPickerResultMsg{branch: "fork/picked"} + } + } + + var cmd tea.Cmd + d, cmd = d.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) + if !called { + t.Fatal("expected ctrl+f to open branch picker") + } + if cmd == nil { + t.Fatal("expected ctrl+f to return a branch picker command") + } + + d, _ = d.Update(cmd()) + if got := d.branchInput.Value(); got != "fork/picked" { + t.Fatalf("branch = %q, want %q", got, "fork/picked") + } +} + +func TestForkDialog_BranchPickerErrorIsShown(t *testing.T) { + d := NewForkDialog() + d.Show("Test", "/tmp/project", "group") + d.worktreeEnabled = true + d.focusIndex = 2 + d.updateFocus() + + d, _ = d.Update(branchPickerResultMsg{err: os.ErrNotExist}) + if !strings.Contains(d.validationErr, os.ErrNotExist.Error()) { + t.Fatalf("expected picker error in validationErr, got %q", d.validationErr) + } +} diff --git a/internal/ui/home.go b/internal/ui/home.go index 4caa513f7..a166d68ec 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -2988,6 +2988,19 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return h, nil + case branchPickerResultMsg: + if h.newDialog.IsVisible() { + var cmd tea.Cmd + h.newDialog, cmd = h.newDialog.Update(msg) + return h, cmd + } + if h.forkDialog.IsVisible() { + var cmd tea.Cmd + h.forkDialog, cmd = h.forkDialog.Update(msg) + return h, cmd + } + return h, nil + case sessionCreatedMsg: // Handle reload scenario: session was already started in tmux, we MUST save it to JSON // even during reload, otherwise the session becomes orphaned (exists in tmux but not in storage) diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 31e8cd22f..285077326 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -800,6 +800,36 @@ func (d *NewDialog) isTextInputFocused() bool { } } +func (d *NewDialog) worktreePickerPath() string { + if d.multiRepoEnabled { + for _, path := range d.multiRepoPaths { + path = strings.Trim(strings.TrimSpace(path), "'\"") + if path != "" { + return path + } + } + } + return strings.Trim(strings.TrimSpace(d.pathInput.Value()), "'\"") +} + +func (d *NewDialog) applyBranchPickerResult(msg branchPickerResultMsg) { + if msg.err != nil { + d.SetError(msg.err.Error()) + return + } + if msg.canceled { + return + } + if msg.branch == "" { + return + } + + d.branchInput.SetValue(msg.branch) + d.branchInput.SetCursor(len(msg.branch)) + d.branchAutoSet = false + d.ClearError() +} + func (d *NewDialog) Update(msg tea.Msg) (*NewDialog, tea.Cmd) { if !d.visible { return d, nil @@ -810,6 +840,10 @@ func (d *NewDialog) Update(msg tea.Msg) (*NewDialog, tea.Cmd) { cur := d.currentTarget() switch msg := msg.(type) { + case branchPickerResultMsg: + d.applyBranchPickerResult(msg) + return d, nil + case tea.KeyMsg: // Recent sessions picker handling if d.showRecentPicker && len(d.recentSessions) > 0 { @@ -954,6 +988,11 @@ func (d *NewDialog) Update(msg tea.Msg) (*NewDialog, tea.Cmd) { return d, nil } + case "ctrl+f": + if cur == focusBranch { + return d, openBranchPicker(d.worktreePickerPath()) + } + case "down": if cur == focusMultiRepo && d.multiRepoEnabled && !d.multiRepoEditing { if d.multiRepoPathCursor < len(d.multiRepoPaths)-1 { @@ -1672,6 +1711,8 @@ func (d *NewDialog) View() string { } else { helpText = "Tab autocomplete │ ^N/^P recent │ ↑↓ navigate │ Enter create │ Esc cancel" } + } else if cur == focusBranch { + helpText = "^F fzf pick │ Tab next │ Enter create │ Esc cancel" } else if cur == focusCommand { selectedCmd := d.GetSelectedCommand() if selectedCmd == "gemini" || selectedCmd == "codex" { diff --git a/internal/ui/newdialog_test.go b/internal/ui/newdialog_test.go index d1fb4b628..abf22334f 100644 --- a/internal/ui/newdialog_test.go +++ b/internal/ui/newdialog_test.go @@ -994,6 +994,61 @@ func TestNewDialog_ShowInGroup_ResetsBranchAutoSet(t *testing.T) { } } +func TestNewDialog_CtrlFBranchPickerAppliesSelection(t *testing.T) { + d := NewNewDialog() + d.Show() + d.pathInput.SetValue("/tmp/project") + d.ToggleWorktree() + d.rebuildFocusTargets() + d.focusIndex = d.indexOf(focusBranch) + d.updateFocus() + + origPicker := openBranchPicker + defer func() { openBranchPicker = origPicker }() + + called := false + openBranchPicker = func(path string) tea.Cmd { + called = true + if path != "/tmp/project" { + t.Fatalf("picker path = %q, want %q", path, "/tmp/project") + } + return func() tea.Msg { + return branchPickerResultMsg{branch: "feature/picked"} + } + } + + var cmd tea.Cmd + d, cmd = d.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) + if !called { + t.Fatal("expected ctrl+f to open branch picker") + } + if cmd == nil { + t.Fatal("expected ctrl+f to return a branch picker command") + } + + d, _ = d.Update(cmd()) + if got := d.branchInput.Value(); got != "feature/picked" { + t.Fatalf("branch = %q, want %q", got, "feature/picked") + } + if d.validationErr != "" { + t.Fatalf("expected no validation error, got %q", d.validationErr) + } +} + +func TestNewDialog_BranchPickerErrorIsShown(t *testing.T) { + d := NewNewDialog() + d.Show() + d.ToggleWorktree() + d.rebuildFocusTargets() + d.focusIndex = d.indexOf(focusBranch) + d.updateFocus() + + d, _ = d.Update(branchPickerResultMsg{err: os.ErrNotExist}) + if !strings.Contains(d.validationErr, os.ErrNotExist.Error()) { + t.Fatalf("expected picker error in validationErr, got %q", d.validationErr) + } +} + // ===== Soft-Select Tests ===== func TestNewDialog_SoftSelect_InitialState(t *testing.T) { From 772facbe3fa55602e1d79fc6a75fb7011737a647 Mon Sep 17 00:00:00 2001 From: chenwl Date: Wed, 18 Mar 2026 22:55:50 +0800 Subject: [PATCH 06/10] Add happy wrapper support for Claude and Codex --- internal/session/instance.go | 110 +++++--- internal/session/instance_test.go | 241 ++++++++++++++++++ internal/session/tooloptions.go | 10 + internal/session/tooloptions_test.go | 38 ++- internal/session/userconfig.go | 13 + internal/session/userconfig_test.go | 32 +++ internal/ui/claudeoptions.go | 34 ++- internal/ui/home.go | 6 +- internal/ui/newdialog.go | 39 ++- internal/ui/newdialog_test.go | 28 +- internal/ui/settings_panel.go | 69 ++++- internal/ui/settings_panel_test.go | 50 ++++ internal/ui/yolooptions.go | 66 ++++- .../agent-deck/references/config-reference.md | 6 + 14 files changed, 653 insertions(+), 89 deletions(-) diff --git a/internal/session/instance.go b/internal/session/instance.go index 4d74c9892..30c9fc6a5 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -481,16 +481,21 @@ func (i *Instance) buildClaudeCommandWithMessage(baseCommand, message string) st return baseCommand } - // Get the configured Claude command (e.g., "claude", "cdw", "cdp") - // If a custom command is set, we skip CLAUDE_CONFIG_DIR prefix since the alias handles it - claudeCmd := GetClaudeCommand() - hasCustomCommand := claudeCmd != "claude" + // Get options - either from instance or create defaults from config + opts := i.GetClaudeOptions() + if opts == nil { + // Fall back to config defaults + userConfig, _ := LoadUserConfig() + opts = NewClaudeOptions(userConfig) + } + + claudeCmd, hasCustomCommand := resolveClaudeLaunchCommand(opts) // Check if CLAUDE_CONFIG_DIR is explicitly configured (env var or config.toml) // If NOT explicit, we don't set it in the command - let the shell's environment handle it. // This is critical for WSL and other environments where users have CLAUDE_CONFIG_DIR // set in their .bashrc/.zshrc - we should NOT override that with a default path. - // Also skip if using a custom command (alias handles config dir) + // Also skip if using a custom command alias (alias handles config dir). configDirPrefix := "" if !hasCustomCommand && IsClaudeConfigDirExplicit() { configDir := GetClaudeConfigDir() @@ -502,14 +507,6 @@ func (i *Instance) buildClaudeCommandWithMessage(baseCommand, message string) st instanceIDPrefix := fmt.Sprintf("AGENTDECK_INSTANCE_ID=%s ", i.ID) configDirPrefix = instanceIDPrefix + configDirPrefix - // Get options - either from instance or create defaults from config - opts := i.GetClaudeOptions() - if opts == nil { - // Fall back to config defaults - userConfig, _ := LoadUserConfig() - opts = NewClaudeOptions(userConfig) - } - // If baseCommand is just "claude", build the appropriate command if baseCommand == "claude" { // Build extra flags string from options (includes --add-dir if ParentProjectPath set) @@ -590,6 +587,32 @@ func (i *Instance) buildClaudeCommandWithMessage(baseCommand, message string) st return baseCommand } +func resolveClaudeLaunchCommand(opts *ClaudeOptions) (cmd string, hasCustomCommand bool) { + if opts == nil { + userConfig, _ := LoadUserConfig() + opts = NewClaudeOptions(userConfig) + } + + configuredCmd := GetClaudeCommand() + if configuredCmd == "" { + configuredCmd = "claude" + } + + hasCustomCommand = configuredCmd != "claude" + if hasCustomCommand { + return configuredCmd, true + } + + if opts != nil { + if opts.UseHappy { + return "happy", false + } + return configuredCmd, false + } + + return configuredCmd, false +} + // buildBashExportPrefix builds the export prefix used in bash -c commands. // It always exports AGENTDECK_INSTANCE_ID, and conditionally adds CLAUDE_CONFIG_DIR. func (i *Instance) buildBashExportPrefix() string { @@ -784,8 +807,8 @@ func (i *Instance) DetectOpenCodeSession() { i.detectOpenCodeSessionAsync() } -// buildCodexCommand builds the command for OpenAI Codex CLI -// resolveCodexYoloFlag returns " --yolo" if yolo mode is enabled (per-session override > global config), or "". +// resolveCodexYoloFlag returns " --yolo" if yolo mode is enabled +// (per-session override > global config), or "". func (i *Instance) resolveCodexYoloFlag() string { opts := i.GetCodexOptions() if opts != nil && opts.YoloMode != nil { @@ -803,6 +826,17 @@ func (i *Instance) resolveCodexYoloFlag() string { return "" } +func (i *Instance) resolveCodexUseHappy() bool { + opts := i.GetCodexOptions() + if opts != nil && opts.UseHappy != nil { + return *opts.UseHappy + } + if config, err := LoadUserConfig(); err == nil && config != nil { + return config.Codex.UseHappy + } + return false +} + // Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/*.jsonl // Resume: codex resume or codex resume --last // Also sources .env files from [shell].env_files @@ -817,18 +851,22 @@ func (i *Instance) buildCodexCommand(baseCommand string) string { envPrefix += agentdeckEnvPrefix yoloFlag := i.resolveCodexYoloFlag() + codexCmd := "codex" + if i.resolveCodexUseHappy() { + codexCmd = "happy codex" + } // If baseCommand is just "codex", handle specially if baseCommand == "codex" { // If we already have a session ID, use resume. // CODEX_SESSION_ID is propagated via host-side SetEnvironment after tmux start. if i.CodexSessionID != "" { - return envPrefix + fmt.Sprintf("codex%s resume %s", - yoloFlag, i.CodexSessionID) + return envPrefix + fmt.Sprintf("%s%s resume %s", + codexCmd, yoloFlag, i.CodexSessionID) } // Start Codex fresh - session ID will be captured async after startup - return envPrefix + "codex" + yoloFlag + return envPrefix + codexCmd + yoloFlag } // For custom commands (e.g., resume commands), preserve env propagation. @@ -4050,14 +4088,19 @@ func (i *Instance) Restart() error { return nil } -// buildClaudeResumeCommand builds the claude resume command with proper config options -// Respects: CLAUDE_CONFIG_DIR, dangerous_mode from user config +// buildClaudeResumeCommand builds the Claude resume command with proper config options. +// Respects: CLAUDE_CONFIG_DIR, use_happy, and dangerous_mode from user/session config. // CLAUDE_SESSION_ID is set via host-side SetEnvironment (called by SyncSessionIDsToTmux after restart) func (i *Instance) buildClaudeResumeCommand() string { - // Get the configured Claude command (e.g., "claude", "cdw", "cdp") - // If a custom command is set, we skip CLAUDE_CONFIG_DIR prefix since the alias handles it - claudeCmd := GetClaudeCommand() - hasCustomCommand := claudeCmd != "claude" + opts := i.GetClaudeOptions() + if opts == nil { + userConfig, _ := LoadUserConfig() + opts = NewClaudeOptions(userConfig) + } + + // Get the configured Claude command (e.g., "claude", "cdw", "happy") + // If a custom command alias is set, we skip CLAUDE_CONFIG_DIR prefix since the alias handles it. + claudeCmd, hasCustomCommand := resolveClaudeLaunchCommand(opts) // Check if CLAUDE_CONFIG_DIR is explicitly configured // If NOT explicit, don't set it - let the shell's environment handle it @@ -4072,13 +4115,6 @@ func (i *Instance) buildClaudeResumeCommand() string { // can identify which agent-deck session they belong to. instanceIDPrefix := fmt.Sprintf("AGENTDECK_INSTANCE_ID=%s ", i.ID) configDirPrefix = instanceIDPrefix + configDirPrefix - - // Get per-session permission settings (falls back to config if not persisted) - opts := i.GetClaudeOptions() - if opts == nil { - userConfig, _ := LoadUserConfig() - opts = NewClaudeOptions(userConfig) - } dangerousMode := opts.SkipPermissions allowDangerousMode := opts.AllowSkipPermissions @@ -4244,7 +4280,8 @@ func (i *Instance) buildClaudeForkCommandForTarget(target *Instance, opts *Claud workDir := target.ProjectPath // IMPORTANT: For capture-resume commands (which contain $(...) syntax), we MUST use - // "claude" binary + explicit env exports, NOT a custom command alias like "cdw". + // the default launch binary ("claude" or "happy") + explicit env exports, NOT a + // custom command alias like "cdw". // Reason: Commands with $(...) get wrapped in `bash -c` for fish compatibility (#47), // and shell aliases are not available in non-interactive bash shells. bashExportPrefix := target.buildBashExportPrefix() @@ -4255,6 +4292,11 @@ func (i *Instance) buildClaudeForkCommandForTarget(target *Instance, opts *Claud opts = NewClaudeOptions(userConfig) } + claudeCmd, hasCustomCommand := resolveClaudeLaunchCommand(opts) + if hasCustomCommand { + claudeCmd = "claude" + } + // Build extra flags from options (for fork, we use ToArgsForFork which excludes session mode) extraFlags := i.buildClaudeExtraFlags(opts) @@ -4267,9 +4309,9 @@ func (i *Instance) buildClaudeForkCommandForTarget(target *Instance, opts *Claud target.ClaudeSessionID = forkUUID cmd := fmt.Sprintf( `cd '%s' && `+ - `%sexec claude --session-id "%s" --resume %s --fork-session%s`, + `%sexec %s --session-id "%s" --resume %s --fork-session%s`, workDir, - bashExportPrefix, forkUUID, i.ClaudeSessionID, extraFlags) + bashExportPrefix, claudeCmd, forkUUID, i.ClaudeSessionID, extraFlags) cmd, err := i.applyWrapper(cmd) if err != nil { return "", err diff --git a/internal/session/instance_test.go b/internal/session/instance_test.go index 5b2d8950c..26efaeaf9 100644 --- a/internal/session/instance_test.go +++ b/internal/session/instance_test.go @@ -504,6 +504,106 @@ config_dir = "~/.claude-work" } } +func TestBuildClaudeCommand_UseHappy(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[claude] +use_happy = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + inst := NewInstanceWithTool("happy-claude", "/tmp/test", "claude") + cmd := inst.buildClaudeCommand("claude") + + if !strings.Contains(cmd, "exec happy --session-id") { + t.Errorf("Should launch Claude via happy when use_happy=true, got: %s", cmd) + } +} + +func TestBuildClaudeCommand_CustomAliasWinsOverHappy(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[claude] +command = "cdw" +use_happy = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + inst := NewInstanceWithTool("alias-claude", "/tmp/test", "claude") + cmd := inst.buildClaudeCommand("claude") + + if !strings.Contains(cmd, "cdw") { + t.Errorf("Should use custom Claude command when configured, got: %s", cmd) + } + if strings.Contains(cmd, "exec happy") { + t.Errorf("Custom Claude command should win over happy, got: %s", cmd) + } +} + +func TestBuildClaudeCommand_PerSessionUseHappyOverride(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[claude] +use_happy = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + inst := NewInstanceWithTool("plain-claude", "/tmp/test", "claude") + if err := inst.SetClaudeOptions(&ClaudeOptions{SessionMode: "new", UseHappy: false}); err != nil { + t.Fatalf("SetClaudeOptions failed: %v", err) + } + + cmd := inst.buildClaudeCommand("claude") + if strings.Contains(cmd, "exec happy") { + t.Errorf("Per-session UseHappy=false should override global config, got: %s", cmd) + } + if !strings.Contains(cmd, "exec claude --session-id") { + t.Errorf("Expected plain claude command when per-session UseHappy=false, got: %s", cmd) + } +} + // TestBuildClaudeCommand_SubagentAddDir tests that subagents get --add-dir // for access to parent's project directory (for worktrees, etc.) func TestBuildClaudeCommand_SubagentAddDir(t *testing.T) { @@ -2319,6 +2419,147 @@ func TestBuildClaudeCommand_ExportsInstanceID(t *testing.T) { } } +func TestBuildCodexCommand_UseHappy(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[codex] +use_happy = true +yolo_mode = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + inst := NewInstanceWithTool("happy-codex", "/tmp/test", "codex") + cmd := inst.buildCodexCommand("codex") + + if !strings.Contains(cmd, "happy codex --yolo") { + t.Errorf("Should launch Codex via happy with yolo flag, got: %s", cmd) + } +} + +func TestBuildCodexCommand_PerSessionUseHappyOverride(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[codex] +use_happy = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + inst := NewInstanceWithTool("plain-codex", "/tmp/test", "codex") + inst.CodexSessionID = "codex-session-123" + if err := inst.SetCodexOptions(&CodexOptions{ + YoloMode: boolPtr(true), + UseHappy: boolPtr(false), + }); err != nil { + t.Fatalf("SetCodexOptions failed: %v", err) + } + + cmd := inst.buildCodexCommand("codex") + + if strings.Contains(cmd, "happy codex") { + t.Errorf("Per-session UseHappy=false should override global config, got: %s", cmd) + } + if !strings.Contains(cmd, "codex --yolo resume codex-session-123") { + t.Errorf("Expected plain codex resume command with yolo, got: %s", cmd) + } +} + +func TestBuildClaudeResumeCommand_UseHappy(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[claude] +use_happy = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + inst := NewInstanceWithTool("resume-happy", "/tmp/test", "claude") + inst.ClaudeSessionID = "resume-session-123" + inst.ClaudeDetectedAt = time.Now() + + cmd := inst.buildClaudeResumeCommand() + if !strings.Contains(cmd, "happy --session-id resume-session-123") && + !strings.Contains(cmd, "happy --resume resume-session-123") { + t.Errorf("Resume command should use happy when configured, got: %s", cmd) + } +} + +func TestBuildClaudeForkCommandForTarget_UseHappy(t *testing.T) { + origHome := os.Getenv("HOME") + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".agent-deck") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configContent := `[claude] +use_happy = true +` + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(configContent), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ClearUserConfigCache() + defer func() { + os.Setenv("HOME", origHome) + ClearUserConfigCache() + }() + + parent := NewInstanceWithTool("parent", "/tmp/test", "claude") + parent.ClaudeSessionID = "parent-session-123" + parent.ClaudeDetectedAt = time.Now() + target := NewInstanceWithTool("fork", "/tmp/test", "claude") + + cmd, err := parent.buildClaudeForkCommandForTarget(target, nil) + if err != nil { + t.Fatalf("buildClaudeForkCommandForTarget failed: %v", err) + } + if !strings.Contains(cmd, "exec happy --session-id") { + t.Errorf("Fork command should use happy when configured, got: %s", cmd) + } +} + // TestBuildClaudeResumeCommand_ExportsInstanceID verifies that AGENTDECK_INSTANCE_ID // is included in the resume command string. func TestBuildClaudeResumeCommand_ExportsInstanceID(t *testing.T) { diff --git a/internal/session/tooloptions.go b/internal/session/tooloptions.go index f64c017db..8d07e05f5 100644 --- a/internal/session/tooloptions.go +++ b/internal/session/tooloptions.go @@ -20,6 +20,8 @@ type ClaudeOptions struct { SessionMode string `json:"session_mode,omitempty"` // ResumeSessionID is the session ID for -r flag (only when SessionMode="resume") ResumeSessionID string `json:"resume_session_id,omitempty"` + // UseHappy launches Claude via the happy wrapper + UseHappy bool `json:"use_happy,omitempty"` // SkipPermissions adds --dangerously-skip-permissions flag SkipPermissions bool `json:"skip_permissions,omitempty"` // AllowSkipPermissions adds --allow-dangerously-skip-permissions flag @@ -99,6 +101,7 @@ func NewClaudeOptions(config *UserConfig) *ClaudeOptions { SessionMode: "new", } if config != nil { + opts.UseHappy = config.Claude.UseHappy opts.SkipPermissions = config.Claude.GetDangerousMode() opts.AllowSkipPermissions = config.Claude.AllowDangerousMode } @@ -110,6 +113,9 @@ type CodexOptions struct { // YoloMode enables --yolo flag (bypass approvals and sandbox) // nil = inherit from global config, true/false = explicit override YoloMode *bool `json:"yolo_mode,omitempty"` + // UseHappy launches Codex via "happy codex" + // nil = inherit from global config, true/false = explicit override + UseHappy *bool `json:"use_happy,omitempty"` } // ToolName returns "codex" @@ -133,6 +139,10 @@ func NewCodexOptions(config *UserConfig) *CodexOptions { yolo := true opts.YoloMode = &yolo } + if config != nil && config.Codex.UseHappy { + useHappy := true + opts.UseHappy = &useHappy + } return opts } diff --git a/internal/session/tooloptions_test.go b/internal/session/tooloptions_test.go index c26d57b18..e7ad66b7b 100644 --- a/internal/session/tooloptions_test.go +++ b/internal/session/tooloptions_test.go @@ -192,6 +192,7 @@ func TestNewClaudeOptions_WithConfig(t *testing.T) { config := &UserConfig{ Claude: ClaudeSettings{ DangerousMode: &dangerousModeBool, + UseHappy: true, }, } @@ -203,6 +204,9 @@ func TestNewClaudeOptions_WithConfig(t *testing.T) { if !opts.SkipPermissions { t.Error("expected SkipPermissions=true when config.DangerousMode=true") } + if !opts.UseHappy { + t.Error("expected UseHappy=true when config.Claude.UseHappy=true") + } } func TestNewClaudeOptions_NilConfig(t *testing.T) { @@ -217,6 +221,9 @@ func TestNewClaudeOptions_NilConfig(t *testing.T) { if opts.AllowSkipPermissions { t.Error("expected AllowSkipPermissions=false when config is nil") } + if opts.UseHappy { + t.Error("expected UseHappy=false when config is nil") + } } func TestNewClaudeOptions_AllowDangerousMode(t *testing.T) { @@ -302,6 +309,7 @@ func TestUnmarshalClaudeOptions(t *testing.T) { opts := &ClaudeOptions{ SessionMode: "resume", ResumeSessionID: "test-session-123", + UseHappy: true, SkipPermissions: true, UseChrome: true, UseTeammateMode: true, @@ -327,6 +335,9 @@ func TestUnmarshalClaudeOptions(t *testing.T) { if !result.SkipPermissions { t.Error("expected SkipPermissions=true") } + if !result.UseHappy { + t.Error("expected UseHappy=true") + } if !result.UseChrome { t.Error("expected UseChrome=true") } @@ -413,23 +424,29 @@ func TestCodexOptions_ToArgs(t *testing.T) { } func TestNewCodexOptions_WithConfig(t *testing.T) { - // Global yolo=true + // Global yolo=true, use_happy=true config := &UserConfig{ - Codex: CodexSettings{YoloMode: true}, + Codex: CodexSettings{YoloMode: true, UseHappy: true}, } opts := NewCodexOptions(config) if opts.YoloMode == nil || !*opts.YoloMode { t.Error("expected YoloMode=true when config.Codex.YoloMode=true") } + if opts.UseHappy == nil || !*opts.UseHappy { + t.Error("expected UseHappy=true when config.Codex.UseHappy=true") + } - // Global yolo=false + // Global yolo=false, use_happy=false config2 := &UserConfig{ - Codex: CodexSettings{YoloMode: false}, + Codex: CodexSettings{YoloMode: false, UseHappy: false}, } opts2 := NewCodexOptions(config2) if opts2.YoloMode != nil { t.Errorf("expected YoloMode=nil when config.Codex.YoloMode=false, got %v", *opts2.YoloMode) } + if opts2.UseHappy != nil { + t.Errorf("expected UseHappy=nil when config.Codex.UseHappy=false, got %v", *opts2.UseHappy) + } } func TestNewCodexOptions_NilConfig(t *testing.T) { @@ -437,10 +454,13 @@ func TestNewCodexOptions_NilConfig(t *testing.T) { if opts.YoloMode != nil { t.Errorf("expected YoloMode=nil when config is nil, got %v", *opts.YoloMode) } + if opts.UseHappy != nil { + t.Errorf("expected UseHappy=nil when config is nil, got %v", *opts.UseHappy) + } } func TestCodexOptions_MarshalUnmarshal(t *testing.T) { - original := &CodexOptions{YoloMode: boolPtr(true)} + original := &CodexOptions{YoloMode: boolPtr(true), UseHappy: boolPtr(true)} data, err := MarshalToolOptions(original) if err != nil { @@ -455,6 +475,9 @@ func TestCodexOptions_MarshalUnmarshal(t *testing.T) { if restored.YoloMode == nil || !*restored.YoloMode { t.Error("expected YoloMode=true after roundtrip") } + if restored.UseHappy == nil || !*restored.UseHappy { + t.Error("expected UseHappy=true after roundtrip") + } } func TestUnmarshalCodexOptions_EmptyData(t *testing.T) { @@ -482,7 +505,7 @@ func TestUnmarshalCodexOptions_WrongTool(t *testing.T) { } func TestCodexOptions_RoundTrip_NilYolo(t *testing.T) { - original := &CodexOptions{YoloMode: nil} + original := &CodexOptions{YoloMode: nil, UseHappy: nil} data, err := MarshalToolOptions(original) if err != nil { @@ -497,6 +520,9 @@ func TestCodexOptions_RoundTrip_NilYolo(t *testing.T) { if restored.YoloMode != nil { t.Errorf("expected YoloMode=nil after roundtrip, got %v", *restored.YoloMode) } + if restored.UseHappy != nil { + t.Errorf("expected UseHappy=nil after roundtrip, got %v", *restored.UseHappy) + } } func TestClaudeOptions_RoundTrip_AllowSkipPermissions(t *testing.T) { diff --git a/internal/session/userconfig.go b/internal/session/userconfig.go index 6da112555..86589a767 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -528,6 +528,11 @@ type ClaudeSettings struct { // This allows using shell aliases that set CLAUDE_CONFIG_DIR automatically Command string `toml:"command"` + // UseHappy launches Claude via the happy wrapper by default. + // Ignored when Command is set to a custom alias or command. + // Default: false + UseHappy bool `toml:"use_happy"` + // ConfigDir is the path to Claude's config directory // Default: ~/.claude (or CLAUDE_CONFIG_DIR env var) ConfigDir string `toml:"config_dir"` @@ -622,6 +627,10 @@ type CodexSettings struct { // YoloMode enables --yolo flag for Codex sessions (bypass approvals and sandbox) // Default: false YoloMode bool `toml:"yolo_mode"` + + // UseHappy launches Codex via "happy codex" by default. + // Default: false + UseHappy bool `toml:"use_happy"` } // WorktreeSettings contains git worktree preferences. @@ -1648,6 +1657,8 @@ func CreateExampleConfig() error { # config_dir = "~/.claude-work" # Enable --dangerously-skip-permissions by default (default: false) # dangerous_mode = true +# Launch Claude via happy by default (default: false) +# use_happy = true # Gemini CLI integration # [gemini] @@ -1665,6 +1676,8 @@ func CreateExampleConfig() error { # [codex] # Enable --yolo (bypass approvals and sandbox) by default (default: false) # yolo_mode = true +# Launch Codex via happy by default (default: false) +# use_happy = true # Log file management # Agent-deck logs session output to ~/.agent-deck/logs/ for status detection diff --git a/internal/session/userconfig_test.go b/internal/session/userconfig_test.go index e1ab870fc..8298ab33f 100644 --- a/internal/session/userconfig_test.go +++ b/internal/session/userconfig_test.go @@ -14,6 +14,7 @@ func TestUserConfig_ClaudeConfigDir(t *testing.T) { configContent := ` [claude] config_dir = "~/.claude-work" +use_happy = true [tools.test] command = "test" @@ -33,6 +34,9 @@ command = "test" if config.Claude.ConfigDir != "~/.claude-work" { t.Errorf("Claude.ConfigDir = %s, want ~/.claude-work", config.Claude.ConfigDir) } + if !config.Claude.UseHappy { + t.Error("Claude.UseHappy should be true") + } } func TestUserConfig_ProfileClaudeConfigDir(t *testing.T) { @@ -93,6 +97,34 @@ command = "test" if config.Claude.ConfigDir != "" { t.Errorf("Claude.ConfigDir = %s, want empty string", config.Claude.ConfigDir) } + if config.Claude.UseHappy { + t.Error("Claude.UseHappy should default to false") + } +} + +func TestUserConfig_CodexUseHappy(t *testing.T) { + tmpDir := t.TempDir() + configContent := ` +[codex] +yolo_mode = true +use_happy = true +` + configPath := filepath.Join(tmpDir, "config.toml") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + var config UserConfig + if _, err := toml.DecodeFile(configPath, &config); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + if !config.Codex.YoloMode { + t.Error("Codex.YoloMode should be true") + } + if !config.Codex.UseHappy { + t.Error("Codex.UseHappy should be true") + } } func TestIsClaudeCommand(t *testing.T) { diff --git a/internal/ui/claudeoptions.go b/internal/ui/claudeoptions.go index 3ebcc8f40..42757c07b 100644 --- a/internal/ui/claudeoptions.go +++ b/internal/ui/claudeoptions.go @@ -15,6 +15,7 @@ type ClaudeOptionsPanel struct { // Resume session ID input (only for mode=resume) resumeIDInput textinput.Model // Checkbox states + useHappy bool skipPermissions bool allowSkipPermissions bool useChrome bool @@ -30,8 +31,10 @@ type ClaudeOptionsPanel struct { // Focus indices for NewDialog mode: // 0: Session mode (radio) // 1: Resume ID input (only when mode=resume) -// 2: Skip permissions checkbox -// 3: Chrome checkbox +// 2: Use happy checkbox +// 3: Skip permissions checkbox +// 4: Chrome checkbox +// 5: Teammate checkbox // Focus indices for ForkDialog mode: // 0: Skip permissions checkbox @@ -48,7 +51,7 @@ func NewClaudeOptionsPanel() *ClaudeOptionsPanel { sessionMode: 0, // new resumeIDInput: resumeInput, isForkMode: false, - focusCount: 5, // Will adjust dynamically + focusCount: 6, // Will adjust dynamically } } @@ -65,6 +68,7 @@ func NewClaudeOptionsPanelForFork() *ClaudeOptionsPanel { // SetDefaults applies default values from config func (p *ClaudeOptionsPanel) SetDefaults(config *session.UserConfig) { if config != nil { + p.useHappy = config.Claude.UseHappy p.skipPermissions = config.Claude.GetDangerousMode() p.allowSkipPermissions = config.Claude.AllowDangerousMode } @@ -84,6 +88,7 @@ func (p *ClaudeOptionsPanel) SetFromOptions(opts *session.ClaudeOptions) { default: p.sessionMode = 0 } + p.useHappy = opts.UseHappy p.skipPermissions = opts.SkipPermissions p.allowSkipPermissions = opts.AllowSkipPermissions p.useChrome = opts.UseChrome @@ -117,6 +122,7 @@ func (p *ClaudeOptionsPanel) AtTop() bool { // GetOptions returns current options as ClaudeOptions func (p *ClaudeOptionsPanel) GetOptions() *session.ClaudeOptions { opts := &session.ClaudeOptions{ + UseHappy: p.useHappy, SkipPermissions: p.skipPermissions, AllowSkipPermissions: p.allowSkipPermissions, UseChrome: p.useChrome, @@ -219,6 +225,8 @@ func (p *ClaudeOptionsPanel) handleSpaceKey() { case "sessionMode": // Cycle through modes on space p.sessionMode = (p.sessionMode + 1) % 3 + case "useHappy": + p.useHappy = !p.useHappy case "skipPermissions": p.skipPermissions = !p.skipPermissions case "chrome": @@ -253,16 +261,20 @@ func (p *ClaudeOptionsPanel) getFocusType() string { } idx-- // Adjust for missing resume input } - // 2: skip permissions + // 2: use happy if idx == 1 { - return "skipPermissions" + return "useHappy" } - // 3: chrome + // 3: skip permissions if idx == 2 { - return "chrome" + return "skipPermissions" } - // 4: teammate mode + // 4: chrome if idx == 3 { + return "chrome" + } + // 5: teammate mode + if idx == 4 { return "teammateMode" } } @@ -275,7 +287,7 @@ func (p *ClaudeOptionsPanel) getFocusCount() int { return 3 // skip, chrome, teammate } - count := 4 // session mode, skip, chrome, teammate + count := 5 // session mode, use happy, skip, chrome, teammate if p.sessionMode == 2 { count++ // resume input } @@ -351,6 +363,10 @@ func (p *ClaudeOptionsPanel) viewNewMode(labelStyle, activeStyle, dimStyle, head focusIdx++ } + // Use happy checkbox + content += renderCheckboxLine("Use happy wrapper", p.useHappy, p.focusIndex == focusIdx) + focusIdx++ + // Skip permissions checkbox content += renderCheckboxLine("Skip permissions", p.skipPermissions, p.focusIndex == focusIdx) focusIdx++ diff --git a/internal/ui/home.go b/internal/ui/home.go index 4caa513f7..156ec5bbe 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -4311,9 +4311,9 @@ func (h *Home) handleNewDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if command == "claude" && claudeOpts != nil { toolOptionsJSON, _ = session.MarshalToolOptions(claudeOpts) } else if command == "codex" { - yolo := h.newDialog.GetCodexYoloMode() - codexOpts := &session.CodexOptions{YoloMode: &yolo} - toolOptionsJSON, _ = session.MarshalToolOptions(codexOpts) + if codexOpts := h.newDialog.GetCodexOptions(); codexOpts != nil { + toolOptionsJSON, _ = session.MarshalToolOptions(codexOpts) + } } // Only non-worktree sessions may need interactive "create directory" confirmation. diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 31e8cd22f..6b65a39dd 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -97,6 +97,7 @@ type dialogSnapshot struct { claudeOptions *session.ClaudeOptions geminiYolo bool codexYolo bool + codexUseHappy bool multiRepoEnabled bool multiRepoPaths []string } @@ -181,8 +182,8 @@ func NewNewDialog() *NewDialog { commandInput: commandInput, branchInput: branchInput, claudeOptions: NewClaudeOptionsPanel(), - geminiOptions: NewYoloOptionsPanel("Gemini", "YOLO mode - auto-approve all"), - codexOptions: NewYoloOptionsPanel("Codex", "YOLO mode - bypass approvals and sandbox"), + geminiOptions: NewYoloOptionsPanel("Gemini", "YOLO mode - auto-approve all", false), + codexOptions: NewYoloOptionsPanel("Codex", "YOLO mode - bypass approvals and sandbox", true), focusIndex: 0, visible: false, presetCommands: buildPresetCommands(), @@ -241,10 +242,10 @@ func (d *NewDialog) ShowInGroup(groupPath, groupName, defaultPath string) { d.pathSoftSelected = true // activate soft-select for pre-filled path. // Initialize tool options from global config. d.geminiOptions.SetDefaults(false) - d.codexOptions.SetDefaults(false) + d.codexOptions.SetDefaults(false, false) if userConfig, err := session.LoadUserConfig(); err == nil && userConfig != nil { d.geminiOptions.SetDefaults(userConfig.Gemini.YoloMode) - d.codexOptions.SetDefaults(userConfig.Codex.YoloMode) + d.codexOptions.SetDefaults(userConfig.Codex.YoloMode, userConfig.Codex.UseHappy) d.claudeOptions.SetDefaults(userConfig) d.sandboxEnabled = userConfig.Docker.DefaultEnabled d.inheritedSettings = buildInheritedSettings(userConfig.Docker) @@ -326,6 +327,7 @@ func (d *NewDialog) saveSnapshot() *dialogSnapshot { claudeOptions: claudeOpts, geminiYolo: d.geminiOptions.GetYoloMode(), codexYolo: d.codexOptions.GetYoloMode(), + codexUseHappy: d.codexOptions.GetUseHappy(), multiRepoEnabled: d.multiRepoEnabled, multiRepoPaths: append([]string{}, d.multiRepoPaths...), } @@ -345,7 +347,7 @@ func (d *NewDialog) restoreSnapshot(s *dialogSnapshot) { d.claudeOptions.SetFromOptions(s.claudeOptions) } d.geminiOptions.SetDefaults(s.geminiYolo) - d.codexOptions.SetDefaults(s.codexYolo) + d.codexOptions.SetDefaults(s.codexYolo, s.codexUseHappy) d.multiRepoEnabled = s.multiRepoEnabled d.multiRepoPaths = append([]string{}, s.multiRepoPaths...) d.multiRepoPathCursor = 0 @@ -402,8 +404,18 @@ func (d *NewDialog) previewRecentSession(rs *statedb.RecentSessionRow) { var wrapper session.ToolOptionsWrapper if err := json.Unmarshal(rs.ToolOptions, &wrapper); err == nil && wrapper.Tool == "codex" { var opts session.CodexOptions - if err := json.Unmarshal(wrapper.Options, &opts); err == nil && opts.YoloMode != nil { - d.codexOptions.SetDefaults(*opts.YoloMode) + if err := json.Unmarshal(wrapper.Options, &opts); err == nil { + yoloMode := d.codexOptions.GetYoloMode() + if opts.YoloMode != nil { + yoloMode = *opts.YoloMode + } + useHappy := d.codexOptions.GetUseHappy() + if opts.UseHappy != nil { + useHappy = *opts.UseHappy + } + if opts.YoloMode != nil || opts.UseHappy != nil { + d.codexOptions.SetDefaults(yoloMode, useHappy) + } } } } @@ -530,6 +542,19 @@ func (d *NewDialog) GetCodexYoloMode() bool { return d.codexOptions.GetYoloMode() } +// GetCodexOptions returns the Codex-specific options (only relevant if command is "codex") +func (d *NewDialog) GetCodexOptions() *session.CodexOptions { + if d.GetSelectedCommand() != "codex" { + return nil + } + yoloMode := d.codexOptions.GetYoloMode() + useHappy := d.codexOptions.GetUseHappy() + return &session.CodexOptions{ + YoloMode: &yoloMode, + UseHappy: &useHappy, + } +} + // IsSandboxEnabled returns whether Docker sandbox mode is enabled. func (d *NewDialog) IsSandboxEnabled() bool { return d.sandboxEnabled diff --git a/internal/ui/newdialog_test.go b/internal/ui/newdialog_test.go index d1fb4b628..6431fe389 100644 --- a/internal/ui/newdialog_test.go +++ b/internal/ui/newdialog_test.go @@ -379,6 +379,7 @@ func TestNewDialog_RestoreSnapshot_RestoresToolOptionsAndCommandInput(t *testing originalClaude := &session.ClaudeOptions{ SessionMode: "resume", ResumeSessionID: "abc123", + UseHappy: true, SkipPermissions: true, AllowSkipPermissions: false, UseChrome: true, @@ -390,7 +391,7 @@ func TestNewDialog_RestoreSnapshot_RestoresToolOptionsAndCommandInput(t *testing d.commandInput.SetValue("echo original") d.claudeOptions.SetFromOptions(originalClaude) d.geminiOptions.SetDefaults(true) - d.codexOptions.SetDefaults(true) + d.codexOptions.SetDefaults(true, true) snapshot := d.saveSnapshot() @@ -401,7 +402,7 @@ func TestNewDialog_RestoreSnapshot_RestoresToolOptionsAndCommandInput(t *testing d.commandInput.SetValue("echo mutated") d.claudeOptions.SetFromOptions(&session.ClaudeOptions{SessionMode: "new"}) d.geminiOptions.SetDefaults(false) - d.codexOptions.SetDefaults(false) + d.codexOptions.SetDefaults(false, false) d.restoreSnapshot(snapshot) @@ -426,7 +427,7 @@ func TestNewDialog_RestoreSnapshot_RestoresToolOptionsAndCommandInput(t *testing t.Fatalf("restored Claude session mode/id = %q/%q, want resume/abc123", restoredClaude.SessionMode, restoredClaude.ResumeSessionID) } - if !restoredClaude.SkipPermissions || !restoredClaude.UseChrome || !restoredClaude.UseTeammateMode { + if !restoredClaude.UseHappy || !restoredClaude.SkipPermissions || !restoredClaude.UseChrome || !restoredClaude.UseTeammateMode { t.Fatalf("restored Claude toggles incorrect: %+v", restoredClaude) } if !d.geminiOptions.GetYoloMode() { @@ -435,6 +436,27 @@ func TestNewDialog_RestoreSnapshot_RestoresToolOptionsAndCommandInput(t *testing if !d.codexOptions.GetYoloMode() { t.Fatal("codex yolo mode was not restored") } + if !d.codexOptions.GetUseHappy() { + t.Fatal("codex happy mode was not restored") + } +} + +func TestNewDialog_GetCodexOptions(t *testing.T) { + d := NewNewDialog() + d.commandCursor = 4 // codex + d.updateToolOptions() + d.codexOptions.SetDefaults(true, true) + + opts := d.GetCodexOptions() + if opts == nil { + t.Fatal("GetCodexOptions returned nil") + } + if opts.YoloMode == nil || !*opts.YoloMode { + t.Fatalf("expected YoloMode=true, got %+v", opts) + } + if opts.UseHappy == nil || !*opts.UseHappy { + t.Fatalf("expected UseHappy=true, got %+v", opts) + } } // ===== Worktree Support Tests ===== diff --git a/internal/ui/settings_panel.go b/internal/ui/settings_panel.go index b47d7d3af..0a40dd8e9 100644 --- a/internal/ui/settings_panel.go +++ b/internal/ui/settings_panel.go @@ -20,7 +20,9 @@ const ( SettingDefaultTool SettingDangerousMode SettingClaudeConfigDir + SettingClaudeUseHappy SettingGeminiYoloMode + SettingCodexUseHappy SettingCodexYoloMode SettingCheckForUpdates SettingAutoUpdate @@ -36,7 +38,7 @@ const ( ) // Total number of navigable settings. -const settingsCount = 17 +const settingsCount = 19 // SettingsPanel displays and edits user configuration type SettingsPanel struct { @@ -57,7 +59,9 @@ type SettingsPanel struct { dangerousMode bool claudeConfigDir string claudeConfigIsScope bool // true = profile override, false = global [claude] + claudeUseHappy bool geminiYoloMode bool + codexUseHappy bool codexYoloMode bool checkForUpdates bool autoUpdate bool @@ -202,6 +206,7 @@ func (s *SettingsPanel) LoadConfig(config *session.UserConfig) { s.dangerousMode = config.Claude.GetDangerousMode() s.claudeConfigDir = config.Claude.ConfigDir s.claudeConfigIsScope = false + s.claudeUseHappy = config.Claude.UseHappy if s.profile != "" && config.Profiles != nil { if profileCfg, ok := config.Profiles[s.profile]; ok && profileCfg.Claude.ConfigDir != "" { s.claudeConfigDir = profileCfg.Claude.ConfigDir @@ -213,6 +218,7 @@ func (s *SettingsPanel) LoadConfig(config *session.UserConfig) { s.geminiYoloMode = config.Gemini.YoloMode // Codex settings + s.codexUseHappy = config.Codex.UseHappy s.codexYoloMode = config.Codex.YoloMode // Update settings @@ -290,6 +296,17 @@ func (s *SettingsPanel) GetConfig() *session.UserConfig { MCPs: make(map[string]session.MCPDef), } + if s.originalConfig != nil { + config.Claude = s.originalConfig.Claude + config.Gemini = s.originalConfig.Gemini + config.Codex = s.originalConfig.Codex + config.Updates = s.originalConfig.Updates + config.Logs = s.originalConfig.Logs + config.GlobalSearch = s.originalConfig.GlobalSearch + config.Preview = s.originalConfig.Preview + config.Maintenance = s.originalConfig.Maintenance + } + // Theme if s.selectedTheme < len(themeValues) { config.Theme = themeValues[s.selectedTheme] @@ -303,6 +320,7 @@ func (s *SettingsPanel) GetConfig() *session.UserConfig { // Claude settings dangerousModeVal := s.dangerousMode config.Claude.DangerousMode = &dangerousModeVal + config.Claude.UseHappy = s.claudeUseHappy if !s.claudeConfigIsScope { config.Claude.ConfigDir = s.claudeConfigDir } @@ -311,6 +329,7 @@ func (s *SettingsPanel) GetConfig() *session.UserConfig { config.Gemini.YoloMode = s.geminiYoloMode // Codex settings + config.Codex.UseHappy = s.codexUseHappy config.Codex.YoloMode = s.codexYoloMode // Update settings @@ -481,10 +500,18 @@ func (s *SettingsPanel) toggleValue() bool { s.dangerousMode = !s.dangerousMode return true + case SettingClaudeUseHappy: + s.claudeUseHappy = !s.claudeUseHappy + return true + case SettingGeminiYoloMode: s.geminiYoloMode = !s.geminiYoloMode return true + case SettingCodexUseHappy: + s.codexUseHappy = !s.codexUseHappy + return true + case SettingCodexYoloMode: s.codexYoloMode = !s.codexYoloMode return true @@ -664,6 +691,12 @@ func (s *SettingsPanel) View() string { if s.cursor == int(SettingClaudeConfigDir) { line = highlightStyle.Render(line) } + content.WriteString(" " + labelStyle.Render(line) + "\n") + + line = s.renderCheckbox("Use happy wrapper", s.claudeUseHappy) + " - Launch Claude via happy" + if s.cursor == int(SettingClaudeUseHappy) { + line = highlightStyle.Render(line) + } content.WriteString(" " + labelStyle.Render(line) + "\n\n") // GEMINI @@ -681,6 +714,12 @@ func (s *SettingsPanel) View() string { content.WriteString(sectionStyle.Render("CODEX")) content.WriteString("\n") + line = s.renderCheckbox("Use happy wrapper", s.codexUseHappy) + " - Launch Codex via happy" + if s.cursor == int(SettingCodexUseHappy) { + line = highlightStyle.Render(line) + } + content.WriteString(" " + labelStyle.Render(line) + "\n") + // YOLO mode checkbox line = s.renderCheckbox("YOLO mode", s.codexYoloMode) + " - Bypass approvals and sandbox" if s.cursor == int(SettingCodexYoloMode) { @@ -817,19 +856,21 @@ func (s *SettingsPanel) View() string { 7, // SettingDefaultTool 11, // SettingDangerousMode 12, // SettingClaudeConfigDir - 15, // SettingGeminiYoloMode - 18, // SettingCodexYoloMode - 21, // SettingCheckForUpdates - 22, // SettingAutoUpdate - 25, // SettingLogMaxSize - 25, // SettingLogMaxLines (shares line with LogMaxSize) - 26, // SettingRemoveOrphans - 29, // SettingGlobalSearchEnabled - 30, // SettingSearchTier - 31, // SettingRecentDays - 34, // SettingShowOutput - 35, // SettingShowAnalytics - 38, // SettingMaintenanceEnabled + 13, // SettingClaudeUseHappy + 16, // SettingGeminiYoloMode + 19, // SettingCodexUseHappy + 20, // SettingCodexYoloMode + 23, // SettingCheckForUpdates + 24, // SettingAutoUpdate + 27, // SettingLogMaxSize + 27, // SettingLogMaxLines (shares line with LogMaxSize) + 28, // SettingRemoveOrphans + 31, // SettingGlobalSearchEnabled + 32, // SettingSearchTier + 33, // SettingRecentDays + 36, // SettingShowOutput + 37, // SettingShowAnalytics + 40, // SettingMaintenanceEnabled } cursorLine := cursorToLine[s.cursor] diff --git a/internal/ui/settings_panel_test.go b/internal/ui/settings_panel_test.go index 0175f0192..be2eb3436 100644 --- a/internal/ui/settings_panel_test.go +++ b/internal/ui/settings_panel_test.go @@ -68,6 +68,11 @@ func TestSettingsPanel_LoadConfig(t *testing.T) { Claude: session.ClaudeSettings{ DangerousMode: &dangerousModeBool, ConfigDir: "~/.claude-work", + UseHappy: true, + }, + Codex: session.CodexSettings{ + UseHappy: true, + YoloMode: true, }, Updates: session.UpdateSettings{ CheckEnabled: false, @@ -96,6 +101,15 @@ func TestSettingsPanel_LoadConfig(t *testing.T) { if panel.claudeConfigDir != "~/.claude-work" { t.Errorf("claudeConfigDir: got %q, want %q", panel.claudeConfigDir, "~/.claude-work") } + if !panel.claudeUseHappy { + t.Error("claudeUseHappy should be true") + } + if !panel.codexUseHappy { + t.Error("codexUseHappy should be true") + } + if !panel.codexYoloMode { + t.Error("codexYoloMode should be true") + } if panel.checkForUpdates { t.Error("checkForUpdates should be false") } @@ -247,6 +261,9 @@ func TestSettingsPanel_GetConfig(t *testing.T) { panel.selectedTool = 2 // opencode panel.dangerousMode = true panel.claudeConfigDir = "~/.claude-custom" + panel.claudeUseHappy = true + panel.codexUseHappy = true + panel.codexYoloMode = true panel.checkForUpdates = false panel.autoUpdate = true panel.logMaxSizeMB = 15 @@ -267,6 +284,15 @@ func TestSettingsPanel_GetConfig(t *testing.T) { if config.Claude.ConfigDir != "~/.claude-custom" { t.Errorf("ConfigDir: got %q, want %q", config.Claude.ConfigDir, "~/.claude-custom") } + if !config.Claude.UseHappy { + t.Error("Claude.UseHappy should be true") + } + if !config.Codex.UseHappy { + t.Error("Codex.UseHappy should be true") + } + if !config.Codex.YoloMode { + t.Error("Codex.YoloMode should be true") + } if config.Updates.CheckEnabled { t.Error("CheckEnabled should be false") } @@ -486,6 +512,29 @@ func TestSettingsPanel_Update_ToggleCheckbox(t *testing.T) { } } +func TestSettingsPanel_Update_ToggleHappyCheckboxes(t *testing.T) { + panel := NewSettingsPanel() + panel.Show() + + panel.cursor = int(SettingClaudeUseHappy) + _, _, changed := panel.Update(tea.KeyMsg{Type: tea.KeySpace}) + if !changed { + t.Fatal("Claude use_happy toggle should report a change") + } + if !panel.claudeUseHappy { + t.Fatal("claudeUseHappy should toggle on") + } + + panel.cursor = int(SettingCodexUseHappy) + _, _, changed = panel.Update(tea.KeyMsg{Type: tea.KeySpace}) + if !changed { + t.Fatal("Codex use_happy toggle should report a change") + } + if !panel.codexUseHappy { + t.Fatal("codexUseHappy should toggle on") + } +} + func TestSettingsPanel_Update_RadioSelection(t *testing.T) { panel := NewSettingsPanel() panel.Show() @@ -632,6 +681,7 @@ func TestSettingsPanel_View_Visible(t *testing.T) { "Gemini", "CLAUDE", "Dangerous mode", + "Use happy wrapper", "UPDATES", "LOGS", "GLOBAL SEARCH", diff --git a/internal/ui/yolooptions.go b/internal/ui/yolooptions.go index fc20b3f9d..56e4e90bd 100644 --- a/internal/ui/yolooptions.go +++ b/internal/ui/yolooptions.go @@ -8,33 +8,44 @@ import ( // YoloOptionsPanel is a UI panel for YOLO/dangerous mode options. // Used for Gemini and Codex in NewDialog, matching ClaudeOptionsPanel's visual style. type YoloOptionsPanel struct { - toolName string // "Gemini" or "Codex" - label string // Checkbox label text - yoloMode bool - focused bool + toolName string // "Gemini" or "Codex" + label string // Checkbox label text + yoloMode bool + useHappy bool + showHappy bool + focusIndex int + focused bool } -// NewYoloOptionsPanel creates a new options panel for a tool with a single YOLO checkbox. -func NewYoloOptionsPanel(toolName, label string) *YoloOptionsPanel { +// NewYoloOptionsPanel creates a new options panel for a tool with a YOLO checkbox +// and an optional happy checkbox. +func NewYoloOptionsPanel(toolName, label string, showHappy bool) *YoloOptionsPanel { return &YoloOptionsPanel{ - toolName: toolName, - label: label, + toolName: toolName, + label: label, + showHappy: showHappy, } } // SetDefaults applies default value from config. -func (p *YoloOptionsPanel) SetDefaults(yoloMode bool) { +func (p *YoloOptionsPanel) SetDefaults(yoloMode bool, useHappy ...bool) { p.yoloMode = yoloMode + p.useHappy = false + if len(useHappy) > 0 { + p.useHappy = useHappy[0] + } } // Focus sets focus to this panel. func (p *YoloOptionsPanel) Focus() { p.focused = true + p.focusIndex = 0 } // Blur removes focus from this panel. func (p *YoloOptionsPanel) Blur() { p.focused = false + p.focusIndex = -1 } // IsFocused returns true if the panel has focus. @@ -47,9 +58,14 @@ func (p *YoloOptionsPanel) GetYoloMode() bool { return p.yoloMode } -// AtTop returns true (single element, always at top). +// GetUseHappy returns the current happy state. +func (p *YoloOptionsPanel) GetUseHappy() bool { + return p.useHappy +} + +// AtTop returns true when focus is on the first checkbox. func (p *YoloOptionsPanel) AtTop() bool { - return true + return p.focusIndex <= 0 } // Update handles key events. @@ -57,7 +73,24 @@ func (p *YoloOptionsPanel) Update(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case " ", "y": + case "down", "tab": + if p.showHappy && p.focusIndex < 1 { + p.focusIndex++ + } + return nil + case "up", "shift+tab": + if p.showHappy && p.focusIndex > 0 { + p.focusIndex-- + } + return nil + case " ": + if p.showHappy && p.focusIndex == 0 { + p.useHappy = !p.useHappy + return nil + } + p.yoloMode = !p.yoloMode + return nil + case "y": p.yoloMode = !p.yoloMode return nil } @@ -71,6 +104,13 @@ func (p *YoloOptionsPanel) View() string { var content string content += headerStyle.Render("─ "+p.toolName+" Options ─") + "\n" - content += renderCheckboxLine(p.label, p.yoloMode, p.focused) + if p.showHappy { + content += renderCheckboxLine("Use happy wrapper", p.useHappy, p.focused && p.focusIndex == 0) + } + yoloFocusIndex := 0 + if p.showHappy { + yoloFocusIndex = 1 + } + content += renderCheckboxLine(p.label, p.yoloMode, p.focused && p.focusIndex == yoloFocusIndex) return content } diff --git a/skills/agent-deck/references/config-reference.md b/skills/agent-deck/references/config-reference.md index c83cc703d..9ab275949 100644 --- a/skills/agent-deck/references/config-reference.md +++ b/skills/agent-deck/references/config-reference.md @@ -57,6 +57,7 @@ Claude Code integration settings. ```toml [claude] config_dir = "~/.claude" # Path to Claude config directory +use_happy = false # Launch Claude via happy dangerous_mode = true # Enable --dangerously-skip-permissions allow_dangerous_mode = false # Enable --allow-dangerously-skip-permissions env_file = "~/.claude.env" # .env file specific to Claude sessions @@ -69,6 +70,7 @@ config_dir = "~/.claude-work" # Optional override for profile "work" |-----|------|---------|-------------| | `config_dir` | string | `~/.claude` | Claude config directory. Override with `CLAUDE_CONFIG_DIR` env. | | `profiles..claude.config_dir` | string | none | Profile-specific Claude config directory. Takes precedence over `[claude].config_dir` when that profile is active. | +| `use_happy` | bool | `false` | Launch built-in Claude sessions via `happy`. Ignored when `[claude].command` is set to a custom alias/command. | | `dangerous_mode` | bool | `false` | Adds `--dangerously-skip-permissions`. Forces bypass on. Takes precedence over `allow_dangerous_mode`. | | `allow_dangerous_mode` | bool | `false` | Adds `--allow-dangerously-skip-permissions`. Unlocks bypass as an option without activating it. Ignored when `dangerous_mode` is true. | | `env_file` | string | `""` | A .env file sourced for Claude sessions only. Sourced after global `[shell].env_files`. See [Path Resolution](#path-resolution). | @@ -117,11 +119,13 @@ Codex CLI integration settings. ```toml [codex] yolo_mode = true # Enable --yolo (bypass approvals and sandbox) +use_happy = false # Launch Codex via happy codex ``` | Key | Type | Default | Description | |-----|------|---------|-------------| | `yolo_mode` | bool | `false` | Maps to `codex --yolo` (`--dangerously-bypass-approvals-and-sandbox`). Can be overridden per-session. | +| `use_happy` | bool | `false` | Launch built-in Codex sessions via `happy codex`. Can be overridden per-session. | ## [docker] Section @@ -412,6 +416,7 @@ ignore_missing_env_files = true [claude] config_dir = "~/.claude" +use_happy = false dangerous_mode = true env_file = "~/.claude.env" @@ -420,6 +425,7 @@ config_dir = "~/.claude-work" [codex] yolo_mode = false +use_happy = false [docker] default_enabled = false From 1f3dcf36c6f35c21ca7438a783d37f0b2b01006c Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 18 Mar 2026 09:00:53 -0600 Subject: [PATCH 07/10] fix(tmux): set extended-keys per-session to avoid breaking dashboard key input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extended-keys option was set server-wide (`set -sq`), which caused tmux to activate xterm modifyOtherKeys mode on the outer terminal (iTerm2, etc.). This persisted even after the tmux option was turned off, causing Ctrl+R and other modified keys to be sent as escape sequences that Bubble Tea cannot parse — breaking the recent sessions picker and other Ctrl-key shortcuts in the dashboard. Two fixes: - tmux.go: changed `set -sq extended-keys on` to per-session `set-option -t -q extended-keys on` at both call sites - keyboard_compat.go: also disable xterm modifyOtherKeys (ESC[>4;0m) on TUI startup alongside the existing Kitty protocol disable, as a defense-in-depth measure Fixes regression introduced in b427418 (#342). --- internal/tmux/tmux.go | 4 +- internal/ui/keyboard_compat.go | 25 ++++++++----- internal/ui/keyboard_compat_test.go | 8 ++-- internal/ui/newdialog_test.go | 58 +++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 256b5883b..3374e7c24 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1238,7 +1238,7 @@ func (s *Session) Start(command string) error { "set-option", "-t", s.Name, "set-clipboard", "on", ";", "set-option", "-t", s.Name, "history-limit", "10000", ";", "set-option", "-t", s.Name, "escape-time", "10", ";", - "set", "-sq", "extended-keys", "on").Run() + "set-option", "-t", s.Name, "-q", "extended-keys", "on").Run() // Idempotent: only append terminal-features if not already present ensureTerminalFeatures("hyperlinks", "extkeys") @@ -1479,7 +1479,7 @@ func (s *Session) EnableMouseMode() error { "set-option", "-t", s.Name, "-q", "allow-passthrough", "on", ";", "set-option", "-t", s.Name, "history-limit", "10000", ";", "set-option", "-t", s.Name, "escape-time", "10", ";", - "set", "-sq", "extended-keys", "on") + "set-option", "-t", s.Name, "-q", "extended-keys", "on") // Ignore errors - all these are non-fatal enhancements // Older tmux versions may not support some options _ = enhanceCmd.Run() diff --git a/internal/ui/keyboard_compat.go b/internal/ui/keyboard_compat.go index 79d765a94..68adc7fa6 100644 --- a/internal/ui/keyboard_compat.go +++ b/internal/ui/keyboard_compat.go @@ -25,19 +25,26 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// DisableKittyKeyboard writes the escape sequence that pushes keyboard mode 0 -// (legacy) on the Kitty keyboard protocol stack. After this call, Kitty-protocol- -// aware terminals stop sending CSI u sequences and revert to legacy key -// reporting. Terminals that do not support the protocol ignore the sequence. +// DisableKittyKeyboard writes escape sequences that disable extended keyboard +// protocols so that the terminal reverts to legacy key reporting: +// +// - Kitty keyboard protocol: ESC[>0u pushes mode 0 (legacy) on the stack. +// - xterm modifyOtherKeys: ESC[>4;0m disables modifyOtherKeys mode. +// +// Terminals that do not support a protocol ignore the corresponding sequence. +// Both must be disabled because tmux's "extended-keys on" option can activate +// modifyOtherKeys on the outer terminal, and it may persist even after the +// tmux option is turned off. func DisableKittyKeyboard(w io.Writer) { - _, _ = io.WriteString(w, "\x1b[>0u") + _, _ = io.WriteString(w, "\x1b[>0u") // Disable Kitty protocol + _, _ = io.WriteString(w, "\x1b[>4;0m") // Disable xterm modifyOtherKeys } -// RestoreKittyKeyboard writes the escape sequence that pops the keyboard mode -// stack, restoring the terminal to its previous keyboard mode. Call this when -// the TUI exits so that the terminal returns to normal operation. +// RestoreKittyKeyboard writes escape sequences that restore the terminal to +// its previous keyboard mode when the TUI exits. func RestoreKittyKeyboard(w io.Writer) { - _, _ = io.WriteString(w, "\x1b[4;1m") // Restore modifyOtherKeys mode 1 (default) } // ParseCSIu parses a Kitty keyboard protocol (CSI u) escape sequence and diff --git a/internal/ui/keyboard_compat_test.go b/internal/ui/keyboard_compat_test.go index dd65acbc5..0e6f55c8e 100644 --- a/internal/ui/keyboard_compat_test.go +++ b/internal/ui/keyboard_compat_test.go @@ -104,23 +104,23 @@ func TestParseCSIuCtrlA(t *testing.T) { } } -// TestDisableKittyKeyboard tests that DisableKittyKeyboard writes the correct escape sequence. +// TestDisableKittyKeyboard tests that DisableKittyKeyboard writes the correct escape sequences. func TestDisableKittyKeyboard(t *testing.T) { var buf bytes.Buffer DisableKittyKeyboard(&buf) got := buf.String() - want := "\x1b[>0u" + want := "\x1b[>0u\x1b[>4;0m" if got != want { t.Errorf("DisableKittyKeyboard wrote %q, want %q", got, want) } } -// TestRestoreKittyKeyboard tests that RestoreKittyKeyboard writes the correct escape sequence. +// TestRestoreKittyKeyboard tests that RestoreKittyKeyboard writes the correct escape sequences. func TestRestoreKittyKeyboard(t *testing.T) { var buf bytes.Buffer RestoreKittyKeyboard(&buf) got := buf.String() - want := "\x1b[ Date: Wed, 18 Mar 2026 22:18:45 +0700 Subject: [PATCH 08/10] fix: move cost dashboard from $ to C key, restore $ for error filter (#374) --- internal/ui/help.go | 2 +- internal/ui/home.go | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/ui/help.go b/internal/ui/help.go index c8dedc8ac..471b07deb 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -184,7 +184,7 @@ func (h *HelpOverlay) View() string { {moveKey, "Move to group"}, {mcpKey, "MCP Manager (Claude/Gemini)"}, {skillsKey, "Skills Manager (Claude)"}, - {"$", "Cost Dashboard"}, + {"C", "Cost Dashboard"}, {previewKey, "Toggle preview mode (output/stats/both)"}, {unreadKey, "Mark unread"}, {reorderKeys, "Reorder up/down"}, diff --git a/internal/ui/home.go b/internal/ui/home.go index 1c28ce72b..e350557eb 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -5532,13 +5532,7 @@ func (h *Home) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return h, nil case "$", "shift+4": - // Cost dashboard (when cost tracking is active), otherwise filter to error sessions - if h.costStore != nil { - h.showCostDashboard = true - h.costDashboard = newCostDashboard(h.costStore, h.width, h.height) - return h, nil - } - // Fallback: filter to error sessions only + // Filter to error sessions only if h.statusFilter == session.StatusError { h.statusFilter = "" // Toggle off } else { @@ -5546,6 +5540,14 @@ func (h *Home) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } h.rebuildFlatItems() return h, nil + + case "C": + // Cost dashboard + if h.costStore != nil { + h.showCostDashboard = true + h.costDashboard = newCostDashboard(h.costStore, h.width, h.height) + return h, nil + } } return h, nil From ea1521c1168069de2994f2a4e5dfb83e312f6e24 Mon Sep 17 00:00:00 2001 From: Clarity-89 Date: Wed, 25 Mar 2026 08:49:54 +0200 Subject: [PATCH 09/10] feat(worktree): add setup script support for post-creation automation Add support for a convention-based setup script at .agent-deck/worktree-setup.sh that runs automatically after worktree creation. This allows repos to copy gitignored config files (.env, .mcp.json, etc.) into new worktrees without manual intervention. - FindWorktreeSetupScript discovers the script in the source repo - RunWorktreeSetupScript executes it with AGENT_DECK_REPO_ROOT and AGENT_DECK_WORKTREE_PATH env vars, sh -e, 60s timeout - CreateWorktreeWithSetup wraps CreateWorktree with setup script execution (non-fatal on failure) - All 4 worktree creation call sites updated (CLI + TUI) - CLI paths stream stdout/stderr in real-time for progress feedback - TUI paths buffer output and log on failure --- README.md | 17 +++ cmd/agent-deck/launch_cmd.go | 6 +- cmd/agent-deck/main.go | 6 +- cmd/agent-deck/session_cmd.go | 6 +- internal/git/setup.go | 70 +++++++++ internal/git/setup_test.go | 267 ++++++++++++++++++++++++++++++++++ internal/ui/home.go | 15 +- 7 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 internal/git/setup.go create mode 100644 internal/git/setup_test.go diff --git a/README.md b/README.md index f1e035413..c817470ab 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,23 @@ default_location = "subdirectory" # "sibling" (default), "subdirectory", or a c `sibling` creates worktrees next to the repo (`repo-branch`). `subdirectory` creates them inside it (`repo/.worktrees/branch`). A custom path like `~/worktrees` or `/tmp/worktrees` creates repo-namespaced worktrees at `//`. The `--location` flag overrides the config per session. +#### Worktree Setup Script + +Gitignored files (`.env`, `.mcp.json`, etc.) aren't copied into new worktrees. To automate this, create a setup script at `.agent-deck/worktree-setup.sh` in your repo. Agent-deck runs it automatically after creating a worktree. + +```sh +#!/bin/sh +for f in .env .env.local .mcp.json; do + [ -f "$AGENT_DECK_REPO_ROOT/$f" ] && cp "$AGENT_DECK_REPO_ROOT/$f" "$AGENT_DECK_WORKTREE_PATH/$f" +done +``` + +The script receives two environment variables: +- `AGENT_DECK_REPO_ROOT` — path to the main repository +- `AGENT_DECK_WORKTREE_PATH` — path to the new worktree + +The script runs via `sh -e` with a 60-second timeout. If it fails, the worktree is still created — you'll see a warning but the session proceeds normally. + ### Docker Sandbox Run sessions inside isolated Docker containers. The project directory is bind-mounted read-write, so agents work on your code while the rest of the system stays protected. diff --git a/cmd/agent-deck/launch_cmd.go b/cmd/agent-deck/launch_cmd.go index 13d58b746..40d5894c3 100644 --- a/cmd/agent-deck/launch_cmd.go +++ b/cmd/agent-deck/launch_cmd.go @@ -188,10 +188,14 @@ func handleLaunch(profile string, args []string) { os.Exit(1) } - if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil { + setupErr, err := git.CreateWorktreeWithSetup(repoRoot, worktreePath, wtBranch, os.Stdout, os.Stderr) + if err != nil { out.Error(fmt.Sprintf("failed to create worktree: %v", err), ErrCodeInvalidOperation) os.Exit(1) } + if setupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: worktree setup script failed: %v\n", setupErr) + } } worktreeRepoRoot = repoRoot diff --git a/cmd/agent-deck/main.go b/cmd/agent-deck/main.go index e993c5504..c55833c64 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -1064,7 +1064,8 @@ func handleAdd(profile string, args []string) { // Create worktree atomically (git handles existence checks). // This avoids a TOCTOU race from separate check-then-create steps. - if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil { + setupErr, err := git.CreateWorktreeWithSetup(repoRoot, worktreePath, wtBranch, os.Stdout, os.Stderr) + if err != nil { if isWorktreeAlreadyExistsError(err) { fmt.Fprintf(os.Stderr, "Error: worktree already exists at %s\n", worktreePath) fmt.Fprintf(os.Stderr, "Tip: Use 'agent-deck add %s' to add the existing worktree\n", worktreePath) @@ -1073,6 +1074,9 @@ func handleAdd(profile string, args []string) { fmt.Fprintf(os.Stderr, "Error: failed to create worktree: %v\n", err) os.Exit(1) } + if setupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: worktree setup script failed: %v\n", setupErr) + } fmt.Printf("Created worktree at: %s\n", worktreePath) } diff --git a/cmd/agent-deck/session_cmd.go b/cmd/agent-deck/session_cmd.go index b76e28be8..466fa2b87 100644 --- a/cmd/agent-deck/session_cmd.go +++ b/cmd/agent-deck/session_cmd.go @@ -516,10 +516,14 @@ func handleSessionFork(profile string, args []string) { os.Exit(1) } - if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil { + setupErr, err := git.CreateWorktreeWithSetup(repoRoot, worktreePath, wtBranch, os.Stdout, os.Stderr) + if err != nil { out.Error(fmt.Sprintf("worktree creation failed: %v", err), ErrCodeInvalidOperation) os.Exit(1) } + if setupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: worktree setup script failed: %v\n", setupErr) + } } userConfig, _ := session.LoadUserConfig() diff --git a/internal/git/setup.go b/internal/git/setup.go new file mode 100644 index 000000000..12d2a0979 --- /dev/null +++ b/internal/git/setup.go @@ -0,0 +1,70 @@ +package git + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" +) + +// FindWorktreeSetupScript returns the path to the worktree setup script +// if one exists at /.agent-deck/worktree-setup.sh, or empty string. +func FindWorktreeSetupScript(repoDir string) string { + p := filepath.Join(repoDir, ".agent-deck", "worktree-setup.sh") + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// worktreeSetupTimeout is the maximum time a setup script is allowed to run. +var worktreeSetupTimeout = 60 * time.Second + +// RunWorktreeSetupScript executes the setup script with AGENT_DECK_REPO_ROOT +// and AGENT_DECK_WORKTREE_PATH environment variables set. Working directory +// is set to worktreePath. Output is streamed to the provided writers. +func RunWorktreeSetupScript(scriptPath, repoDir, worktreePath string, stdout, stderr io.Writer) error { + ctx, cancel := context.WithTimeout(context.Background(), worktreeSetupTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-e", scriptPath) + cmd.Dir = worktreePath + cmd.Env = append(os.Environ(), + "AGENT_DECK_REPO_ROOT="+repoDir, + "AGENT_DECK_WORKTREE_PATH="+worktreePath, + ) + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.WaitDelay = 5 * time.Second + + err := cmd.Run() + + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("worktree setup script timed out after %s", worktreeSetupTimeout) + } + if err != nil { + return fmt.Errorf("worktree setup script failed: %w", err) + } + return nil +} + +// CreateWorktreeWithSetup creates a worktree and runs the setup script if present. +// Setup script failure is non-fatal: the worktree is still valid. +// Output is streamed to the provided writers. +func CreateWorktreeWithSetup(repoDir, worktreePath, branchName string, stdout, stderr io.Writer) (setupErr error, err error) { + if err = CreateWorktree(repoDir, worktreePath, branchName); err != nil { + return nil, err + } + + scriptPath := FindWorktreeSetupScript(repoDir) + if scriptPath == "" { + return nil, nil + } + + fmt.Fprintln(stderr, "Running worktree setup script...") + setupErr = RunWorktreeSetupScript(scriptPath, repoDir, worktreePath, stdout, stderr) + return setupErr, nil +} diff --git a/internal/git/setup_test.go b/internal/git/setup_test.go new file mode 100644 index 000000000..d9d199bac --- /dev/null +++ b/internal/git/setup_test.go @@ -0,0 +1,267 @@ +package git + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestFindWorktreeSetupScript_NotPresent(t *testing.T) { + dir := t.TempDir() + + result := FindWorktreeSetupScript(dir) + if result != "" { + t.Errorf("expected empty string, got %q", result) + } +} + +func TestFindWorktreeSetupScript_Present(t *testing.T) { + dir := t.TempDir() + + // Create .agent-deck/worktree-setup.sh + scriptDir := filepath.Join(dir, ".agent-deck") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + scriptPath := filepath.Join(scriptDir, "worktree-setup.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + result := FindWorktreeSetupScript(dir) + if result != scriptPath { + t.Errorf("expected %q, got %q", scriptPath, result) + } +} + +func TestRunWorktreeSetupScript_Success(t *testing.T) { + repoDir := t.TempDir() + worktreeDir := t.TempDir() + + // Create a file in repoDir that the script will copy + testFile := filepath.Join(repoDir, ".mcp.json") + if err := os.WriteFile(testFile, []byte(`{"test": true}`), 0o644); err != nil { + t.Fatal(err) + } + + // Script copies .mcp.json using env vars + script := `#!/bin/sh +cp "$AGENT_DECK_REPO_ROOT/.mcp.json" "$AGENT_DECK_WORKTREE_PATH/.mcp.json" +echo "copying done" +` + scriptPath := filepath.Join(t.TempDir(), "setup.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + err := RunWorktreeSetupScript(scriptPath, repoDir, worktreeDir, &stdout, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v (stderr: %s)", err, stderr.String()) + } + + // Verify file was copied + copied, err := os.ReadFile(filepath.Join(worktreeDir, ".mcp.json")) + if err != nil { + t.Fatalf("expected .mcp.json to be copied: %v", err) + } + if string(copied) != `{"test": true}` { + t.Errorf("unexpected content: %s", copied) + } + + // Verify output was streamed to stdout + if !strings.Contains(stdout.String(), "copying done") { + t.Errorf("expected stdout to contain 'copying done', got %q", stdout.String()) + } +} + +func TestRunWorktreeSetupScript_Failure(t *testing.T) { + worktreeDir := t.TempDir() + + script := `#!/bin/sh +echo "something went wrong" >&2 +exit 1 +` + scriptPath := filepath.Join(t.TempDir(), "setup.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + err := RunWorktreeSetupScript(scriptPath, t.TempDir(), worktreeDir, &stdout, &stderr) + if err == nil { + t.Fatal("expected error from failing script") + } + if !strings.Contains(stderr.String(), "something went wrong") { + t.Errorf("expected stderr to contain error message, got: %q", stderr.String()) + } +} + +func TestRunWorktreeSetupScript_Timeout(t *testing.T) { + worktreeDir := t.TempDir() + + // Override timeout to 1s for test speed + orig := worktreeSetupTimeout + worktreeSetupTimeout = 1 * time.Second + t.Cleanup(func() { worktreeSetupTimeout = orig }) + + script := `#!/bin/sh +sleep 300 +` + scriptPath := filepath.Join(t.TempDir(), "setup.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + err := RunWorktreeSetupScript(scriptPath, t.TempDir(), worktreeDir, &stdout, &stderr) + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("expected timeout error, got: %v", err) + } +} + +// createTestRepoForSetup creates a git repo with an initial commit. +// Uses the same pattern as createTestRepo in git_test.go but avoids +// name collision since both are in the same package. +func createTestRepoForSetup(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@test.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git %s failed: %v", args[0], err) + } + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test"), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "add", ".") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + cmd = exec.Command("git", "commit", "-m", "init") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatal(err) + } +} + +func TestCreateWorktreeWithSetup_NoScript(t *testing.T) { + dir := t.TempDir() + createTestRepoForSetup(t, dir) + worktreePath := filepath.Join(dir, ".worktrees", "test-branch") + + var stdout, stderr bytes.Buffer + setupErr, err := CreateWorktreeWithSetup(dir, worktreePath, "test-branch", &stdout, &stderr) + if err != nil { + t.Fatalf("worktree creation failed: %v", err) + } + if setupErr != nil { + t.Errorf("unexpected setup error: %v", setupErr) + } + if stdout.Len() != 0 { + t.Errorf("expected no output, got %q", stdout.String()) + } + + // Verify worktree was created + if _, err := os.Stat(filepath.Join(worktreePath, "README.md")); err != nil { + t.Error("worktree directory should contain README.md") + } +} + +func TestCreateWorktreeWithSetup_WithScript(t *testing.T) { + dir := t.TempDir() + createTestRepoForSetup(t, dir) + + // Create a config file to copy + if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{"ok":true}`), 0o644); err != nil { + t.Fatal(err) + } + + // Create setup script + scriptDir := filepath.Join(dir, ".agent-deck") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + script := `#!/bin/sh +cp "$AGENT_DECK_REPO_ROOT/.mcp.json" "$AGENT_DECK_WORKTREE_PATH/.mcp.json" +echo "setup done" +` + if err := os.WriteFile(filepath.Join(scriptDir, "worktree-setup.sh"), []byte(script), 0o644); err != nil { + t.Fatal(err) + } + + worktreePath := filepath.Join(dir, ".worktrees", "setup-branch") + var stdout, stderr bytes.Buffer + setupErr, err := CreateWorktreeWithSetup(dir, worktreePath, "setup-branch", &stdout, &stderr) + if err != nil { + t.Fatalf("worktree creation failed: %v", err) + } + if setupErr != nil { + t.Errorf("unexpected setup error: %v", setupErr) + } + if !strings.Contains(stdout.String(), "setup done") { + t.Errorf("expected stdout to contain 'setup done', got %q", stdout.String()) + } + + // Verify file was copied + data, err := os.ReadFile(filepath.Join(worktreePath, ".mcp.json")) + if err != nil { + t.Fatalf("expected .mcp.json to be copied: %v", err) + } + if string(data) != `{"ok":true}` { + t.Errorf("unexpected content: %s", data) + } +} + +func TestCreateWorktreeWithSetup_SetupFails(t *testing.T) { + dir := t.TempDir() + createTestRepoForSetup(t, dir) + + // Create a failing setup script + scriptDir := filepath.Join(dir, ".agent-deck") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + script := `#!/bin/sh +echo "fail" >&2 +exit 1 +` + if err := os.WriteFile(filepath.Join(scriptDir, "worktree-setup.sh"), []byte(script), 0o644); err != nil { + t.Fatal(err) + } + + worktreePath := filepath.Join(dir, ".worktrees", "fail-branch") + var stdout, stderr bytes.Buffer + setupErr, err := CreateWorktreeWithSetup(dir, worktreePath, "fail-branch", &stdout, &stderr) + + // Worktree creation should succeed + if err != nil { + t.Fatalf("worktree creation should succeed: %v", err) + } + + // Setup should fail (non-fatal) + if setupErr == nil { + t.Error("expected setup error from failing script") + } + if !strings.Contains(stderr.String(), "fail") { + t.Errorf("expected stderr to contain 'fail', got %q", stderr.String()) + } + + // Worktree should still be valid + if _, err := os.Stat(filepath.Join(worktreePath, "README.md")); err != nil { + t.Error("worktree should still exist after setup failure") + } +} diff --git a/internal/ui/home.go b/internal/ui/home.go index e350557eb..72a3eab4a 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -1,6 +1,7 @@ package ui import ( + "bytes" "context" "encoding/json" "fmt" @@ -6310,9 +6311,14 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { return sessionCreatedMsg{err: fmt.Errorf("failed to create parent directory: %w", err)} } - if err := git.CreateWorktree(worktreeRepoRoot, worktreePath, worktreeBranch); err != nil { + var setupBuf bytes.Buffer + setupErr, err := git.CreateWorktreeWithSetup(worktreeRepoRoot, worktreePath, worktreeBranch, &setupBuf, &setupBuf) + if err != nil { return sessionCreatedMsg{err: fmt.Errorf("failed to create worktree: %w", err)} } + if setupErr != nil { + uiLog.Warn("worktree_setup_script_failed", slog.String("error", setupErr.Error()), slog.String("output", setupBuf.String())) + } } path = worktreePath } @@ -6682,9 +6688,14 @@ func (h *Home) forkSessionCmdWithOptions( if err := os.MkdirAll(filepath.Dir(opts.WorktreePath), 0o755); err != nil { return sessionForkedMsg{err: fmt.Errorf("failed to create directory: %w", err), sourceID: sourceID} } - if err := git.CreateWorktree(opts.WorktreeRepoRoot, opts.WorktreePath, opts.WorktreeBranch); err != nil { + var setupBuf bytes.Buffer + setupErr, err := git.CreateWorktreeWithSetup(opts.WorktreeRepoRoot, opts.WorktreePath, opts.WorktreeBranch, &setupBuf, &setupBuf) + if err != nil { return sessionForkedMsg{err: fmt.Errorf("worktree creation failed: %w", err), sourceID: sourceID} } + if setupErr != nil { + uiLog.Warn("worktree_setup_script_failed", slog.String("error", setupErr.Error()), slog.String("output", setupBuf.String())) + } } } From be40f314462ca46b09e84b53be7ae74c4f36fe1a Mon Sep 17 00:00:00 2001 From: Clarity-89 Date: Tue, 24 Mar 2026 16:45:43 +0200 Subject: [PATCH 10/10] feat(ui): show immediate placeholder when creating worktree sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the TUI showed no feedback while a worktree + setup script was running — the session only appeared after completion. Now a lightweight "creating" placeholder appears immediately in the session list with a spinner animation and dedicated preview panel. - Add CreatingSession struct and creatingSessions map (UI-only state, excluded from save/polling/search per Codex review feedback) - Add tempID to sessionCreatedMsg for placeholder lifecycle tracking - Add renderCreatingSessionItem for list rows and renderCreatingPreview for the preview panel - Guard actions (move, attach, etc.) against nil Session on placeholders - Export GenerateID from session package --- internal/session/discovery.go | 2 +- internal/session/groups.go | 3 + internal/session/instance.go | 7 +- internal/session/session_test.go | 4 +- internal/ui/home.go | 213 +++++++++++++++++++++++++++++-- 5 files changed, 211 insertions(+), 18 deletions(-) diff --git a/internal/session/discovery.go b/internal/session/discovery.go index bd71d80da..72566bf25 100644 --- a/internal/session/discovery.go +++ b/internal/session/discovery.go @@ -67,7 +67,7 @@ func DiscoverExistingTmuxSessions(existingInstances []*Instance) ([]*Instance, e } inst := &Instance{ - ID: generateID(), + ID: GenerateID(), Title: title, ProjectPath: projectPath, GroupPath: groupPath, diff --git a/internal/session/groups.go b/internal/session/groups.go index ada99671a..60dde34b3 100644 --- a/internal/session/groups.go +++ b/internal/session/groups.go @@ -47,6 +47,9 @@ type Item struct { WindowName string // Tmux window name (for ItemTypeWindow) WindowSessionID string // Parent session ID (for ItemTypeWindow) WindowTool string // Detected tool in this window (claude, gemini, etc.) + CreatingID string // Non-empty for placeholder items (worktree creation in progress) + CreatingTitle string // Display title for creating placeholder + CreatingTool string // Tool for creating placeholder } // Group represents a group of sessions diff --git a/internal/session/instance.go b/internal/session/instance.go index 30c9fc6a5..1ab3799d6 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -388,7 +388,7 @@ func (inst *Instance) ClearParent() { // NewInstance creates a new session instance func NewInstance(title, projectPath string) *Instance { - id := generateID() + id := GenerateID() tmuxSess := tmux.NewSession(title, projectPath) tmuxSess.InstanceID = id // Pass instance ID for activity hooks tmuxSess.SetInjectStatusLine(GetTmuxSettings().GetInjectStatusLine()) @@ -414,7 +414,7 @@ func NewInstanceWithGroup(title, projectPath, groupPath string) *Instance { // NewInstanceWithTool creates a new session with tool-specific initialization func NewInstanceWithTool(title, projectPath, tool string) *Instance { - id := generateID() + id := GenerateID() tmuxSess := tmux.NewSession(title, projectPath) tmuxSess.InstanceID = id // Pass instance ID for activity hooks tmuxSess.SetInjectStatusLine(GetTmuxSettings().GetInjectStatusLine()) @@ -5219,7 +5219,8 @@ func generateUUID() string { } // generateID generates a unique session ID -func generateID() string { +// GenerateID creates a unique session identifier. +func GenerateID() string { return fmt.Sprintf("%s-%d", randomString(8), time.Now().Unix()) } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 8aa669817..de147e5e5 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -26,8 +26,8 @@ func TestNewInstance(t *testing.T) { } func TestGenerateID(t *testing.T) { - id1 := generateID() - id2 := generateID() + id1 := GenerateID() + id2 := GenerateID() if id1 == "" || id2 == "" { t.Error("generateID should not return empty string") diff --git a/internal/ui/home.go b/internal/ui/home.go index 72a3eab4a..0a6017d33 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -45,6 +45,27 @@ func SetVersion(v string) { Version = v } +// CreatingSession is a lightweight placeholder shown in the UI while +// a worktree + session is being created asynchronously. +// It is NOT a real session.Instance — it is excluded from save, polling, and search. +type CreatingSession struct { + ID string // Temporary ID for tracking + Title string + Tool string + GroupPath string + StartTime time.Time +} + +// isCreatingPlaceholder returns true if the currently selected flat item is a +// worktree-creation placeholder (not a real session). Actions like attach, +// delete, fork, and restart must be suppressed for these items. +func (h *Home) isCreatingPlaceholder() bool { + if h.cursor < 0 || h.cursor >= len(h.flatItems) { + return false + } + return h.flatItems[h.cursor].CreatingID != "" +} + // Structured loggers for UI components var ( uiLog = logging.ForComponent(logging.CompUI) @@ -289,8 +310,9 @@ type Home struct { launchingSessions map[string]time.Time // sessionID -> creation time resumingSessions map[string]time.Time // sessionID -> resume time (for restart/resume) mcpLoadingSessions map[string]time.Time // sessionID -> MCP reload time - forkingSessions map[string]time.Time // sessionID -> fork start time (fork in progress) - animationFrame int // Current frame for spinner animation + forkingSessions map[string]time.Time // sessionID -> fork start time (fork in progress) + creatingSessions map[string]*CreatingSession // tempID -> placeholder for worktree creation in progress + animationFrame int // Current frame for spinner animation // Context for cleanup ctx context.Context @@ -477,6 +499,7 @@ type loadSessionsMsg struct { type sessionCreatedMsg struct { instance *session.Instance err error + tempID string // matches creatingSessions key for placeholder removal } type sessionForkedMsg struct { @@ -680,6 +703,7 @@ func NewHomeWithProfileAndMode(profile string) *Home { resumingSessions: make(map[string]time.Time), mcpLoadingSessions: make(map[string]time.Time), forkingSessions: make(map[string]time.Time), + creatingSessions: make(map[string]*CreatingSession), lastLogActivity: make(map[string]time.Time), windowsCollapsed: make(map[string]bool), worktreeDirtyCache: make(map[string]bool), @@ -1240,6 +1264,41 @@ func (h *Home) rebuildFlatItems() { // Invalidate mouse double-click tracking (item indices may have shifted) h.lastClickIndex = -1 + // Inject creating session placeholders (worktree creation in progress) + for _, creating := range h.creatingSessions { + item := session.Item{ + Type: session.ItemTypeSession, + Level: 1, + Path: creating.GroupPath, + CreatingID: creating.ID, + CreatingTitle: creating.Title, + CreatingTool: creating.Tool, + } + // Insert at the appropriate group position + inserted := false + if creating.GroupPath != "" { + for i := len(h.flatItems) - 1; i >= 0; i-- { + fi := h.flatItems[i] + if fi.Type == session.ItemTypeGroup && fi.Path == creating.GroupPath { + // Insert after the group header + h.flatItems = append(h.flatItems[:i+1], append([]session.Item{item}, h.flatItems[i+1:]...)...) + inserted = true + break + } + if fi.Path == creating.GroupPath && (fi.Type == session.ItemTypeSession || fi.CreatingID != "") { + // Insert after the last session in this group + h.flatItems = append(h.flatItems[:i+1], append([]session.Item{item}, h.flatItems[i+1:]...)...) + inserted = true + break + } + } + } + if !inserted { + // No group found or no group — append at end (before remotes) + h.flatItems = append(h.flatItems, item) + } + } + // Ensure cursor is valid if h.cursor >= len(h.flatItems) { h.cursor = len(h.flatItems) - 1 @@ -3003,6 +3062,11 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return h, nil case sessionCreatedMsg: + // Remove the creating placeholder (if any) — always, on success or error + if msg.tempID != "" { + delete(h.creatingSessions, msg.tempID) + } + // Handle reload scenario: session was already started in tmux, we MUST save it to JSON // even during reload, otherwise the session becomes orphaned (exists in tmux but not in storage) h.reloadMu.Lock() @@ -3025,6 +3089,9 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { h.setError(msg.err) + if msg.tempID != "" { + h.rebuildFlatItems() // Remove placeholder from list + } } else { h.instancesMu.Lock() h.instances = append(h.instances, msg.instance) @@ -4352,6 +4419,28 @@ func (h *Home) handleNewDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { additionalPaths = multiRepoPaths[1:] } + // Show immediate placeholder in UI while worktree + session is created async + var tempID string + if worktreeEnabled && branchName != "" { + tempID = session.GenerateID() + h.creatingSessions[tempID] = &CreatingSession{ + ID: tempID, + Title: name, + Tool: command, + GroupPath: groupPath, + StartTime: time.Now(), + } + h.rebuildFlatItems() + // Auto-select the placeholder + for i, item := range h.flatItems { + if item.CreatingID == tempID { + h.cursor = i + h.syncViewport() + break + } + } + } + return h, h.createSessionInGroupWithWorktreeAndOptions( name, path, @@ -4365,6 +4454,7 @@ func (h *Home) handleNewDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { toolOptionsJSON, multiRepoEnabled, additionalPaths, + tempID, ) case "esc": @@ -4940,7 +5030,9 @@ func (h *Home) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case session.ItemTypeGroup: h.groupTree.MoveGroupUp(item.Path) case session.ItemTypeSession: - h.groupTree.MoveSessionUp(item.Session) + if item.Session != nil { + h.groupTree.MoveSessionUp(item.Session) + } } h.rebuildFlatItems() if h.cursor > 0 { @@ -4958,7 +5050,9 @@ func (h *Home) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case session.ItemTypeGroup: h.groupTree.MoveGroupDown(item.Path) case session.ItemTypeSession: - h.groupTree.MoveSessionDown(item.Session) + if item.Session != nil { + h.groupTree.MoveSessionDown(item.Session) + } } h.rebuildFlatItems() if h.cursor < len(h.flatItems)-1 { @@ -5596,6 +5690,7 @@ func (h *Home) handleConfirmDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { pendingToolOpts, false, nil, + "", // no placeholder — non-worktree sessions are fast ) case "n", "N", "esc": h.confirmDialog.Hide() @@ -6293,11 +6388,12 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( toolOptionsJSON json.RawMessage, multiRepoEnabled bool, additionalPaths []string, + tempID string, ) tea.Cmd { return func() tea.Msg { // Check tmux availability before creating session if err := tmux.IsTmuxAvailable(); err != nil { - return sessionCreatedMsg{err: fmt.Errorf("cannot create session: %w", err)} + return sessionCreatedMsg{err: fmt.Errorf("cannot create session: %w", err), tempID: tempID} } if worktreePath != "" && worktreeRepoRoot != "" && worktreeBranch != "" && !multiRepoEnabled { @@ -6309,12 +6405,12 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( worktreePath = existingPath } else { if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { - return sessionCreatedMsg{err: fmt.Errorf("failed to create parent directory: %w", err)} + return sessionCreatedMsg{err: fmt.Errorf("failed to create parent directory: %w", err), tempID: tempID} } var setupBuf bytes.Buffer setupErr, err := git.CreateWorktreeWithSetup(worktreeRepoRoot, worktreePath, worktreeBranch, &setupBuf, &setupBuf) if err != nil { - return sessionCreatedMsg{err: fmt.Errorf("failed to create worktree: %w", err)} + return sessionCreatedMsg{err: fmt.Errorf("failed to create worktree: %w", err), tempID: tempID} } if setupErr != nil { uiLog.Warn("worktree_setup_script_failed", slog.String("error", setupErr.Error()), slog.String("output", setupBuf.String())) @@ -6386,7 +6482,7 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( parentDir := filepath.Join(home, ".agent-deck", "multi-repo-worktrees", fmt.Sprintf("%s-%s", sanitizedBranch, inst.ID[:8])) if mkErr := os.MkdirAll(parentDir, 0o755); mkErr != nil { - return sessionCreatedMsg{err: fmt.Errorf("failed to create multi-repo worktree dir: %w", mkErr)} + return sessionCreatedMsg{err: fmt.Errorf("failed to create multi-repo worktree dir: %w", mkErr), tempID: tempID} } if resolved, evalErr := filepath.EvalSymlinks(parentDir); evalErr == nil { parentDir = resolved @@ -6450,7 +6546,7 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( home, _ := os.UserHomeDir() parentDir := filepath.Join(home, ".agent-deck", "multi-repo-worktrees", inst.ID[:8]) if mkErr := os.MkdirAll(parentDir, 0o755); mkErr != nil { - return sessionCreatedMsg{err: fmt.Errorf("failed to create multi-repo dir: %w", mkErr)} + return sessionCreatedMsg{err: fmt.Errorf("failed to create multi-repo dir: %w", mkErr), tempID: tempID} } if resolved, evalErr := filepath.EvalSymlinks(parentDir); evalErr == nil { parentDir = resolved @@ -6487,10 +6583,10 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( ) if err := inst.Start(); err != nil { uiLog.Error("session_create_failed", slog.String("error", err.Error())) - return sessionCreatedMsg{err: err} + return sessionCreatedMsg{err: err, tempID: tempID} } uiLog.Info("session_create_succeeded", slog.String("id", inst.ID)) - return sessionCreatedMsg{instance: inst} + return sessionCreatedMsg{instance: inst, tempID: tempID} } } @@ -6614,6 +6710,7 @@ func (h *Home) quickCreateSession() tea.Cmd { "", "", "", // no worktree geminiYoloMode, false, toolOptionsJSON, false, nil, // no multi-repo + "", // no placeholder ) } @@ -9118,7 +9215,11 @@ func (h *Home) renderItem( case session.ItemTypeGroup: h.renderGroupItem(b, item, selected, itemIndex, groupStats) case session.ItemTypeSession: - h.renderSessionItem(b, item, selected, snapshot) + if item.CreatingID != "" { + h.renderCreatingSessionItem(b, item, selected) + } else { + h.renderSessionItem(b, item, selected, snapshot) + } case session.ItemTypeWindow: h.renderWindowItem(b, item, selected) case session.ItemTypeRemoteGroup: @@ -9215,6 +9316,86 @@ const ( // renderSessionItem renders a single session item for the left panel // PERFORMANCE: Uses cached styles from styles.go to avoid allocations +func (h *Home) renderCreatingPreview(creating *CreatingSession, width, height int) string { + var b strings.Builder + spinnerFrames := []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + spinner := spinnerFrames[h.animationFrame] + + centerStyle := lipgloss.NewStyle(). + Width(width - 4). + Align(lipgloss.Center) + + // Spinner line + spinnerStyle := lipgloss.NewStyle(). + Foreground(ColorPurple). + Bold(true) + spinnerLine := spinnerStyle.Render(spinner + " " + spinner + " " + spinner) + b.WriteString("\n\n") + b.WriteString(centerStyle.Render(spinnerLine)) + b.WriteString("\n\n") + + // Title + titleStyle := lipgloss.NewStyle(). + Foreground(ColorPurple). + Bold(true) + b.WriteString(centerStyle.Render(titleStyle.Render("🔨 Creating Worktree"))) + b.WriteString("\n\n") + + // Description + descStyle := lipgloss.NewStyle(). + Foreground(ColorText) + b.WriteString(centerStyle.Render(descStyle.Render("Setting up " + creating.Title + "..."))) + b.WriteString("\n\n") + + // Elapsed time + elapsed := time.Since(creating.StartTime).Truncate(time.Second) + timeStyle := lipgloss.NewStyle(). + Foreground(ColorTextDim). + Italic(true) + b.WriteString(centerStyle.Render(timeStyle.Render(fmt.Sprintf("Elapsed: %s", elapsed)))) + b.WriteString("\n\n") + + // Progress dots animation + dots := strings.Repeat("·", (h.animationFrame%4)+1) + strings.Repeat(" ", 3-h.animationFrame%4) + dotStyle := lipgloss.NewStyle().Foreground(ColorPurple) + b.WriteString(centerStyle.Render(dotStyle.Render(dots))) + + return b.String() +} + +func (h *Home) renderCreatingSessionItem( + b *strings.Builder, + item session.Item, + selected bool, +) { + spinnerFrames := []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + spinner := spinnerFrames[h.animationFrame] + + // Selection styling + if selected { + b.WriteString(lipgloss.NewStyle(). + Foreground(ColorAccent). + Bold(true). + Render("▸ ")) + } else { + b.WriteString(" ") + } + + // Tree connector + if item.Level > 0 { + b.WriteString(TreeConnectorStyle.Render("├── ")) + } + + // Spinner + title + spinnerStyle := lipgloss.NewStyle().Foreground(ColorPurple) + titleStyle := lipgloss.NewStyle().Foreground(ColorText).Italic(true) + b.WriteString(spinnerStyle.Render(spinner)) + b.WriteString(" ") + b.WriteString(titleStyle.Render(item.CreatingTitle)) + b.WriteString(lipgloss.NewStyle().Foreground(ColorTextDim).Italic(true).Render(" (creating worktree...)")) + b.WriteString("\n") +} + func (h *Home) renderSessionItem( b *strings.Builder, item session.Item, @@ -10009,6 +10190,14 @@ func (h *Home) renderPreviewPane(width, height int) string { item.Session = parentInst } + // Creating session placeholder: show dedicated animation + if item.CreatingID != "" { + if creating, ok := h.creatingSessions[item.CreatingID]; ok { + return h.renderCreatingPreview(creating, width, height) + } + return "" + } + // Session preview selected := item.Session