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.
276 lines
7.2 KiB
Go
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 "", ""
|
|
}
|
|
}
|