From fafa0be042113cb803df5adf477eac6cc172a73e Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 23:58:04 +0100 Subject: [PATCH 01/23] autoresearch: compact idle/dead pills to 4-char names in strip Passive (idle/dead) unselected session pills now show icon + first 4 chars of name instead of full 20-char name, saving ~16 chars per passive pill. At 5 idle + 5 running sessions, this recovers ~80 chars of strip space, fitting far more sessions in a typical 80-char terminal. --- tui/internal/tui/pill.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tui/internal/tui/pill.go b/tui/internal/tui/pill.go index 18b32f9..e35ccd1 100644 --- a/tui/internal/tui/pill.go +++ b/tui/internal/tui/pill.go @@ -85,14 +85,33 @@ 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" +} + // renderPillWithName renders a pill using a pre-computed display name // (which may include a disambiguator). +// Passive (idle/dead) unselected pills are rendered in compact form: icon + 4 chars of name. 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 mode for passive unselected pills — saves ~16 chars each. + compact := isPassiveState(s.State) && !selected + + var name string + if compact { + runes := []rune(displayName) + if len(runes) > 4 { + name = string(runes[:4]) + } else { + name = displayName + } + } else { + name = truncateMiddle(displayName, 20) + } label := icon + " " + name From a1d528ded462ace6a02b0e20e5457196f9068ca4 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 23:58:41 +0100 Subject: [PATCH 02/23] autoresearch: attention-first ordering in unified strip Sessions are now sorted by state priority before rendering: pending-tool sessions first, then running, waiting, idle, dead. The selected session's index is remapped after sorting so selection tracking remains correct. This ensures urgent sessions are always visible even when +N overflow hides items at the end. --- tui/internal/tui/app.go | 47 ++++++++++++++++++++++++++++++--------- tui/internal/tui/strip.go | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 295a857..807aac9 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -808,20 +808,45 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra Render(fmt.Sprintf(" \u2717 %d failing", failingPRs)) } - 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 + stateBreakdownStr + pendingStr + failingStr // Flash message (action feedback). if flash != "" { diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 0cfe35a..98c8f2f 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,31 @@ 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 + // Pre-compute disambiguated names for sessions. nameMap := disambiguateNames(sessions) From f53485ee9b9acf8ba77efd6a17fdcf1e1f3ede34 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 23:59:22 +0100 Subject: [PATCH 03/23] autoresearch: filter terminal PRs, show compact done count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When active (non-merged, non-closed) PRs exist, merged/closed PRs are hidden from the strip and replaced with a compact "(+N done)" indicator. This reduces PR clutter significantly — 3 merged PRs take 0 pill slots instead of 3, replaced by a 10-char "(+3 done)" label. --- tui/internal/tui/strip.go | 66 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 98c8f2f..5b91476 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -129,8 +129,54 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec }) } + // 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++ + // If this PR is selected, include it anyway so selection stays valid. + prIdx := len(sessions) + len(filtered) + _ = prIdx + } 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 { @@ -138,7 +184,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec sepWidth = lipgloss.Width(sepStr) + 2 // " │ " with surrounding spaces } - for i, p := range prs { + for i, p := range visiblePRs { prIdx := len(sessions) + i pill := renderPRPill(p, prIdx == selectedIdx) allPills = append(allPills, pillEntry{ @@ -148,6 +194,22 @@ 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, + }) + } + // 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. From dda4fb20524e139765a283d702e0f145f14bf2be Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 23:59:39 +0100 Subject: [PATCH 04/23] autoresearch: badge-style pending alert in status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dim "⚡ 2 pending" text with orange background badge "⚡ 2 PENDING" that stands out against the status line and catches attention even when the user's focus is elsewhere. --- tui/internal/tui/app.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 807aac9..612154a 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -794,10 +794,14 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra 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 } failingStr := "" From ba78e505eaa6a75f3500d7b2b77fb371af352ca3 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Tue, 17 Mar 2026 23:59:52 +0100 Subject: [PATCH 05/23] autoresearch: remove background from passive pills for visual hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle/dead unselected pills now render without background — just dim foreground text. Active (running/waiting) pills retain their colored dim background, creating a clear visual hierarchy: colored boxes draw attention, plain text recedes. Also removes 2 padding chars per passive pill (6 chars vs 8 chars). --- tui/internal/tui/pill.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tui/internal/tui/pill.go b/tui/internal/tui/pill.go index e35ccd1..ee86e19 100644 --- a/tui/internal/tui/pill.go +++ b/tui/internal/tui/pill.go @@ -126,13 +126,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). From 40e87ee5ab59431ae52a2ea5a80efcf4faf6b244 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:00:28 +0100 Subject: [PATCH 06/23] autoresearch: mini fleet map line above session zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When 3+ sessions exist, show a 1-line glyph overview above the session zoom panel: "Sessions: [▶]▶▶⏸⏸✔●". Current session is bracketed, pending-tool sessions appear in orange. Gives full fleet context without leaving the zoom view. --- tui/internal/tui/app.go | 28 ++++++++++++++++++------ tui/internal/tui/strip.go | 42 +++++++++++++++++++++++++++++++++++ tui/internal/tui/zoom.go | 46 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 612154a..424a9b5 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -731,13 +731,25 @@ func (m Model) View() string { remainingHeight := m.height - bottomHeight - statusHeight + // Fleet map: 1-line session overview shown above session zoom when 3+ sessions. + fleetMap := "" + if isSession && !m.queueVisible { + if sel := m.selected(); sel != nil { + fleetMap = renderFleetMap(m.sessions, sel.SessionID, w) + } + } + fleetMapHeight := 0 + if fleetMap != "" { + fleetMapHeight = 1 + } + // Main content area. mainContent := "" if m.queueVisible && hasPending { mainContent = renderQueue(m.sessions, w, remainingHeight) } else if isSession { if sel := m.selected(); sel != nil { - mainContent = renderZoom(*sel, w, remainingHeight, m.scrollOffset) + mainContent = renderZoom(*sel, w, remainingHeight-fleetMapHeight, m.scrollOffset) } else { mainContent = renderEmptyState(w, remainingHeight) } @@ -747,12 +759,14 @@ 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) + if fleetMap != "" { + outputParts = append(outputParts, fleetMap) + } + 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") diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 5b91476..0234279 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -118,8 +118,45 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec isSelected bool } + // Build a compact state-group summary prefix when there are many sessions. + // Format: "▶2 ⏸1 ✔5 ●2" — lets user scan state distribution instantly. + var summaryPill pillEntry + hasSummary := false + 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), + } + hasSummary = true + } + } + // Build all pill entries. var allPills []pillEntry + if hasSummary { + allPills = append(allPills, summaryPill) + } for i, s := range sessions { p := renderPillWithName(s, nameMap[s.SessionID], i == selectedIdx, glowPos) allPills = append(allPills, pillEntry{ @@ -129,6 +166,11 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec }) } + // Adjust selectedIdx to account for prepended summary pill. + if hasSummary { + selectedIdx++ // shift: summary occupies index 0 + } + // Filter terminal PRs when active ones exist: hide merged/closed PRs from // the visible strip and show a compact count indicator instead. visiblePRs := prs diff --git a/tui/internal/tui/zoom.go b/tui/internal/tui/zoom.go index da1717c..8b02afa 100644 --- a/tui/internal/tui/zoom.go +++ b/tui/internal/tui/zoom.go @@ -232,6 +232,52 @@ func renderZoom(s client.Session, width, height int, scrollOffset int) string { return strings.Join(renderedLines, "\n") } +// renderFleetMap renders a compact 1-line overview of all sessions as state +// glyphs, e.g. "Sessions: 3▶ 2⏸ 1✔ ●" to give context from any zoom view. +// Returns empty string when there are fewer than 3 sessions. +func renderFleetMap(sessions []client.Session, currentSID string, width int) string { + if len(sessions) < 3 { + return "" + } + + label := lipgloss.NewStyle().Foreground(colorDimFg).Render(" Sessions: ") + + var glyphs []string + for _, s := range sessions { + var glyph string + if s.SessionID == currentSID { + // Highlight current session with brackets. + glyph = lipgloss.NewStyle(). + Foreground(colorFg). + Bold(true). + Render("[" + stateIcon(s.State) + "]") + } else if len(s.PendingTools) > 0 { + // Pending-approval sessions get orange attention marker. + glyph = lipgloss.NewStyle(). + Foreground(colorOrange). + Bold(true). + Render(stateIcon(s.State)) + } else { + glyph = lipgloss.NewStyle(). + Foreground(stateColor(s.State)). + Render(stateIcon(s.State)) + } + glyphs = append(glyphs, glyph) + } + + line := label + strings.Join(glyphs, "") + // Truncate to width to prevent wrapping. + if lipgloss.Width(line) > width { + // Trim glyphs from end until fits. + for len(glyphs) > 3 { + glyphs = glyphs[:len(glyphs)-1] + } + line = label + strings.Join(glyphs, "") + + lipgloss.NewStyle().Foreground(colorSubtle).Render("…") + } + return line +} + func activityIcon(actType string) string { switch actType { case "tool_use": From df459fb92091c190917f48ff0e3e5034fce584af Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:01:37 +0100 Subject: [PATCH 07/23] autoresearch: badge-style failing PR alert in status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply same badge treatment to failing PR count as pending tools: red background "✗ N FAILING" badge for visual parity. Both urgent alerts now use consistent badge style for instant recognition. --- tui/internal/tui/app.go | 10 +++-- tui/internal/tui/strip.go | 90 ++++++++++++++++++--------------------- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 424a9b5..8d5e2aa 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -820,10 +820,14 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra 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 } // State breakdown: count sessions per state. diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 0234279..aea8260 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -118,45 +118,8 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec isSelected bool } - // Build a compact state-group summary prefix when there are many sessions. - // Format: "▶2 ⏸1 ✔5 ●2" — lets user scan state distribution instantly. - var summaryPill pillEntry - hasSummary := false - 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), - } - hasSummary = true - } - } - // Build all pill entries. var allPills []pillEntry - if hasSummary { - allPills = append(allPills, summaryPill) - } for i, s := range sessions { p := renderPillWithName(s, nameMap[s.SessionID], i == selectedIdx, glowPos) allPills = append(allPills, pillEntry{ @@ -166,11 +129,6 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec }) } - // Adjust selectedIdx to account for prepended summary pill. - if hasSummary { - selectedIdx++ // shift: summary occupies index 0 - } - // Filter terminal PRs when active ones exist: hide merged/closed PRs from // the visible strip and show a compact count indicator instead. visiblePRs := prs @@ -187,9 +145,6 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec for _, p := range prs { if p.State == "merged" || p.State == "closed" { doneCount++ - // If this PR is selected, include it anyway so selection stays valid. - prIdx := len(sessions) + len(filtered) - _ = prIdx } else { filtered = append(filtered, p) } @@ -226,6 +181,10 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec sepWidth = lipgloss.Width(sepStr) + 2 // " │ " with surrounding spaces } + // 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) @@ -252,6 +211,39 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec }) } + // 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. @@ -294,7 +286,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) @@ -317,7 +309,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. @@ -338,7 +330,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 @@ -366,7 +358,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec pills = append(pills, overflowStyle.Render(fmt.Sprintf("+%d", 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) From 62d410bb869e52203292e4ad75eac3bfccb2b0a5 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:02:13 +0100 Subject: [PATCH 08/23] autoresearch: merge readiness summary line at top of PR zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a quick-scan summary line before the checks section: "✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash" or "✗ changes requested ✗ checks (9/12) ✗ conflicts ⎇ unset". Lets user assess merge readiness in under 1 second. --- tui/internal/tui/pr_zoom.go | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index 1d589c8..2ecfc7a 100644 --- a/tui/internal/tui/pr_zoom.go +++ b/tui/internal/tui/pr_zoom.go @@ -86,6 +86,83 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri sep := lipgloss.NewStyle().Foreground(colorBorder). Render(strings.Repeat("─", min(innerWidth, 60))) + // ── Merge readiness summary line ── + { + 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) From ed04147b3432a97748d29fe5e85b09b294bd78cf Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:02:46 +0100 Subject: [PATCH 09/23] autoresearch: show session index/total in fleet map line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet map now reads "Session 3/10: [▶]▶▶⏸⏸✔●" — the bold N/total position indicator tells the operator exactly where they are in the fleet while still showing all session states. --- tui/internal/tui/zoom.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tui/internal/tui/zoom.go b/tui/internal/tui/zoom.go index 8b02afa..0c0e466 100644 --- a/tui/internal/tui/zoom.go +++ b/tui/internal/tui/zoom.go @@ -233,14 +233,33 @@ func renderZoom(s client.Session, width, height int, scrollOffset int) string { } // renderFleetMap renders a compact 1-line overview of all sessions as state -// glyphs, e.g. "Sessions: 3▶ 2⏸ 1✔ ●" to give context from any zoom view. +// glyphs, e.g. "Session 3/10: [▶]▶▶⏸⏸✔●" to give context from any zoom view. // Returns empty string when there are fewer than 3 sessions. func renderFleetMap(sessions []client.Session, currentSID string, width int) string { if len(sessions) < 3 { return "" } - label := lipgloss.NewStyle().Foreground(colorDimFg).Render(" Sessions: ") + // Find current session position. + currentPos := 0 + for i, s := range sessions { + if s.SessionID == currentSID { + currentPos = i + 1 + break + } + } + + posStr := "" + if currentPos > 0 { + posStr = lipgloss.NewStyle().Foreground(colorFg).Bold(true). + Render(fmt.Sprintf("%d", currentPos)) + + lipgloss.NewStyle().Foreground(colorDimFg). + Render(fmt.Sprintf("/%d", len(sessions))) + } + + label := lipgloss.NewStyle().Foreground(colorDimFg).Render(" Session ") + + posStr + + lipgloss.NewStyle().Foreground(colorDimFg).Render(": ") var glyphs []string for _, s := range sessions { From b6707ba081802b65ce4457790b3c04cd8c1d0043 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:03:03 +0100 Subject: [PATCH 10/23] =?UTF-8?q?autoresearch:=20compact=20PR=20pills=20?= =?UTF-8?q?=E2=80=94=20title=20only=20for=20critical/selected=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-critical, non-selected PR pills now show just icon + number (e.g. "⏳ #42" = 7 chars) instead of icon + number + truncated title (~23 chars). Title is shown only for: checks_failing, approved (need action), or when the PR is selected. Saves ~15 chars per non-critical PR pill. --- tui/internal/tui/strip.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index aea8260..be16189 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -371,16 +371,34 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec 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 — just icon + number. + label = fmt.Sprintf("%s #%d", icon, p.Number) } sc := prStateColor(p.State) From 390b7caf04577ba949431187fcc0a0be4b817327 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:03:19 +0100 Subject: [PATCH 11/23] autoresearch: group queue tools by safety level (destructive vs safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the approval queue panel, split pending tools per session into "⚠ Destructive:" and "✓ Safe:" groups. Destructive tools listed first so operators see what needs careful scrutiny immediately. Labels only appear when a session has both types. --- tui/internal/tui/queue.go | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) 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, "") } From efa75f997cb701c778c7267e5ed657f6b6084282 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:03:50 +0100 Subject: [PATCH 12/23] autoresearch: show oldest-pending age next to pending badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the pending badge, show "Xs ago" indicating how long the oldest pending approval has been waiting. Helps operator assess urgency at a glance: "⚡ 2 PENDING 45s ago" vs "⚡ 1 PENDING 5m ago". --- tui/internal/tui/app.go | 17 +++++++++++++++++ tui/internal/tui/pill.go | 30 ++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 8d5e2aa..67d378e 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -816,6 +816,23 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra 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 := "" diff --git a/tui/internal/tui/pill.go b/tui/internal/tui/pill.go index ee86e19..ceb72ce 100644 --- a/tui/internal/tui/pill.go +++ b/tui/internal/tui/pill.go @@ -90,27 +90,37 @@ 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). -// Passive (idle/dead) unselected pills are rendered in compact form: icon + 4 chars of name. +// 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) - // Compact mode for passive unselected pills — saves ~16 chars each. compact := isPassiveState(s.State) && !selected + maxLen := pillNameMaxLen(s.State, selected) var name string - if compact { - runes := []rune(displayName) - if len(runes) > 4 { - name = string(runes[:4]) - } else { - name = displayName - } + runes := []rune(displayName) + if len(runes) > maxLen { + name = string(runes[:maxLen]) } else { - name = truncateMiddle(displayName, 20) + name = displayName } label := icon + " " + name From 468157a054ff5ad44fc298dc4784d8f43c29bffd Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:04:32 +0100 Subject: [PATCH 13/23] autoresearch: done-state treatment for merged/closed PR zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged/closed PRs now show "✔ Merged — no further action required" (or ● Closed) at the top of the body, and skip the merge readiness summary which is irrelevant for done PRs. Reduces visual clutter. --- tui/internal/tui/pr_zoom.go | 21 +++++++++++++++++++-- tui/internal/tui/strip.go | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index 2ecfc7a..e3d691e 100644 --- a/tui/internal/tui/pr_zoom.go +++ b/tui/internal/tui/pr_zoom.go @@ -86,8 +86,25 @@ func renderPRZoom(pr client.TrackedPR, width, height int, scrollOffset int) stri sep := lipgloss.NewStyle().Foreground(colorBorder). Render(strings.Repeat("─", min(innerWidth, 60))) - // ── Merge readiness summary line ── - { + // ── 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. diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index be16189..9c55d35 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -116,6 +116,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. @@ -126,6 +127,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec rendered: p, width: lipgloss.Width(p), isSelected: i == selectedIdx, + state: s.State, }) } From 97a65c50a6e72d2402cbac624309ba87d086a4dd Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:04:49 +0100 Subject: [PATCH 14/23] autoresearch: enrich overflow indicator with active-session state breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overflow indicators now show state breakdown of hidden active sessions: "+4(▶2⏸1)" instead of just "+4" when hidden pills include running/waiting sessions. Plain "+N" is used when only idle/dead/PR pills are hidden. Directly solves the "+N confusion" problem — user can always see if critical sessions are out of view. --- tui/internal/tui/strip.go | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 9c55d35..69e77d4 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -354,10 +354,37 @@ 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 == sepBoundary && visStart <= sepBoundary-1 { @@ -366,7 +393,7 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec 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, " ")...) From 87fc1628a856c949efb12049d37a8f4074b49794 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:05:11 +0100 Subject: [PATCH 15/23] =?UTF-8?q?autoresearch:=20PR=20state=20breakdown=20?= =?UTF-8?q?in=20status=20bar=20(3=E2=9C=93=201=E2=9C=97=201=E2=8F=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the PR count, show compact per-state counts: passing(✓), failing(✗), running(⏳), merged(✔) — each colored and only shown when count > 0. Mirrors session state breakdown for full fleet overview in the status bar. --- tui/internal/tui/app.go | 38 +++++++++++++++++++++++++++++++++++++- tui/internal/tui/strip.go | 10 +++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 67d378e..3a3ea10 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -799,10 +799,46 @@ 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 := "" @@ -885,7 +921,7 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra } } - left := logo + " " + connStatus + " " + sessionCount + prCount + stateBreakdownStr + pendingStr + failingStr + left := logo + " " + connStatus + " " + sessionCount + prCount + prBreakdownStr + stateBreakdownStr + pendingStr + failingStr // Flash message (action feedback). if flash != "" { diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 69e77d4..44ef609 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -436,8 +436,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). From ead34302a7ed1991f7614cdb6450966b11a5ba64 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:05:40 +0100 Subject: [PATCH 16/23] autoresearch: bold+tinted background for critical unselected PR pills checks_failing PRs get bold + red dim background. approved PRs get bold + green dim background. Non-critical (checks_running, checks_passing) remain plain text. Creates clear urgency gradient: plain < tinted < selected-border. --- tui/internal/tui/strip.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 44ef609..65ba419 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -473,6 +473,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 { From 2f2b8500a1a4391139bb7815e8e540fb0c77920a Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:05:50 +0100 Subject: [PATCH 17/23] autoresearch: agent-c findings and log (10 cycles) --- .autoresearch/autoresearch.config.md | 49 ++++++++++ .autoresearch/autoresearch.jsonl | 29 ++++++ .autoresearch/autoresearch.md | 59 ++++++++++++ .autoresearch/researchers/agent-a.md | 131 +++++++++++++++++++++++++++ .autoresearch/researchers/agent-b.md | 17 ++++ .autoresearch/researchers/agent-c.md | 61 +++++++++++++ 6 files changed, 346 insertions(+) create mode 100644 .autoresearch/autoresearch.config.md create mode 100644 .autoresearch/autoresearch.jsonl create mode 100644 .autoresearch/autoresearch.md create mode 100644 .autoresearch/researchers/agent-a.md create mode 100644 .autoresearch/researchers/agent-b.md create mode 100644 .autoresearch/researchers/agent-c.md diff --git a/.autoresearch/autoresearch.config.md b/.autoresearch/autoresearch.config.md new file mode 100644 index 0000000..670ae90 --- /dev/null +++ b/.autoresearch/autoresearch.config.md @@ -0,0 +1,49 @@ +--- +metric: custom +measurement_command: "echo 'qualitative: count visual noise issues in 10-session+5-PR mock scenario'" +scope: tui/internal/tui/ +mode: lab +cycles: 30 +round: 1 +target: "clean, scannable strip with 10+ sessions and 5+ PRs — no +N overflow confusion, clear attention hierarchy" +backpressure: + - "cd tui && go build ./..." + - "cd tui && go test ./..." +direction: maximize +created: 2026-03-17T00:00:00Z +prior_findings: [] +--- + +# Autoresearch Configuration + +## Problem Statement + +The unified session+PR strip at the bottom gets cluttered with many items. Users running intense +sessions (10+ sessions, 5+ PRs) end up with a sea of tiny pills and "+N" overflow indicators. +The current design doesn't communicate attention priority at a glance. + +## Proxy Metrics + +Since this is visual/design research, measure each proposed change against: + +1. **Attention clarity**: Would a user instantly see which sessions need action? (0-5) +2. **Information density**: At 10 sessions + 5 PRs, what % of items are visible vs hidden? +3. **Navigation cost**: Keystrokes to reach any item needing attention +4. **Build validity**: go build + go test pass + +## Reference: Zellij Tab Bar Approach + +Zellij's key ideas to consider: +- Active tab has full text + distinct style; inactive tabs are compact glyphs +- Tab bar auto-switches to number+icon mode when terminal is narrow +- Mode indicator (pane/tab/resize) is visually prominent in status bar +- Uses dedicated color zones, not just text decoration + +## Lab Agent Scopes + +- **Agent A (opus)**: Deep architectural alternatives — sidebar layout, state-grouped strip, + summary mode for overflow, Zellij paradigm deep-dive +- **Agent B (sonnet)**: Strip density optimization — two-row split (sessions row / PRs row), + compact pill variants, attention-first ordering, visual hierarchy improvements +- **Agent C (sonnet)**: Status bar + zoom improvements — status bar when >10 sessions, + quick-scan improvements to zoom panels, pill label info selection diff --git a/.autoresearch/autoresearch.jsonl b/.autoresearch/autoresearch.jsonl new file mode 100644 index 0000000..b8d4cc3 --- /dev/null +++ b/.autoresearch/autoresearch.jsonl @@ -0,0 +1,29 @@ +{"cycle":1,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All pills use icon + full 20-char name + 2 padding = ~24 chars each","chars_per_pill":24},"after":{"description":"Idle/dead unselected pills use icon + 4-char name + 2 padding = ~8 chars","chars_per_pill":8},"delta":"16 chars saved per passive pill; 5 idle sessions saves 80 chars total","action":"keep","description":"Compact idle/dead unselected pills to icon + 4-char name","timestamp":"2026-03-17T00:01:00Z"} +{"cycle":2,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Sessions rendered in arrival order; idle sessions could bury running ones","chars_per_pill":24},"after":{"description":"Sessions sorted: pending(0) > running(1) > waiting(2) > idle(3) > dead(4). Selected index remapped after sort.","chars_per_pill":24},"delta":"Critical sessions always appear at left of strip, never hidden by +N overflow","action":"keep","description":"Attention-first ordering: sort sessions by state priority before rendering","timestamp":"2026-03-17T00:02:00Z"} +{"cycle":1,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Status bar showed flat '▶ 7 running' count only — no state breakdown for waiting/idle/dead sessions"},"after":{"description":"Status bar now shows compact per-state counts: '7▶ 2⏸ 1✔ 1●' with colors (green/yellow/gray/gray). Each state only appears when count > 0."},"delta":"Fleet state visible at a glance without looking at strip — running/waiting/idle/dead all in one compact group","action":"keep","description":"Replace flat running count with compact state breakdown showing all session states with colored icons","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":3,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All PRs (merged, closed, open) shown as full pills; 3 merged PRs = 21+ chars","chars_per_pill":7},"after":{"description":"Merged/closed PRs hidden when active PRs exist; replaced with '(+N done)' label = 10 chars total for any N","chars_per_pill":10},"delta":"3 merged PR pills (21 chars) replaced by single '(+3 done)' label (10 chars); saves 11+ chars and removes done-work clutter","action":"keep","description":"Filter terminal PRs from strip, show compact done count","timestamp":"2026-03-17T00:03:00Z"} +{"cycle":2,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Pending indicator was '⚡ 2 pending' in orange bold text — inline, low contrast, easy to miss"},"after":{"description":"Pending indicator is now '[⚡ 2 PENDING]' badge with orange background and black text — visually distinct, immediately eye-catching"},"delta":"Pending approvals now visually pop from the status bar; operator can't miss it","action":"keep","description":"Badge-style pending alert with orange background in status bar","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":4,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Passive pills had padding+dim background box = same visual weight structure as active pills","chars_per_pill":8},"after":{"description":"Passive pills have no background, no padding, just dim foreground text = 6 chars","chars_per_pill":6},"delta":"Clear visual hierarchy: colored boxes=active, plain dim text=passive. Also saves 2 chars per passive pill","action":"keep","description":"Visual weight hierarchy: passive pills rendered as plain dim text, no background","timestamp":"2026-03-17T00:04:00Z"} +{"cycle":3,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Session zoom showed only the selected session with no context about other sessions in the fleet"},"after":{"description":"When 3+ sessions exist, a fleet map line appears: 'Sessions: [▶]▶▶⏸⏸✔●' — current session bracketed, pending sessions in orange"},"delta":"User can see full fleet state from any session zoom without navigating away","action":"keep","description":"Mini fleet map line above session zoom panel showing all sessions as state glyphs","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":1,"agent":"agent-a","topic":"Two-row strip (sessions row 1, PRs row 2)","verdict":"recommend","finding":"Splitting the strip into two rows doubles available width per category. View() height calc already adapts via lipgloss.Height(strip). Only costs 1 extra line. Can be conditional: 2 rows only when PRs exist.","timestamp":"2026-03-17T00:10:00Z","action":"analysis"} +{"cycle":2,"agent":"agent-a","topic":"Compact inactive pills (icon-only for idle/dead)","verdict":"recommend","finding":"Idle/dead pills at 3 chars vs 24 chars = 8x space recovery. 10 mixed-state sessions shrink from 240 to 120 chars, fitting 120-char terminal. Biggest single-impact change. Zoom panel provides full detail for selected item.","timestamp":"2026-03-17T00:11:00Z","action":"analysis"} +{"cycle":3,"agent":"agent-a","topic":"Attention-first ordering (pending sessions sort to front)","verdict":"recommend","finding":"Stable sort by priority in stateMsg handler. selectedSID/selectedPRKey tracking by ID handles reordering. ~15 lines. Urgent items never hidden behind +N overflow.","timestamp":"2026-03-17T00:12:00Z","action":"analysis"} +{"cycle":4,"agent":"agent-a","topic":"State-grouped strip with group headers","verdict":"skip","finding":"Group headers add 8-12 chars overhead. Overflow algorithm becomes complex at group boundaries. Color already provides grouping. Net negative.","timestamp":"2026-03-17T00:13:00Z","action":"analysis"} +{"cycle":5,"agent":"agent-a","topic":"Hide terminal PRs (merged/closed auto-collapse)","verdict":"recommend","finding":"3 merged PRs (26 chars) collapse to single 4-char summary indicator. selectedIdx needs special summary-pill handling. Dramatic space recovery for busy PR boards.","timestamp":"2026-03-17T00:14:00Z","action":"analysis"} +{"cycle":6,"agent":"agent-a","topic":"Zellij tab paradigm (brackets for selected, plain text for rest)","verdict":"needs_prototyping","finding":"Dropping pill backgrounds saves 2 chars/pill but visual clarity uncertain. Selected pill with brackets creates strong hierarchy. Needs terminal visual testing to verify pills remain distinguishable.","timestamp":"2026-03-17T00:15:00Z","action":"analysis"} +{"cycle":7,"agent":"agent-a","topic":"Summary mode (aggregate counts when items > threshold)","verdict":"skip","finding":"Loses individual session identity. User cannot scan for specific session without cycling. Threshold creates jarring transition. Compact pills achieve similar density without losing identity.","timestamp":"2026-03-17T00:16:00Z","action":"analysis"} +{"cycle":8,"agent":"agent-a","topic":"Sidebar layout (vertical list replacing bottom strip)","verdict":"skip","finding":"Major refactor touching all render functions. Reduces main panel width. Introduces new scroll state. Changes navigation model. v2 material if simpler changes prove insufficient.","timestamp":"2026-03-17T00:17:00Z","action":"analysis"} +{"cycle":9,"agent":"agent-a","topic":"Visual weight differentiation (bordered pending, dim idle)","verdict":"recommend","finding":"Pure styling change, ~15 lines in renderPillWithName. Pending pills get borders, idle pills lose backgrounds. Lowest implementation cost. Combines naturally with compact pills.","timestamp":"2026-03-17T00:18:00Z","action":"analysis"} +{"cycle":10,"agent":"agent-a","topic":"Synthesis — ranked recommendation list","verdict":"recommend","finding":"Top 3 (compact pills + visual weight + attention-first ordering) can ship as single PR with ~50 lines. Together solve core problem: urgent items visible, inactive fade, strip fits 2-3x more items. Ranked by impact*simplicity.","timestamp":"2026-03-17T00:19:00Z","action":"analysis"} +{"cycle":4,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Failing PR count was '✗ 2 failing' in plain red bold text"},"after":{"description":"Failing PR count is '✗ 2 FAILING' red background badge, visually consistent with pending badge"},"delta":"Both urgent alerts (pending tools + failing PRs) now use consistent badge style for instant recognition","action":"keep","description":"Badge-style failing PR alert matching pending badge visual treatment","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":5,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"PR zoom started with checks list — no quick-scan summary of merge readiness"},"after":{"description":"PR zoom now starts with a readiness summary: '✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash' or failure indicators. User can assess in <1s."},"delta":"PR merge readiness visible at first glance without reading through check list","action":"keep","description":"Merge readiness summary line at top of PR zoom body showing approval/checks/mergeability/method","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":5,"agent":"agent-b","metric":"strip_clarity","before":{"description":"No state overview; user must scan each pill icon to understand session state distribution","chars_per_pill":0},"after":{"description":"For 5+ sessions: compact summary prefix '▶3 ⏸1 ✔5 ●1' prepended = ~12 chars total overhead; sepBoundary variable used throughout to keep separator placement correct","chars_per_pill":12},"delta":"Users see full state distribution at a glance in 12 chars instead of reading 10 individual icons","action":"keep","description":"State-group summary prefix: ▶N ⏸N ✔N ●N shown when 5+ sessions present","timestamp":"2026-03-17T00:05:00Z"} +{"cycle":6,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Fleet map showed 'Sessions: [▶]▶▶⏸' — no position context"},"after":{"description":"Fleet map shows 'Session 3/10: [▶]▶▶⏸' — bold current position and total fleet size"},"delta":"User knows their exact position in the fleet without counting glyphs","action":"keep","description":"Session index/total indicator added to fleet map line","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":6,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All active PR pills show icon+number+15-char title = ~23 chars each","chars_per_pill":23},"after":{"description":"Non-critical unselected PRs show icon+number only = ~7 chars. Title shown for checks_failing, approved, or selected.","chars_per_pill":7},"delta":"15 chars saved per non-critical PR pill; 3 running-checks PRs saves 45 chars","action":"keep","description":"Compact PR pills: title only for critical/selected states","timestamp":"2026-03-17T00:06:00Z"} +{"cycle":7,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Queue panel listed all pending tools flat, mixed safety levels"},"after":{"description":"Queue groups tools per session into '⚠ Destructive:' and '✓ Safe:' sections. Destructive shown first. Labels only when both types present."},"delta":"Operators can immediately identify which tools need careful scrutiny vs. quick approval","action":"keep","description":"Safety-grouped tool listing in queue panel","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":8,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Pending badge showed count only: '⚡ 2 PENDING' — no age/urgency signal"},"after":{"description":"Pending badge now followed by age: '⚡ 2 PENDING 45s ago' — oldest pending session's LastActivity time"},"delta":"Operator can assess urgency without switching to the session; '5m ago' means something is blocked","action":"keep","description":"Oldest-pending age indicator appended to pending badge in status bar","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":7,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Running/waiting unselected pills used truncateMiddle(20) = 24 chars each","chars_per_pill":24},"after":{"description":"Running/waiting unselected pills use 8-char name + 2 padding = 12 chars each","chars_per_pill":12},"delta":"12 chars saved per active pill; 3 running sessions saves 36 chars. Total savings vs baseline at 5 running + 5 idle: 60+80=140 chars","action":"keep","description":"Tiered name length: pillNameMaxLen() returns 20(selected)/8(active)/4(passive)","timestamp":"2026-03-17T00:07:00Z"} +{"cycle":9,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Merged/closed PRs showed same dense view as open PRs including irrelevant merge readiness summary"},"after":{"description":"Merged/closed PRs show '✔ Merged — no further action required' at body top. Merge readiness summary skipped for done PRs."},"delta":"Done PRs are clearly distinguished; clutter reduced; actionable PRs are visually prioritized","action":"keep","description":"Done-state treatment for merged/closed PRs in zoom panel","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":8,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Overflow indicator was plain '+N' — user had no idea what states were hidden","chars_per_pill":3},"after":{"description":"Overflow indicator shows '+N(▶R⏸W)' when hidden pills include active sessions — e.g. '+4(▶2⏸1)'","chars_per_pill":12},"delta":"Solves '+N confusion' problem: user can see how many running/waiting sessions are hidden without scrolling","action":"keep","description":"Enriched overflow indicator: state breakdown of hidden active sessions","timestamp":"2026-03-17T00:08:00Z"} +{"cycle":10,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"PR count showed '5 PRs' — flat count, no state breakdown"},"after":{"description":"PR count now shows '5 PRs 3✓ 1✗ 1⏳' — compact per-state breakdown: passing/failing/running/merged in their respective colors"},"delta":"Full fleet overview now possible from status bar alone: sessions by state + PRs by state + urgent alerts","action":"keep","description":"PR state breakdown in status bar mirroring session state breakdown","timestamp":"2026-03-17T00:00:00Z"} +{"cycle":9,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All unselected PR pills used same style: colored foreground, no background","chars_per_pill":0},"after":{"description":"Critical PR pills (checks_failing=red tint, approved=green tint) get bold+dim background. Non-critical stay plain.","chars_per_pill":0},"delta":"3-level urgency gradient for PRs: plain < tinted < selected-border. User can spot failing PRs without reading each label","action":"keep","description":"Critical PR visual emphasis: bold+tinted background for checks_failing and approved","timestamp":"2026-03-17T00:09:00Z"} diff --git a/.autoresearch/autoresearch.md b/.autoresearch/autoresearch.md new file mode 100644 index 0000000..40217fe --- /dev/null +++ b/.autoresearch/autoresearch.md @@ -0,0 +1,59 @@ +# Autoresearch: TUI Visual Clarity Sweep + +## Objective + +Make the session+PR listings clean and scannable under load (10+ sessions, 5+ PRs). +Specifically: strip must show attention hierarchy clearly without +N overflow confusion. +Draw inspiration from Zellij's tab bar approach — compact inactive items, clear active item. + +## Current State + +Cycle 0/30 | Not started + +### Current Strip Architecture +- Horizontal pill row: `[sess1] [sess2] ... │ [PR#1] [PR#2]` +- Overflow: `+N` on either side when budget exceeded +- Pills: 20-char truncated name + state color + pending badge +- Selected pill: inverted color (bg=state color, fg=white, underline) +- Session states: running(green), waiting(yellow), idle(gray), dead(gray) +- PR states: failing(red), running(yellow), passing(green), merged(gray) + +### Known Pain Points +1. At 10+ sessions, most pills hidden behind `+7` — no state info visible +2. No visual grouping — running sessions look same as idle in overflow +3. The `│` separator between sessions/PRs is subtle and easy to miss +4. Merged/closed PRs take up same space as active ones +5. Attention-needing sessions (pending) only distinguishable when visible in strip +6. Status bar already has counts (5 running, 2 pending) but strip doesn't leverage this + +## Strategy + +Start with the highest-impact structural changes first. Each agent explores a different axis: +- A: Can we change the strip layout fundamentally (sidebar, rows, grouping)? +- B: Can we make the existing strip smarter (compact modes, ordering, visual hierarchy)? +- C: Can we make status bar + zoom carry more of the load? + +## What Worked +(populated during cycles) + +## Dead Ends +(populated during cycles) + +## Next Experiments + +### Agent A (Opus) — Architectural Alternatives +1. Two-row strip: sessions on row 1, PRs on row 2 — doubles density without overflow +2. State-grouped sections: `[▶▶▶] [⏸⏸] [✔]` with mini labels +3. Sidebar layout: left vertical list, main area takes remaining width +4. Summary strip: `5▶ 3⏸ 2✔ | 4✓ 1✗` → expand on hover/select + +### Agent B (Sonnet) — Strip Density +1. Compact inactive pills: idle/dead → just icon (1 char) to save space +2. Attention-first ordering: pending sessions always at front of strip +3. Visual weight: pending sessions get bordered pill, others plain +4. PR filtering: hide merged/closed PRs by default, show count only + +### Agent C (Sonnet) — Status Bar + Zoom +1. Status bar attention widget: flashing/colored attention count when any session pending +2. Zoom header: add mini-map of all sessions (1 glyph each) when >5 sessions +3. Strip context: show `Sessions [3▶ 2⏸] PRs [2✓ 1✗]` instead of plain counts diff --git a/.autoresearch/researchers/agent-a.md b/.autoresearch/researchers/agent-a.md new file mode 100644 index 0000000..75af3ff --- /dev/null +++ b/.autoresearch/researchers/agent-a.md @@ -0,0 +1,131 @@ +# Researcher A (Opus): Deep Architectural Analysis + +## Assignment +Analyze TUI visual clarity problem for 10+ session / 5+ PR load scenario. +Generate specific, implementation-ready proposals for the highest-impact structural changes. +No file modifications — this is pure design research. Output goes here only. + +## Findings + +### Cycle 1: Two-row strip (sessions on row 1, PRs on row 2) +- Verdict: RECOMMEND +- Why: Doubles available width per category. The `View()` height calculation already uses `lipgloss.Height(strip)` so a 2-row strip automatically adjusts `remainingHeight`. Only costs 1 extra line of terminal height. Can be conditional: 2 rows only when PRs exist. +- Implementation sketch: Split `renderUnifiedStrip` into two calls to a new `renderPillRow(pills, budget)` helper. Combine into single `styleStripBar` block. Separator `│` is eliminated — row separation IS the separator. Budget per row = `width - 2` (full width). With 120-char terminal, each row fits ~5 full-size pills or ~15+ compact pills. + +### Cycle 2: Compact inactive pills (icon-only for idle/dead) +- Verdict: RECOMMEND +- Why: Biggest single impact change. Idle pills at 3 chars vs 24 chars = 8x space recovery per pill. 10 mixed-state sessions shrink from ~240 chars to ~120 chars, fitting a 120-char terminal without overflow. The zoom panel already provides full detail for the selected item. +- Implementation sketch: In `renderPillWithName`, add a `compact` path based on state and selection: + - `idle`/`dead` + not selected + no pending: render icon only, no name, `Padding(0,0)` = 1-3 chars + - `running` + not selected: render icon + first 5 chars of name = ~8 chars + - `waiting` with pending OR selected: full pill (current behavior) = ~24 chars + - Selected always gets full treatment regardless of state + +### Cycle 3: Attention-first ordering (pending sessions sort to front) +- Verdict: RECOMMEND +- Why: Ensures urgent items are never hidden behind `+N` overflow. ~15 lines of code — a stable sort by priority in the `stateMsg` handler. The existing `selectedSID`/`selectedPRKey` tracking by ID already handles position changes after reordering. +- Implementation sketch: After `m.sessions = msg.Sessions` (app.go ~line 184), insert: + ```go + sort.SliceStable(m.sessions, func(i, j int) bool { + return statePriority(m.sessions[i]) < statePriority(m.sessions[j]) + }) + ``` + Where `statePriority`: pending(0) > running(1) > waiting(2) > idle(3) > dead(4). + +### Cycle 4: State-grouped strip with group headers +- Verdict: SKIP +- Why: Group headers add 8-12 chars of overhead. The overflow algorithm becomes significantly more complex when group boundaries straddle the visible window. Color differentiation (which already exists) + compact pills achieve the same information density more simply. + +### Cycle 5: Hide terminal PRs (merged/closed auto-collapse) +- Verdict: RECOMMEND +- Why: 3 merged PRs at ~8 chars each + spaces = ~26 chars become a single 4-char summary indicator. Dramatic space recovery when many PRs are completed. Pairs naturally with two-row strip (Cycle 1) — the PR row stays clean. +- Implementation sketch: In `renderUnifiedStrip`, before building PR pills: + ```go + var activePRs, terminalPRs []client.TrackedPR + for _, p := range prs { + if p.State == "merged" || p.State == "closed" { + terminalPRs = append(terminalPRs, p) + } else { + activePRs = append(activePRs, p) + } + } + // Render activePRs as pills, append summary for terminalPRs: + // dimStyle.Render(fmt.Sprintf("(%d done)", len(terminalPRs))) + ``` + When the summary is selected, the zoom panel shows a list of all terminal PRs. + +### Cycle 6: Zellij tab paradigm (brackets for selected, plain text for rest) +- Verdict: NEEDS_PROTOTYPING +- Why: Dropping pill backgrounds for non-selected items saves modest space (2 chars/pill) but the visual clarity is uncertain. Selected pill with brackets `[▶ myapp]` vs borderless non-selected creates strong hierarchy. The loss of dim backgrounds may make the strip look like noisy text rather than distinct items. Needs visual testing on actual terminals. + +### Cycle 7: Summary mode (aggregate counts when items > threshold) +- Verdict: SKIP +- Why: Aggregate counts (`5▶ 3⏸ 2✔`) lose individual session identity. User cannot scan for a specific session without cycling through them. The threshold (e.g., 8 items) creates a jarring transition. Compact pills achieve similar density without losing identity. + +### Cycle 8: Sidebar layout (vertical list replacing bottom strip) +- Verdict: SKIP +- Why: Major refactor touching every render function (`renderZoom`, `renderPRZoom`, `renderQueue`, `renderEmptyState`, `renderHelp`). Reduces main panel width by 22 chars. Introduces new scroll state. Changes navigation model fundamentally. Better suited for v2 if simpler improvements prove insufficient. + +### Cycle 9: Visual weight differentiation (bordered pending, dim idle) +- Verdict: RECOMMEND +- Why: Pure styling change, ~15 lines in `renderPillWithName`. Pending pills get borders (draw eye), idle/dead pills drop background (fade out). Immediate at-a-glance scanning. Combines naturally with compact pills. Lowest implementation cost of all recommendations. +- Implementation sketch: In `renderPillWithName` (pill.go): + ```go + if !selected && len(s.PendingTools) > 0 { + style = style.Border(lipgloss.RoundedBorder()). + BorderForeground(colorOrange) + } else if !selected && (s.State == "idle" || s.State == "dead") { + style = lipgloss.NewStyle().Foreground(colorDimFg) + // No background, no padding — minimal visual weight + } + ``` + +### Cycle 10: Synthesis +- Verdict: RECOMMEND (the ranked list below) +- Why: The top 3 recommendations (compact pills, visual weight, attention-first ordering) can be implemented as a single coherent PR with ~50 lines of changes. Together they solve the core problem: urgent items are visible, inactive items fade, and the strip fits 2-3x more items. + +## Top Recommendations (ranked by impact x simplicity) + +1. **Compact inactive pills** — idle/dead pills show icon-only (1-3 chars) instead of full name (24 chars) + - Files: `tui/internal/tui/pill.go` (renderPillWithName), `tui/internal/tui/strip.go` (renderUnifiedStrip — pass state info) + - Complexity: Low + - Impact: High + - Sketch: Add `compact` flag based on `!selected && state in (idle, dead)`. Compact pills render as `icon` with `Padding(0,0)` and `Foreground(dimColor)`. Running non-selected pills show `icon + name[:5]`. + +2. **Visual weight differentiation** — pending pills get borders, idle pills lose backgrounds + - Files: `tui/internal/tui/pill.go` (renderPillWithName) + - Complexity: Low + - Impact: High + - Sketch: `if !selected && hasPending { style = style.Border(lipgloss.RoundedBorder()) }` + `if !selected && isPassive { style = lipgloss.NewStyle().Foreground(colorDimFg) }`. ~15 lines. + +3. **Attention-first ordering** — pending sessions sort to front of strip + - Files: `tui/internal/tui/app.go` (stateMsg handler) + - Complexity: Low + - Impact: Medium-High + - Sketch: `sort.SliceStable(m.sessions, func(i,j) { return priority(i) < priority(j) })` after line 184. ~15 lines including the priority function. + +4. **Hide terminal PRs** — merged/closed PRs collapse to `(+N done)` summary + - Files: `tui/internal/tui/strip.go` (renderUnifiedStrip), `tui/internal/tui/app.go` (selectedPR handling) + - Complexity: Medium + - Impact: Medium + - Sketch: Partition PRs into active/terminal before building pills. Render terminal count as a single styled summary entry. Summary selection shows list in zoom panel. + +5. **Two-row strip** — sessions on top row, PRs on bottom row + - Files: `tui/internal/tui/strip.go` (renderUnifiedStrip or new function) + - Complexity: Medium + - Impact: Medium + - Sketch: Two calls to a `renderPillRow(pills, budget)` helper, joined vertically inside `styleStripBar`. Conditional: only 2 rows when both sessions and PRs exist. `View()` height math already adapts via `lipgloss.Height(strip)`. + +## Dead Ends + +- **State-grouped strip** (Cycle 4): Sounds intuitive but group headers waste 8-12 chars, overflow algorithm becomes complex at group boundaries, and color already provides grouping. Net negative. +- **Summary mode** (Cycle 7): Loses individual session identity. Users need to see which specific session is "myapp" vs "debug", not just that there are "5 running". Compact pills preserve identity. +- **Sidebar layout** (Cycle 8): Major refactor touching all render functions, reduces main panel width, changes navigation model. Correct direction for v2 but overkill for current iteration. + +## Remaining Opportunities + +- **Zellij-style brackets** (Cycle 6): Worth prototyping visually. If backgrounds can be dropped for non-selected pills without losing visual structure, this gives another 2 chars/pill and stronger selection contrast. +- **Progressive compaction**: Instead of a hard compact/full threshold, gradually shorten pill names as the strip gets more crowded: 20 chars → 12 → 8 → 5 → icon-only. This would require a width-aware name truncation strategy. +- **Keyboard-driven expansion**: Press a key (e.g., `e`) to temporarily expand the strip to show all pills with full names in a multi-line popup, then dismiss. Similar to how the help screen overlays the main content. +- **PR state-color in strip separator**: Instead of `│`, use a colored border between session and PR sections that reflects overall PR health (green = all passing, red = any failing, yellow = running). Zero-width visual cue. +- **Glow animation for pending**: The existing `glowPos` ping-pong animation could be applied specifically to pending pills' borders, making them pulse. Already half-built — currently used for the "running" session name character highlight. diff --git a/.autoresearch/researchers/agent-b.md b/.autoresearch/researchers/agent-b.md new file mode 100644 index 0000000..a0c8ae7 --- /dev/null +++ b/.autoresearch/researchers/agent-b.md @@ -0,0 +1,17 @@ +# Researcher B (Sonnet): Strip Density Optimizer + +## Assignment +Implement visual improvements to strip.go, pill.go, and styles.go. +Target: clean, scannable strip with 10+ sessions and 5+ PRs. +Focus: compact inactive pills, attention-first ordering, visual hierarchy. + +## Scope +- tui/internal/tui/strip.go +- tui/internal/tui/pill.go +- tui/internal/tui/styles.go + +## Findings +(populated by researcher agent) + +## Experiments +(populated by researcher agent) diff --git a/.autoresearch/researchers/agent-c.md b/.autoresearch/researchers/agent-c.md new file mode 100644 index 0000000..1fa7e5b --- /dev/null +++ b/.autoresearch/researchers/agent-c.md @@ -0,0 +1,61 @@ +# Researcher C (Sonnet): Status Bar + Zoom Improvements + +## Assignment +Implement visual improvements to app.go (status bar), zoom.go, pr_zoom.go, hints.go, queue.go. +Target: status bar that communicates session load at a glance; zoom panels that stay useful under load. +Focus: richer status bar summary, attention widgets, zoom panel quick-scan improvements. + +## Scope +- tui/internal/tui/app.go +- tui/internal/tui/zoom.go +- tui/internal/tui/pr_zoom.go +- tui/internal/tui/hints.go +- tui/internal/tui/queue.go + +## Findings + +### Status Bar (app.go) + +1. **Session state breakdown** — The flat "7 running" count was replaced with a per-state compact breakdown: `7▶ 2⏸ 1✔ 1●` with colors. Running=green, waiting=yellow, idle/dead=gray. Only non-zero states shown. Gives instant fleet overview without looking at the strip. + +2. **Pending badge** — Changed `⚡ 2 pending` (inline orange text) to an orange background badge `[⚡ 2 PENDING]` with black text. Far more visually prominent; eye-catching even when looking away. + +3. **Oldest-pending age** — After the pending badge, show `45s ago` indicating how long the oldest pending approval has been waiting. Urgency context: `⚡ 2 PENDING 5m ago` signals something is truly blocked. + +4. **Failing PR badge** — Changed `✗ 2 failing` to red background badge `[✗ 2 FAILING]` — visually consistent with pending badge. Both urgent alerts use the same treatment. + +5. **PR state breakdown** — Added compact per-state PR counts after the PR total: `5 PRs 3✓ 1✗ 1⏳`. Passing=green, failing=red, running=yellow, merged=dim gray. Mirrors session breakdown. + +Full status bar under load: `██ CCC ● connected 10 sessions 7▶ 2⏸ 1✔ 5 PRs 3✓ 1✗ [⚡ 2 PENDING] 45s ago [✗ 1 FAILING]` + +### Session Zoom (zoom.go) + +6. **Fleet map line** — When 3+ sessions exist, a 1-line fleet map appears above the session zoom: `Session 3/10: [▶]▶▶⏸⏸✔●`. Current session is bracketed and bold. Sessions with pending tools are shown in orange. Position indicator `3/10` tells operator where they are in the fleet. + +### PR Zoom (pr_zoom.go) + +7. **Merge readiness summary** — First line of PR zoom body shows quick-scan summary: `✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash` or failure indicators. Lets user assess merge readiness in under 1 second. + +8. **Done-state treatment** — Merged/closed PRs show `✔ Merged — no further action required` at top of body. Merge readiness summary is skipped for done PRs. Reduces clutter and visually distinguishes actionable vs. done PRs. + +### Queue Panel (queue.go) + +9. **Safety-grouped tools** — Pending tools grouped per session into `⚠ Destructive:` and `✓ Safe:` sections. Destructive listed first. Labels only shown when a session has both types. Operators can immediately identify which tools need careful scrutiny. + +## Experiments + +| Cycle | File | Change | Result | +|-------|------|--------|--------| +| 1 | app.go | Session state breakdown `7▶ 2⏸ 1✔` | keep | +| 2 | app.go | Orange badge for pending: `[⚡ 2 PENDING]` | keep | +| 3 | zoom.go, app.go | Fleet map line above session zoom | keep | +| 4 | app.go | Red badge for failing PRs: `[✗ N FAILING]` | keep | +| 5 | pr_zoom.go | Merge readiness summary line at PR zoom top | keep | +| 6 | zoom.go | Session N/total in fleet map label | keep | +| 7 | queue.go | Safety-grouped tools in queue panel | keep | +| 8 | app.go | Oldest-pending age next to pending badge | keep | +| 9 | pr_zoom.go | Done-state treatment for merged/closed PRs | keep | +| 10 | app.go | PR state breakdown `3✓ 1✗ 1⏳` in status bar | keep | + +## Key Principle +All 10 cycles kept — every improvement passed build and tests. The visual language is now consistent: badge = urgent alert, breakdown = fleet state, glyphs = per-item states. From 7671cbe40445a10c6b3b61a80124121fadd4c75c Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:06:35 +0100 Subject: [PATCH 18/23] autoresearch: collapse dead sessions to compact count at 8+ session load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When 8+ sessions are present, dead sessions without pending tools or active selection are removed from the pill list and replaced with a compact "(●N)" count indicator. This eliminates ~6 chars per dead pill (3 dead sessions → 18 chars replaced by 5-char "(●3)"). Saves ~13 chars and removes truly-done sessions from the active view. --- tui/internal/tui/strip.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 65ba419..3a17123 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -102,6 +102,35 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec } 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) @@ -131,6 +160,15 @@ func renderUnifiedStrip(sessions []client.Session, prs []client.TrackedPR, selec }) } + // 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 From a05f46d82d50e77bea34bdbc95983b1cc72fa9d1 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:08:51 +0100 Subject: [PATCH 19/23] autoresearch: visual clarity sweep complete (30 cycles, 3 agents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip: 295→118 chars at 10+5 load (60% reduction) Status bar: state breakdowns, badge alerts, fleet map Zoom: merge readiness summary, safety grouping, dead-state treatment --- .autoresearch/autoresearch.jsonl | 1 + .autoresearch/autoresearch.md | 47 ++++++++++++++- .autoresearch/researchers/agent-b.md | 86 +++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/.autoresearch/autoresearch.jsonl b/.autoresearch/autoresearch.jsonl index b8d4cc3..8ddecef 100644 --- a/.autoresearch/autoresearch.jsonl +++ b/.autoresearch/autoresearch.jsonl @@ -27,3 +27,4 @@ {"cycle":8,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Overflow indicator was plain '+N' — user had no idea what states were hidden","chars_per_pill":3},"after":{"description":"Overflow indicator shows '+N(▶R⏸W)' when hidden pills include active sessions — e.g. '+4(▶2⏸1)'","chars_per_pill":12},"delta":"Solves '+N confusion' problem: user can see how many running/waiting sessions are hidden without scrolling","action":"keep","description":"Enriched overflow indicator: state breakdown of hidden active sessions","timestamp":"2026-03-17T00:08:00Z"} {"cycle":10,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"PR count showed '5 PRs' — flat count, no state breakdown"},"after":{"description":"PR count now shows '5 PRs 3✓ 1✗ 1⏳' — compact per-state breakdown: passing/failing/running/merged in their respective colors"},"delta":"Full fleet overview now possible from status bar alone: sessions by state + PRs by state + urgent alerts","action":"keep","description":"PR state breakdown in status bar mirroring session state breakdown","timestamp":"2026-03-17T00:00:00Z"} {"cycle":9,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All unselected PR pills used same style: colored foreground, no background","chars_per_pill":0},"after":{"description":"Critical PR pills (checks_failing=red tint, approved=green tint) get bold+dim background. Non-critical stay plain.","chars_per_pill":0},"delta":"3-level urgency gradient for PRs: plain < tinted < selected-border. User can spot failing PRs without reading each label","action":"keep","description":"Critical PR visual emphasis: bold+tinted background for checks_failing and approved","timestamp":"2026-03-17T00:09:00Z"} +{"cycle":10,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Dead sessions shown as individual pills (6 chars each); 3 dead = 18 chars","chars_per_pill":6},"after":{"description":"8+ session mode: dead sessions collapsed to '(●N)' = 5 chars for any N","chars_per_pill":5},"delta":"3 dead sessions: 18 chars -> 5 chars (saves 13 chars). Reduces visual noise from completed sessions.","action":"keep","description":"Collapse dead sessions to compact count indicator at 8+ session load","timestamp":"2026-03-17T00:10:00Z"} diff --git a/.autoresearch/autoresearch.md b/.autoresearch/autoresearch.md index 40217fe..cb81607 100644 --- a/.autoresearch/autoresearch.md +++ b/.autoresearch/autoresearch.md @@ -8,7 +8,7 @@ Draw inspiration from Zellij's tab bar approach — compact inactive items, clea ## Current State -Cycle 0/30 | Not started +Cycle 30/30 | **COMPLETE** — All 3 agents finished, 28/30 cycles kept (Agent A analyst = 10 analysis entries) ### Current Strip Architecture - Horizontal pill row: `[sess1] [sess2] ... │ [PR#1] [PR#2]` @@ -33,11 +33,52 @@ Start with the highest-impact structural changes first. Each agent explores a di - B: Can we make the existing strip smarter (compact modes, ordering, visual hierarchy)? - C: Can we make status bar + zoom carry more of the load? +## Results + +| Agent | Files | Cycles | All Kept | Summary | +|-------|-------|--------|----------|---------| +| A (Opus analyst) | none | 10 | n/a | 5 RECOMMEND, 3 SKIP, 2 other | +| B (Sonnet) | strip.go, pill.go, styles.go | 10 | ✓ | Strip: 295→118 chars (60% reduction) | +| C (Sonnet) | app.go, zoom.go, pr_zoom.go, hints.go, queue.go | 10 | ✓ | 10 improvements all kept | + +**Build/tests: PASS** (combined result, `go build ./... && go test ./...`) + ## What Worked -(populated during cycles) + +### Strip (Agent B) +- **Tiered pill compaction**: selected=20 chars, running/waiting=8, idle/dead=4. Biggest single space win. +- **Passive pill weight**: idle/dead get no background, just dim text. Visual hierarchy without layout change. +- **Attention-first sort**: pending→running→waiting→idle→dead. Critical sessions never scroll off left. +- **Terminal PR collapse**: merged/closed → `(+N done)` when active PRs exist. Removes done clutter. +- **Compact PR pills**: title only for failing/approved. Running/passing show `⏳ #42` = 7 chars. +- **State-group summary**: `▶3 ⏸1 ✔5 ●1` prefix at 5+ sessions. Instant fleet scan. +- **Enriched overflow**: `+4(▶2⏸1)` instead of bare `+4`. Hidden state info. +- **Dead session collapse**: `(●3)` at 8+ sessions. Removes truly-done clutter. +- **Critical PR emphasis**: failing→red dim bg, approved→green dim bg. 3-level urgency. + +### Status Bar / Zoom (Agent C) +- **Session state breakdown**: `7▶ 2⏸ 1✔` replaces flat "7 running" +- **Pending badge**: `[⚡ 2 PENDING]` orange background — eye-catching alert +- **Oldest-pending age**: `[⚡ 2 PENDING] 45s ago` — urgency signal +- **Failing PR badge**: `[✗ N FAILING]` red background — consistent with pending +- **PR state breakdown**: `5 PRs 3✓ 1✗ 1⏳` mirrors session breakdown +- **Fleet map**: above session zoom, `Session 3/10: [▶]▶▶⏸⏸✔●` +- **Merge readiness**: first line of PR zoom, `✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash` +- **Done-state treatment**: merged/closed PR zoom shows clean "✔ Merged" header +- **Safety grouping**: queue groups destructive vs safe tools per session ## Dead Ends -(populated during cycles) +(from Agent A analysis) +- **State-grouped strip headers**: group headers waste 8-12 chars, overflow algo becomes complex at boundaries +- **Summary mode** (aggregate counts): loses individual session identity +- **Sidebar layout**: major refactor, reduces main panel width, v2 territory + +## Remaining Opportunities +- Zellij-style brackets (needs visual prototyping) +- Progressive compaction (continuous rather than tiered name shortening) +- Keyboard-driven expansion popup +- PR state-color in strip separator +- Glow animation for pending pill borders (glowPos already exists) ## Next Experiments diff --git a/.autoresearch/researchers/agent-b.md b/.autoresearch/researchers/agent-b.md index a0c8ae7..a3780ec 100644 --- a/.autoresearch/researchers/agent-b.md +++ b/.autoresearch/researchers/agent-b.md @@ -10,8 +10,88 @@ Focus: compact inactive pills, attention-first ordering, visual hierarchy. - tui/internal/tui/pill.go - tui/internal/tui/styles.go -## Findings -(populated by researcher agent) +## Summary +Completed 10 cycles of visual improvement. Total chars used at 10-session + 5-PR load +reduced from ~240+ chars (baseline) to ~115 chars (120-char terminal fits without overflow). ## Experiments -(populated by researcher agent) + +### Cycle 1: Compact idle/dead pills (KEPT) +- **Change**: Passive (idle/dead) unselected pills truncated to 4-char names +- **Before**: icon + 20-char name + 2 padding = 24 chars each +- **After**: icon + 4-char name, no padding = 6 chars each +- **Savings**: 18 chars per passive pill; 5 idle sessions = 90 chars saved + +### Cycle 2: Attention-first ordering (KEPT) +- **Change**: Sessions sorted by state priority before rendering: pending(0) > running(1) > waiting(2) > idle(3) > dead(4) +- **Before**: Sessions in arrival order — idle could bury running +- **After**: Critical sessions always at left of strip, survive overflow truncation +- **Key**: Selected session ID tracked through sort to remap selectedIdx correctly + +### Cycle 3: Filter terminal PRs (KEPT) +- **Change**: merged/closed PRs hidden when active PRs exist; replaced with `(+N done)` label +- **Before**: All 5 PRs shown (3 merged = 21+ chars) +- **After**: 3 merged PRs → `(+3 done)` = 9 chars total +- **Savings**: 12+ chars, removes done-work clutter from visible strip + +### Cycle 4: Visual weight hierarchy for passive pills (KEPT) +- **Change**: Passive (idle/dead) unselected pills lose background entirely; use dim text only +- **Before**: Passive pills had padding + black bg = same visual structure as active +- **After**: Passive pills are plain dim text — no box, no padding +- **Effect**: Clear visual hierarchy: colored boxes = active, plain text = passive + +### Cycle 5: State-group summary prefix (KEPT) +- **Change**: For 5+ sessions, prepend `▶3 ⏸1 ✔5 ●1` summary to strip +- **Before**: User had to read each pill icon to understand state distribution +- **After**: ~12-char summary gives instant state overview +- **Implementation**: `sepBoundary` variable tracks separator position through prepend + +### Cycle 6: Compact PR pills (KEPT) +- **Change**: PR title shown only for critical/selected states; non-critical shows `icon #N` only +- **Before**: All active PR pills: icon + number + 15-char title = ~23 chars +- **After**: checks_running/checks_passing: `⏳ #42` = 7 chars; title only for failing/approved +- **Savings**: 15 chars per non-critical PR pill + +### Cycle 7: Tiered session name length (KEPT) +- **Change**: `pillNameMaxLen()` function: selected=20 chars, running/waiting=8 chars, idle/dead=4 chars +- **Before**: Running/waiting unselected: 20-char name = 24 chars total +- **After**: Running/waiting unselected: 8-char name = 12 chars total +- **Savings**: 12 chars per active pill; 3 running sessions = 36 chars saved + +### Cycle 8: Enriched overflow indicator (KEPT) +- **Change**: Overflow shows `+N(▶R⏸W)` when hidden pills include active sessions +- **Before**: Plain `+4` — no information about hidden states +- **After**: `+4(▶2⏸1)` — user sees how many running/waiting sessions are hidden +- **Solves**: The "+N confusion" problem directly + +### Cycle 9: Critical PR visual emphasis (KEPT) +- **Change**: checks_failing → bold + red dim bg; approved → bold + green dim bg +- **Before**: Critical PRs looked like non-critical (just colored foreground) +- **After**: 3-level urgency: plain text < tinted background < selected border +- **Effect**: User can spot failing PRs at a glance without reading labels + +### Cycle 10: Collapse dead sessions at 8+ load (KEPT) +- **Change**: 8+ sessions: dead sessions without pending/selection collapsed to `(●N)` = 5 chars +- **Before**: 3 dead sessions = 18 chars (3 × 6) +- **After**: `(●3)` = 5 chars +- **Savings**: 13 chars, removes truly-done session clutter + +## Cumulative Strip Width Savings (10 sessions + 5 PRs at 120-char terminal) + +| Source | Baseline | After | Saved | +|--------|----------|-------|-------| +| 3 running pills | 72 | 36 | 36 | +| 1 waiting pill | 24 | 12 | 12 | +| 5 idle pills | 120 | 30 | 90 | +| 2 dead pills (hidden) | 12 | 5 | 7 | +| 3 merged PRs | 21 | 9 | 12 | +| 2 non-critical PRs | 46 | 14 | 32 | +| State summary | 0 | 12 | -12 | +| **Total** | **295** | **118** | **177** | + +## Key Design Principles Established +1. **Attention hierarchy**: Critical state → full pill; Active → compact box; Passive → plain text; Hidden → count +2. **Sort before render**: Most important sessions are always leftmost, survive overflow +3. **Information-dense overflow**: `+N(▶R⏸W)` provides state info for hidden sessions +4. **Terminal PRs hidden**: merged/closed collapsed to count when active PRs exist +5. **Tier truncation**: 20 chars (selected) → 8 chars (active) → 4 chars (passive) From 9aaffba322d27903273e8817c26400469890b118 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:28:39 +0100 Subject: [PATCH 20/23] fix: remove duplicate mergeable/method from PR zoom line 2, show repo#N in compact pills --- tui/internal/tui/pr_zoom.go | 9 +-------- tui/internal/tui/strip.go | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tui/internal/tui/pr_zoom.go b/tui/internal/tui/pr_zoom.go index e3d691e..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))) diff --git a/tui/internal/tui/strip.go b/tui/internal/tui/strip.go index 3a17123..cb274aa 100644 --- a/tui/internal/tui/strip.go +++ b/tui/internal/tui/strip.go @@ -464,8 +464,8 @@ func renderPRPill(p client.TrackedPR, selected bool) string { // Important: show title for context. label = fmt.Sprintf("%s #%d %s", icon, p.Number, truncateWordBoundary(p.Title, 15)) } else { - // Non-critical unselected: compact — just icon + number. - label = fmt.Sprintf("%s #%d", icon, p.Number) + // Non-critical unselected: compact — icon + repo + number. + label = fmt.Sprintf("%s %s#%d", icon, p.Repo, p.Number) } sc := prStateColor(p.State) From 90ead623cb75ed9397ee74671204005641790b61 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 00:37:17 +0100 Subject: [PATCH 21/23] chore: gitignore .autoresearch/, remove from tracking --- .autoresearch/autoresearch.config.md | 49 ---------- .autoresearch/autoresearch.jsonl | 30 ------ .autoresearch/autoresearch.md | 100 -------------------- .autoresearch/researchers/agent-a.md | 131 --------------------------- .autoresearch/researchers/agent-b.md | 97 -------------------- .autoresearch/researchers/agent-c.md | 61 ------------- .gitignore | 1 + 7 files changed, 1 insertion(+), 468 deletions(-) delete mode 100644 .autoresearch/autoresearch.config.md delete mode 100644 .autoresearch/autoresearch.jsonl delete mode 100644 .autoresearch/autoresearch.md delete mode 100644 .autoresearch/researchers/agent-a.md delete mode 100644 .autoresearch/researchers/agent-b.md delete mode 100644 .autoresearch/researchers/agent-c.md diff --git a/.autoresearch/autoresearch.config.md b/.autoresearch/autoresearch.config.md deleted file mode 100644 index 670ae90..0000000 --- a/.autoresearch/autoresearch.config.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -metric: custom -measurement_command: "echo 'qualitative: count visual noise issues in 10-session+5-PR mock scenario'" -scope: tui/internal/tui/ -mode: lab -cycles: 30 -round: 1 -target: "clean, scannable strip with 10+ sessions and 5+ PRs — no +N overflow confusion, clear attention hierarchy" -backpressure: - - "cd tui && go build ./..." - - "cd tui && go test ./..." -direction: maximize -created: 2026-03-17T00:00:00Z -prior_findings: [] ---- - -# Autoresearch Configuration - -## Problem Statement - -The unified session+PR strip at the bottom gets cluttered with many items. Users running intense -sessions (10+ sessions, 5+ PRs) end up with a sea of tiny pills and "+N" overflow indicators. -The current design doesn't communicate attention priority at a glance. - -## Proxy Metrics - -Since this is visual/design research, measure each proposed change against: - -1. **Attention clarity**: Would a user instantly see which sessions need action? (0-5) -2. **Information density**: At 10 sessions + 5 PRs, what % of items are visible vs hidden? -3. **Navigation cost**: Keystrokes to reach any item needing attention -4. **Build validity**: go build + go test pass - -## Reference: Zellij Tab Bar Approach - -Zellij's key ideas to consider: -- Active tab has full text + distinct style; inactive tabs are compact glyphs -- Tab bar auto-switches to number+icon mode when terminal is narrow -- Mode indicator (pane/tab/resize) is visually prominent in status bar -- Uses dedicated color zones, not just text decoration - -## Lab Agent Scopes - -- **Agent A (opus)**: Deep architectural alternatives — sidebar layout, state-grouped strip, - summary mode for overflow, Zellij paradigm deep-dive -- **Agent B (sonnet)**: Strip density optimization — two-row split (sessions row / PRs row), - compact pill variants, attention-first ordering, visual hierarchy improvements -- **Agent C (sonnet)**: Status bar + zoom improvements — status bar when >10 sessions, - quick-scan improvements to zoom panels, pill label info selection diff --git a/.autoresearch/autoresearch.jsonl b/.autoresearch/autoresearch.jsonl deleted file mode 100644 index 8ddecef..0000000 --- a/.autoresearch/autoresearch.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"cycle":1,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All pills use icon + full 20-char name + 2 padding = ~24 chars each","chars_per_pill":24},"after":{"description":"Idle/dead unselected pills use icon + 4-char name + 2 padding = ~8 chars","chars_per_pill":8},"delta":"16 chars saved per passive pill; 5 idle sessions saves 80 chars total","action":"keep","description":"Compact idle/dead unselected pills to icon + 4-char name","timestamp":"2026-03-17T00:01:00Z"} -{"cycle":2,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Sessions rendered in arrival order; idle sessions could bury running ones","chars_per_pill":24},"after":{"description":"Sessions sorted: pending(0) > running(1) > waiting(2) > idle(3) > dead(4). Selected index remapped after sort.","chars_per_pill":24},"delta":"Critical sessions always appear at left of strip, never hidden by +N overflow","action":"keep","description":"Attention-first ordering: sort sessions by state priority before rendering","timestamp":"2026-03-17T00:02:00Z"} -{"cycle":1,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Status bar showed flat '▶ 7 running' count only — no state breakdown for waiting/idle/dead sessions"},"after":{"description":"Status bar now shows compact per-state counts: '7▶ 2⏸ 1✔ 1●' with colors (green/yellow/gray/gray). Each state only appears when count > 0."},"delta":"Fleet state visible at a glance without looking at strip — running/waiting/idle/dead all in one compact group","action":"keep","description":"Replace flat running count with compact state breakdown showing all session states with colored icons","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":3,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All PRs (merged, closed, open) shown as full pills; 3 merged PRs = 21+ chars","chars_per_pill":7},"after":{"description":"Merged/closed PRs hidden when active PRs exist; replaced with '(+N done)' label = 10 chars total for any N","chars_per_pill":10},"delta":"3 merged PR pills (21 chars) replaced by single '(+3 done)' label (10 chars); saves 11+ chars and removes done-work clutter","action":"keep","description":"Filter terminal PRs from strip, show compact done count","timestamp":"2026-03-17T00:03:00Z"} -{"cycle":2,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Pending indicator was '⚡ 2 pending' in orange bold text — inline, low contrast, easy to miss"},"after":{"description":"Pending indicator is now '[⚡ 2 PENDING]' badge with orange background and black text — visually distinct, immediately eye-catching"},"delta":"Pending approvals now visually pop from the status bar; operator can't miss it","action":"keep","description":"Badge-style pending alert with orange background in status bar","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":4,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Passive pills had padding+dim background box = same visual weight structure as active pills","chars_per_pill":8},"after":{"description":"Passive pills have no background, no padding, just dim foreground text = 6 chars","chars_per_pill":6},"delta":"Clear visual hierarchy: colored boxes=active, plain dim text=passive. Also saves 2 chars per passive pill","action":"keep","description":"Visual weight hierarchy: passive pills rendered as plain dim text, no background","timestamp":"2026-03-17T00:04:00Z"} -{"cycle":3,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Session zoom showed only the selected session with no context about other sessions in the fleet"},"after":{"description":"When 3+ sessions exist, a fleet map line appears: 'Sessions: [▶]▶▶⏸⏸✔●' — current session bracketed, pending sessions in orange"},"delta":"User can see full fleet state from any session zoom without navigating away","action":"keep","description":"Mini fleet map line above session zoom panel showing all sessions as state glyphs","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":1,"agent":"agent-a","topic":"Two-row strip (sessions row 1, PRs row 2)","verdict":"recommend","finding":"Splitting the strip into two rows doubles available width per category. View() height calc already adapts via lipgloss.Height(strip). Only costs 1 extra line. Can be conditional: 2 rows only when PRs exist.","timestamp":"2026-03-17T00:10:00Z","action":"analysis"} -{"cycle":2,"agent":"agent-a","topic":"Compact inactive pills (icon-only for idle/dead)","verdict":"recommend","finding":"Idle/dead pills at 3 chars vs 24 chars = 8x space recovery. 10 mixed-state sessions shrink from 240 to 120 chars, fitting 120-char terminal. Biggest single-impact change. Zoom panel provides full detail for selected item.","timestamp":"2026-03-17T00:11:00Z","action":"analysis"} -{"cycle":3,"agent":"agent-a","topic":"Attention-first ordering (pending sessions sort to front)","verdict":"recommend","finding":"Stable sort by priority in stateMsg handler. selectedSID/selectedPRKey tracking by ID handles reordering. ~15 lines. Urgent items never hidden behind +N overflow.","timestamp":"2026-03-17T00:12:00Z","action":"analysis"} -{"cycle":4,"agent":"agent-a","topic":"State-grouped strip with group headers","verdict":"skip","finding":"Group headers add 8-12 chars overhead. Overflow algorithm becomes complex at group boundaries. Color already provides grouping. Net negative.","timestamp":"2026-03-17T00:13:00Z","action":"analysis"} -{"cycle":5,"agent":"agent-a","topic":"Hide terminal PRs (merged/closed auto-collapse)","verdict":"recommend","finding":"3 merged PRs (26 chars) collapse to single 4-char summary indicator. selectedIdx needs special summary-pill handling. Dramatic space recovery for busy PR boards.","timestamp":"2026-03-17T00:14:00Z","action":"analysis"} -{"cycle":6,"agent":"agent-a","topic":"Zellij tab paradigm (brackets for selected, plain text for rest)","verdict":"needs_prototyping","finding":"Dropping pill backgrounds saves 2 chars/pill but visual clarity uncertain. Selected pill with brackets creates strong hierarchy. Needs terminal visual testing to verify pills remain distinguishable.","timestamp":"2026-03-17T00:15:00Z","action":"analysis"} -{"cycle":7,"agent":"agent-a","topic":"Summary mode (aggregate counts when items > threshold)","verdict":"skip","finding":"Loses individual session identity. User cannot scan for specific session without cycling. Threshold creates jarring transition. Compact pills achieve similar density without losing identity.","timestamp":"2026-03-17T00:16:00Z","action":"analysis"} -{"cycle":8,"agent":"agent-a","topic":"Sidebar layout (vertical list replacing bottom strip)","verdict":"skip","finding":"Major refactor touching all render functions. Reduces main panel width. Introduces new scroll state. Changes navigation model. v2 material if simpler changes prove insufficient.","timestamp":"2026-03-17T00:17:00Z","action":"analysis"} -{"cycle":9,"agent":"agent-a","topic":"Visual weight differentiation (bordered pending, dim idle)","verdict":"recommend","finding":"Pure styling change, ~15 lines in renderPillWithName. Pending pills get borders, idle pills lose backgrounds. Lowest implementation cost. Combines naturally with compact pills.","timestamp":"2026-03-17T00:18:00Z","action":"analysis"} -{"cycle":10,"agent":"agent-a","topic":"Synthesis — ranked recommendation list","verdict":"recommend","finding":"Top 3 (compact pills + visual weight + attention-first ordering) can ship as single PR with ~50 lines. Together solve core problem: urgent items visible, inactive fade, strip fits 2-3x more items. Ranked by impact*simplicity.","timestamp":"2026-03-17T00:19:00Z","action":"analysis"} -{"cycle":4,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Failing PR count was '✗ 2 failing' in plain red bold text"},"after":{"description":"Failing PR count is '✗ 2 FAILING' red background badge, visually consistent with pending badge"},"delta":"Both urgent alerts (pending tools + failing PRs) now use consistent badge style for instant recognition","action":"keep","description":"Badge-style failing PR alert matching pending badge visual treatment","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":5,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"PR zoom started with checks list — no quick-scan summary of merge readiness"},"after":{"description":"PR zoom now starts with a readiness summary: '✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash' or failure indicators. User can assess in <1s."},"delta":"PR merge readiness visible at first glance without reading through check list","action":"keep","description":"Merge readiness summary line at top of PR zoom body showing approval/checks/mergeability/method","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":5,"agent":"agent-b","metric":"strip_clarity","before":{"description":"No state overview; user must scan each pill icon to understand session state distribution","chars_per_pill":0},"after":{"description":"For 5+ sessions: compact summary prefix '▶3 ⏸1 ✔5 ●1' prepended = ~12 chars total overhead; sepBoundary variable used throughout to keep separator placement correct","chars_per_pill":12},"delta":"Users see full state distribution at a glance in 12 chars instead of reading 10 individual icons","action":"keep","description":"State-group summary prefix: ▶N ⏸N ✔N ●N shown when 5+ sessions present","timestamp":"2026-03-17T00:05:00Z"} -{"cycle":6,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Fleet map showed 'Sessions: [▶]▶▶⏸' — no position context"},"after":{"description":"Fleet map shows 'Session 3/10: [▶]▶▶⏸' — bold current position and total fleet size"},"delta":"User knows their exact position in the fleet without counting glyphs","action":"keep","description":"Session index/total indicator added to fleet map line","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":6,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All active PR pills show icon+number+15-char title = ~23 chars each","chars_per_pill":23},"after":{"description":"Non-critical unselected PRs show icon+number only = ~7 chars. Title shown for checks_failing, approved, or selected.","chars_per_pill":7},"delta":"15 chars saved per non-critical PR pill; 3 running-checks PRs saves 45 chars","action":"keep","description":"Compact PR pills: title only for critical/selected states","timestamp":"2026-03-17T00:06:00Z"} -{"cycle":7,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Queue panel listed all pending tools flat, mixed safety levels"},"after":{"description":"Queue groups tools per session into '⚠ Destructive:' and '✓ Safe:' sections. Destructive shown first. Labels only when both types present."},"delta":"Operators can immediately identify which tools need careful scrutiny vs. quick approval","action":"keep","description":"Safety-grouped tool listing in queue panel","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":8,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Pending badge showed count only: '⚡ 2 PENDING' — no age/urgency signal"},"after":{"description":"Pending badge now followed by age: '⚡ 2 PENDING 45s ago' — oldest pending session's LastActivity time"},"delta":"Operator can assess urgency without switching to the session; '5m ago' means something is blocked","action":"keep","description":"Oldest-pending age indicator appended to pending badge in status bar","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":7,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Running/waiting unselected pills used truncateMiddle(20) = 24 chars each","chars_per_pill":24},"after":{"description":"Running/waiting unselected pills use 8-char name + 2 padding = 12 chars each","chars_per_pill":12},"delta":"12 chars saved per active pill; 3 running sessions saves 36 chars. Total savings vs baseline at 5 running + 5 idle: 60+80=140 chars","action":"keep","description":"Tiered name length: pillNameMaxLen() returns 20(selected)/8(active)/4(passive)","timestamp":"2026-03-17T00:07:00Z"} -{"cycle":9,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"Merged/closed PRs showed same dense view as open PRs including irrelevant merge readiness summary"},"after":{"description":"Merged/closed PRs show '✔ Merged — no further action required' at body top. Merge readiness summary skipped for done PRs."},"delta":"Done PRs are clearly distinguished; clutter reduced; actionable PRs are visually prioritized","action":"keep","description":"Done-state treatment for merged/closed PRs in zoom panel","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":8,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Overflow indicator was plain '+N' — user had no idea what states were hidden","chars_per_pill":3},"after":{"description":"Overflow indicator shows '+N(▶R⏸W)' when hidden pills include active sessions — e.g. '+4(▶2⏸1)'","chars_per_pill":12},"delta":"Solves '+N confusion' problem: user can see how many running/waiting sessions are hidden without scrolling","action":"keep","description":"Enriched overflow indicator: state breakdown of hidden active sessions","timestamp":"2026-03-17T00:08:00Z"} -{"cycle":10,"agent":"agent-c","metric":"status_zoom_clarity","before":{"description":"PR count showed '5 PRs' — flat count, no state breakdown"},"after":{"description":"PR count now shows '5 PRs 3✓ 1✗ 1⏳' — compact per-state breakdown: passing/failing/running/merged in their respective colors"},"delta":"Full fleet overview now possible from status bar alone: sessions by state + PRs by state + urgent alerts","action":"keep","description":"PR state breakdown in status bar mirroring session state breakdown","timestamp":"2026-03-17T00:00:00Z"} -{"cycle":9,"agent":"agent-b","metric":"strip_clarity","before":{"description":"All unselected PR pills used same style: colored foreground, no background","chars_per_pill":0},"after":{"description":"Critical PR pills (checks_failing=red tint, approved=green tint) get bold+dim background. Non-critical stay plain.","chars_per_pill":0},"delta":"3-level urgency gradient for PRs: plain < tinted < selected-border. User can spot failing PRs without reading each label","action":"keep","description":"Critical PR visual emphasis: bold+tinted background for checks_failing and approved","timestamp":"2026-03-17T00:09:00Z"} -{"cycle":10,"agent":"agent-b","metric":"strip_clarity","before":{"description":"Dead sessions shown as individual pills (6 chars each); 3 dead = 18 chars","chars_per_pill":6},"after":{"description":"8+ session mode: dead sessions collapsed to '(●N)' = 5 chars for any N","chars_per_pill":5},"delta":"3 dead sessions: 18 chars -> 5 chars (saves 13 chars). Reduces visual noise from completed sessions.","action":"keep","description":"Collapse dead sessions to compact count indicator at 8+ session load","timestamp":"2026-03-17T00:10:00Z"} diff --git a/.autoresearch/autoresearch.md b/.autoresearch/autoresearch.md deleted file mode 100644 index cb81607..0000000 --- a/.autoresearch/autoresearch.md +++ /dev/null @@ -1,100 +0,0 @@ -# Autoresearch: TUI Visual Clarity Sweep - -## Objective - -Make the session+PR listings clean and scannable under load (10+ sessions, 5+ PRs). -Specifically: strip must show attention hierarchy clearly without +N overflow confusion. -Draw inspiration from Zellij's tab bar approach — compact inactive items, clear active item. - -## Current State - -Cycle 30/30 | **COMPLETE** — All 3 agents finished, 28/30 cycles kept (Agent A analyst = 10 analysis entries) - -### Current Strip Architecture -- Horizontal pill row: `[sess1] [sess2] ... │ [PR#1] [PR#2]` -- Overflow: `+N` on either side when budget exceeded -- Pills: 20-char truncated name + state color + pending badge -- Selected pill: inverted color (bg=state color, fg=white, underline) -- Session states: running(green), waiting(yellow), idle(gray), dead(gray) -- PR states: failing(red), running(yellow), passing(green), merged(gray) - -### Known Pain Points -1. At 10+ sessions, most pills hidden behind `+7` — no state info visible -2. No visual grouping — running sessions look same as idle in overflow -3. The `│` separator between sessions/PRs is subtle and easy to miss -4. Merged/closed PRs take up same space as active ones -5. Attention-needing sessions (pending) only distinguishable when visible in strip -6. Status bar already has counts (5 running, 2 pending) but strip doesn't leverage this - -## Strategy - -Start with the highest-impact structural changes first. Each agent explores a different axis: -- A: Can we change the strip layout fundamentally (sidebar, rows, grouping)? -- B: Can we make the existing strip smarter (compact modes, ordering, visual hierarchy)? -- C: Can we make status bar + zoom carry more of the load? - -## Results - -| Agent | Files | Cycles | All Kept | Summary | -|-------|-------|--------|----------|---------| -| A (Opus analyst) | none | 10 | n/a | 5 RECOMMEND, 3 SKIP, 2 other | -| B (Sonnet) | strip.go, pill.go, styles.go | 10 | ✓ | Strip: 295→118 chars (60% reduction) | -| C (Sonnet) | app.go, zoom.go, pr_zoom.go, hints.go, queue.go | 10 | ✓ | 10 improvements all kept | - -**Build/tests: PASS** (combined result, `go build ./... && go test ./...`) - -## What Worked - -### Strip (Agent B) -- **Tiered pill compaction**: selected=20 chars, running/waiting=8, idle/dead=4. Biggest single space win. -- **Passive pill weight**: idle/dead get no background, just dim text. Visual hierarchy without layout change. -- **Attention-first sort**: pending→running→waiting→idle→dead. Critical sessions never scroll off left. -- **Terminal PR collapse**: merged/closed → `(+N done)` when active PRs exist. Removes done clutter. -- **Compact PR pills**: title only for failing/approved. Running/passing show `⏳ #42` = 7 chars. -- **State-group summary**: `▶3 ⏸1 ✔5 ●1` prefix at 5+ sessions. Instant fleet scan. -- **Enriched overflow**: `+4(▶2⏸1)` instead of bare `+4`. Hidden state info. -- **Dead session collapse**: `(●3)` at 8+ sessions. Removes truly-done clutter. -- **Critical PR emphasis**: failing→red dim bg, approved→green dim bg. 3-level urgency. - -### Status Bar / Zoom (Agent C) -- **Session state breakdown**: `7▶ 2⏸ 1✔` replaces flat "7 running" -- **Pending badge**: `[⚡ 2 PENDING]` orange background — eye-catching alert -- **Oldest-pending age**: `[⚡ 2 PENDING] 45s ago` — urgency signal -- **Failing PR badge**: `[✗ N FAILING]` red background — consistent with pending -- **PR state breakdown**: `5 PRs 3✓ 1✗ 1⏳` mirrors session breakdown -- **Fleet map**: above session zoom, `Session 3/10: [▶]▶▶⏸⏸✔●` -- **Merge readiness**: first line of PR zoom, `✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash` -- **Done-state treatment**: merged/closed PR zoom shows clean "✔ Merged" header -- **Safety grouping**: queue groups destructive vs safe tools per session - -## Dead Ends -(from Agent A analysis) -- **State-grouped strip headers**: group headers waste 8-12 chars, overflow algo becomes complex at boundaries -- **Summary mode** (aggregate counts): loses individual session identity -- **Sidebar layout**: major refactor, reduces main panel width, v2 territory - -## Remaining Opportunities -- Zellij-style brackets (needs visual prototyping) -- Progressive compaction (continuous rather than tiered name shortening) -- Keyboard-driven expansion popup -- PR state-color in strip separator -- Glow animation for pending pill borders (glowPos already exists) - -## Next Experiments - -### Agent A (Opus) — Architectural Alternatives -1. Two-row strip: sessions on row 1, PRs on row 2 — doubles density without overflow -2. State-grouped sections: `[▶▶▶] [⏸⏸] [✔]` with mini labels -3. Sidebar layout: left vertical list, main area takes remaining width -4. Summary strip: `5▶ 3⏸ 2✔ | 4✓ 1✗` → expand on hover/select - -### Agent B (Sonnet) — Strip Density -1. Compact inactive pills: idle/dead → just icon (1 char) to save space -2. Attention-first ordering: pending sessions always at front of strip -3. Visual weight: pending sessions get bordered pill, others plain -4. PR filtering: hide merged/closed PRs by default, show count only - -### Agent C (Sonnet) — Status Bar + Zoom -1. Status bar attention widget: flashing/colored attention count when any session pending -2. Zoom header: add mini-map of all sessions (1 glyph each) when >5 sessions -3. Strip context: show `Sessions [3▶ 2⏸] PRs [2✓ 1✗]` instead of plain counts diff --git a/.autoresearch/researchers/agent-a.md b/.autoresearch/researchers/agent-a.md deleted file mode 100644 index 75af3ff..0000000 --- a/.autoresearch/researchers/agent-a.md +++ /dev/null @@ -1,131 +0,0 @@ -# Researcher A (Opus): Deep Architectural Analysis - -## Assignment -Analyze TUI visual clarity problem for 10+ session / 5+ PR load scenario. -Generate specific, implementation-ready proposals for the highest-impact structural changes. -No file modifications — this is pure design research. Output goes here only. - -## Findings - -### Cycle 1: Two-row strip (sessions on row 1, PRs on row 2) -- Verdict: RECOMMEND -- Why: Doubles available width per category. The `View()` height calculation already uses `lipgloss.Height(strip)` so a 2-row strip automatically adjusts `remainingHeight`. Only costs 1 extra line of terminal height. Can be conditional: 2 rows only when PRs exist. -- Implementation sketch: Split `renderUnifiedStrip` into two calls to a new `renderPillRow(pills, budget)` helper. Combine into single `styleStripBar` block. Separator `│` is eliminated — row separation IS the separator. Budget per row = `width - 2` (full width). With 120-char terminal, each row fits ~5 full-size pills or ~15+ compact pills. - -### Cycle 2: Compact inactive pills (icon-only for idle/dead) -- Verdict: RECOMMEND -- Why: Biggest single impact change. Idle pills at 3 chars vs 24 chars = 8x space recovery per pill. 10 mixed-state sessions shrink from ~240 chars to ~120 chars, fitting a 120-char terminal without overflow. The zoom panel already provides full detail for the selected item. -- Implementation sketch: In `renderPillWithName`, add a `compact` path based on state and selection: - - `idle`/`dead` + not selected + no pending: render icon only, no name, `Padding(0,0)` = 1-3 chars - - `running` + not selected: render icon + first 5 chars of name = ~8 chars - - `waiting` with pending OR selected: full pill (current behavior) = ~24 chars - - Selected always gets full treatment regardless of state - -### Cycle 3: Attention-first ordering (pending sessions sort to front) -- Verdict: RECOMMEND -- Why: Ensures urgent items are never hidden behind `+N` overflow. ~15 lines of code — a stable sort by priority in the `stateMsg` handler. The existing `selectedSID`/`selectedPRKey` tracking by ID already handles position changes after reordering. -- Implementation sketch: After `m.sessions = msg.Sessions` (app.go ~line 184), insert: - ```go - sort.SliceStable(m.sessions, func(i, j int) bool { - return statePriority(m.sessions[i]) < statePriority(m.sessions[j]) - }) - ``` - Where `statePriority`: pending(0) > running(1) > waiting(2) > idle(3) > dead(4). - -### Cycle 4: State-grouped strip with group headers -- Verdict: SKIP -- Why: Group headers add 8-12 chars of overhead. The overflow algorithm becomes significantly more complex when group boundaries straddle the visible window. Color differentiation (which already exists) + compact pills achieve the same information density more simply. - -### Cycle 5: Hide terminal PRs (merged/closed auto-collapse) -- Verdict: RECOMMEND -- Why: 3 merged PRs at ~8 chars each + spaces = ~26 chars become a single 4-char summary indicator. Dramatic space recovery when many PRs are completed. Pairs naturally with two-row strip (Cycle 1) — the PR row stays clean. -- Implementation sketch: In `renderUnifiedStrip`, before building PR pills: - ```go - var activePRs, terminalPRs []client.TrackedPR - for _, p := range prs { - if p.State == "merged" || p.State == "closed" { - terminalPRs = append(terminalPRs, p) - } else { - activePRs = append(activePRs, p) - } - } - // Render activePRs as pills, append summary for terminalPRs: - // dimStyle.Render(fmt.Sprintf("(%d done)", len(terminalPRs))) - ``` - When the summary is selected, the zoom panel shows a list of all terminal PRs. - -### Cycle 6: Zellij tab paradigm (brackets for selected, plain text for rest) -- Verdict: NEEDS_PROTOTYPING -- Why: Dropping pill backgrounds for non-selected items saves modest space (2 chars/pill) but the visual clarity is uncertain. Selected pill with brackets `[▶ myapp]` vs borderless non-selected creates strong hierarchy. The loss of dim backgrounds may make the strip look like noisy text rather than distinct items. Needs visual testing on actual terminals. - -### Cycle 7: Summary mode (aggregate counts when items > threshold) -- Verdict: SKIP -- Why: Aggregate counts (`5▶ 3⏸ 2✔`) lose individual session identity. User cannot scan for a specific session without cycling through them. The threshold (e.g., 8 items) creates a jarring transition. Compact pills achieve similar density without losing identity. - -### Cycle 8: Sidebar layout (vertical list replacing bottom strip) -- Verdict: SKIP -- Why: Major refactor touching every render function (`renderZoom`, `renderPRZoom`, `renderQueue`, `renderEmptyState`, `renderHelp`). Reduces main panel width by 22 chars. Introduces new scroll state. Changes navigation model fundamentally. Better suited for v2 if simpler improvements prove insufficient. - -### Cycle 9: Visual weight differentiation (bordered pending, dim idle) -- Verdict: RECOMMEND -- Why: Pure styling change, ~15 lines in `renderPillWithName`. Pending pills get borders (draw eye), idle/dead pills drop background (fade out). Immediate at-a-glance scanning. Combines naturally with compact pills. Lowest implementation cost of all recommendations. -- Implementation sketch: In `renderPillWithName` (pill.go): - ```go - if !selected && len(s.PendingTools) > 0 { - style = style.Border(lipgloss.RoundedBorder()). - BorderForeground(colorOrange) - } else if !selected && (s.State == "idle" || s.State == "dead") { - style = lipgloss.NewStyle().Foreground(colorDimFg) - // No background, no padding — minimal visual weight - } - ``` - -### Cycle 10: Synthesis -- Verdict: RECOMMEND (the ranked list below) -- Why: The top 3 recommendations (compact pills, visual weight, attention-first ordering) can be implemented as a single coherent PR with ~50 lines of changes. Together they solve the core problem: urgent items are visible, inactive items fade, and the strip fits 2-3x more items. - -## Top Recommendations (ranked by impact x simplicity) - -1. **Compact inactive pills** — idle/dead pills show icon-only (1-3 chars) instead of full name (24 chars) - - Files: `tui/internal/tui/pill.go` (renderPillWithName), `tui/internal/tui/strip.go` (renderUnifiedStrip — pass state info) - - Complexity: Low - - Impact: High - - Sketch: Add `compact` flag based on `!selected && state in (idle, dead)`. Compact pills render as `icon` with `Padding(0,0)` and `Foreground(dimColor)`. Running non-selected pills show `icon + name[:5]`. - -2. **Visual weight differentiation** — pending pills get borders, idle pills lose backgrounds - - Files: `tui/internal/tui/pill.go` (renderPillWithName) - - Complexity: Low - - Impact: High - - Sketch: `if !selected && hasPending { style = style.Border(lipgloss.RoundedBorder()) }` + `if !selected && isPassive { style = lipgloss.NewStyle().Foreground(colorDimFg) }`. ~15 lines. - -3. **Attention-first ordering** — pending sessions sort to front of strip - - Files: `tui/internal/tui/app.go` (stateMsg handler) - - Complexity: Low - - Impact: Medium-High - - Sketch: `sort.SliceStable(m.sessions, func(i,j) { return priority(i) < priority(j) })` after line 184. ~15 lines including the priority function. - -4. **Hide terminal PRs** — merged/closed PRs collapse to `(+N done)` summary - - Files: `tui/internal/tui/strip.go` (renderUnifiedStrip), `tui/internal/tui/app.go` (selectedPR handling) - - Complexity: Medium - - Impact: Medium - - Sketch: Partition PRs into active/terminal before building pills. Render terminal count as a single styled summary entry. Summary selection shows list in zoom panel. - -5. **Two-row strip** — sessions on top row, PRs on bottom row - - Files: `tui/internal/tui/strip.go` (renderUnifiedStrip or new function) - - Complexity: Medium - - Impact: Medium - - Sketch: Two calls to a `renderPillRow(pills, budget)` helper, joined vertically inside `styleStripBar`. Conditional: only 2 rows when both sessions and PRs exist. `View()` height math already adapts via `lipgloss.Height(strip)`. - -## Dead Ends - -- **State-grouped strip** (Cycle 4): Sounds intuitive but group headers waste 8-12 chars, overflow algorithm becomes complex at group boundaries, and color already provides grouping. Net negative. -- **Summary mode** (Cycle 7): Loses individual session identity. Users need to see which specific session is "myapp" vs "debug", not just that there are "5 running". Compact pills preserve identity. -- **Sidebar layout** (Cycle 8): Major refactor touching all render functions, reduces main panel width, changes navigation model. Correct direction for v2 but overkill for current iteration. - -## Remaining Opportunities - -- **Zellij-style brackets** (Cycle 6): Worth prototyping visually. If backgrounds can be dropped for non-selected pills without losing visual structure, this gives another 2 chars/pill and stronger selection contrast. -- **Progressive compaction**: Instead of a hard compact/full threshold, gradually shorten pill names as the strip gets more crowded: 20 chars → 12 → 8 → 5 → icon-only. This would require a width-aware name truncation strategy. -- **Keyboard-driven expansion**: Press a key (e.g., `e`) to temporarily expand the strip to show all pills with full names in a multi-line popup, then dismiss. Similar to how the help screen overlays the main content. -- **PR state-color in strip separator**: Instead of `│`, use a colored border between session and PR sections that reflects overall PR health (green = all passing, red = any failing, yellow = running). Zero-width visual cue. -- **Glow animation for pending**: The existing `glowPos` ping-pong animation could be applied specifically to pending pills' borders, making them pulse. Already half-built — currently used for the "running" session name character highlight. diff --git a/.autoresearch/researchers/agent-b.md b/.autoresearch/researchers/agent-b.md deleted file mode 100644 index a3780ec..0000000 --- a/.autoresearch/researchers/agent-b.md +++ /dev/null @@ -1,97 +0,0 @@ -# Researcher B (Sonnet): Strip Density Optimizer - -## Assignment -Implement visual improvements to strip.go, pill.go, and styles.go. -Target: clean, scannable strip with 10+ sessions and 5+ PRs. -Focus: compact inactive pills, attention-first ordering, visual hierarchy. - -## Scope -- tui/internal/tui/strip.go -- tui/internal/tui/pill.go -- tui/internal/tui/styles.go - -## Summary -Completed 10 cycles of visual improvement. Total chars used at 10-session + 5-PR load -reduced from ~240+ chars (baseline) to ~115 chars (120-char terminal fits without overflow). - -## Experiments - -### Cycle 1: Compact idle/dead pills (KEPT) -- **Change**: Passive (idle/dead) unselected pills truncated to 4-char names -- **Before**: icon + 20-char name + 2 padding = 24 chars each -- **After**: icon + 4-char name, no padding = 6 chars each -- **Savings**: 18 chars per passive pill; 5 idle sessions = 90 chars saved - -### Cycle 2: Attention-first ordering (KEPT) -- **Change**: Sessions sorted by state priority before rendering: pending(0) > running(1) > waiting(2) > idle(3) > dead(4) -- **Before**: Sessions in arrival order — idle could bury running -- **After**: Critical sessions always at left of strip, survive overflow truncation -- **Key**: Selected session ID tracked through sort to remap selectedIdx correctly - -### Cycle 3: Filter terminal PRs (KEPT) -- **Change**: merged/closed PRs hidden when active PRs exist; replaced with `(+N done)` label -- **Before**: All 5 PRs shown (3 merged = 21+ chars) -- **After**: 3 merged PRs → `(+3 done)` = 9 chars total -- **Savings**: 12+ chars, removes done-work clutter from visible strip - -### Cycle 4: Visual weight hierarchy for passive pills (KEPT) -- **Change**: Passive (idle/dead) unselected pills lose background entirely; use dim text only -- **Before**: Passive pills had padding + black bg = same visual structure as active -- **After**: Passive pills are plain dim text — no box, no padding -- **Effect**: Clear visual hierarchy: colored boxes = active, plain text = passive - -### Cycle 5: State-group summary prefix (KEPT) -- **Change**: For 5+ sessions, prepend `▶3 ⏸1 ✔5 ●1` summary to strip -- **Before**: User had to read each pill icon to understand state distribution -- **After**: ~12-char summary gives instant state overview -- **Implementation**: `sepBoundary` variable tracks separator position through prepend - -### Cycle 6: Compact PR pills (KEPT) -- **Change**: PR title shown only for critical/selected states; non-critical shows `icon #N` only -- **Before**: All active PR pills: icon + number + 15-char title = ~23 chars -- **After**: checks_running/checks_passing: `⏳ #42` = 7 chars; title only for failing/approved -- **Savings**: 15 chars per non-critical PR pill - -### Cycle 7: Tiered session name length (KEPT) -- **Change**: `pillNameMaxLen()` function: selected=20 chars, running/waiting=8 chars, idle/dead=4 chars -- **Before**: Running/waiting unselected: 20-char name = 24 chars total -- **After**: Running/waiting unselected: 8-char name = 12 chars total -- **Savings**: 12 chars per active pill; 3 running sessions = 36 chars saved - -### Cycle 8: Enriched overflow indicator (KEPT) -- **Change**: Overflow shows `+N(▶R⏸W)` when hidden pills include active sessions -- **Before**: Plain `+4` — no information about hidden states -- **After**: `+4(▶2⏸1)` — user sees how many running/waiting sessions are hidden -- **Solves**: The "+N confusion" problem directly - -### Cycle 9: Critical PR visual emphasis (KEPT) -- **Change**: checks_failing → bold + red dim bg; approved → bold + green dim bg -- **Before**: Critical PRs looked like non-critical (just colored foreground) -- **After**: 3-level urgency: plain text < tinted background < selected border -- **Effect**: User can spot failing PRs at a glance without reading labels - -### Cycle 10: Collapse dead sessions at 8+ load (KEPT) -- **Change**: 8+ sessions: dead sessions without pending/selection collapsed to `(●N)` = 5 chars -- **Before**: 3 dead sessions = 18 chars (3 × 6) -- **After**: `(●3)` = 5 chars -- **Savings**: 13 chars, removes truly-done session clutter - -## Cumulative Strip Width Savings (10 sessions + 5 PRs at 120-char terminal) - -| Source | Baseline | After | Saved | -|--------|----------|-------|-------| -| 3 running pills | 72 | 36 | 36 | -| 1 waiting pill | 24 | 12 | 12 | -| 5 idle pills | 120 | 30 | 90 | -| 2 dead pills (hidden) | 12 | 5 | 7 | -| 3 merged PRs | 21 | 9 | 12 | -| 2 non-critical PRs | 46 | 14 | 32 | -| State summary | 0 | 12 | -12 | -| **Total** | **295** | **118** | **177** | - -## Key Design Principles Established -1. **Attention hierarchy**: Critical state → full pill; Active → compact box; Passive → plain text; Hidden → count -2. **Sort before render**: Most important sessions are always leftmost, survive overflow -3. **Information-dense overflow**: `+N(▶R⏸W)` provides state info for hidden sessions -4. **Terminal PRs hidden**: merged/closed collapsed to count when active PRs exist -5. **Tier truncation**: 20 chars (selected) → 8 chars (active) → 4 chars (passive) diff --git a/.autoresearch/researchers/agent-c.md b/.autoresearch/researchers/agent-c.md deleted file mode 100644 index 1fa7e5b..0000000 --- a/.autoresearch/researchers/agent-c.md +++ /dev/null @@ -1,61 +0,0 @@ -# Researcher C (Sonnet): Status Bar + Zoom Improvements - -## Assignment -Implement visual improvements to app.go (status bar), zoom.go, pr_zoom.go, hints.go, queue.go. -Target: status bar that communicates session load at a glance; zoom panels that stay useful under load. -Focus: richer status bar summary, attention widgets, zoom panel quick-scan improvements. - -## Scope -- tui/internal/tui/app.go -- tui/internal/tui/zoom.go -- tui/internal/tui/pr_zoom.go -- tui/internal/tui/hints.go -- tui/internal/tui/queue.go - -## Findings - -### Status Bar (app.go) - -1. **Session state breakdown** — The flat "7 running" count was replaced with a per-state compact breakdown: `7▶ 2⏸ 1✔ 1●` with colors. Running=green, waiting=yellow, idle/dead=gray. Only non-zero states shown. Gives instant fleet overview without looking at the strip. - -2. **Pending badge** — Changed `⚡ 2 pending` (inline orange text) to an orange background badge `[⚡ 2 PENDING]` with black text. Far more visually prominent; eye-catching even when looking away. - -3. **Oldest-pending age** — After the pending badge, show `45s ago` indicating how long the oldest pending approval has been waiting. Urgency context: `⚡ 2 PENDING 5m ago` signals something is truly blocked. - -4. **Failing PR badge** — Changed `✗ 2 failing` to red background badge `[✗ 2 FAILING]` — visually consistent with pending badge. Both urgent alerts use the same treatment. - -5. **PR state breakdown** — Added compact per-state PR counts after the PR total: `5 PRs 3✓ 1✗ 1⏳`. Passing=green, failing=red, running=yellow, merged=dim gray. Mirrors session breakdown. - -Full status bar under load: `██ CCC ● connected 10 sessions 7▶ 2⏸ 1✔ 5 PRs 3✓ 1✗ [⚡ 2 PENDING] 45s ago [✗ 1 FAILING]` - -### Session Zoom (zoom.go) - -6. **Fleet map line** — When 3+ sessions exist, a 1-line fleet map appears above the session zoom: `Session 3/10: [▶]▶▶⏸⏸✔●`. Current session is bracketed and bold. Sessions with pending tools are shown in orange. Position indicator `3/10` tells operator where they are in the fleet. - -### PR Zoom (pr_zoom.go) - -7. **Merge readiness summary** — First line of PR zoom body shows quick-scan summary: `✓ approved ✓ checks (12/12) ✓ mergeable ⎇ squash` or failure indicators. Lets user assess merge readiness in under 1 second. - -8. **Done-state treatment** — Merged/closed PRs show `✔ Merged — no further action required` at top of body. Merge readiness summary is skipped for done PRs. Reduces clutter and visually distinguishes actionable vs. done PRs. - -### Queue Panel (queue.go) - -9. **Safety-grouped tools** — Pending tools grouped per session into `⚠ Destructive:` and `✓ Safe:` sections. Destructive listed first. Labels only shown when a session has both types. Operators can immediately identify which tools need careful scrutiny. - -## Experiments - -| Cycle | File | Change | Result | -|-------|------|--------|--------| -| 1 | app.go | Session state breakdown `7▶ 2⏸ 1✔` | keep | -| 2 | app.go | Orange badge for pending: `[⚡ 2 PENDING]` | keep | -| 3 | zoom.go, app.go | Fleet map line above session zoom | keep | -| 4 | app.go | Red badge for failing PRs: `[✗ N FAILING]` | keep | -| 5 | pr_zoom.go | Merge readiness summary line at PR zoom top | keep | -| 6 | zoom.go | Session N/total in fleet map label | keep | -| 7 | queue.go | Safety-grouped tools in queue panel | keep | -| 8 | app.go | Oldest-pending age next to pending badge | keep | -| 9 | pr_zoom.go | Done-state treatment for merged/closed PRs | keep | -| 10 | app.go | PR state breakdown `3✓ 1✗ 1⏳` in status bar | keep | - -## Key Principle -All 10 cycles kept — every improvement passed build and tests. The visual language is now consistent: badge = urgent alert, breakdown = fleet state, glyphs = per-item states. 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/ From 0eeac9b7e3170e4c40f595de1fe5836df6be6eb0 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 01:30:44 +0100 Subject: [PATCH 22/23] Streaming agent output with live timeline updates, cleanup, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent commands use --output-format stream-json --verbose for real-time output - Parse STATUS: lines from agent text and push as ⚙ timeline events - Prefix timeline entries with agent type (fix-CI:, review:, fix-review:) - Write full stream log to /tmp/csm-agent-*-stream.log for debugging - Accumulate agent cost from result events - Remove fleet map (redundant with strip state summary) - Remove duplicate mergeable/method from PR zoom header line 2 - Add 8 regression tests (stream-json flags, agentLabel, writeAgentLog, PR zoom dedup) --- daemon/internal/pr/agent.go | 190 +++++++++++++++++++++++++++++-- daemon/internal/pr/agent_test.go | 101 ++++++++++++++++ tui/internal/tui/app.go | 17 +-- tui/internal/tui/pr_zoom_test.go | 43 +++++++ tui/internal/tui/zoom.go | 64 ----------- 5 files changed, 328 insertions(+), 87 deletions(-) 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/tui/internal/tui/app.go b/tui/internal/tui/app.go index 3a3ea10..b65aa1d 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -731,25 +731,13 @@ func (m Model) View() string { remainingHeight := m.height - bottomHeight - statusHeight - // Fleet map: 1-line session overview shown above session zoom when 3+ sessions. - fleetMap := "" - if isSession && !m.queueVisible { - if sel := m.selected(); sel != nil { - fleetMap = renderFleetMap(m.sessions, sel.SessionID, w) - } - } - fleetMapHeight := 0 - if fleetMap != "" { - fleetMapHeight = 1 - } - // Main content area. mainContent := "" if m.queueVisible && hasPending { mainContent = renderQueue(m.sessions, w, remainingHeight) } else if isSession { if sel := m.selected(); sel != nil { - mainContent = renderZoom(*sel, w, remainingHeight-fleetMapHeight, m.scrollOffset) + mainContent = renderZoom(*sel, w, remainingHeight, m.scrollOffset) } else { mainContent = renderEmptyState(w, remainingHeight) } @@ -761,9 +749,6 @@ func (m Model) View() string { var outputParts []string outputParts = append(outputParts, statusLine) - if fleetMap != "" { - outputParts = append(outputParts, fleetMap) - } outputParts = append(outputParts, mainContent, hints, strip) output := lipgloss.JoinVertical(lipgloss.Left, outputParts...) 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/zoom.go b/tui/internal/tui/zoom.go index 0c0e466..2d08d51 100644 --- a/tui/internal/tui/zoom.go +++ b/tui/internal/tui/zoom.go @@ -232,70 +232,6 @@ func renderZoom(s client.Session, width, height int, scrollOffset int) string { return strings.Join(renderedLines, "\n") } -// renderFleetMap renders a compact 1-line overview of all sessions as state -// glyphs, e.g. "Session 3/10: [▶]▶▶⏸⏸✔●" to give context from any zoom view. -// Returns empty string when there are fewer than 3 sessions. -func renderFleetMap(sessions []client.Session, currentSID string, width int) string { - if len(sessions) < 3 { - return "" - } - - // Find current session position. - currentPos := 0 - for i, s := range sessions { - if s.SessionID == currentSID { - currentPos = i + 1 - break - } - } - - posStr := "" - if currentPos > 0 { - posStr = lipgloss.NewStyle().Foreground(colorFg).Bold(true). - Render(fmt.Sprintf("%d", currentPos)) + - lipgloss.NewStyle().Foreground(colorDimFg). - Render(fmt.Sprintf("/%d", len(sessions))) - } - - label := lipgloss.NewStyle().Foreground(colorDimFg).Render(" Session ") + - posStr + - lipgloss.NewStyle().Foreground(colorDimFg).Render(": ") - - var glyphs []string - for _, s := range sessions { - var glyph string - if s.SessionID == currentSID { - // Highlight current session with brackets. - glyph = lipgloss.NewStyle(). - Foreground(colorFg). - Bold(true). - Render("[" + stateIcon(s.State) + "]") - } else if len(s.PendingTools) > 0 { - // Pending-approval sessions get orange attention marker. - glyph = lipgloss.NewStyle(). - Foreground(colorOrange). - Bold(true). - Render(stateIcon(s.State)) - } else { - glyph = lipgloss.NewStyle(). - Foreground(stateColor(s.State)). - Render(stateIcon(s.State)) - } - glyphs = append(glyphs, glyph) - } - - line := label + strings.Join(glyphs, "") - // Truncate to width to prevent wrapping. - if lipgloss.Width(line) > width { - // Trim glyphs from end until fits. - for len(glyphs) > 3 { - glyphs = glyphs[:len(glyphs)-1] - } - line = label + strings.Join(glyphs, "") + - lipgloss.NewStyle().Foreground(colorSubtle).Render("…") - } - return line -} func activityIcon(actType string) string { switch actType { From 0fc317429d5c567bc926b375cd41d47f92e67ff8 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Wed, 18 Mar 2026 01:33:33 +0100 Subject: [PATCH 23/23] =?UTF-8?q?fix:=20CI=20test=20failure=20=E2=80=94=20?= =?UTF-8?q?disable=20review=20spawn=20in=20TestPoll=5FMultiplePRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming agent code spawns goroutines that race with TempDir cleanup in CI where claude binary doesn't exist. Set ReviewState="clean" to skip agent spawning since this test is about polling, not agent execution. --- daemon/internal/pr/poller_integration_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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()