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.
169 lines
4.1 KiB
Go
169 lines
4.1 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
sidebarCols = 28
|
|
statusRows = 1
|
|
)
|
|
|
|
// drawSidebar paints the right-rail session tree + scratchpad list.
|
|
// SPEC §4: the rail is the active session's child hierarchy on top and
|
|
// the scratchpad list (with preview) on the bottom.
|
|
//
|
|
// Implementation note: the PTY child's winsize is constrained to the
|
|
// computed main viewport, so the sidebar region is outside the child's
|
|
// cursor range. We can redraw freely without fighting the child for cells.
|
|
func (st *uiState) drawSidebar() {
|
|
st.mu.Lock()
|
|
palOpen := st.palette != nil
|
|
focus := st.focusedID
|
|
st.mu.Unlock()
|
|
if palOpen {
|
|
return
|
|
}
|
|
|
|
layout := st.layoutSnapshot()
|
|
if !layout.sidebarVisible || layout.hostRows < 4 {
|
|
return
|
|
}
|
|
left := int(layout.sidebarLeft)
|
|
width := int(layout.sidebarWidth) - 1
|
|
maxRow := int(layout.statusRow) - statusRows
|
|
|
|
var b strings.Builder
|
|
for r := 1; r <= maxRow; r++ {
|
|
fmt.Fprintf(&b, "\x1b[%d;%dH%s│%s", r, left-1, styleBorder, styleReset)
|
|
}
|
|
|
|
row := 1
|
|
// write paints one styled line into the sidebar column band and pads
|
|
// it out to `width` cells. Content may carry inline SGR escapes —
|
|
// visibleLen ignores them when computing padding.
|
|
write := func(content string) {
|
|
if row > maxRow {
|
|
return
|
|
}
|
|
pad := width - visibleLen(content)
|
|
if pad < 0 {
|
|
pad = 0
|
|
}
|
|
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s%s\x1b[K",
|
|
row, left, content, strings.Repeat(" ", pad), styleReset)
|
|
row++
|
|
}
|
|
writeHeader := func(text string) {
|
|
write(" " + styleActive + text + styleReset)
|
|
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
|
|
}
|
|
statusGlyph := func(c *Child, focused bool) string {
|
|
if c.Status() != StatusRunning {
|
|
return styleDim + "○" + styleReset
|
|
}
|
|
if focused {
|
|
return styleAccent + "●" + styleReset
|
|
}
|
|
return styleHint + "●" + styleReset
|
|
}
|
|
|
|
writeHeader("Session tree")
|
|
children := visibleSessionTree(st.sess.Children(), focus)
|
|
if len(children) == 0 {
|
|
write(" " + styleDim + "(empty)" + styleReset)
|
|
}
|
|
for _, c := range children {
|
|
if row > maxRow {
|
|
break
|
|
}
|
|
indent := ""
|
|
if c.ParentID != "" {
|
|
indent = " "
|
|
}
|
|
focused := c.ID == focus
|
|
glyph := statusGlyph(c, focused)
|
|
var line string
|
|
if focused {
|
|
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
|
|
styleBold + c.Name + styleReset
|
|
} else {
|
|
line = " " + indent + glyph + " " + styleHint + c.Name + styleReset
|
|
}
|
|
write(line)
|
|
}
|
|
|
|
// Scratchpads list — pick the most-recently-modified one as the
|
|
// preview target. SPEC §4.
|
|
var previewName string
|
|
if row+2 <= maxRow {
|
|
write("")
|
|
writeHeader("Scratchpads")
|
|
entries, err := st.pads.List()
|
|
if err == nil {
|
|
if len(entries) == 0 {
|
|
write(" " + styleDim + "(none)" + styleReset)
|
|
} else {
|
|
var newestTS string
|
|
for _, e := range entries {
|
|
if e.ModifiedAt > newestTS {
|
|
newestTS = e.ModifiedAt
|
|
previewName = e.Name
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
if row > maxRow {
|
|
break
|
|
}
|
|
var line string
|
|
if e.Name == previewName {
|
|
line = " " + styleAccent + "▎" + styleReset + " " +
|
|
styleBold + e.Name + styleReset
|
|
} else {
|
|
line = " " + styleHint + e.Name + styleReset
|
|
}
|
|
write(line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preview pane: dim file content under a thin divider.
|
|
if previewName != "" && row+2 <= maxRow {
|
|
write("")
|
|
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
|
|
write(" " + styleActive + previewName + styleReset)
|
|
content, _, err := st.pads.Read(previewName)
|
|
if err == nil {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
if row > maxRow {
|
|
break
|
|
}
|
|
write(" " + styleDim + line + styleReset)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Blank-fill any rows the rail content didn't cover so stale
|
|
// content from a previous redraw doesn't linger.
|
|
for row <= maxRow {
|
|
write("")
|
|
}
|
|
|
|
frame := b.String()
|
|
st.chromeCacheMu.Lock()
|
|
if frame == st.sidebarCache {
|
|
st.chromeCacheMu.Unlock()
|
|
return
|
|
}
|
|
st.sidebarCache = frame
|
|
st.chromeCacheMu.Unlock()
|
|
|
|
st.outMu.Lock()
|
|
// Save cursor; emit the sidebar; restore.
|
|
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
|
|
st.outMu.Unlock()
|
|
}
|