Each agent tab now prefixes its label with the same one-rune idle indicator the sidebar uses (✕ error, ? permission, ◐ thinking, ○ idle, ● working), so the state of every open agent is visible without opening or focusing each tab. Tab redraws now fire on idle-state changes in addition to sidebar redraws.
280 lines
7.3 KiB
Go
280 lines
7.3 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 {
|
|
startCol int
|
|
width int
|
|
label string
|
|
glyph string
|
|
glyphStyle string
|
|
active bool
|
|
}
|
|
activeTab := -1
|
|
|
|
// 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{
|
|
startCol: col,
|
|
width: w,
|
|
label: label,
|
|
glyph: glyph,
|
|
glyphStyle: glyphStyle,
|
|
active: active,
|
|
})
|
|
if tabs[len(tabs)-1].active {
|
|
activeTab = len(tabs) - 1
|
|
}
|
|
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)
|
|
}
|
|
|
|
if activeTab >= 0 {
|
|
tab := tabs[activeTab]
|
|
summaryWidth := tab.width - 2
|
|
if summary := st.activeSummaryText(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 "", ""
|
|
}
|
|
}
|