Files
patterm/internal/app/tabbar.go
Harry Bayliss d2342f99cf Show every agent tab's summary, not just the focused one
The tab bar's row-2 summary was painted only for the active tab. Add a
per-child summaryTextFor/summaryRawFor helper (active variants now
delegate to it), carry each tab's childID on its tabRect, and loop over
all visible tabs so each renders its own summary under its column.
Layout is unchanged (still 3 rows); narrow tabs clip as before.

Resolves the per-tab summary TODO item.
2026-05-25 13:06:53 +01:00

276 lines
7.2 KiB
Go

package app
import (
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
)
// Three-row tab bar: labels row, active-thread summary row, underline row. The PTY viewport's top
// row is therefore mainTop == tabBarRows + 1.
const tabBarRows = 3
// drawTabBar renders the top tab strip across the full host width.
// Tabs share the available width with a flex layout — each visible
// session gets roughly width/N cells, with the remainder distributed
// to the leftmost tabs so the strip fills the screen edge-to-edge.
// A trailing "+ new" hint sits in the rightmost reserved slot.
func (st *uiState) drawTabBar() {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
st.mu.Lock()
palOpen := st.palette != nil
// Highlight the top-level agent tab even when focus has stepped
// into a sub-agent (or a Processes pane entry). activeAgentID walks
// the parent chain to the root, so the user always sees which tab
// their current thread belongs to.
focus := st.activeAgentID
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
width := int(layout.childCols())
if width < 8 {
return
}
// Tabs list top-level agent sessions only. Command/terminal
// processes live in the Processes sidebar section and never own a
// tab — they're global to the session, not per-tab.
var sessions []*Child
for _, c := range st.sess.Children() {
if c.Kind != KindAgent {
continue
}
if c.ParentID == "" && c.Status() == StatusRunning {
sessions = append(sessions, c)
}
}
const (
newHint = "+ new"
minTabWidth = 6 // enough for two pad cells + "x…" or similar
)
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct {
childID string
startCol int
width int
label string
glyph string
glyphStyle string
active bool
}
// Reserve space at the right edge for "+ new". If there are too
// many tabs to fit even at minTabWidth, drop tabs from the right
// until they do. The current focus stays visible.
tabBudget := width - newHintW
if tabBudget < minTabWidth {
tabBudget = width
newHintW = 0
}
visible := sessions
if len(visible) > 0 {
maxTabs := tabBudget / minTabWidth
if maxTabs < 1 {
maxTabs = 1
}
if len(visible) > maxTabs {
// Keep the focused tab plus as many leftward tabs as fit.
focusIdx := -1
for i, c := range visible {
if c.ID == focus {
focusIdx = i
break
}
}
if focusIdx < 0 {
focusIdx = 0
}
start := focusIdx - maxTabs + 1
if start < 0 {
start = 0
}
end := start + maxTabs
if end > len(visible) {
end = len(visible)
}
visible = visible[start:end]
}
}
tabs := make([]tabRect, 0, len(visible))
if n := len(visible); n > 0 {
base := tabBudget / n
extra := tabBudget - base*n
col := 1
for i, c := range visible {
w := base
if i < extra {
w++
}
active := c.ID == focus
glyph, glyphStyle := tabIdleGlyph(c.IdleState(), active)
label := c.DisplayName()
labelW := utf8.RuneCountInString(label)
// Reserve room for the glyph + its trailing space when present
// (1 + 1 runes), on top of the one-cell pad on each side.
maxLabelW := w - 2
if glyph != "" {
maxLabelW -= 2
}
if maxLabelW < 1 {
maxLabelW = 1
}
if labelW > maxLabelW {
if maxLabelW > 1 {
label = clipRunes(label, maxLabelW-1) + "…"
} else {
label = clipRunes(label, maxLabelW)
}
labelW = utf8.RuneCountInString(label)
}
tabs = append(tabs, tabRect{
childID: c.ID,
startCol: col,
width: w,
label: label,
glyph: glyph,
glyphStyle: glyphStyle,
active: active,
})
col += w
}
}
var b strings.Builder
// Clear all tab-bar rows so stale labels or summaries from the
// previous frame can't
// bleed through. Use ECH clamped to `width` (= childCols) instead of
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
// and if drawSidebar's chrome cache is fresh it won't repaint to
// fill them back in — the user sees a gap where the sidebar border
// and content should be.
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width)
for _, t := range tabs {
// Row 1: centre-ish glyph+label inside the tab cell.
labelW := utf8.RuneCountInString(t.label)
visibleW := labelW
if t.glyph != "" {
visibleW += 2 // glyph + separator space
}
leftPad := (t.width - visibleW) / 2
if leftPad < 1 {
leftPad = 1
}
rightPad := t.width - visibleW - leftPad
if rightPad < 0 {
rightPad = 0
}
cellStyle := styleHint
if t.active {
cellStyle = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
b.WriteString(cellStyle)
b.WriteString(strings.Repeat(" ", leftPad))
if t.glyph != "" {
// Glyph uses its own colour so error/permission states pop
// regardless of tab focus, matching the sidebar's vocabulary.
b.WriteString(styleReset)
b.WriteString(t.glyphStyle)
b.WriteString(t.glyph)
b.WriteString(styleReset)
b.WriteString(cellStyle)
b.WriteString(" ")
}
b.WriteString(t.label)
b.WriteString(strings.Repeat(" ", rightPad))
b.WriteString(styleReset)
// Row 3: underline. Thick accent for the active tab, faint
// border for the rest.
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
if t.active {
b.WriteString(styleAccent)
b.WriteString(strings.Repeat("━", t.width))
} else {
b.WriteString(styleBorder)
b.WriteString(strings.Repeat("─", t.width))
}
b.WriteString(styleReset)
}
// "+ new" hint right-aligned in the reserved slot.
if newHintW > 0 {
hintCol := width - newHintW + 1
fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset)
// Underline continues faintly under the hint so the strip
// reads as one bar.
fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s",
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
}
for _, tab := range tabs {
summaryWidth := tab.width - 2
if summary := st.summaryTextFor(tab.childID, summaryWidth); summary != "" {
fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset)
}
}
frame := b.String()
st.chromeCacheMu.Lock()
if frame == st.tabBarCache {
st.chromeCacheMu.Unlock()
if st.metrics != nil {
st.metrics.recordTabbar(time.Since(entry), true)
}
return
}
st.tabBarCache = frame
st.chromeCacheMu.Unlock()
if st.metrics != nil {
defer func() { st.metrics.recordTabbar(time.Since(entry), false) }()
}
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
}
// tabIdleGlyph returns the one-rune state indicator (and its SGR style)
// to render before a tab's label. Mirrors the sidebar's vocabulary so
// users learn the symbols in one place: ✕ error, ? permission, ◐
// thinking, ○ idle, ● working. Returns ("", "") for StateUnknown so the
// first frame after spawn doesn't show a misleading badge.
func tabIdleGlyph(state IdleState, active bool) (string, string) {
base := styleHint
if active {
base = styleAccent
}
switch state {
case StateError:
return "✕", styleError
case StatePermission:
return "?", styleAccent
case StateThinking:
return "◐", base
case StateIdle:
return "○", base
case StateWorking:
return "●", base
default:
return "", ""
}
}