From 36cbc2e25c504b76e666845535f743e2a1da87ab Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 21:08:32 +0100 Subject: [PATCH 1/2] Fix PR auto-merge: scanner detection, merge method config, trigger spam, install race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: isClaudeCLI — resolve claude binary via which+EvalSymlinks at startup so versioned paths (e.g. ~/.local/share/claude/versions/2.1.77) are recognised. Fix 2: Merge method — new PRs start with MergeMethod="" (unset); ShouldAutoMerge blocks until configured. TUI auto-shows picker for new repos on add, persists choice via SetMergeMethod to daemon; per-repo preference stored in prs.json so future PRs from same repo inherit it. YOLO no longer requires completed checks. Fix 3: MergeTriggered flag — auto-merge fires once per eligible state; resets only if checks regress to failing/running, preventing /aviator merge spam. Fix 4: Install race — wait up to 2s for old csm-daemon to exit after launchctl unload before loading the new one, preventing stale state overwrites. Closes #22 --- daemon/internal/ctlserver/handler.go | 42 +++++- daemon/internal/hookserver/handler.go | 2 +- daemon/internal/pr/model.go | 43 +++--- daemon/internal/pr/model_test.go | 3 + daemon/internal/pr/poller.go | 98 +++++++++--- daemon/internal/pr/poller_integration_test.go | 21 +-- daemon/internal/pr/poller_test.go | 32 ++-- daemon/internal/scanner/scanner.go | 43 ++++-- daemon/internal/scanner/scanner_test.go | 8 +- daemon/main.go | 17 ++- tui/internal/client/client.go | 69 +++++---- tui/internal/tui/app.go | 139 +++++++++++++++--- 12 files changed, 383 insertions(+), 134 deletions(-) diff --git a/daemon/internal/ctlserver/handler.go b/daemon/internal/ctlserver/handler.go index b423c12..93fe59f 100644 --- a/daemon/internal/ctlserver/handler.go +++ b/daemon/internal/ctlserver/handler.go @@ -15,10 +15,11 @@ import ( ) type ctlRequest struct { - Action string `json:"action"` - SessionID string `json:"session_id,omitempty"` - PRURL string `json:"pr_url,omitempty"` // for add_pr - PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr + Action string `json:"action"` + SessionID string `json:"session_id,omitempty"` + PRURL string `json:"pr_url,omitempty"` // for add_pr + PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method + MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method } type ctlResponse struct { @@ -27,6 +28,7 @@ type ctlResponse struct { PRs []pr.TrackedPR `json:"prs,omitempty"` Event string `json:"event,omitempty"` AutopilotMode string `json:"autopilot_mode,omitempty"` + NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo } type Handler struct { @@ -70,6 +72,8 @@ func (h *Handler) Handle(conn net.Conn) { h.handleRemovePR(conn, req.PRKey) case "cycle_pr_autopilot": h.handleCyclePRAutopilot(conn, req.PRKey) + case "set_merge_method": + h.handleSetMergeMethod(conn, req.PRKey, req.MergeMethod) } } } @@ -155,20 +159,44 @@ func (h *Handler) handleAddPR(conn net.Conn, url string) { writeJSON(conn, ctlResponse{OK: &f}) return } - tracked, err := h.prPoll.AddFromURL(url) + tracked, newRepo, err := h.prPoll.AddFromURL(url) if err != nil { log.Printf("ctl: add_pr failed: %v", err) f := false writeJSON(conn, ctlResponse{OK: &f}) return } - log.Printf("ctl: added PR %s/%s#%d", tracked.Owner, tracked.Repo, tracked.Number) + log.Printf("ctl: added PR %s/%s#%d (newRepo=%v)", tracked.Owner, tracked.Repo, tracked.Number, newRepo) ok := true - writeJSON(conn, ctlResponse{OK: &ok}) + writeJSON(conn, ctlResponse{OK: &ok, NewRepo: newRepo}) // Trigger immediate poll for the new PR. go h.prPoll.Poll() } +func (h *Handler) handleSetMergeMethod(conn net.Conn, key, method string) { + if h.prPoll == nil { + f := false + writeJSON(conn, ctlResponse{OK: &f}) + return + } + parts := strings.SplitN(key, "#", 2) + if len(parts) != 2 { + f := false + writeJSON(conn, ctlResponse{OK: &f}) + return + } + ownerRepo := strings.SplitN(parts[0], "/", 2) + if len(ownerRepo) != 2 { + f := false + writeJSON(conn, ctlResponse{OK: &f}) + return + } + var number int + fmt.Sscanf(parts[1], "%d", &number) + ok := h.prPoll.SetMergeMethod(ownerRepo[0], ownerRepo[1], number, method) + writeJSON(conn, ctlResponse{OK: &ok}) +} + func (h *Handler) handleCyclePRAutopilot(conn net.Conn, key string) { if h.prPoll == nil { f := false diff --git a/daemon/internal/hookserver/handler.go b/daemon/internal/hookserver/handler.go index b7265c0..6c47612 100644 --- a/daemon/internal/hookserver/handler.go +++ b/daemon/internal/hookserver/handler.go @@ -200,7 +200,7 @@ func (h *Handler) handlePostToolUse(req hookRequest) { return } log.Printf("hook: PostToolUse detected PR URL: %s", url) - if _, err := h.prPoll.AddFromURL(url); err != nil { + if _, _, err := h.prPoll.AddFromURL(url); err != nil { log.Printf("hook: auto-add PR failed: %v", err) } else { log.Printf("hook: auto-added PR %s", url) diff --git a/daemon/internal/pr/model.go b/daemon/internal/pr/model.go index dc114ba..764683c 100644 --- a/daemon/internal/pr/model.go +++ b/daemon/internal/pr/model.go @@ -61,12 +61,13 @@ type TrackedPR struct { Timeline []PREvent `json:"timeline"` // Tracking config - AutopilotMode string `json:"autopilot_mode"` // "off", "auto", "yolo" - Hammer bool `json:"hammer"` // auto-fix CI failures - HammerCount int `json:"hammer_count"` // fix attempts so far - MaxHammer int `json:"max_hammer"` // max fix attempts (default 3) - MergeMethod string `json:"merge_method"` // "squash", "merge", "rebase", "aviator" - RunReview bool `json:"run_review"` // run code-review skill on creation + AutopilotMode string `json:"autopilot_mode"` // "off", "auto", "yolo" + Hammer bool `json:"hammer"` // auto-fix CI failures + HammerCount int `json:"hammer_count"` // fix attempts so far + MaxHammer int `json:"max_hammer"` // max fix attempts (default 3) + MergeMethod string `json:"merge_method"` // "squash", "merge", "rebase", "aviator", "" = unset + MergeTriggered bool `json:"merge_triggered"` // true once auto-merge has been fired; resets on check regression + RunReview bool `json:"run_review"` // run code-review skill on creation } // PR autopilot modes. @@ -87,14 +88,25 @@ func (pr *TrackedPR) ShouldAutoMerge() bool { if pr.Mergeable != "MERGEABLE" { return false } + // Merge method must be configured — auto-merge is blocked until the user picks one. + if pr.MergeMethod == "" { + return false + } - // All checks must pass (ignore still-running ones). + // No check may be failing. for _, c := range pr.Checks { if c.Conclusion == "FAILURE" { return false } } - // At least one check must have completed. + + if pr.AutopilotMode == PRYolo { + // YOLO: no checks required, no approval required. + // Repos with no CI (empty Checks) can still be merged. + return true + } + + // AUTO: at least one completed check required. hasCompleted := false for _, c := range pr.Checks { if c.Status == "COMPLETED" { @@ -106,22 +118,13 @@ func (pr *TrackedPR) ShouldAutoMerge() bool { return false } - // Check approval. - hasApproval := false + // AUTO: needs at least one approval. for _, r := range pr.Reviews { if r.State == "APPROVED" { - hasApproval = true - break + return true } } - - if pr.AutopilotMode == PRYolo { - // YOLO doesn't need human approval. - return true - } - - // AUTO needs at least one approval. - return hasApproval + return false } // ShouldHammer returns true if the daemon should spawn a fix-CI agent. diff --git a/daemon/internal/pr/model_test.go b/daemon/internal/pr/model_test.go index 7978f5a..819c7cf 100644 --- a/daemon/internal/pr/model_test.go +++ b/daemon/internal/pr/model_test.go @@ -153,6 +153,7 @@ func TestShouldAutoMerge_AutoWithApprovalAndGreenChecks(t *testing.T) { pr := TrackedPR{ AutopilotMode: PRAuto, Mergeable: "MERGEABLE", + MergeMethod: "squash", Checks: []Check{{Conclusion: "SUCCESS", Status: "COMPLETED"}}, Reviews: []Review{{State: "APPROVED"}}, } @@ -188,6 +189,7 @@ func TestShouldAutoMerge_YoloWithGreenChecks(t *testing.T) { pr := TrackedPR{ AutopilotMode: PRYolo, Mergeable: "MERGEABLE", + MergeMethod: "squash", Checks: []Check{{Conclusion: "SUCCESS", Status: "COMPLETED"}}, // No reviews — YOLO doesn't need them. } @@ -289,6 +291,7 @@ func TestShouldAutoMerge_MixedChecksAllGreenAndInProgress(t *testing.T) { pr := TrackedPR{ AutopilotMode: PRAuto, Mergeable: "MERGEABLE", + MergeMethod: "squash", Checks: []Check{ {Name: "ci", Status: "COMPLETED", Conclusion: "SUCCESS"}, {Name: "deploy", Status: "IN_PROGRESS", Conclusion: ""}, diff --git a/daemon/internal/pr/poller.go b/daemon/internal/pr/poller.go index a7babbc..fd458db 100644 --- a/daemon/internal/pr/poller.go +++ b/daemon/internal/pr/poller.go @@ -13,9 +13,10 @@ import ( // Poller fetches PR data from GitHub via the gh CLI. type Poller struct { - mu sync.RWMutex - tracked map[string]*TrackedPR // "owner/repo#number" → PR - onChange func() // called when PR state changes + mu sync.RWMutex + tracked map[string]*TrackedPR // "owner/repo#number" → PR + repoMethods map[string]string // "owner/repo" → preferred merge method + onChange func() // called when PR state changes storePath string // persistence path (~/.csm/prs.json) } @@ -23,24 +24,31 @@ type Poller struct { // NewPoller creates a PR poller. func NewPoller(storePath string, onChange func()) *Poller { p := &Poller{ - tracked: make(map[string]*TrackedPR), - onChange: onChange, - storePath: storePath, + tracked: make(map[string]*TrackedPR), + repoMethods: make(map[string]string), + onChange: onChange, + storePath: storePath, } p.load() return p } -// Add starts tracking a PR. -func (p *Poller) Add(owner, repo string, number int) *TrackedPR { +// Add starts tracking a PR. Returns the PR and a bool indicating whether this +// is the first PR seen from this repo (newRepo=true means no merge method is +// configured yet and the caller should prompt the user to pick one). +func (p *Poller) Add(owner, repo string, number int) (*TrackedPR, bool) { key := fmt.Sprintf("%s/%s#%d", owner, repo, number) + repoKey := fmt.Sprintf("%s/%s", owner, repo) p.mu.Lock() defer p.mu.Unlock() if pr, ok := p.tracked[key]; ok { - return pr + return pr, false } + method := p.repoMethods[repoKey] // "" if repo is new + newRepo := method == "" + pr := &TrackedPR{ Owner: owner, Repo: repo, @@ -48,7 +56,7 @@ func (p *Poller) Add(owner, repo string, number int) *TrackedPR { AutopilotMode: PRAuto, Hammer: true, MaxHammer: 3, - MergeMethod: "squash", + MergeMethod: method, Timeline: []PREvent{{Time: time.Now(), Icon: "📝", Message: "Added to tracking"}}, } p.tracked[key] = pr @@ -56,25 +64,53 @@ func (p *Poller) Add(owner, repo string, number int) *TrackedPR { if p.onChange != nil { p.onChange() } - return pr + return pr, newRepo +} + +// SetMergeMethod updates the merge method for a tracked PR and stores the +// preference for the repo so future PRs inherit it. +func (p *Poller) SetMergeMethod(owner, repo string, number int, method string) bool { + key := fmt.Sprintf("%s/%s#%d", owner, repo, number) + repoKey := fmt.Sprintf("%s/%s", owner, repo) + p.mu.Lock() + defer p.mu.Unlock() + + pr, ok := p.tracked[key] + if !ok { + return false + } + pr.MergeMethod = method + p.repoMethods[repoKey] = method + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), + Icon: "⚙", + Message: fmt.Sprintf("Merge method → %s", method), + }) + p.save() + if p.onChange != nil { + p.onChange() + } + return true } // AddFromURL parses a GitHub PR URL and starts tracking. -func (p *Poller) AddFromURL(url string) (*TrackedPR, error) { +// Returns the PR, a newRepo flag, and any parse error. +func (p *Poller) AddFromURL(url string) (*TrackedPR, bool, error) { // Parse: https://github.com/owner/repo/pull/123 url = strings.TrimSpace(url) url = strings.TrimSuffix(url, "/") parts := strings.Split(url, "/") if len(parts) < 5 || parts[len(parts)-2] != "pull" { - return nil, fmt.Errorf("invalid PR URL: %s", url) + return nil, false, fmt.Errorf("invalid PR URL: %s", url) } owner := parts[len(parts)-4] repo := parts[len(parts)-3] var number int if _, err := fmt.Sscanf(parts[len(parts)-1], "%d", &number); err != nil { - return nil, fmt.Errorf("invalid PR number in URL: %s", url) + return nil, false, fmt.Errorf("invalid PR number in URL: %s", url) } - return p.Add(owner, repo, number), nil + pr, newRepo := p.Add(owner, repo, number) + return pr, newRepo, nil } // Remove stops tracking a PR. @@ -312,8 +348,14 @@ func (p *Poller) pollOne(owner, repo string, number int) bool { }) } - // Auto-merge if conditions met. - if pr.ShouldAutoMerge() { + // Reset MergeTriggered if checks have regressed so we can re-fire later. + if pr.State == StateChecksFailing || pr.State == StateChecksRunning { + pr.MergeTriggered = false + } + + // Auto-merge once — don't re-fire on every poll cycle. + if pr.ShouldAutoMerge() && !pr.MergeTriggered { + pr.MergeTriggered = true go p.triggerMerge(pr) } @@ -475,11 +517,27 @@ func defaultGhBin() string { // --- persistence --- +// pollerStore is the on-disk format for prs.json. +type pollerStore struct { + PRs map[string]*TrackedPR `json:"prs"` + RepoMethods map[string]string `json:"repo_methods,omitempty"` +} + func (p *Poller) load() { data, err := os.ReadFile(p.storePath) if err != nil { return } + // Try new wrapper format first. + var store pollerStore + if err := json.Unmarshal(data, &store); err == nil && store.PRs != nil { + p.tracked = store.PRs + if store.RepoMethods != nil { + p.repoMethods = store.RepoMethods + } + return + } + // Backward compat: old format was a bare map[string]*TrackedPR. var prs map[string]*TrackedPR if err := json.Unmarshal(data, &prs); err != nil { return @@ -488,7 +546,11 @@ func (p *Poller) load() { } func (p *Poller) save() { - data, err := json.MarshalIndent(p.tracked, "", " ") + store := pollerStore{ + PRs: p.tracked, + RepoMethods: p.repoMethods, + } + data, err := json.MarshalIndent(store, "", " ") if err != nil { return } diff --git a/daemon/internal/pr/poller_integration_test.go b/daemon/internal/pr/poller_integration_test.go index de9155d..1284a80 100644 --- a/daemon/internal/pr/poller_integration_test.go +++ b/daemon/internal/pr/poller_integration_test.go @@ -315,7 +315,7 @@ func TestPollOne_StateTransitionTimeline(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) // Disable auto-merge and hammer to avoid extra timeline events. tracked.AutopilotMode = PROff tracked.Hammer = false @@ -533,7 +533,7 @@ func TestTriggerMerge_Squash(t *testing.T) { changed := false storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, func() { changed = true }) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.MergeMethod = "squash" changed = false // Reset after Add. @@ -563,7 +563,7 @@ func TestTriggerMerge_Rebase(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.MergeMethod = "rebase" p.triggerMerge(tracked) @@ -588,7 +588,7 @@ func TestTriggerMerge_Merge(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.MergeMethod = "merge" p.triggerMerge(tracked) @@ -613,7 +613,7 @@ func TestTriggerMerge_Aviator(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.MergeMethod = "aviator" p.triggerMerge(tracked) @@ -638,7 +638,7 @@ func TestTriggerMerge_Failure(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.MergeMethod = "squash" p.triggerMerge(tracked) @@ -663,7 +663,7 @@ func TestTriggerMerge_RemovedDuringMerge(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.MergeMethod = "squash" // Remove before triggerMerge runs its lock section. @@ -685,7 +685,7 @@ func TestPollOne_HammerOnChecksFailing(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.Hammer = true tracked.AutopilotMode = PRAuto tracked.MaxHammer = 3 @@ -719,7 +719,7 @@ func TestPollOne_NoHammerOnSameState(t *testing.T) { storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.Hammer = true tracked.AutopilotMode = PRAuto tracked.MaxHammer = 3 @@ -775,8 +775,9 @@ esac storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, nil) - tracked := p.Add("test", "repo", 1) + tracked, _ := p.Add("test", "repo", 1) tracked.AutopilotMode = PRAuto + tracked.MergeMethod = "squash" p.pollOne("test", "repo", 1) diff --git a/daemon/internal/pr/poller_test.go b/daemon/internal/pr/poller_test.go index e2ae09c..96d9b9d 100644 --- a/daemon/internal/pr/poller_test.go +++ b/daemon/internal/pr/poller_test.go @@ -27,7 +27,7 @@ func newTestPollerWithCallback(t *testing.T) (*Poller, *int) { func TestAdd_New(t *testing.T) { p := newTestPoller(t) - pr := p.Add("octocat", "hello-world", 42) + pr, _ := p.Add("octocat", "hello-world", 42) if pr.Owner != "octocat" { t.Errorf("owner = %q, want octocat", pr.Owner) @@ -47,8 +47,8 @@ func TestAdd_New(t *testing.T) { if pr.MaxHammer != 3 { t.Errorf("max_hammer = %d, want 3", pr.MaxHammer) } - if pr.MergeMethod != "squash" { - t.Errorf("merge_method = %q, want squash", pr.MergeMethod) + if pr.MergeMethod != "" { + t.Errorf("merge_method = %q, want empty (unset for new repo)", pr.MergeMethod) } if len(pr.Timeline) != 1 { t.Errorf("timeline should have 1 event, got %d", len(pr.Timeline)) @@ -57,8 +57,8 @@ func TestAdd_New(t *testing.T) { func TestAdd_Duplicate(t *testing.T) { p := newTestPoller(t) - pr1 := p.Add("octocat", "repo", 1) - pr2 := p.Add("octocat", "repo", 1) + pr1, _ := p.Add("octocat", "repo", 1) + pr2, _ := p.Add("octocat", "repo", 1) if pr1 != pr2 { t.Error("adding same PR twice should return the same pointer") @@ -125,7 +125,7 @@ func TestAddFromURL_Valid(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestPoller(t) - pr, err := p.AddFromURL(tt.url) + pr, _, err := p.AddFromURL(tt.url) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -158,7 +158,7 @@ func TestAddFromURL_Invalid(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestPoller(t) - _, err := p.AddFromURL(tt.url) + _, _, err := p.AddFromURL(tt.url) if err == nil { t.Errorf("expected error for URL %q", tt.url) } @@ -293,11 +293,11 @@ func TestFailingCount_None(t *testing.T) { func TestFailingCount_Some(t *testing.T) { p := newTestPoller(t) - pr1 := p.Add("owner", "repo", 1) + pr1, _ := p.Add("owner", "repo", 1) pr1.State = StateChecksFailing - pr2 := p.Add("owner", "repo", 2) + pr2, _ := p.Add("owner", "repo", 2) pr2.State = StateChecksPassing - pr3 := p.Add("owner", "repo", 3) + pr3, _ := p.Add("owner", "repo", 3) pr3.State = StateChecksFailing if n := p.FailingCount(); n != 2 { @@ -313,7 +313,7 @@ func TestPersistence_SaveAndLoad(t *testing.T) { // Create a poller and add PRs. p1 := NewPoller(storePath, nil) p1.Add("octocat", "hello-world", 42) - pr2 := p1.Add("other", "project", 7) + pr2, _ := p1.Add("other", "project", 7) pr2.Title = "Test PR" // Manually set title via direct access (simulating pollOne). @@ -375,11 +375,11 @@ func TestPersistence_AddPersists(t *testing.T) { if err != nil { t.Fatalf("failed to read store file: %v", err) } - var stored map[string]*TrackedPR + var stored pollerStore if err := json.Unmarshal(data, &stored); err != nil { t.Fatalf("failed to parse store file: %v", err) } - if _, ok := stored["owner/repo#1"]; !ok { + if _, ok := stored.PRs["owner/repo#1"]; !ok { t.Error("store file should contain owner/repo#1") } } @@ -505,7 +505,7 @@ func TestGhPRDataParsing_DraftPR(t *testing.T) { func TestAddFromURL_MinimalPath(t *testing.T) { p := newTestPoller(t) // URL with minimal path segments but valid structure. - pr, err := p.AddFromURL("https://github.com/a/b/pull/1") + pr, _, err := p.AddFromURL("https://github.com/a/b/pull/1") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -516,7 +516,7 @@ func TestAddFromURL_MinimalPath(t *testing.T) { func TestAddFromURL_LargeNumber(t *testing.T) { p := newTestPoller(t) - pr, err := p.AddFromURL("https://github.com/a/b/pull/99999") + pr, _, err := p.AddFromURL("https://github.com/a/b/pull/99999") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -573,7 +573,7 @@ func TestConcurrentCycleAutopilot(t *testing.T) { func TestConcurrentFailingCount(t *testing.T) { p := newTestPoller(t) - pr := p.Add("owner", "repo", 1) + pr, _ := p.Add("owner", "repo", 1) pr.State = StateChecksFailing done := make(chan struct{}) go func() { diff --git a/daemon/internal/scanner/scanner.go b/daemon/internal/scanner/scanner.go index 448ff0b..8fa7552 100644 --- a/daemon/internal/scanner/scanner.go +++ b/daemon/internal/scanner/scanner.go @@ -17,23 +17,42 @@ const recentThresholdHours = 24 // Scanner discovers Claude Code sessions via process table and JSONL files. type Scanner struct { claudeProjectsDir string + resolvedClaudeBin string // real path of the claude binary (may be a versioned path) } // New creates a scanner. func New() *Scanner { home, _ := os.UserHomeDir() + resolved := resolveClaudeBin() return &Scanner{ claudeProjectsDir: filepath.Join(home, ".claude", "projects"), + resolvedClaudeBin: resolved, } } +// resolveClaudeBin resolves the claude binary to its real on-disk path, +// following symlinks. Claude installs as a versioned binary (e.g. +// ~/.local/share/claude/versions/2.1.77) symlinked from ~/.local/bin/claude, +// so filepath.Base of the running process is "2.1.77", not "claude". +func resolveClaudeBin() string { + p, err := exec.LookPath("claude") + if err != nil { + return "" + } + real, err := filepath.EvalSymlinks(p) + if err != nil { + return p + } + return real +} + // Discover finds all active and recently-dead CC sessions. func (s *Scanner) Discover() []*model.Session { var sessions []*model.Session seenJSONL := make(map[string]bool) // Phase 1: Running processes. - procs := findClaudeProcesses() + procs := s.findClaudeProcesses() cwdToPID := make(map[string]procInfo) for _, p := range procs { if existing, ok := cwdToPID[p.cwd]; !ok || p.pid > existing.pid { @@ -109,7 +128,10 @@ type procInfo struct { // isClaudeCLI returns true if cmd looks like the actual `claude` CLI binary, // excluding Claude.app, csm-daemon, and claude-session-manager. -func isClaudeCLI(cmd string) bool { +// resolvedBin is the real on-disk path of the claude binary (from which claude + +// EvalSymlinks), used to match versioned installs where the binary name is a +// version string rather than "claude". +func isClaudeCLI(cmd, resolvedBin string) bool { // Exclude known non-CLI binaries first. if strings.Contains(cmd, "Claude.app") { return false @@ -117,15 +139,16 @@ func isClaudeCLI(cmd string) bool { if strings.Contains(cmd, "csm-daemon") || strings.Contains(cmd, "claude-session-manager") { return false } - // Match any path ending in "claude" as the binary name. - // filepath.Base handles all path formats: - // "claude" → "claude" - // "/usr/local/bin/claude" → "claude" - // "/Users/x/.local/share/claude/versions/2.1.76/claude" → "claude" - return filepath.Base(cmd) == "claude" + // Standard install: binary named "claude". + if filepath.Base(cmd) == "claude" { + return true + } + // Versioned install: binary path matches the resolved real path + // e.g. ~/.local/share/claude/versions/2.1.77 + return resolvedBin != "" && cmd == resolvedBin } -func findClaudeProcesses() []procInfo { +func (s *Scanner) findClaudeProcesses() []procInfo { // Use ps to find Claude CLI processes with TTY info. out, err := exec.Command("ps", "-eo", "pid,tty,args").Output() if err != nil { @@ -158,7 +181,7 @@ func findClaudeProcesses() []procInfo { tty = "" } // The command starts at fields[2]. - if !isClaudeCLI(fields[2]) { + if !isClaudeCLI(fields[2], s.resolvedClaudeBin) { continue } // Extract session ID from --resume flag. diff --git a/daemon/internal/scanner/scanner_test.go b/daemon/internal/scanner/scanner_test.go index d2c3943..8c489e3 100644 --- a/daemon/internal/scanner/scanner_test.go +++ b/daemon/internal/scanner/scanner_test.go @@ -14,15 +14,19 @@ import ( // --- isClaudeCLI --- func TestIsClaudeCLI(t *testing.T) { + const versionedBin = "/Users/x/.local/share/claude/versions/2.1.77" + tests := []struct { cmd string want bool }{ - // Should match + // Should match — named "claude" {"claude", true}, {"/usr/local/bin/claude", true}, {"/Users/x/.local/share/claude/versions/2.1.76/claude", true}, {"/opt/homebrew/bin/claude", true}, + // Should match — versioned binary resolved via which+EvalSymlinks + {versionedBin, true}, // Should NOT match {"Claude.app", false}, @@ -39,7 +43,7 @@ func TestIsClaudeCLI(t *testing.T) { } for _, tt := range tests { t.Run(tt.cmd, func(t *testing.T) { - got := isClaudeCLI(tt.cmd) + got := isClaudeCLI(tt.cmd, versionedBin) if got != tt.want { t.Errorf("isClaudeCLI(%q) = %v, want %v", tt.cmd, got, tt.want) } diff --git a/daemon/main.go b/daemon/main.go index 7c13289..716ffe1 100644 --- a/daemon/main.go +++ b/daemon/main.go @@ -201,8 +201,10 @@ func install() { } fmt.Printf("installed plist to %s\n", plistPath) - // Load service. + // Unload and wait for the old daemon to exit before loading the new one. + // Without the wait, the old process can overwrite prs.json with stale state. _ = exec.Command("launchctl", "unload", plistPath).Run() + waitForOldDaemon() if err := exec.Command("launchctl", "load", plistPath).Run(); err != nil { log.Printf("warning: launchctl load failed: %v", err) } else { @@ -218,6 +220,19 @@ func install() { fmt.Println("installation complete") } +// waitForOldDaemon polls pgrep for up to 2s waiting for the old csm-daemon +// process to exit after launchctl unload. +func waitForOldDaemon() { + for i := 0; i < 10; i++ { + out, err := exec.Command("pgrep", "-x", "csm-daemon").Output() + if err != nil || strings.TrimSpace(string(out)) == "" { + return + } + time.Sleep(200 * time.Millisecond) + } + log.Println("warning: old csm-daemon still running after 2s — proceeding anyway") +} + func uninstall() { home, err := os.UserHomeDir() if err != nil { diff --git a/tui/internal/client/client.go b/tui/internal/client/client.go index 1796c70..820ffa4 100644 --- a/tui/internal/client/client.go +++ b/tui/internal/client/client.go @@ -98,14 +98,16 @@ type serverEvent struct { Sessions []Session `json:"sessions,omitempty"` PRs []TrackedPR `json:"prs,omitempty"` OK *bool `json:"ok,omitempty"` + NewRepo bool `json:"new_repo,omitempty"` } // request is the shape of NDJSON messages sent to the daemon. type request struct { - Action string `json:"action"` - SessionID string `json:"session_id,omitempty"` - PRURL string `json:"pr_url,omitempty"` - PRKey string `json:"pr_key,omitempty"` + Action string `json:"action"` + SessionID string `json:"session_id,omitempty"` + PRURL string `json:"pr_url,omitempty"` + PRKey string `json:"pr_key,omitempty"` + MergeMethod string `json:"merge_method,omitempty"` } // Client manages the connection to the CSM daemon. @@ -167,11 +169,11 @@ func (c *Client) Subscribe() (<-chan StateUpdate, error) { } // sendCommand opens a separate short-lived connection for a command, -// waits for the server response, and returns whether it succeeded. -func (c *Client) sendCommand(req request) error { +// waits for the server response, and returns the response and any error. +func (c *Client) sendCommand(req request) (serverEvent, error) { conn, err := net.Dial("unix", c.socketPath) if err != nil { - return err + return serverEvent{}, err } defer conn.Close() @@ -180,66 +182,83 @@ func (c *Client) sendCommand(req request) error { data, err := json.Marshal(req) if err != nil { - return err + return serverEvent{}, err } data = append(data, '\n') if _, err := conn.Write(data); err != nil { - return err + return serverEvent{}, err } // Read the server response to ensure it was processed. - scanner := bufio.NewScanner(conn) - if scanner.Scan() { + sc := bufio.NewScanner(conn) + if sc.Scan() { var resp serverEvent - if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { - return err + if err := json.Unmarshal(sc.Bytes(), &resp); err != nil { + return serverEvent{}, err } if resp.OK != nil && !*resp.OK { - return fmt.Errorf("command rejected by daemon") + return resp, fmt.Errorf("command rejected by daemon") } + return resp, nil } - return nil + return serverEvent{}, nil } // ToggleAutopilot toggles autopilot for the given session. func (c *Client) ToggleAutopilot(sessionID string) error { - return c.sendCommand(request{Action: "toggle_autopilot", SessionID: sessionID}) + _, err := c.sendCommand(request{Action: "toggle_autopilot", SessionID: sessionID}) + return err } // Approve approves the pending tool for the given session. func (c *Client) Approve(sessionID string) error { - return c.sendCommand(request{Action: "approve", SessionID: sessionID}) + _, err := c.sendCommand(request{Action: "approve", SessionID: sessionID}) + return err } // Reject rejects the pending tool for the given session. func (c *Client) Reject(sessionID string) error { - return c.sendCommand(request{Action: "reject", SessionID: sessionID}) + _, err := c.sendCommand(request{Action: "reject", SessionID: sessionID}) + return err } // ApproveAll approves all non-destructive pending tools across all sessions. func (c *Client) ApproveAll() error { - return c.sendCommand(request{Action: "approve_all"}) + _, err := c.sendCommand(request{Action: "approve_all"}) + return err } -// AddPR adds a PR to tracking by URL. -func (c *Client) AddPR(url string) error { - return c.sendCommand(request{Action: "add_pr", PRURL: url}) +// AddPR adds a PR to tracking by URL. Returns newRepo=true if this is the +// first PR seen from that repo (merge method needs to be configured). +func (c *Client) AddPR(url string) (newRepo bool, err error) { + resp, err := c.sendCommand(request{Action: "add_pr", PRURL: url}) + return resp.NewRepo, err +} + +// SetMergeMethod sets the merge method for a tracked PR and persists the +// preference for the repo. key is "owner/repo#number". +func (c *Client) SetMergeMethod(key, method string) error { + _, err := c.sendCommand(request{Action: "set_merge_method", PRKey: key, MergeMethod: method}) + return err } // RemovePR removes a PR from tracking by key (owner/repo#number). func (c *Client) RemovePR(key string) error { - return c.sendCommand(request{Action: "remove_pr", PRKey: key}) + _, err := c.sendCommand(request{Action: "remove_pr", PRKey: key}) + return err } // CyclePRAutopilot cycles PR autopilot: off → auto → yolo → off. func (c *Client) CyclePRAutopilot(key string) error { - return c.sendCommand(request{Action: "cycle_pr_autopilot", PRKey: key}) + _, err := c.sendCommand(request{Action: "cycle_pr_autopilot", PRKey: key}) + return err } // Focus focuses the Ghostty tab for the given session. func (c *Client) Focus(sessionID string) error { - return c.sendCommand(request{Action: "focus", SessionID: sessionID}) + _, err := c.sendCommand(request{Action: "focus", SessionID: sessionID}) + return err } // Close tears down the subscribe connection. diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 379abea..a75d720 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -34,6 +34,13 @@ type actionResultMsg struct { err error } +// addPRResultMsg carries the result of an add_pr command. +type addPRResultMsg struct { + prKey string // "owner/repo#N" + newRepo bool // true if this is the first PR from this repo + err error +} + // clearFlashMsg clears the status flash after a delay. type clearFlashMsg struct{} @@ -58,8 +65,10 @@ type Model struct { glowDir int // 1 or -1 for ping-pong inputMode bool // text input active (for + add PR) inputBuffer string // text being typed - mergePickerVisible bool // merge strategy picker showing - mergePickerPR *client.TrackedPR // PR being merged + mergePickerVisible bool // merge strategy picker showing + mergePickerPR *client.TrackedPR // PR being merged (may be nil for config-only mode) + mergePickerPRKey string // "owner/repo#N" — used for SetMergeMethod + mergePickerForConfig bool // true when picker is for configuring method (new repo), not immediate merge scrollOffset int // scroll position in zoom body } @@ -213,6 +222,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, clearFlashAfter(2 * time.Second) + case addPRResultMsg: + if msg.err != nil { + m.flash = fmt.Sprintf("add PR failed: %v", msg.err) + m.flashStyle = lipgloss.NewStyle().Foreground(colorDestructive).Bold(true) + return m, clearFlashAfter(2 * time.Second) + } + // New repo — auto-show merge picker so the user configures the method. + if msg.newRepo && msg.prKey != "" { + // Try to find the PR in the current list; it may already be polled. + var found *client.TrackedPR + for i := range m.prs { + if prKey(&m.prs[i]) == msg.prKey { + found = &m.prs[i] + break + } + } + m.mergePickerPR = found + m.mergePickerPRKey = msg.prKey + m.mergePickerForConfig = true + m.mergePickerVisible = true + return m, nil + } + m.flash = "added PR" + m.flashStyle = lipgloss.NewStyle().Foreground(colorRunning) + return m, clearFlashAfter(2 * time.Second) + case clearFlashMsg: m.flash = "" return m, nil @@ -231,11 +266,13 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inputBuffer = "" if url != "" { return m, func() tea.Msg { - err := m.client.AddPR(url) + newRepo, err := m.client.AddPR(url) if err != nil { - return actionResultMsg{action: "add PR", err: err} + return addPRResultMsg{err: err} } - return actionResultMsg{action: "added PR"} + // Derive key from URL for newRepo prompt (daemon will confirm via poll). + key := prKeyFromURL(url) + return addPRResultMsg{prKey: key, newRepo: newRepo} } } return m, nil @@ -470,21 +507,30 @@ end tell`, tabIdx, tabIdx) // Merge selected PR — show merge strategy picker. if pr := m.selectedPR(); pr != nil { m.mergePickerPR = pr + m.mergePickerPRKey = prKey(pr) + m.mergePickerForConfig = false m.mergePickerVisible = true return m, nil } case "1": // Merge picker: squash automerge. - if m.mergePickerVisible && m.mergePickerPR != nil { + if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { pr := m.mergePickerPR + key := m.mergePickerPRKey + forConfig := m.mergePickerForConfig m.mergePickerVisible = false m.mergePickerPR = nil - owner, repo, number := pr.Owner, pr.Repo, pr.Number + m.mergePickerPRKey = "" + m.mergePickerForConfig = false return m, func() tea.Msg { + _ = m.client.SetMergeMethod(key, "squash") + if forConfig { + return actionResultMsg{action: "merge method set: squash"} + } err := exec.Command("gh", "pr", "merge", - fmt.Sprintf("%d", number), - "--repo", fmt.Sprintf("%s/%s", owner, repo), + fmt.Sprintf("%d", pr.Number), + "--repo", fmt.Sprintf("%s/%s", pr.Owner, pr.Repo), "--squash", "--auto").Run() if err != nil { return actionResultMsg{action: "squash merge", err: err} @@ -495,15 +541,22 @@ end tell`, tabIdx, tabIdx) case "2": // Merge picker: rebase automerge. - if m.mergePickerVisible && m.mergePickerPR != nil { + if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { pr := m.mergePickerPR + key := m.mergePickerPRKey + forConfig := m.mergePickerForConfig m.mergePickerVisible = false m.mergePickerPR = nil - owner, repo, number := pr.Owner, pr.Repo, pr.Number + m.mergePickerPRKey = "" + m.mergePickerForConfig = false return m, func() tea.Msg { + _ = m.client.SetMergeMethod(key, "rebase") + if forConfig { + return actionResultMsg{action: "merge method set: rebase"} + } err := exec.Command("gh", "pr", "merge", - fmt.Sprintf("%d", number), - "--repo", fmt.Sprintf("%s/%s", owner, repo), + fmt.Sprintf("%d", pr.Number), + "--repo", fmt.Sprintf("%s/%s", pr.Owner, pr.Repo), "--rebase", "--auto").Run() if err != nil { return actionResultMsg{action: "rebase merge", err: err} @@ -514,15 +567,22 @@ end tell`, tabIdx, tabIdx) case "3": // Merge picker: Aviator merge queue. - if m.mergePickerVisible && m.mergePickerPR != nil { + if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { pr := m.mergePickerPR + key := m.mergePickerPRKey + forConfig := m.mergePickerForConfig m.mergePickerVisible = false m.mergePickerPR = nil - owner, repo, number := pr.Owner, pr.Repo, pr.Number + m.mergePickerPRKey = "" + m.mergePickerForConfig = false return m, func() tea.Msg { + _ = m.client.SetMergeMethod(key, "aviator") + if forConfig { + return actionResultMsg{action: "merge method set: aviator"} + } err := exec.Command("gh", "pr", "comment", - fmt.Sprintf("%d", number), - "--repo", fmt.Sprintf("%s/%s", owner, repo), + fmt.Sprintf("%d", pr.Number), + "--repo", fmt.Sprintf("%s/%s", pr.Owner, pr.Repo), "--body", "/aviator merge").Run() if err != nil { return actionResultMsg{action: "aviator merge", err: err} @@ -533,15 +593,22 @@ end tell`, tabIdx, tabIdx) case "4": // Merge picker: merge commit automerge. - if m.mergePickerVisible && m.mergePickerPR != nil { + if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { pr := m.mergePickerPR + key := m.mergePickerPRKey + forConfig := m.mergePickerForConfig m.mergePickerVisible = false m.mergePickerPR = nil - owner, repo, number := pr.Owner, pr.Repo, pr.Number + m.mergePickerPRKey = "" + m.mergePickerForConfig = false return m, func() tea.Msg { + _ = m.client.SetMergeMethod(key, "merge") + if forConfig { + return actionResultMsg{action: "merge method set: merge commit"} + } err := exec.Command("gh", "pr", "merge", - fmt.Sprintf("%d", number), - "--repo", fmt.Sprintf("%s/%s", owner, repo), + fmt.Sprintf("%d", pr.Number), + "--repo", fmt.Sprintf("%s/%s", pr.Owner, pr.Repo), "--merge", "--auto").Run() if err != nil { return actionResultMsg{action: "merge commit", err: err} @@ -554,6 +621,8 @@ end tell`, tabIdx, tabIdx) if m.mergePickerVisible { m.mergePickerVisible = false m.mergePickerPR = nil + m.mergePickerPRKey = "" + m.mergePickerForConfig = false } else if m.inputMode { m.inputMode = false m.inputBuffer = "" @@ -605,6 +674,22 @@ func (m Model) selectedPR() *client.TrackedPR { return nil } +// prKey returns the canonical "owner/repo#N" key for a PR. +func prKey(pr *client.TrackedPR) string { + return fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) +} + +// prKeyFromURL extracts "owner/repo#N" from a GitHub PR URL. +// Returns an empty string on parse failure. +func prKeyFromURL(url string) string { + url = strings.TrimSuffix(strings.TrimSpace(url), "/") + parts := strings.Split(url, "/") + if len(parts) < 5 || parts[len(parts)-2] != "pull" { + return "" + } + return fmt.Sprintf("%s/%s#%s", parts[len(parts)-4], parts[len(parts)-3], parts[len(parts)-1]) +} + // isSessionSelected returns true if a session (not PR) is selected. func (m Model) isSessionSelected() bool { return m.selectedIdx < len(m.sessions) @@ -657,10 +742,16 @@ func (m Model) View() string { bottomHeight := stripHeight + hintsHeight // Merge strategy picker overlay. - if m.mergePickerVisible && m.mergePickerPR != nil { - pr := m.mergePickerPR + if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { + var header string + if m.mergePickerPR != nil { + pr := m.mergePickerPR + header = fmt.Sprintf(" Merge #%d %s\n", pr.Number, pr.Title) + } else { + header = fmt.Sprintf(" Set merge method for %s\n", m.mergePickerPRKey) + } picker := lipgloss.NewStyle().Padding(1, 2).Render( - styleZoomHeader.Render(fmt.Sprintf(" Merge #%d %s\n", pr.Number, pr.Title)) + "\n" + + styleZoomHeader.Render(header) + "\n" + lipgloss.NewStyle().Foreground(colorFg).Render( " [1] Squash automerge\n"+ " [2] Rebase automerge\n"+ From a2a61c2d846ead14985c6857cfd27dc83da4263c Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 21:20:01 +0100 Subject: [PATCH 2/2] PR tab: show merge method, add M key to change it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR zoom panel now shows current merge method (e.g. '⎇ squash') or '⎇ unset' in the info line next to branch/commits/mergeable - M key (shift+m) opens the merge picker in config-only mode — persists the method to daemon without triggering an immediate gh pr merge - m key retains existing behavior: pick method + trigger immediate merge - Add MergeMethod field to client TrackedPR so it flows through to the TUI - Hints bar shows 'M set method' when a PR is selected --- tui/internal/client/client.go | 11 ++++++----- tui/internal/tui/app.go | 23 +++++++++++++++++++---- tui/internal/tui/hints.go | 1 + tui/internal/tui/pr_zoom.go | 5 +++++ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tui/internal/client/client.go b/tui/internal/client/client.go index 820ffa4..f6e012a 100644 --- a/tui/internal/client/client.go +++ b/tui/internal/client/client.go @@ -64,11 +64,12 @@ type TrackedPR struct { Additions int `json:"additions"` Deletions int `json:"deletions"` CommitCount int `json:"commit_count"` - AutopilotMode string `json:"autopilot_mode"` - Hammer bool `json:"hammer"` - HammerCount int `json:"hammer_count"` - CreatedAt time.Time `json:"created_at"` - Timeline []PREvent `json:"timeline"` + AutopilotMode string `json:"autopilot_mode"` + Hammer bool `json:"hammer"` + HammerCount int `json:"hammer_count"` + MergeMethod string `json:"merge_method"` + CreatedAt time.Time `json:"created_at"` + Timeline []PREvent `json:"timeline"` } type PRCheck struct { diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index a75d720..25ae731 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -504,7 +504,7 @@ end tell`, tabIdx, tabIdx) } case "m": - // Merge selected PR — show merge strategy picker. + // Merge selected PR — show merge strategy picker (immediate merge + persist). if pr := m.selectedPR(); pr != nil { m.mergePickerPR = pr m.mergePickerPRKey = prKey(pr) @@ -513,6 +513,16 @@ end tell`, tabIdx, tabIdx) return m, nil } + case "M": + // Change merge method for selected PR — picker in config-only mode. + if pr := m.selectedPR(); pr != nil { + m.mergePickerPR = pr + m.mergePickerPRKey = prKey(pr) + m.mergePickerForConfig = true + m.mergePickerVisible = true + return m, nil + } + case "1": // Merge picker: squash automerge. if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { @@ -744,11 +754,16 @@ func (m Model) View() string { // Merge strategy picker overlay. if m.mergePickerVisible && (m.mergePickerPR != nil || m.mergePickerForConfig) { var header string - if m.mergePickerPR != nil { + if m.mergePickerForConfig { + if m.mergePickerPR != nil { + pr := m.mergePickerPR + header = fmt.Sprintf(" Set merge method for #%d %s\n", pr.Number, pr.Title) + } else { + header = fmt.Sprintf(" Set merge method for %s\n", m.mergePickerPRKey) + } + } else { pr := m.mergePickerPR header = fmt.Sprintf(" Merge #%d %s\n", pr.Number, pr.Title) - } else { - header = fmt.Sprintf(" Set merge method for %s\n", m.mergePickerPRKey) } picker := lipgloss.NewStyle().Padding(1, 2).Render( styleZoomHeader.Render(header) + "\n" + diff --git a/tui/internal/tui/hints.go b/tui/internal/tui/hints.go index 3bfe13a..8a5e328 100644 --- a/tui/internal/tui/hints.go +++ b/tui/internal/tui/hints.go @@ -24,6 +24,7 @@ func renderHints(queueVisible bool, hasPending bool, isPRSelected bool, width in keys = append(keys, hint{"Enter", "open PR"}) keys = append(keys, hint{"a", "autopilot"}) keys = append(keys, hint{"m", "merge"}) + keys = append(keys, hint{"M", "set method"}) keys = append(keys, hint{"+", "add PR"}) keys = append(keys, hint{"-", "remove"}) } else { diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index adf84df..3d4f600 100644 --- a/tui/internal/tui/pr_zoom.go +++ b/tui/internal/tui/pr_zoom.go @@ -57,6 +57,11 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri if pr.AutopilotMode == "auto" || pr.AutopilotMode == "yolo" { infoParts = append(infoParts, "automerge") } + if pr.MergeMethod != "" { + infoParts = append(infoParts, lipgloss.NewStyle().Foreground(colorAccent).Render("⎇ "+pr.MergeMethod)) + } else { + infoParts = append(infoParts, lipgloss.NewStyle().Foreground(colorWaiting).Render("⎇ unset")) + } headerLines = append(headerLines, " "+lipgloss.NewStyle().Foreground(colorDimFg). Render(strings.Join(infoParts, " ")))