diff --git a/.gitignore b/.gitignore index a1c3e51..34631b7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ node_modules/ # Logs *.log .claude/worktrees/ +.autoresearch/ diff --git a/daemon/internal/pr/agent.go b/daemon/internal/pr/agent.go index 0dd5ab4..f942137 100644 --- a/daemon/internal/pr/agent.go +++ b/daemon/internal/pr/agent.go @@ -1,6 +1,8 @@ package pr import ( + "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -57,6 +59,11 @@ func cloneForAgent(owner, repo, branch string) (string, error) { return tmpDir, nil } +// statusInstruction is appended to all agent prompts to get live status updates. +const statusInstruction = "\n\nIMPORTANT: At each major step, print a short status line starting with " + + "\"STATUS: \" (e.g. \"STATUS: reading CI logs\", \"STATUS: found root cause in foo.go\", " + + "\"STATUS: running tests\", \"STATUS: pushing fix\"). These are shown in a live dashboard." + // --- command builders --- func buildFixCICmd(pr *TrackedPR, workDir string) *exec.Cmd { @@ -82,10 +89,11 @@ func buildFixCICmd(pr *TrackedPR, workDir string) *exec.Cmd { "Do not change test expectations unless the test itself is wrong.", pr.Number, pr.Owner, pr.Repo, pr.HeadBranch, strings.Join(failing, "\n"), - ) + ) + statusInstruction args := []string{ "-p", prompt, + "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--max-budget-usd", "5", "--model", "sonnet", @@ -117,10 +125,11 @@ func buildCodeReviewCmd(pr *TrackedPR, workDir string) *exec.Cmd { "If the code is clean, output: []\n"+ "Output the JSON array and nothing else.", pr.HeadBranch, pr.BaseBranch, pr.BaseBranch, - ) + ) + statusInstruction args := []string{ "-p", prompt, + "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--max-budget-usd", "3", "--model", "sonnet", @@ -151,10 +160,11 @@ func buildFixReviewCmd(pr *TrackedPR, workDir string) *exec.Cmd { "1. Run tests to verify nothing is broken\n"+ "2. Commit and push to the current branch", strings.Join(issues, "\n"), - ) + ) + statusInstruction args := []string{ "-p", prompt, + "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--max-budget-usd", "5", "--model", "sonnet", @@ -175,6 +185,147 @@ func buildFixReviewCmd(pr *TrackedPR, workDir string) *exec.Cmd { return cmd } +// --- streaming agent runner --- + +// streamEvent is the minimal structure for parsing claude stream-json events. +type streamEvent struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + Result string `json:"result"` + CostUSD float64 `json:"total_cost_usd"` + Duration float64 `json:"duration_ms"` +} + +// runStreamingAgent runs a claude -p command with stream-json output, +// parsing STATUS: lines and forwarding them to the PR timeline in real-time. +// Returns the final result text and accumulated full output for logging. +func (p *Poller) runStreamingAgent(ctx context.Context, cmd *exec.Cmd, key, agentType string) (result string, allOutput []byte, err error) { + stdout, pipeErr := cmd.StdoutPipe() + if pipeErr != nil { + return "", nil, fmt.Errorf("stdout pipe: %w", pipeErr) + } + // Capture stderr separately for diagnostics. + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if startErr := cmd.Start(); startErr != nil { + return "", nil, fmt.Errorf("start: %w", startErr) + } + + var fullOutput bytes.Buffer + scanner := bufio.NewScanner(stdout) + // stream-json can have long lines (tool results with file contents). + scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) + + // Live stream log — write every event to a file for debugging. + safe := strings.NewReplacer("/", "-", "#", "-").Replace(key) + streamLogPath := fmt.Sprintf("/tmp/csm-agent-%s-%s-stream.log", safe, agentType) + streamLog, _ := os.Create(streamLogPath) + defer func() { + if streamLog != nil { + streamLog.Close() + } + }() + log.Printf("pr: agent %s stream log: %s", agentType, streamLogPath) + + for scanner.Scan() { + line := scanner.Bytes() + fullOutput.Write(line) + fullOutput.WriteByte('\n') + if streamLog != nil { + streamLog.Write(line) + streamLog.WriteString("\n") + streamLog.Sync() + } + + var ev streamEvent + if json.Unmarshal(line, &ev) != nil { + continue + } + + switch ev.Type { + case "assistant": + // Look for STATUS: lines in assistant text content. + for _, block := range ev.Message.Content { + if block.Type != "text" { + continue + } + for _, textLine := range strings.Split(block.Text, "\n") { + trimmed := strings.TrimSpace(textLine) + if strings.HasPrefix(trimmed, "STATUS:") { + status := strings.TrimSpace(strings.TrimPrefix(trimmed, "STATUS:")) + if status != "" { + p.agentProgress(key, agentType, status) + } + } + } + } + case "result": + result = ev.Result + if ev.CostUSD > 0 { + p.agentCostUpdate(key, ev.CostUSD) + } + } + } + + waitErr := cmd.Wait() + + // Append stderr to output for logging. + if stderrBuf.Len() > 0 { + fullOutput.WriteString("\n--- stderr ---\n") + fullOutput.Write(stderrBuf.Bytes()) + } + + return result, fullOutput.Bytes(), waitErr +} + +// agentLabel returns a human-friendly label for a timeline prefix. +func agentLabel(agentType string) string { + switch agentType { + case "fix_ci": + return "fix-CI" + case "review": + return "review" + case "fix_review": + return "fix-review" + default: + return agentType + } +} + +// agentProgress adds a status update to the PR timeline from a running agent. +func (p *Poller) agentProgress(key, agentType, status string) { + p.mu.Lock() + pr, ok := p.tracked[key] + if ok { + pr.Timeline = append(pr.Timeline, PREvent{ + Time: time.Now(), Icon: "⚙", + Message: agentLabel(agentType) + ": " + status, + }) + p.save() + } + p.mu.Unlock() + + if ok && p.onChange != nil { + p.onChange() + } +} + +// agentCostUpdate records the agent cost on the PR. +func (p *Poller) agentCostUpdate(key string, costUSD float64) { + p.mu.Lock() + pr, ok := p.tracked[key] + if ok { + pr.AgentCostUSD += costUSD + } + p.mu.Unlock() +} + // --- spawn functions --- const agentTimeout = 15 * time.Minute @@ -198,7 +349,7 @@ func (p *Poller) spawnFixCI(pr *TrackedPR) { cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd.Dir = workDir - output, err := cmd.CombinedOutput() + _, output, err := p.runStreamingAgent(ctx, cmd, key, "fix_ci") p.agentComplete(key, "fix_ci", err, output) }() } @@ -222,7 +373,12 @@ func (p *Poller) spawnCodeReview(pr *TrackedPR) { cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd.Dir = workDir - output, err := cmd.CombinedOutput() + result, output, err := p.runStreamingAgent(ctx, cmd, key, "review") + // For review, the result field contains the final text output. + // Pass it as output for parseReviewOutput. + if err == nil && result != "" { + output = []byte(result) + } p.agentComplete(key, "review", err, output) }() } @@ -246,11 +402,30 @@ func (p *Poller) spawnFixReview(pr *TrackedPR) { cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd.Dir = workDir - output, err := cmd.CombinedOutput() + _, output, err := p.runStreamingAgent(ctx, cmd, key, "fix_review") p.agentComplete(key, "fix_review", err, output) }() } +// writeAgentLog writes agent output + error to /tmp/csm-agent--.log. +// Returns the log path for use in the daemon log line. +func writeAgentLog(key, agentType string, output []byte, runErr error) string { + // Sanitize key for use in filename (replace / and # with -). + safe := strings.NewReplacer("/", "-", "#", "-").Replace(key) + path := fmt.Sprintf("/tmp/csm-agent-%s-%s.log", safe, agentType) + var buf strings.Builder + buf.WriteString(fmt.Sprintf("=== CSM agent log: %s %s ===\n", key, agentType)) + buf.WriteString(fmt.Sprintf("error: %v\n", runErr)) + buf.WriteString("--- output ---\n") + if len(output) > 0 { + buf.Write(output) + } else { + buf.WriteString("(no output)\n") + } + _ = os.WriteFile(path, []byte(buf.String()), 0o644) + return path +} + // --- completion callback --- func (p *Poller) agentComplete(key, agentType string, err error, output []byte) { @@ -268,7 +443,8 @@ func (p *Poller) agentComplete(key, agentType string, err error, output []byte) Time: time.Now(), Icon: "✗", Message: fmt.Sprintf("Agent %s failed: %v", agentType, err), }) - log.Printf("pr: agent %s for %s failed: %v", agentType, key, err) + logFile := writeAgentLog(key, agentType, output, err) + log.Printf("pr: agent %s for %s failed: %v (log: %s)", agentType, key, err, logFile) } else { msg := fmt.Sprintf("Agent %s completed", agentType) diff --git a/daemon/internal/pr/agent_test.go b/daemon/internal/pr/agent_test.go index 13387c5..afa2aca 100644 --- a/daemon/internal/pr/agent_test.go +++ b/daemon/internal/pr/agent_test.go @@ -1,6 +1,7 @@ package pr import ( + "fmt" "os" "strings" "testing" @@ -173,6 +174,106 @@ func TestParseReviewOutput_Empty(t *testing.T) { } } +// === stream-json flags === + +func TestBuildFixCICmd_StreamJSON(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "fix", AutopilotMode: PRAuto, + Checks: []Check{{Name: "ci", Conclusion: "FAILURE"}}, + } + args := strings.Join(buildFixCICmd(pr, "/tmp").Args, " ") + if !strings.Contains(args, "--output-format stream-json") { + t.Error("fix_ci should use stream-json output") + } + if !strings.Contains(args, "--verbose") { + t.Error("stream-json requires --verbose") + } + if !strings.Contains(args, "STATUS:") { + t.Error("prompt should contain STATUS instruction") + } +} + +func TestBuildCodeReviewCmd_StreamJSON(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "feat", BaseBranch: "main", + } + args := strings.Join(buildCodeReviewCmd(pr, "/tmp").Args, " ") + if !strings.Contains(args, "--output-format stream-json") { + t.Error("review should use stream-json output") + } + if !strings.Contains(args, "--verbose") { + t.Error("stream-json requires --verbose") + } +} + +func TestBuildFixReviewCmd_StreamJSON(t *testing.T) { + pr := &TrackedPR{ + Owner: "test", Repo: "repo", Number: 1, + HeadBranch: "fix", AutopilotMode: PRAuto, + ReviewFindings: []ReviewFinding{ + {Severity: SeverityCritical, File: "a.go", Message: "bug"}, + }, + } + args := strings.Join(buildFixReviewCmd(pr, "/tmp").Args, " ") + if !strings.Contains(args, "--output-format stream-json") { + t.Error("fix_review should use stream-json output") + } + if !strings.Contains(args, "--verbose") { + t.Error("stream-json requires --verbose") + } +} + +// === agentLabel === + +func TestAgentLabel(t *testing.T) { + cases := []struct{ in, want string }{ + {"fix_ci", "fix-CI"}, + {"review", "review"}, + {"fix_review", "fix-review"}, + {"unknown", "unknown"}, + } + for _, c := range cases { + if got := agentLabel(c.in); got != c.want { + t.Errorf("agentLabel(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// === writeAgentLog === + +func TestWriteAgentLog_CreatesFile(t *testing.T) { + path := writeAgentLog("test/repo#1", "fix_ci", []byte("some output"), nil) + defer os.Remove(path) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read log: %v", err) + } + s := string(data) + if !strings.Contains(s, "test/repo#1") { + t.Error("log should contain PR key") + } + if !strings.Contains(s, "some output") { + t.Error("log should contain output") + } +} + +func TestWriteAgentLog_NoOutput(t *testing.T) { + path := writeAgentLog("test/repo#2", "review", nil, fmt.Errorf("signal: killed")) + defer os.Remove(path) + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "signal: killed") { + t.Error("log should contain error") + } + if !strings.Contains(s, "(no output)") { + t.Error("log should indicate no output") + } +} + // === cloneForAgent (mock test) === func TestCloneForAgent_BadRepo(t *testing.T) { diff --git a/daemon/internal/pr/poller_integration_test.go b/daemon/internal/pr/poller_integration_test.go index dadba92..874bd06 100644 --- a/daemon/internal/pr/poller_integration_test.go +++ b/daemon/internal/pr/poller_integration_test.go @@ -475,8 +475,11 @@ func TestPoll_MultiplePRs(t *testing.T) { changed := false storePath := filepath.Join(t.TempDir(), "prs.json") p := NewPoller(storePath, func() { changed = true }) - p.Add("test", "repo", 1) - p.Add("test", "repo", 2) + pr1, _ := p.Add("test", "repo", 1) + pr2, _ := p.Add("test", "repo", 2) + // Disable review spawning — this test is about polling, not agent execution. + pr1.ReviewState = "clean" + pr2.ReviewState = "clean" p.Poll() diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 295a857..b65aa1d 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -747,12 +747,11 @@ func (m Model) View() string { mainContent = renderEmptyState(w, remainingHeight) } - output := lipgloss.JoinVertical(lipgloss.Left, - statusLine, - mainContent, - hints, - strip, - ) + var outputParts []string + outputParts = append(outputParts, statusLine) + outputParts = append(outputParts, mainContent, hints, strip) + + output := lipgloss.JoinVertical(lipgloss.Left, outputParts...) // Hard clip to terminal height to prevent overflow pushing status bar off screen. lines := strings.Split(output, "\n") @@ -785,43 +784,129 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra Render(pluralize(len(sessions), "session", "sessions")) prCount := "" + prBreakdownStr := "" if len(prs) > 0 { prCount = " " + lipgloss.NewStyle(). Foreground(colorDimFg). Render(pluralize(len(prs), "PR", "PRs")) + + // PR state breakdown: passing / failing / running counts. + passing, failing, running, merged := 0, 0, 0, 0 + for _, p := range prs { + switch p.State { + case "checks_passing", "approved": + passing++ + case "checks_failing": + failing++ + case "checks_running": + running++ + case "merged": + merged++ + } + } + var prParts []string + if passing > 0 { + prParts = append(prParts, lipgloss.NewStyle().Foreground(colorRunning). + Render(fmt.Sprintf("%d\u2713", passing))) + } + if failing > 0 { + prParts = append(prParts, lipgloss.NewStyle().Foreground(colorDestructive). + Render(fmt.Sprintf("%d\u2717", failing))) + } + if running > 0 { + prParts = append(prParts, lipgloss.NewStyle().Foreground(colorWaiting). + Render(fmt.Sprintf("%d\u23f3", running))) + } + if merged > 0 { + prParts = append(prParts, lipgloss.NewStyle().Foreground(colorDimFg). + Render(fmt.Sprintf("%d\u2714", merged))) + } + if len(prParts) > 0 { + prBreakdownStr = " " + strings.Join(prParts, " ") + } } pendingStr := "" pending := countPending(sessions) if pending > 0 { - pendingStr = lipgloss.NewStyle(). - Foreground(colorOrange). + // Badge-style: dark text on orange background to catch the eye. + pendingBadge := lipgloss.NewStyle(). + Foreground(lipgloss.ANSIColor(0)). + Background(colorOrange). Bold(true). - Render(fmt.Sprintf(" \u26a1 %d pending", pending)) + Padding(0, 1). + Render(fmt.Sprintf("\u26a1 %d PENDING", pending)) + pendingStr = " " + pendingBadge + + // Find oldest pending session (by LastActivity time). + var oldestTime *time.Time + for _, s := range sessions { + if len(s.PendingTools) > 0 && s.LastActivity != nil { + if oldestTime == nil || s.LastActivity.Before(*oldestTime) { + t := *s.LastActivity + oldestTime = &t + } + } + } + if oldestTime != nil { + age := time.Since(*oldestTime) + pendingStr += lipgloss.NewStyle(). + Foreground(colorOrange). + Render(fmt.Sprintf(" %s ago", formatAge(age))) + } } failingStr := "" if failingPRs > 0 { - failingStr = lipgloss.NewStyle(). - Foreground(colorDestructive). + // Badge-style: dark text on red background, consistent with pending badge. + failingBadge := lipgloss.NewStyle(). + Foreground(lipgloss.ANSIColor(0)). + Background(colorDestructive). Bold(true). - Render(fmt.Sprintf(" \u2717 %d failing", failingPRs)) + Padding(0, 1). + Render(fmt.Sprintf("\u2717 %d FAILING", failingPRs)) + failingStr = " " + failingBadge } - runningStr := "" - running := 0 - for _, s := range sessions { - if s.State == "running" { - running++ + // State breakdown: count sessions per state. + stateBreakdownStr := "" + if len(sessions) > 0 { + running, waiting, idle, dead := 0, 0, 0, 0 + for _, s := range sessions { + switch s.State { + case "running": + running++ + case "waiting": + waiting++ + case "idle": + idle++ + case "dead": + dead++ + } + } + var parts []string + if running > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(colorRunning). + Render(fmt.Sprintf("%d\u25b6", running))) + } + if waiting > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(colorWaiting). + Render(fmt.Sprintf("%d\u23f8", waiting))) + } + if idle > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(colorDimFg). + Render(fmt.Sprintf("%d\u2714", idle))) + } + if dead > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(colorDead). + Render(fmt.Sprintf("%d\u25cf", dead))) + } + if len(parts) > 0 { + stateBreakdownStr = " " + strings.Join(parts, " ") } - } - if running > 0 { - runningStr = lipgloss.NewStyle(). - Foreground(colorRunning). - Render(fmt.Sprintf(" \u25b6 %d running", running)) } - left := logo + " " + connStatus + " " + sessionCount + prCount + runningStr + pendingStr + failingStr + left := logo + " " + connStatus + " " + sessionCount + prCount + prBreakdownStr + stateBreakdownStr + pendingStr + failingStr // Flash message (action feedback). if flash != "" { diff --git a/tui/internal/tui/pill.go b/tui/internal/tui/pill.go index 18b32f9..ceb72ce 100644 --- a/tui/internal/tui/pill.go +++ b/tui/internal/tui/pill.go @@ -85,14 +85,43 @@ func renderPill(s client.Session, selected bool, glowPos int) string { return renderPillWithName(s, pillName(s), selected, glowPos) } +// isPassiveState returns true if the session state is idle or dead (not actively working). +func isPassiveState(state string) bool { + return state == "idle" || state == "dead" +} + +// pillNameMaxLen returns the max name length based on state and selection. +// Selected pills show full 20-char names. +// Active unselected (running/waiting): 8 chars — visible but compact. +// Passive unselected (idle/dead): 4 chars — minimal footprint. +func pillNameMaxLen(state string, selected bool) int { + if selected { + return 20 + } + if isPassiveState(state) { + return 4 + } + return 8 // running, waiting, or other active states +} + // renderPillWithName renders a pill using a pre-computed display name // (which may include a disambiguator). +// Name length is tiered by state and selection for visual hierarchy. func renderPillWithName(s client.Session, displayName string, selected bool, glowPos int) string { sc := stateColor(s.State) dimBg := stateColorDim(s.State) icon := stateIcon(s.State) - name := truncateMiddle(displayName, 20) + compact := isPassiveState(s.State) && !selected + maxLen := pillNameMaxLen(s.State, selected) + + var name string + runes := []rune(displayName) + if len(runes) > maxLen { + name = string(runes[:maxLen]) + } else { + name = displayName + } label := icon + " " + name @@ -107,13 +136,22 @@ func renderPillWithName(s client.Session, displayName string, selected bool, glo Render(fmt.Sprintf("%d", n)) } - style := lipgloss.NewStyle(). - Padding(0, 1). - Foreground(sc). - Background(dimBg) + var style lipgloss.Style + if compact { + // Passive unselected pills: no background, just dim text — lighter visual weight. + style = lipgloss.NewStyle(). + Padding(0, 0). + Foreground(colorDimFg) + } else { + style = lipgloss.NewStyle(). + Padding(0, 1). + Foreground(sc). + Background(dimBg) + } if selected { - style = style. + style = lipgloss.NewStyle(). + Padding(0, 1). Bold(true). Foreground(lipgloss.ANSIColor(15)). Background(sc). diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index 1d589c8..758a254 100644 --- a/tui/internal/tui/pr_zoom.go +++ b/tui/internal/tui/pr_zoom.go @@ -57,19 +57,12 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri infoParts = append(infoParts, pr.HeadBranch+" → "+pr.BaseBranch) infoParts = append(infoParts, fmt.Sprintf("+%d -%d", pr.Additions, pr.Deletions)) infoParts = append(infoParts, fmt.Sprintf("%d commits", pr.CommitCount)) - if pr.Mergeable == "MERGEABLE" { - infoParts = append(infoParts, "mergeable") - } else if pr.Mergeable == "CONFLICTING" { + if pr.Mergeable == "CONFLICTING" { infoParts = append(infoParts, lipgloss.NewStyle().Foreground(colorDestructive).Render("conflicts")) } 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")) - } if pr.AgentCostUSD > 0 { infoParts = append(infoParts, lipgloss.NewStyle().Foreground(colorDimFg). Render(fmt.Sprintf("$%.2f", pr.AgentCostUSD))) @@ -86,6 +79,100 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri sep := lipgloss.NewStyle().Foreground(colorBorder). Render(strings.Repeat("─", min(innerWidth, 60))) + // ── Done state: merged or closed PRs ── + isDone := pr.State == "merged" || pr.State == "closed" + if isDone { + var doneMsg string + if pr.State == "merged" { + doneMsg = lipgloss.NewStyle(). + Foreground(colorDimFg). + Render(" \u2714 Merged \u2014 no further action required") + } else { + doneMsg = lipgloss.NewStyle(). + Foreground(colorDimFg). + Render(" \u25cf Closed \u2014 no further action required") + } + bodyLines = append(bodyLines, doneMsg) + bodyLines = append(bodyLines, sep) + } + + // ── Merge readiness summary line (skip for done PRs) ── + if !isDone { + var summaryParts []string + + // Approval status. + approved := false + changesRequested := false + for _, r := range pr.Reviews { + if r.State == "APPROVED" { + approved = true + } + if r.State == "CHANGES_REQUESTED" { + changesRequested = true + } + } + if changesRequested { + summaryParts = append(summaryParts, + styleDestructive.Render("✗")+" "+ + lipgloss.NewStyle().Foreground(colorDestructive).Render("changes requested")) + } else if approved { + summaryParts = append(summaryParts, + styleSafe.Render("✓")+" "+ + lipgloss.NewStyle().Foreground(colorDimFg).Render("approved")) + } else if len(pr.Reviews) == 0 { + summaryParts = append(summaryParts, + lipgloss.NewStyle().Foreground(colorDimFg).Render("○ no review")) + } + + // Checks summary. + if len(pr.Checks) > 0 { + passing, total := 0, len(pr.Checks) + for _, c := range pr.Checks { + if c.Conclusion == "SUCCESS" || c.Conclusion == "NEUTRAL" { + passing++ + } + } + if passing == total { + summaryParts = append(summaryParts, + styleSafe.Render("✓")+" "+ + lipgloss.NewStyle().Foreground(colorDimFg). + Render(fmt.Sprintf("checks (%d/%d)", passing, total))) + } else { + summaryParts = append(summaryParts, + styleDestructive.Render("✗")+" "+ + lipgloss.NewStyle().Foreground(colorDestructive). + Render(fmt.Sprintf("checks (%d/%d)", passing, total))) + } + } + + // Mergeable. + switch pr.Mergeable { + case "MERGEABLE": + summaryParts = append(summaryParts, + styleSafe.Render("✓")+" "+ + lipgloss.NewStyle().Foreground(colorDimFg).Render("mergeable")) + case "CONFLICTING": + summaryParts = append(summaryParts, + styleDestructive.Render("✗")+" "+ + lipgloss.NewStyle().Foreground(colorDestructive).Render("conflicts")) + } + + // Merge method. + if pr.MergeMethod != "" { + summaryParts = append(summaryParts, + lipgloss.NewStyle().Foreground(colorAccent).Render("⎇ "+pr.MergeMethod)) + } else { + summaryParts = append(summaryParts, + lipgloss.NewStyle().Foreground(colorWaiting).Render("⎇ unset")) + } + + if len(summaryParts) > 0 { + bodyLines = append(bodyLines, + " "+strings.Join(summaryParts, " ")) + bodyLines = append(bodyLines, sep) + } + } + // Checks section. if len(pr.Checks) > 0 { passing, total := 0, len(pr.Checks) diff --git a/tui/internal/tui/pr_zoom_test.go b/tui/internal/tui/pr_zoom_test.go index 8fda37a..278b5d0 100644 --- a/tui/internal/tui/pr_zoom_test.go +++ b/tui/internal/tui/pr_zoom_test.go @@ -469,6 +469,49 @@ func TestRenderPRZoom_CheckWithDuration(t *testing.T) { // === Empty PR (minimal data) === +func TestRenderPRZoom_NoDuplicateMergeableInHeader(t *testing.T) { + pr := testPR() + pr.Mergeable = "MERGEABLE" + pr.MergeMethod = "squash" + out := renderPRZoom(pr, 120, 25, 0) + lines := strings.Split(out, "\n") + // Line 2 (index 1) is the info line with branch, +/-, commits. + // It should NOT contain "mergeable" or "⎇" — those are in the readiness summary only. + if len(lines) < 2 { + t.Fatal("expected at least 2 lines") + } + headerLine := lines[1] + if strings.Contains(headerLine, "mergeable") { + t.Error("header line should not contain 'mergeable' — it's in readiness summary") + } + if strings.Contains(headerLine, "⎇") { + t.Error("header line should not contain merge method — it's in readiness summary") + } + // But the readiness summary (in body) should have them. + body := strings.Join(lines[2:], "\n") + if !strings.Contains(body, "mergeable") { + t.Error("readiness summary should contain 'mergeable'") + } + if !strings.Contains(body, "squash") { + t.Error("readiness summary should contain merge method") + } +} + +func TestRenderPRZoom_ConflictsStillInHeader(t *testing.T) { + pr := testPR() + pr.Mergeable = "CONFLICTING" + out := renderPRZoom(pr, 120, 25, 0) + lines := strings.Split(out, "\n") + if len(lines) < 2 { + t.Fatal("expected at least 2 lines") + } + // Conflicts should still show in header since it's urgent. + headerLine := lines[1] + if !strings.Contains(headerLine, "conflicts") { + t.Error("header line should still show 'conflicts' for CONFLICTING state") + } +} + func TestRenderPRZoom_MinimalPR(t *testing.T) { pr := client.TrackedPR{ Owner: "a", diff --git a/tui/internal/tui/queue.go b/tui/internal/tui/queue.go index f8ce616..a4dacd5 100644 --- a/tui/internal/tui/queue.go +++ b/tui/internal/tui/queue.go @@ -41,6 +41,16 @@ func renderQueue(sessions []client.Session, width, height int) string { name = s.SessionID[:min(8, len(s.SessionID))] } + // Separate tools into destructive and safe groups. + var destructiveTools, safeTools []client.PendingTool + for _, pt := range s.PendingTools { + if pt.Safety == "destructive" { + destructiveTools = append(destructiveTools, pt) + } else { + safeTools = append(safeTools, pt) + } + } + sessionID := s.SessionID[:min(8, len(s.SessionID))] sessionHeader := styleZoomHeader.Render(name) + " " + lipgloss.NewStyle(). @@ -49,12 +59,10 @@ func renderQueue(sessions []client.Session, width, height int) string { lines = append(lines, sessionHeader) - for _, pt := range s.PendingTools { + renderTool := func(pt client.PendingTool) string { marker := safetyMarker(pt.Safety) toolStyle := lipgloss.NewStyle().Foreground(colorFg).Bold(true) toolLine := fmt.Sprintf(" %s %s", marker, toolStyle.Render(pt.ToolName)) - - // Show key details of tool input. detail := toolInputSummary(pt) if detail != "" { detailWidth := innerWidth - 14 @@ -67,9 +75,31 @@ func renderQueue(sessions []client.Session, width, height int) string { Italic(true). Render(detail) } + return toolLine + } - lines = append(lines, toolLine) + // Destructive tools first with a section label. + if len(destructiveTools) > 0 { + lines = append(lines, + lipgloss.NewStyle().Foreground(colorDestructive).Bold(true). + Render(" \u26a0 Destructive:")) + for _, pt := range destructiveTools { + lines = append(lines, renderTool(pt)) + } + } + + // Safe tools with a section label (only if both groups non-empty). + if len(safeTools) > 0 { + if len(destructiveTools) > 0 { + lines = append(lines, + lipgloss.NewStyle().Foreground(colorRunning).Bold(true). + Render(" \u2713 Safe:")) + } + for _, pt := range safeTools { + lines = append(lines, renderTool(pt)) + } } + lines = append(lines, "") } diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 0cfe35a..cb274aa 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "regexp" + "sort" "strings" "github.com/charmbracelet/lipgloss" @@ -46,6 +47,26 @@ func disambiguateNames(sessions []client.Session) map[string]string { return result } +// statePriority returns lower numbers for higher-priority (more urgent) states. +func statePriority(s client.Session) int { + // Sessions with pending tools need attention first. + if len(s.PendingTools) > 0 { + return 0 + } + switch s.State { + case "running": + return 1 + case "waiting": + return 2 + case "idle": + return 3 + case "dead": + return 4 + default: + return 5 + } +} + // renderUnifiedStrip renders sessions + PRs in one strip with a separator. // It caps visible pills to fit within the given width, showing a "+N" // overflow indicator when pills are hidden. @@ -56,6 +77,60 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec emptyStyle.Render(" No active sessions or PRs")) } + // Sort sessions by attention priority: pending > running > waiting > idle > dead. + // Track the selected session ID so we can remap selectedIdx after sorting. + var selectedSessionID string + if selectedIdx >= 0 && selectedIdx < len(sessions) { + selectedSessionID = sessions[selectedIdx].SessionID + } + + // Work on a copy to avoid mutating the caller's slice. + sortedSessions := make([]client.Session, len(sessions)) + copy(sortedSessions, sessions) + sort.SliceStable(sortedSessions, func(i, j int) bool { + return statePriority(sortedSessions[i]) < statePriority(sortedSessions[j]) + }) + + // Remap selectedIdx to new position in sorted slice. + if selectedSessionID != "" { + for i, s := range sortedSessions { + if s.SessionID == selectedSessionID { + selectedIdx = i + break + } + } + } + sessions = sortedSessions + + // Filter dead sessions when there are many sessions (>= 8) and none of + // them have pending tools or are currently selected. Show a compact count. + deadCount := 0 + if len(sessions) >= 8 { + var activeAndIdle []client.Session + for _, s := range sessions { + isDead := s.State == "dead" + isSelectedSession := s.SessionID == selectedSessionID + hasPending := len(s.PendingTools) > 0 + if isDead && !isSelectedSession && !hasPending { + deadCount++ + } else { + activeAndIdle = append(activeAndIdle, s) + } + } + if deadCount > 0 { + // Remap selectedIdx for the shorter slice. + if selectedSessionID != "" { + for i, s := range activeAndIdle { + if s.SessionID == selectedSessionID { + selectedIdx = i + break + } + } + } + sessions = activeAndIdle + } + } + // Pre-compute disambiguated names for sessions. nameMap := disambiguateNames(sessions) @@ -70,6 +145,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec rendered string width int isSelected bool + state string // session state (empty for PR/summary pills) } // Build all pill entries. @@ -80,11 +156,64 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec rendered: p, width: lipgloss.Width(p), isSelected: i == selectedIdx, + state: s.State, }) } + // Append compact "(●N dead)" indicator if dead sessions were filtered. + if deadCount > 0 { + deadStr := lipgloss.NewStyle().Foreground(colorDimFg).Render(fmt.Sprintf("(\u25cf%d)", deadCount)) + allPills = append(allPills, pillEntry{ + rendered: deadStr, + width: lipgloss.Width(deadStr), + }) + } + + // Filter terminal PRs when active ones exist: hide merged/closed PRs from + // the visible strip and show a compact count indicator instead. + visiblePRs := prs + doneCount := 0 + hasActivePR := false + for _, p := range prs { + if p.State != "merged" && p.State != "closed" { + hasActivePR = true + break + } + } + if hasActivePR { + var filtered []client.TrackedPR + for _, p := range prs { + if p.State == "merged" || p.State == "closed" { + doneCount++ + } else { + filtered = append(filtered, p) + } + } + // Remap selectedIdx if we filtered out PRs before the selected one. + if selectedIdx >= len(sessions) { + origPRIdx := selectedIdx - len(sessions) + if origPRIdx < len(prs) { + selectedPR := prs[origPRIdx] + if selectedPR.State == "merged" || selectedPR.State == "closed" { + // Selected PR was filtered; keep it visible. + filtered = append(filtered, selectedPR) + selectedIdx = len(sessions) + len(filtered) - 1 + } else { + // Remap to new position in filtered slice. + for newI, p := range filtered { + if p.Number == selectedPR.Number && p.Owner == selectedPR.Owner { + selectedIdx = len(sessions) + newI + break + } + } + } + } + } + visiblePRs = filtered + } + // Separator between sessions and PRs. - hasSep := len(sessions) > 0 && len(prs) > 0 + hasSep := len(sessions) > 0 && len(visiblePRs) > 0 sepStr := "" sepWidth := 0 if hasSep { @@ -92,7 +221,11 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec sepWidth = lipgloss.Width(sepStr) + 2 // " │ " with surrounding spaces } - for i, p := range prs { + // sepBoundary is the allPills index where the separator should be inserted. + // Initially == len(sessions) (no summary pill prepended yet). + sepBoundary := len(sessions) + + for i, p := range visiblePRs { prIdx := len(sessions) + i pill := renderPRPill(p, prIdx == selectedIdx) allPills = append(allPills, pillEntry{ @@ -102,6 +235,55 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec }) } + // Append a compact "done" indicator if any PRs were filtered. + if doneCount > 0 { + doneStr := lipgloss.NewStyle().Foreground(colorDimFg).Render(fmt.Sprintf("(+%d done)", doneCount)) + // Add separator if no visible PRs were rendered (only done PRs). + if !hasSep && len(sessions) > 0 { + sepStr = lipgloss.NewStyle().Foreground(colorBorder).Render("│") + sepWidth = lipgloss.Width(sepStr) + 2 + hasSep = true + } + allPills = append(allPills, pillEntry{ + rendered: doneStr, + width: lipgloss.Width(doneStr), + isSelected: false, + }) + } + + // Prepend a compact state-group summary when there are many sessions. + // Format: "▶2 ⏸1 ✔5 ●2" — lets user scan state distribution instantly. + // Done after PR processing so we can update selectedIdx and sepBoundary cleanly. + if len(sessions) >= 5 { + counts := map[string]int{} + for _, s := range sessions { + counts[s.State]++ + } + var parts []string + if n := counts["running"]; n > 0 { + parts = append(parts, fmt.Sprintf("\u25b6%d", n)) + } + if n := counts["waiting"]; n > 0 { + parts = append(parts, fmt.Sprintf("\u23f8%d", n)) + } + if n := counts["idle"]; n > 0 { + parts = append(parts, fmt.Sprintf("\u2714%d", n)) + } + if n := counts["dead"]; n > 0 { + parts = append(parts, fmt.Sprintf("\u25cf%d", n)) + } + if len(parts) > 0 { + summaryStr := lipgloss.NewStyle().Foreground(colorDimFg).Render(strings.Join(parts, " ")) + summaryPill := pillEntry{rendered: summaryStr, width: lipgloss.Width(summaryStr)} + // Prepend: shift all indices by 1. + allPills = append([]pillEntry{summaryPill}, allPills...) + if selectedIdx >= 0 { + selectedIdx++ + } + sepBoundary++ // separator now one position further right + } + } + // Fit pills within budget, always including the selected pill. // Strategy: include pills left-to-right until budget exhausted. // If selected pill would be excluded, shift the visible window. @@ -144,7 +326,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec // Everything fits — render all. var pills []string for i, p := range allPills { - if hasSep && i == len(sessions) { + if hasSep && i == sepBoundary { pills = append(pills, sepStr) } pills = append(pills, p.rendered) @@ -167,7 +349,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec if visEnd+1 < totalPills { nextW := allPills[visEnd+1].width + spaceWidth // Account for separator if crossing the boundary. - if hasSep && visEnd+1 == len(sessions) { + if hasSep && visEnd+1 == sepBoundary { nextW += sepWidth } // Reserve space for left overflow indicator. @@ -188,7 +370,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec // Try left. if visStart-1 >= 0 { nextW := allPills[visStart-1].width + spaceWidth - if hasSep && visStart == len(sessions) { + if hasSep && visStart == sepBoundary { nextW += sepWidth } leftOverflow := 0 @@ -210,35 +392,80 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec } } + // overflowLabel builds an overflow indicator like "+3" or "+3(▶2⏸1)" + // for hidden pills. If any hidden pills are active sessions (running/waiting), + // the state breakdown is shown to prevent "+N confusion". + overflowLabel := func(hiddenPills []pillEntry) string { + n := len(hiddenPills) + if n == 0 { + return "" + } + stateCounts := map[string]int{} + for _, p := range hiddenPills { + if p.state != "" { + stateCounts[p.state]++ + } + } + var stateParts []string + if c := stateCounts["running"]; c > 0 { + stateParts = append(stateParts, fmt.Sprintf("\u25b6%d", c)) + } + if c := stateCounts["waiting"]; c > 0 { + stateParts = append(stateParts, fmt.Sprintf("\u23f8%d", c)) + } + if len(stateParts) > 0 { + return fmt.Sprintf("+%d(%s)", n, strings.Join(stateParts, "")) + } + return fmt.Sprintf("+%d", n) + } + // Build visible pills with overflow indicators. var pills []string if visStart > 0 { - pills = append(pills, overflowStyle.Render(fmt.Sprintf("+%d", visStart))) + pills = append(pills, overflowStyle.Render(overflowLabel(allPills[:visStart]))) } for i := visStart; i <= visEnd; i++ { - if hasSep && i == len(sessions) && visStart <= len(sessions)-1 { + if hasSep && i == sepBoundary && visStart <= sepBoundary-1 { pills = append(pills, sepStr) } pills = append(pills, allPills[i].rendered) } if visEnd < totalPills-1 { - pills = append(pills, overflowStyle.Render(fmt.Sprintf("+%d", totalPills-1-visEnd))) + pills = append(pills, overflowStyle.Render(overflowLabel(allPills[visEnd+1:]))) } row := lipgloss.JoinHorizontal(lipgloss.Center, interleave(pills, " ")...) return styleStripBar.Width(width).Render(row) } +// prStateNeedsTitle returns true for PR states where the title adds context. +// Critical states (failing checks, needs review) show the title so the user +// can act. Non-critical states (running checks, passing) just show number. +func prStateNeedsTitle(state string) bool { + switch state { + case "checks_failing", "approved": + return true + default: + return false + } +} + // renderPRPill renders a single PR pill in the strip. func renderPRPill(p client.TrackedPR, selected bool) string { icon := prPillIcon(p.State) - // For merged PRs, just show "#N" since the title no longer matters. + // Show title only for: selected pills, critical states (failing/approved), + // or when merged/closed (which show compact "#N" anyway — handled below). var label string if p.State == "merged" || p.State == "closed" { + // Terminal state: just number, no title needed. label = fmt.Sprintf("%s #%d", icon, p.Number) - } else { + } else if selected || prStateNeedsTitle(p.State) { + // Important: show title for context. label = fmt.Sprintf("%s #%d %s", icon, p.Number, truncateWordBoundary(p.Title, 15)) + } else { + // Non-critical unselected: compact — icon + repo + number. + label = fmt.Sprintf("%s %s#%d", icon, p.Repo, p.Number) } sc := prStateColor(p.State) @@ -247,8 +474,16 @@ func renderPRPill(p client.TrackedPR, selected bool) string { Padding(0, 1). Foreground(sc) + // Critical unselected PRs (failing checks, approved awaiting merge) get + // bold + dim background tint to signal urgency without the full border. + if !selected && prStateNeedsTitle(p.State) { + dimBg := prStateDimBg(p.State) + style = style.Bold(true).Background(dimBg) + } + if selected { - style = style. + style = lipgloss.NewStyle(). + Padding(0, 1). Bold(true). Foreground(lipgloss.ANSIColor(15)). Background(sc). @@ -276,6 +511,18 @@ func prPillIcon(state string) string { } } +// prStateDimBg returns a muted background for critical unselected PR pills. +func prStateDimBg(state string) lipgloss.TerminalColor { + switch state { + case "checks_failing": + return lipgloss.ANSIColor(1) // dark red — failing is urgent + case "approved": + return lipgloss.ANSIColor(2) // dark green — approved/ready to merge + default: + return lipgloss.ANSIColor(0) // black (no tint) + } +} + // interleave inserts a separator between each element. func interleave(items []string, sep string) []string { if len(items) == 0 { diff --git a/tui/internal/tui/zoom.go b/tui/internal/tui/zoom.go index da1717c..2d08d51 100644 --- a/tui/internal/tui/zoom.go +++ b/tui/internal/tui/zoom.go @@ -232,6 +232,7 @@ func renderZoom(s client.Session, width, height int, scrollOffset int) string { return strings.Join(renderedLines, "\n") } + func activityIcon(actType string) string { switch actType { case "tool_use":