Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
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.
200 lines
4.9 KiB
Go
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)
|
|
}
|