Files
patterm/internal/app/tabbar.go
Harry Bayliss 52e06c914e
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
Release v0.0.1
Bundles the in-flight work into the first tagged release. See
CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list.
Highlights:

- Sidebar / chrome stability: clamp absolute cursor positioning and
  printable bytes to the viewport so long-running TUIs (claude, codex)
  can't spray into the right rail; bound tab bar's row clear to the
  viewport width so the rail isn't wiped on every tab redraw; flag
  scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K`
  to viewport columns.
- Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill
  entries mark the focused tab, dead agents drop out of the switch
  list.
- Sidebar: split into Processes (session-wide) + Agent Tree
  (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the
  combined list, Ctrl+A/D steps tabs.
- MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`,
  `ping`), `mcp_injection.kind = cli_override / config_env` so codex
  and opencode pick up the server with no file writes, `lifecycle`
  help topic and tool-description cleanup-duty pointers.
- Lifecycle: orchestrator-spawned children cascade-killed when the
  parent dies; orchestrator-injected prompts end with CR + delayed
  Enter so claude submits cleanly.
2026-05-14 22:04:32 +01:00

200 lines
4.9 KiB
Go

package app
import (
"fmt"
"os"
"strings"
"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() {
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()
return
}
st.tabBarCache = frame
st.chromeCacheMu.Unlock()
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
}