Land staged session/MCP/chrome work + sidebar clear-J fix
This batches the in-flight [Unreleased] block from CHANGELOG.md into a single commit. Highlights: - Real MCP protocol layer (initialize / tools/list / tools/call) so vendor MCP clients can complete the handshake against the per-PID socket. Legacy direct-dispatch preserved for the harness. - New mcp_injection kinds — cli_override for codex, config_env for opencode — joining the existing env-var and config_file paths so patterm can slot into more agents without touching their real config or auth. - Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab process lists, recognised in legacy / kitty CSI u / xterm modifyOtherKeys encodings. - Palette macros (sw / k / sp ) and reordering so open sessions surface above spawn-new entries. - Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe on agent spawn, CR-terminated orchestrator injections, and split- Enter PTY writes so paste-detecting TUIs see Enter as a key event. Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion emits CSI 0 J, which the viewport renderer was forwarding verbatim — wiping the sidebar to the right of the cursor and leaving the chrome cache convinced nothing had changed. CSI 0 J and CSI 1 J are now translated into per-row ECH sequences clamped to the viewport, same as CSI 2 J and CSI K already were. Agent guides (CLAUDE.md / AGENTS.md) now spell out the TODO->CHANGELOG workflow so completed items land in the changelog rather than as ticked entries left behind in TODO.
This commit is contained in:
@@ -7,15 +7,15 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Three-row tab bar: labels row, subtitle row, underline row. The PTY
|
||||
// viewport's top row is therefore mainTop == tabBarRows + 1.
|
||||
const tabBarRows = 3
|
||||
// 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. The
|
||||
// strip has three rows: labels (with horizontal padding), a dim
|
||||
// subtitle showing each child's argv, and an underline that's thick +
|
||||
// accent for the focused tab and faint for the rest. Subtitles are
|
||||
// truncated with `…` to the tab's width.
|
||||
// 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
|
||||
@@ -37,94 +37,123 @@ func (st *uiState) drawTabBar() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
subtitle string
|
||||
active bool
|
||||
}
|
||||
|
||||
const (
|
||||
leadingPad = 2 // host columns before the first tab
|
||||
tabPad = 2 // spaces on each side of the label inside the tab
|
||||
tabGap = 1 // gap columns between adjacent tabs
|
||||
tailReserve = 8 // reserve room for the trailing "+ new" hint
|
||||
)
|
||||
// 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
|
||||
}
|
||||
|
||||
tabs := make([]tabRect, 0, len(sessions))
|
||||
cur := leadingPad + 1
|
||||
for _, c := range sessions {
|
||||
label := c.Name
|
||||
labelW := utf8.RuneCountInString(label)
|
||||
tabW := labelW + tabPad*2
|
||||
|
||||
// If the tab won't fit, try truncating the label down to whatever
|
||||
// space is left (label still has to leave room for "…").
|
||||
if cur+tabW+tabGap+tailReserve > width+1 {
|
||||
avail := width + 1 - cur - tabGap - tailReserve - tabPad*2
|
||||
if avail < 3 {
|
||||
break
|
||||
}
|
||||
label = clipRunes(label, avail-1) + "…"
|
||||
labelW = utf8.RuneCountInString(label)
|
||||
tabW = labelW + tabPad*2
|
||||
tabs = append(tabs, tabRect{
|
||||
startCol: cur, width: tabW,
|
||||
label: label, subtitle: strings.Join(c.Argv, " "),
|
||||
active: c.ID == focus,
|
||||
})
|
||||
cur += tabW + tabGap
|
||||
break
|
||||
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 = append(tabs, tabRect{
|
||||
startCol: cur, width: tabW,
|
||||
label: label, subtitle: strings.Join(c.Argv, " "),
|
||||
active: c.ID == focus,
|
||||
})
|
||||
cur += tabW + tabGap
|
||||
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.Name
|
||||
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 all three rows up front so a stale label from the previous
|
||||
// frame can't bleed through.
|
||||
// Clear both rows so a stale label from the previous frame can't
|
||||
// bleed through.
|
||||
b.WriteString("\x1b[1;1H\x1b[2K")
|
||||
b.WriteString("\x1b[2;1H\x1b[2K")
|
||||
b.WriteString("\x1b[3;1H\x1b[2K")
|
||||
|
||||
for _, t := range tabs {
|
||||
// Row 1: label
|
||||
// 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(" ", tabPad))
|
||||
b.WriteString(strings.Repeat(" ", leftPad))
|
||||
b.WriteString(t.label)
|
||||
b.WriteString(strings.Repeat(" ", tabPad))
|
||||
b.WriteString(strings.Repeat(" ", rightPad))
|
||||
b.WriteString(styleReset)
|
||||
|
||||
// Row 2: subtitle, truncated to tab width and dimmed.
|
||||
sub := t.subtitle
|
||||
if utf8.RuneCountInString(sub) > t.width {
|
||||
if t.width > 1 {
|
||||
sub = clipRunes(sub, t.width-1) + "…"
|
||||
} else {
|
||||
sub = ""
|
||||
}
|
||||
}
|
||||
padR := t.width - utf8.RuneCountInString(sub)
|
||||
if padR < 0 {
|
||||
padR = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s%s",
|
||||
t.startCol, styleDim, sub, strings.Repeat(" ", padR), styleReset)
|
||||
|
||||
// Row 3: underline. Thick accent for the active tab, faint
|
||||
// Row 2: underline. Thick accent for the active tab, faint
|
||||
// border for the rest.
|
||||
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
|
||||
fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol)
|
||||
if t.active {
|
||||
b.WriteString(styleAccent)
|
||||
b.WriteString(strings.Repeat("━", t.width))
|
||||
@@ -135,26 +164,26 @@ func (st *uiState) drawTabBar() {
|
||||
b.WriteString(styleReset)
|
||||
}
|
||||
|
||||
// "+ new" hint at the end of the labels row, in dim.
|
||||
if cur+3 <= width {
|
||||
fmt.Fprintf(&b, "\x1b[1;%dH%s+ new%s", cur+1, styleDim, 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)
|
||||
}
|
||||
|
||||
// Extend the faint underline across the rest of the host width so
|
||||
// the tab strip reads as one continuous divider.
|
||||
if cur <= width {
|
||||
remain := width - cur + 1
|
||||
if remain > 0 {
|
||||
fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s",
|
||||
cur, styleBorder, strings.Repeat("─", remain), styleReset)
|
||||
}
|
||||
}
|
||||
if leadingPad > 0 {
|
||||
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s",
|
||||
styleBorder, strings.Repeat("─", leadingPad), 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", b.String())
|
||||
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user