Polish chrome and rework tab-switch repaint
Module renamed github.com/harrybrwn/patterm → github.com/hjbdev/patterm across imports. Chrome: - Palette redrawn with rounded box-drawing borders, accent left-bar for the selected item, dim hints, and a separator-aware footer. - Tab bar grew from 1 row to 3: labels with breathing room, a dim argv subtitle truncated to each tab's width, and an accent thick underline for the focused tab with a faint divider extending across the rest of the host width. Layout, viewport-renderer, and screen- renderer tests updated for the new mainTop. - Sidebar reuses the same palette: accent section headers, `▎` selection marker, `●`/`○` status glyphs, dim previews. - Shared SGR constants moved into internal/app/style.go. Palette input: - Adjacent duplicate arrow events (legacy `\x1b[B` + kitty `\x1b[57353u` for one keypress, or two of the same form) are now collapsed via peekArrowEvent + chunk-level dedupe in processStdin. - On open, push `\x1b[>0u` onto the host's kitty keyboard stack so palette input is in plain legacy mode regardless of what the child pushed (codex/ratatui pushes its own flags which had been leaking to the host). Popped on close. Tab-switch repaint (repaintFocused): - Use the emulator's SerializeVT bytes (with SGR / cursor / DECSTBM / tabstops) instead of plain text, fed through the per-focused viewport renderer so the shifter translates row positions. - Prelude resets host SGR / DECOM / DECSTBM (pinned to viewport) / cursor visibility before the replay, so leftover modes from the previously-focused child don't distort the new snapshot. - Re-emit the saved cursor as a child-space CUP after the serialized bytes so the host cursor lands at the emulator's actual position (overriding DECSTBM's home side-effect and the tabstop-setup CHA sequences) AND the renderer's vr.row/vr.col get re-synced via trackCSI. - cursorShifter now carries childRows and rewrites empty `\x1b[r` to `\x1b[<mainTop>;<mainBottom>r` (host coords) — the default (1,1) shifted to (4,4) was producing a one-row scrolling region that scroll-exploded the replay. - After the snapshot lands, nudge the focused child with a one-row PTY winsize toggle so the kernel emits SIGWINCH and ratatui-style TUIs throw away their diff state and emit a fresh frame. Codex still renders incorrectly after a focus switch; see TODO.md "Switch-back render divergence" for the deep investigation handoff.
This commit is contained in:
@@ -36,96 +36,112 @@ func (st *uiState) drawSidebar() {
|
||||
maxRow := int(layout.statusRow) - statusRows
|
||||
|
||||
var b strings.Builder
|
||||
// Border column at left-1: a single vertical pipe.
|
||||
for r := 1; r <= maxRow; r++ {
|
||||
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[2m│\x1b[0m", r, left-1)
|
||||
fmt.Fprintf(&b, "\x1b[%d;%dH%s│%s", r, left-1, styleBorder, styleReset)
|
||||
}
|
||||
|
||||
row := 1
|
||||
writeLine := func(s string, style string) {
|
||||
// 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
|
||||
}
|
||||
if len(s) > width {
|
||||
s = s[:width]
|
||||
pad := width - visibleLen(content)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s\x1b[0m\x1b[K", row, left, style, padRight(s, width))
|
||||
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
|
||||
}
|
||||
|
||||
writeLine(" Session tree", "\x1b[1m")
|
||||
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
||||
|
||||
writeHeader("Session tree")
|
||||
children := visibleSessionTree(st.sess.Children(), focus)
|
||||
if len(children) == 0 {
|
||||
writeLine(" (empty)", "\x1b[2m")
|
||||
write(" " + styleDim + "(empty)" + styleReset)
|
||||
}
|
||||
for _, c := range children {
|
||||
glyph := "◉"
|
||||
marker := " "
|
||||
if c.ID == focus {
|
||||
marker = "▶ "
|
||||
if row > maxRow {
|
||||
break
|
||||
}
|
||||
indent := ""
|
||||
if c.ParentID != "" {
|
||||
indent = " "
|
||||
}
|
||||
line := fmt.Sprintf(" %s%s%s %s", marker, indent, glyph, c.Name)
|
||||
style := ""
|
||||
if c.ID == focus {
|
||||
style = "\x1b[1m"
|
||||
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
|
||||
}
|
||||
writeLine(line, style)
|
||||
write(line)
|
||||
}
|
||||
|
||||
// Scratchpads list — pick the most-recently-modified one as the
|
||||
// preview target. SPEC §4.
|
||||
var previewName string
|
||||
if row+2 <= maxRow {
|
||||
row++
|
||||
writeLine(" Scratchpads", "\x1b[1m")
|
||||
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
||||
write("")
|
||||
writeHeader("Scratchpads")
|
||||
entries, err := st.pads.List()
|
||||
if err == nil {
|
||||
if len(entries) == 0 {
|
||||
writeLine(" (none)", "\x1b[2m")
|
||||
}
|
||||
var newest string
|
||||
var newestTS string
|
||||
for _, e := range entries {
|
||||
if e.ModifiedAt > newestTS {
|
||||
newestTS = e.ModifiedAt
|
||||
newest = e.Name
|
||||
write(" " + styleDim + "(none)" + styleReset)
|
||||
} else {
|
||||
var newestTS string
|
||||
for _, e := range entries {
|
||||
if e.ModifiedAt > newestTS {
|
||||
newestTS = e.ModifiedAt
|
||||
previewName = e.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
previewName = newest
|
||||
for _, e := range entries {
|
||||
if row > maxRow {
|
||||
break
|
||||
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)
|
||||
}
|
||||
marker := " "
|
||||
style := ""
|
||||
if e.Name == previewName {
|
||||
marker = " ▸ "
|
||||
style = "\x1b[1m"
|
||||
}
|
||||
writeLine(marker+e.Name, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview pane at the bottom of the rail. Reserve up to 8 rows.
|
||||
// Preview pane: dim file content under a thin divider.
|
||||
if previewName != "" && row+2 <= maxRow {
|
||||
row++
|
||||
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
||||
writeLine(" "+previewName, "\x1b[1m")
|
||||
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
|
||||
}
|
||||
writeLine(" "+line, "\x1b[2m")
|
||||
write(" " + styleDim + line + styleReset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +149,7 @@ func (st *uiState) drawSidebar() {
|
||||
// Blank-fill any rows the rail content didn't cover so stale
|
||||
// content from a previous redraw doesn't linger.
|
||||
for row <= maxRow {
|
||||
writeLine("", "")
|
||||
write("")
|
||||
}
|
||||
|
||||
st.outMu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user