package app import ( "fmt" "os" "strings" "time" "unicode/utf8" ) // Two-row tab bar: labels row, underline row. The PTY viewport's top // row is therefore mainTop == tabBarRows + 1. const tabBarRows = 2 // 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 focus := st.focusedID 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 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++ } label := c.DisplayName() labelW := utf8.RuneCountInString(label) maxLabelW := w - 2 // one pad on each side 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, active: c.ID == focus, }) col += w } } var b strings.Builder // Clear both rows so a stale label 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) for _, t := range tabs { // Row 1: centre-ish label inside the tab cell. labelW := utf8.RuneCountInString(t.label) leftPad := (t.width - labelW) / 2 if leftPad < 1 { leftPad = 1 } rightPad := t.width - labelW - leftPad if rightPad < 0 { rightPad = 0 } fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol) if t.active { b.WriteString(styleActive) } else { b.WriteString(styleHint) } b.WriteString(strings.Repeat(" ", leftPad)) b.WriteString(t.label) b.WriteString(strings.Repeat(" ", rightPad)) b.WriteString(styleReset) // Row 2: underline. Thick accent for the active tab, faint // border for the rest. fmt.Fprintf(&b, "\x1b[2;%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[2;%dH%s%s%s", hintCol, styleBorder, strings.Repeat("─", newHintW), 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) }