Live metrics (--profile): - New metricsTracker instruments OnPTYOut, viewport renderer, stdout writes, libghostty-vt Write/Title CGO calls, sidebar / tabbar / status draws (with cache-hit accounting), snapshot replays, and the chrome ticker (so we can see ticker fires that did nothing). - Writes metrics.jsonl (one snapshot per second) and metrics.json + summary.txt on exit, alongside the existing pprof files. - All record* methods are nil-safe so disabled paths pay only a cheap nil check; counters are atomic so the per-PTY-chunk hot path stays lock-free. Benchmark suite (go test -bench=.): - Three workload fixtures — plain ASCII, SGR-styled lines, and a ratatui-style cursor-shuffling burst — plus a containsOSC microbenchmark. Reports ns/op, MB/s, allocs/op, B/op. - Initial baseline numbers added to TODO under the perf-audit section, alongside two new findings (renderer allocs ~1 per 4 bytes on styled chunks; styled throughput tops out near 90 MB/s) those benchmarks surfaced.
211 lines
5.2 KiB
Go
211 lines
5.2 KiB
Go
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)
|
|
}
|