From 6d94e8049e42183d2aab09194425cc41a59acc88 Mon Sep 17 00:00:00 2001 From: Ashesh Goplani Date: Wed, 18 Mar 2026 18:28:48 +0700 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 feb8aad0c93b02e63b45a929087a578e918c5e01 Mon Sep 17 00:00:00 2001 From: c2k Date: Sun, 12 Apr 2026 22:42:12 -0700 Subject: [PATCH 9/9] feat: add opt-in smart heartbeat with state diffing Adds a `heartbeat_smart` flag under `[conductor]` settings. When enabled, the per-conductor heartbeat.sh installed by `agent-deck conductor setup` snapshots session state on each tick and only forwards a check-in to the conductor session when the snapshot differs from the previous run. The default remains unchanged (every tick fires a heartbeat) so behavior is backward compatible. The state snapshot is the sorted set of "title:status" pairs from `agent-deck list --json`, excluding the conductor session itself, parsed with portable awk that works on both GNU and BSD. Snapshots are written atomically to `heartbeat.state` next to `heartbeat.sh`. `MigrateConductorHeartbeatScripts` now respects the same setting, so toggling `heartbeat_smart` and rerunning conductor setup (or letting the migration sweep run) refreshes existing managed scripts in place. The managed-script detector recognizes both header variants. Tests cover: - Smart template structure (state diffing, atomic write, status guard, group-scoped message) - `InstallHeartbeatScript(..., false)` writes the standard script and strips `-p` for the default profile - `InstallHeartbeatScript(..., true)` interpolates an absolute state file path, preserves `-p` for non-default profiles, and emits an executable file - `bash -n` syntax check on the rendered smart script so awk/quoting regressions surface at `go test` time - `HeartbeatSmart` defaults to false - Migration sweep recognizes the smart header --- cmd/agent-deck/conductor_cmd.go | 8 +- internal/session/conductor.go | 97 +++++++++++++-- internal/session/conductor_test.go | 193 ++++++++++++++++++++++++++++- 3 files changed, 287 insertions(+), 11 deletions(-) diff --git a/cmd/agent-deck/conductor_cmd.go b/cmd/agent-deck/conductor_cmd.go index ef422bf38..4dbc1606d 100644 --- a/cmd/agent-deck/conductor_cmd.go +++ b/cmd/agent-deck/conductor_cmd.go @@ -518,12 +518,16 @@ func handleConductorSetup(profile string, args []string) { fmt.Println(" [skip] Heartbeat disabled (interval = 0)") } } else { - if err := session.InstallHeartbeatScript(name, resolvedProfile); err != nil { + if err := session.InstallHeartbeatScript(name, resolvedProfile, settings.HeartbeatSmart); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to install heartbeat script: %v\n", err) } else if err := session.InstallHeartbeatDaemon(name, resolvedProfile, interval); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to install heartbeat daemon: %v\n", err) } else if !*jsonOutput { - fmt.Printf(" [ok] Heartbeat timer installed (every %d min)\n", interval) + mode := "" + if settings.HeartbeatSmart { + mode = ", smart" + } + fmt.Printf(" [ok] Heartbeat timer installed (every %d min%s)\n", interval, mode) } } } diff --git a/internal/session/conductor.go b/internal/session/conductor.go index b41dc91f9..fffc36be1 100644 --- a/internal/session/conductor.go +++ b/internal/session/conductor.go @@ -54,6 +54,13 @@ type ConductorSettings struct { // Default: 15 HeartbeatInterval int `toml:"heartbeat_interval"` + // HeartbeatSmart enables state-diffing for heartbeat scripts: the script + // snapshots session state on each tick and only forwards a check-in to the + // conductor when the snapshot differs from the previous run. This avoids + // waking the conductor when nothing has changed. + // Default: false (every timer tick sends a heartbeat) + HeartbeatSmart bool `toml:"heartbeat_smart"` + // Profiles is the list of agent-deck profiles to manage // Kept for backward compat but ignored after migration to meta.json-based discovery Profiles []string `toml:"profiles"` @@ -526,16 +533,26 @@ func SetupConductorWithAgent(name, profile, agent string, heartbeatEnabled bool, } // InstallHeartbeatScript writes the heartbeat.sh script for a conductor. -// This is a standalone heartbeat that works without Telegram. -func InstallHeartbeatScript(name, profile string) error { +// This is a standalone heartbeat that works without Telegram. When smart is +// true, the state-diffing variant is installed: it snapshots session state on +// each tick and only sends a check-in when the snapshot has changed. +func InstallHeartbeatScript(name, profile string, smart bool) error { dir, err := ConductorNameDir(name) if err != nil { return err } profile = normalizeConductorProfile(profile) - script := strings.ReplaceAll(conductorHeartbeatScript, "{NAME}", name) + template := conductorHeartbeatScript + if smart { + template = conductorSmartHeartbeatScript + } + + script := strings.ReplaceAll(template, "{NAME}", name) script = strings.ReplaceAll(script, "{PROFILE}", profile) + if smart { + script = strings.ReplaceAll(script, "{STATE_FILE}", filepath.Join(dir, "heartbeat.state")) + } if profile == DefaultProfile { // For default profile, omit -p flag entirely script = strings.ReplaceAll(script, `-p "$PROFILE" `, "") @@ -742,6 +759,55 @@ if [ "$STATUS" = "idle" ] || [ "$STATUS" = "waiting" ]; then fi ` +// conductorSmartHeartbeatScript is the state-diffing variant of the heartbeat +// script. It snapshots the per-session title:status pairs in the profile and +// only proceeds with a check-in if the snapshot has changed since the last +// run. Snapshots are stored in heartbeat.state next to the script. +// +// JSON parsing uses awk to stay portable across GNU and BSD (macOS). The +// agent-deck `list --json` output is pretty-printed with one field per line, +// so a streaming awk pass that pairs each "title" with the next "status" is +// reliable as long as the InstanceData struct keeps Title before Status. +const conductorSmartHeartbeatScript = `#!/bin/bash +# Smart heartbeat for conductor: {NAME} (profile: {PROFILE}) +# Sends a check-in only when session state has changed since the last tick. + +SESSION="conductor-{NAME}" +PROFILE="{PROFILE}" +STATE_FILE="{STATE_FILE}" + +# Check if conductor is enabled +if ! agent-deck -p "$PROFILE" conductor status --json 2>/dev/null | grep -q '"enabled".*true'; then + exit 0 +fi + +# Snapshot session state: sorted "title:status" pairs, excluding this conductor. +CURRENT=$(agent-deck -p "$PROFILE" list --json 2>/dev/null | awk ' + /"title":/ { sub(/.*"title":[[:space:]]*"/, ""); sub(/".*/, ""); t=$0; next } + /"status":/ { sub(/.*"status":[[:space:]]*"/, ""); sub(/".*/, ""); if (t != "") { print t":"$0; t="" } } +' | grep -v "^$SESSION:" | sort) + +PREVIOUS="" +if [ -f "$STATE_FILE" ]; then + PREVIOUS=$(cat "$STATE_FILE" 2>/dev/null) +fi + +if [ "$CURRENT" = "$PREVIOUS" ]; then + exit 0 +fi + +# State changed — persist the new snapshot before sending so a crash mid-send +# doesn't cause us to re-fire forever. +printf '%s\n' "$CURRENT" > "$STATE_FILE.tmp" 2>/dev/null && mv "$STATE_FILE.tmp" "$STATE_FILE" 2>/dev/null + +# Only send if the conductor session is idle or waiting +STATUS=$(agent-deck -p "$PROFILE" session show "$SESSION" --json 2>/dev/null | awk -F'"' '/"status"/{print $4; exit}') + +if [ "$STATUS" = "idle" ] || [ "$STATUS" = "waiting" ]; then + agent-deck -p "$PROFILE" session send "$SESSION" "Heartbeat: Check sessions in your group ({NAME}). List any that are waiting, auto-respond where safe, and report what needs my attention." --no-wait -q +fi +` + // conductorHeartbeatPlistTemplate is the launchd plist for a per-conductor heartbeat timer const conductorHeartbeatPlistTemplate = ` @@ -1135,13 +1201,18 @@ func MigrateConductorLearnings() ([]string, error) { } // MigrateConductorHeartbeatScripts refreshes managed heartbeat scripts to the -// current template without touching custom user-authored scripts. +// current template without touching custom user-authored scripts. It honours +// the heartbeat_smart conductor setting when choosing between the standard and +// state-diffing templates, so toggling the setting and re-running setup (or +// triggering a migration pass) swaps existing scripts in place. func MigrateConductorHeartbeatScripts() ([]string, error) { conductors, err := ListConductors() if err != nil { return nil, err } + smart := GetConductorSettings().HeartbeatSmart + var migrated []string for _, meta := range conductors { dir, err := ConductorNameDir(meta.Name) @@ -1150,9 +1221,18 @@ func MigrateConductorHeartbeatScripts() ([]string, error) { } scriptPath := filepath.Join(dir, "heartbeat.sh") - expected := strings.ReplaceAll(conductorHeartbeatScript, "{NAME}", meta.Name) - expected = strings.ReplaceAll(expected, "{PROFILE}", normalizeConductorProfile(meta.Profile)) - if normalizeConductorProfile(meta.Profile) == DefaultProfile { + profile := normalizeConductorProfile(meta.Profile) + + template := conductorHeartbeatScript + if smart { + template = conductorSmartHeartbeatScript + } + expected := strings.ReplaceAll(template, "{NAME}", meta.Name) + expected = strings.ReplaceAll(expected, "{PROFILE}", profile) + if smart { + expected = strings.ReplaceAll(expected, "{STATE_FILE}", filepath.Join(dir, "heartbeat.state")) + } + if profile == DefaultProfile { expected = strings.ReplaceAll(expected, `-p "$PROFILE" `, "") } @@ -1167,7 +1247,8 @@ func MigrateConductorHeartbeatScripts() ([]string, error) { } existingStr := string(existing) - managedScript := strings.Contains(existingStr, "# Heartbeat for conductor:") && + managedScript := (strings.Contains(existingStr, "# Heartbeat for conductor:") || + strings.Contains(existingStr, "# Smart heartbeat for conductor:")) && strings.Contains(existingStr, `SESSION="conductor-`) if !managedScript { continue diff --git a/internal/session/conductor_test.go b/internal/session/conductor_test.go index 075718e72..6013b151a 100644 --- a/internal/session/conductor_test.go +++ b/internal/session/conductor_test.go @@ -3,6 +3,7 @@ package session import ( "encoding/json" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -2156,7 +2157,197 @@ func TestGetHeartbeatInterval_ZeroMeansDisabled(t *testing.T) { } } -// --- Slack markdown-to-mrkdwn converter tests --- +// --- Smart heartbeat tests --- + +func TestConductorSmartHeartbeatScript_TemplateStructure(t *testing.T) { + // Smart variant must be identifiable as a managed script (different comment header) + if !strings.Contains(conductorSmartHeartbeatScript, "# Smart heartbeat for conductor:") { + t.Error("smart heartbeat script must contain its identifying comment header") + } + // Must reference the state file placeholder so InstallHeartbeatScript can interpolate it + if !strings.Contains(conductorSmartHeartbeatScript, "{STATE_FILE}") { + t.Error("smart heartbeat script must contain {STATE_FILE} placeholder") + } + // Must short-circuit when the snapshot matches + if !strings.Contains(conductorSmartHeartbeatScript, `if [ "$CURRENT" = "$PREVIOUS" ]`) { + t.Error("smart heartbeat script must compare CURRENT against PREVIOUS") + } + // Must keep the same conductor-status enabled guard as the standard script + if !strings.Contains(conductorSmartHeartbeatScript, "conductor status") { + t.Error("smart heartbeat script must check conductor status before sending") + } + // Must keep the same idle/waiting gate so we don't poke a running conductor + if !strings.Contains(conductorSmartHeartbeatScript, `STATUS=$(`) { + t.Error("smart heartbeat script must read the conductor session status") + } + // Must use the same group-scoped message as the standard heartbeat + if !strings.Contains(conductorSmartHeartbeatScript, "Check sessions in your group") { + t.Error("smart heartbeat script must use the group-scoped check-in message") + } + // Must use atomic write (tmp + mv) so a crash mid-write doesn't corrupt state + if !strings.Contains(conductorSmartHeartbeatScript, "$STATE_FILE.tmp") { + t.Error("smart heartbeat script must persist state via an atomic tmp+rename") + } +} + +func TestInstallHeartbeatScript_StandardWritesScriptWithoutSmartMarkers(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + const name = "alpha" + dir, err := ConductorNameDir(name) + if err != nil { + t.Fatalf("ConductorNameDir: %v", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir conductor dir: %v", err) + } + + if err := InstallHeartbeatScript(name, "default", false); err != nil { + t.Fatalf("InstallHeartbeatScript: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, "heartbeat.sh")) + if err != nil { + t.Fatalf("read heartbeat.sh: %v", err) + } + got := string(content) + + if !strings.Contains(got, "# Heartbeat for conductor: "+name) { + t.Error("standard script should contain the conductor's name in its header") + } + if strings.Contains(got, "Smart heartbeat") || strings.Contains(got, "STATE_FILE") { + t.Error("standard script must not contain smart-heartbeat markers") + } + if strings.Contains(got, `-p "$PROFILE"`) { + t.Error("default profile script should have the -p flag stripped") + } +} + +func TestInstallHeartbeatScript_SmartInterpolatesStateFileAndPreservesProfile(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + const name = "beta" + const profile = "work" + + dir, err := ConductorNameDir(name) + if err != nil { + t.Fatalf("ConductorNameDir: %v", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir conductor dir: %v", err) + } + + if err := InstallHeartbeatScript(name, profile, true); err != nil { + t.Fatalf("InstallHeartbeatScript: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, "heartbeat.sh")) + if err != nil { + t.Fatalf("read heartbeat.sh: %v", err) + } + got := string(content) + + if !strings.Contains(got, "# Smart heartbeat for conductor: "+name) { + t.Error("smart script should contain its identifying header with the conductor name") + } + wantStatePath := filepath.Join(dir, "heartbeat.state") + if !strings.Contains(got, wantStatePath) { + t.Errorf("smart script should reference the absolute state file path %q", wantStatePath) + } + if strings.Contains(got, "{STATE_FILE}") { + t.Error("smart script must not leave the {STATE_FILE} placeholder in the output") + } + if !strings.Contains(got, `-p "$PROFILE"`) { + t.Error("non-default profile script must keep the -p flag") + } + if !strings.Contains(got, `PROFILE="`+profile+`"`) { + t.Errorf("smart script should set PROFILE to %q", profile) + } + // Permissions should be executable. + info, err := os.Stat(filepath.Join(dir, "heartbeat.sh")) + if err != nil { + t.Fatalf("stat heartbeat.sh: %v", err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("heartbeat.sh should be executable, got mode %v", info.Mode().Perm()) + } +} + +func TestInstallHeartbeatScript_SmartDefaultProfileStripsFlag(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + const name = "gamma" + dir, err := ConductorNameDir(name) + if err != nil { + t.Fatalf("ConductorNameDir: %v", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir conductor dir: %v", err) + } + + if err := InstallHeartbeatScript(name, DefaultProfile, true); err != nil { + t.Fatalf("InstallHeartbeatScript: %v", err) + } + + content, _ := os.ReadFile(filepath.Join(dir, "heartbeat.sh")) + got := string(content) + if strings.Contains(got, `-p "$PROFILE"`) { + t.Error("smart script for default profile must strip the -p flag") + } + if !strings.Contains(got, "# Smart heartbeat for conductor: "+name) { + t.Error("smart script should still carry the smart heartbeat header") + } +} + +func TestInstallHeartbeatScript_SmartScriptIsExecutableShell(t *testing.T) { + // Sanity-check the rendered script with `bash -n` so a syntax regression in + // the template (e.g. an unbalanced quote in the awk one-liner) shows up at + // `go test` time rather than the next time a heartbeat fires in production. + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + const name = "delta" + dir, _ := ConductorNameDir(name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir conductor dir: %v", err) + } + if err := InstallHeartbeatScript(name, DefaultProfile, true); err != nil { + t.Fatalf("InstallHeartbeatScript: %v", err) + } + + cmd := exec.Command("bash", "-n", filepath.Join(dir, "heartbeat.sh")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("smart heartbeat script failed shell syntax check: %v\n%s", err, out) + } +} + +func TestConductorSettings_HeartbeatSmartDefault(t *testing.T) { + if (ConductorSettings{}).HeartbeatSmart { + t.Error("HeartbeatSmart must default to false to preserve existing behavior") + } +} + +func TestMigrateConductorHeartbeatScripts_DetectsSmartHeader(t *testing.T) { + // Both header strings must be recognised as managed so that a script + // installed in smart mode is still picked up by the migration sweep that + // refreshes managed templates. + smart := strings.ReplaceAll(conductorSmartHeartbeatScript, "{NAME}", "x") + smart = strings.ReplaceAll(smart, "{PROFILE}", "default") + smart = strings.ReplaceAll(smart, "{STATE_FILE}", "/tmp/x") + + if !strings.Contains(smart, "# Smart heartbeat for conductor:") || + !strings.Contains(smart, `SESSION="conductor-`) { + t.Fatal("rendered smart script missing tokens the migration sweep relies on") + } +} func TestBridgeTemplate_ContainsMarkdownToSlackConverter(t *testing.T) { template := conductorBridgePy