diff --git a/cmd/agent-deck/conductor_cmd.go b/cmd/agent-deck/conductor_cmd.go index ef422bf3..4dbc1606 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/cmd/agent-deck/launch_cmd.go b/cmd/agent-deck/launch_cmd.go index fa6e0e8c..3858825f 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 5269ff4e..ca9d2cba 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -872,8 +872,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 @@ -952,7 +952,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) @@ -1092,17 +1092,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 cf5bce88..82e3ab26 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/session/conductor.go b/internal/session/conductor.go index b41dc91f..fffc36be 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 075718e7..6013b151 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 diff --git a/internal/session/instance_test.go b/internal/session/instance_test.go index 6fb64f4d..e37de063 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) { @@ -2383,6 +2483,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 b0b9553f..519af6f8 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 @@ -107,6 +109,7 @@ func NewClaudeOptions(config *UserConfig) *ClaudeOptions { SessionMode: "new", } if config != nil { + opts.UseHappy = config.Claude.UseHappy opts.SkipPermissions = config.Claude.GetDangerousMode() opts.AutoMode = config.Claude.AutoMode opts.AllowSkipPermissions = config.Claude.AllowDangerousMode @@ -119,6 +122,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" @@ -142,6 +148,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 604328fd..f0f4c948 100644 --- a/internal/session/tooloptions_test.go +++ b/internal/session/tooloptions_test.go @@ -230,6 +230,7 @@ func TestNewClaudeOptions_WithConfig(t *testing.T) { config := &UserConfig{ Claude: ClaudeSettings{ DangerousMode: &dangerousModeBool, + UseHappy: true, }, } @@ -241,6 +242,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_AutoMode(t *testing.T) { @@ -274,6 +278,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) { @@ -359,6 +366,7 @@ func TestUnmarshalClaudeOptions(t *testing.T) { opts := &ClaudeOptions{ SessionMode: "resume", ResumeSessionID: "test-session-123", + UseHappy: true, SkipPermissions: true, UseChrome: true, UseTeammateMode: true, @@ -384,6 +392,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") } @@ -470,23 +481,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) { @@ -494,10 +511,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 { @@ -512,6 +532,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) { @@ -539,7 +562,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 { @@ -554,6 +577,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 ba2a5532..f69c5c5d 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -531,6 +531,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"` @@ -632,6 +637,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. @@ -1727,6 +1736,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] @@ -1744,6 +1755,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 f23e56ec..41bf6782 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/help.go b/internal/ui/help.go index 87d21d39..23678cd3 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 cd50855a..3e9f4849 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -3181,6 +3181,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: // Remove the creating placeholder (if any) — always, on success or error if msg.tempID != "" { @@ -4528,9 +4541,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) + } } parentSessionID := h.newDialog.GetParentSessionID() @@ -5807,13 +5820,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 { diff --git a/internal/ui/newdialog_test.go b/internal/ui/newdialog_test.go index 0688f393..4e845d5a 100644 --- a/internal/ui/newdialog_test.go +++ b/internal/ui/newdialog_test.go @@ -380,6 +380,7 @@ func TestNewDialog_RestoreSnapshot_RestoresToolOptionsAndCommandInput(t *testing originalClaude := &session.ClaudeOptions{ SessionMode: "resume", ResumeSessionID: "abc123", + UseHappy: true, SkipPermissions: true, AllowSkipPermissions: false, UseChrome: true, @@ -391,7 +392,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() @@ -402,7 +403,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) @@ -427,7 +428,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() { @@ -436,6 +437,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 ===== @@ -507,19 +529,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") } } @@ -1045,6 +1079,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) { @@ -1285,6 +1374,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/" { @@ -1314,6 +1492,64 @@ func TestNewDialog_BranchPrefix_Empty_NoPrefix(t *testing.T) { } } +// TestNewDialog_CtrlR_OpensRecentPicker verifies that Ctrl+R opens the recent +// sessions picker when recent sessions are available. +func TestNewDialog_CtrlR_OpensRecentPicker(t *testing.T) { + d := NewNewDialog() + d.SetSize(80, 40) + d.Show() + + // Set up recent sessions + sessions := []*statedb.RecentSessionRow{ + {Title: "session-1", ProjectPath: "/tmp/one", Tool: "claude"}, + {Title: "session-2", ProjectPath: "/tmp/two", Tool: "claude"}, + } + d.SetRecentSessions(sessions) + + if len(d.recentSessions) != 2 { + t.Fatalf("expected 2 recent sessions, got %d", len(d.recentSessions)) + } + + // Verify ^R hint appears in the view + view := d.View() + if !strings.Contains(view, "^R recent") { + t.Error("View should contain '^R recent' hint when recent sessions exist") + } + + // Verify picker is not open yet + if d.showRecentPicker { + t.Fatal("recent picker should not be open before Ctrl+R") + } + + // Send Ctrl+R + d, _ = d.Update(tea.KeyMsg{Type: tea.KeyCtrlR}) + + if !d.showRecentPicker { + t.Error("Ctrl+R should open the recent sessions picker") + } + if d.recentSessionCursor != 0 { + t.Errorf("recentSessionCursor = %d, want 0", d.recentSessionCursor) + } + // First session should be previewed + if d.nameInput.Value() != "session-1" { + t.Errorf("name = %q, want %q (first session should be previewed)", d.nameInput.Value(), "session-1") + } +} + +// TestNewDialog_CtrlR_HintHiddenWhenNoRecents verifies the hint is absent +// when there are no recent sessions. +func TestNewDialog_CtrlR_HintHiddenWhenNoRecents(t *testing.T) { + d := NewNewDialog() + d.SetSize(80, 40) + d.Show() + + // No recent sessions set + view := d.View() + if strings.Contains(view, "^R recent") { + t.Error("View should NOT contain '^R recent' hint when no recent sessions exist") + } +} + func TestNewDialog_BranchPrefix_Placeholder_Updated(t *testing.T) { d := NewNewDialog() d.branchPrefix = "fix/" diff --git a/internal/ui/settings_panel_test.go b/internal/ui/settings_panel_test.go index 403f5003..6f7466a9 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 fc20b3f9..56e4e90b 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 }