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 "", "" } }