Wrap toast bodies, slim the dismiss hint, and stop flicker

Toasts now render three content rows with word-wrapped bodies. The
in-toast "Ctrl-N · N more" hint is replaced by a short
"Ctrl-N · dismiss" entry on the status strip that only appears
while a notification is live.

The box stops flickering while the focused child repaints its TUI:
the overlay is stitched onto the per-chunk PTY write under outMu
and bracketed by DECSET 2026 so supporting terminals buffer the
child's redraw and the box paint into a single frame.
This commit is contained in:
2026-05-15 21:24:18 +01:00
parent ef9b8e71c6
commit cf65d5d707
4 changed files with 220 additions and 56 deletions

View File

@@ -6,6 +6,22 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- Toast notifications now reserve three content rows and word-wrap
the message body inside the box, replacing the previous
single-line+ellipsis layout. The `Ctrl-N · N more` inline hint is
gone; instead the host status strip surfaces a `Ctrl-N · dismiss`
hint, shown only while a notification is on screen so the chord
doesn't advertise itself when it has nothing to dismiss.
### Fixed
- Toast box no longer flickers / half-erases while the focused
child (claude, codex, opencode, etc.) repaints its TUI. The
overlay is now stitched onto the end of the per-chunk PTY write
under `outMu`, and wrapped in DECSET 2026 (synchronized output)
brackets so terminals that support it batch the child's redraw +
the box paint into a single frame instead of racing cell-by-cell.
## [0.0.5] - 2026-05-15 ## [0.0.5] - 2026-05-15
### Changed ### Changed

View File

@@ -969,14 +969,19 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.metrics.recordRender(time.Since(rstart)) st.metrics.recordRender(time.Since(rstart))
} }
} }
// One write covers the autowrap-disable prelude, the chunk, and the // One write covers the autowrap-disable prelude, the chunk, the
// autowrap-restore postlude — three syscalls collapsed into one // autowrap-restore postlude, and (when a toast is up) the toast
// under outMu. The three sequences were already emitted atomically // overlay — four syscalls collapsed into one under outMu. The
// under the lock; coalescing just halves the syscall count. // sequences were already emitted atomically under the lock;
wrapped := make([]byte, 0, len(out)+10) // coalescing just halves the syscall count and makes claude's
// continuous redraws + our toast layer land in the same frame so
// the box doesn't flicker as the child paints over its cells.
overlay := st.toastOverlayBytes()
wrapped := make([]byte, 0, len(out)+len(overlay)+10)
wrapped = append(wrapped, "\x1b[?7l"...) wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...) wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...) wrapped = append(wrapped, "\x1b[?7h"...)
wrapped = append(wrapped, overlay...)
var wstart time.Time var wstart time.Time
if st.metrics != nil { if st.metrics != nil {
wstart = time.Now() wstart = time.Now()
@@ -1219,6 +1224,12 @@ func (st *uiState) drawStatusLine() {
hints = append(hints, "Ctrl-R · restart") hints = append(hints, "Ctrl-R · restart")
} }
} }
// Surface the toast-dismiss chord only while a notification is on
// screen — the hint is noise otherwise, and Ctrl-N falls through
// to the focused PTY when the stack is empty.
if st.toasts.length() > 0 {
hints = append(hints, "Ctrl-N · dismiss")
}
right := strings.Join(hints, " · ") right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 { for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:] hints = hints[1:]

View File

@@ -1,7 +1,6 @@
package app package app
import ( import (
"fmt"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -40,6 +39,11 @@ const toastBoxMaxWidth = 50
// any narrower and there's not enough room for borders + content. // any narrower and there's not enough room for borders + content.
const toastBoxMinWidth = 20 const toastBoxMinWidth = 20
// toastContentRows is how many lines of message body each toast box
// reserves. The dismiss hint lives on the host status strip, so the
// box itself is purely the message.
const toastContentRows = 3
// toastStack owns the ordered list of live toasts. Oldest at // toastStack owns the ordered list of live toasts. Oldest at
// index 0, newest (visually topmost) at the end. The stack's own // index 0, newest (visually topmost) at the end. The stack's own
// mutex is intentionally separate from uiState.mu so push / dismiss // mutex is intentionally separate from uiState.mu so push / dismiss
@@ -115,6 +119,10 @@ func (st *uiState) notifyToast(kind toastKind, text string) {
// canvas). Each of those paths calls renderToasts at the end, so // canvas). Each of those paths calls renderToasts at the end, so
// the toast layer is always reapplied on top of a freshly-drawn // the toast layer is always reapplied on top of a freshly-drawn
// pane. Centralised so push / dismiss / clear share one code path. // pane. Centralised so push / dismiss / clear share one code path.
//
// The status strip also gains/loses the "Ctrl-N · dismiss" hint as
// the stack toggles between empty and non-empty, so we redraw it
// here too rather than waiting for the chrome ticker.
func (st *uiState) refreshToastSurface() { func (st *uiState) refreshToastSurface() {
st.mu.Lock() st.mu.Lock()
focusedPad := st.focusedPad focusedPad := st.focusedPad
@@ -134,6 +142,7 @@ func (st *uiState) refreshToastSurface() {
default: default:
st.renderEmptyState() st.renderEmptyState()
} }
st.drawStatusLine()
} }
// renderToasts draws the toast stack over the top-right of the // renderToasts draws the toast stack over the top-right of the
@@ -142,33 +151,62 @@ func (st *uiState) refreshToastSurface() {
// freshly-redrawn pane content. Safe to call when the stack is // freshly-redrawn pane content. Safe to call when the stack is
// empty (no-op). // empty (no-op).
func (st *uiState) renderToasts() { func (st *uiState) renderToasts() {
bytes := st.toastOverlayBytes()
if len(bytes) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(bytes)
}
// toastOverlayBytes builds the toast layer as a single byte buffer
// without writing to stdout. Returns nil when the stack is empty or
// the layout can't accommodate a box. Callers either write it
// directly (renderToasts) or stitch it onto the end of another
// stdout write so claude/codex/opencode redraws that paint over the
// top-right region can't leave the toast half-erased.
func (st *uiState) toastOverlayBytes() []byte {
items := st.toasts.snapshot() items := st.toasts.snapshot()
if len(items) == 0 { if len(items) == 0 {
return return nil
} }
st.mu.Lock() st.mu.Lock()
palOpen := st.palette != nil palOpen := st.palette != nil
st.mu.Unlock() st.mu.Unlock()
if palOpen { if palOpen {
return return nil
} }
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
paneCols := int(layout.childCols()) paneCols := int(layout.childCols())
paneRows := int(layout.childRows()) paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < 3 { if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 {
return return nil
} }
boxWidth := toastBoxMaxWidth boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth { if max := paneCols - 4; max < boxWidth {
boxWidth = max boxWidth = max
} }
if boxWidth < toastBoxMinWidth { if boxWidth < toastBoxMinWidth {
return return nil
} }
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
// Reserve two columns for the icon prefix on row 1 so wrapped rows
// indent under the body text rather than under the glyph.
const iconCols = 2
bodyRoom := contentWidth - iconCols
if bodyRoom < 1 {
return nil
}
var b strings.Builder var b strings.Builder
b.WriteString("\x1b7\x1b[?25l") // Wrap the whole overlay in DECSET 2026 (synchronized output)
// brackets so terminals that support BSU/ESU buffer the box paint
// into a single frame — without this, claude's continuous redraws
// and our overlay race on each cell, producing visible flicker.
// Terminals that don't recognise 2026 ignore the brackets, so the
// fallback behaviour is the same as before.
b.WriteString("\x1b[?2026h\x1b7\x1b[?25l")
row := int(layout.mainTop) + 1 row := int(layout.mainTop) + 1
col := int(layout.mainLeft) + paneCols - boxWidth - 1 col := int(layout.mainLeft) + paneCols - boxWidth - 1
@@ -180,20 +218,13 @@ func (st *uiState) renderToasts() {
// reverse so the most recent push lands at the smallest row. // reverse so the most recent push lands at the smallest row.
for idx := len(items) - 1; idx >= 0; idx-- { for idx := len(items) - 1; idx >= 0; idx-- {
t := items[idx] t := items[idx]
isTopmost := idx == len(items)-1 height := toastContentRows + 2
hintLine := ""
if isTopmost && len(items) > 1 {
hintLine = fmt.Sprintf("Ctrl-N · %d more", len(items)-1)
}
height := 3
if hintLine != "" {
height++
}
// Stop if we'd run off the bottom of the pane. // Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows { if row+height > int(layout.mainTop)+paneRows {
break break
} }
border := toastBorderStyle(t.kind) border := toastBorderStyle(t.kind)
wrapped := wrapToastBody(t.text, bodyRoom)
// Top border. // Top border.
moveTo(&b, row, col) moveTo(&b, row, col)
@@ -204,40 +235,22 @@ func (st *uiState) renderToasts() {
b.WriteString(styleReset) b.WriteString(styleReset)
row++ row++
// Content row. // Content rows. Row 0 carries the kind glyph; rows 1..N indent
moveTo(&b, row, col) // by iconCols spaces so wrapped text lines up under the body.
b.WriteString(border) for i := 0; i < toastContentRows; i++ {
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
b.WriteString(toastIcon(t.kind))
body := t.text
bodyRoom := contentWidth - 2 // icon + space
if visibleLen(body) > bodyRoom {
body = clipRunes(body, bodyRoom-1) + "…"
}
b.WriteString(body)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(body))))
b.WriteString(" ")
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
row++
// Hint row (topmost only, when stack has more than one).
if hintLine != "" {
if visibleLen(hintLine) > contentWidth {
hintLine = clipRunes(hintLine, contentWidth-1) + "…"
}
moveTo(&b, row, col) moveTo(&b, row, col)
b.WriteString(border) b.WriteString(border)
b.WriteString("│") b.WriteString("│")
b.WriteString(styleReset) b.WriteString(styleReset)
b.WriteString(" ") b.WriteString(" ")
b.WriteString(styleHint) if i == 0 {
b.WriteString(hintLine) b.WriteString(toastIcon(t.kind))
b.WriteString(styleReset) } else {
b.WriteString(strings.Repeat(" ", max(0, contentWidth-visibleLen(hintLine)))) b.WriteString(strings.Repeat(" ", iconCols))
}
line := wrapped[i]
b.WriteString(line)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(line))))
b.WriteString(" ") b.WriteString(" ")
b.WriteString(border) b.WriteString(border)
b.WriteString("│") b.WriteString("│")
@@ -258,11 +271,8 @@ func (st *uiState) renderToasts() {
row++ row++
} }
b.WriteString("\x1b[?25h\x1b8") b.WriteString("\x1b[?25h\x1b8\x1b[?2026l")
return []byte(b.String())
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
} }
func toastBorderStyle(kind toastKind) string { func toastBorderStyle(kind toastKind) string {
@@ -276,6 +286,69 @@ func toastBorderStyle(kind toastKind) string {
} }
} }
// wrapToastBody word-wraps text into exactly toastContentRows lines,
// each at most width visible runes wide. Short messages are padded
// with empty trailing lines so callers can iterate a fixed-size
// slice; messages that don't fit get ellipsized on the last line.
func wrapToastBody(text string, width int) []string {
out := make([]string, toastContentRows)
if width < 1 {
return out
}
all := wrapToastWords(text, width)
if len(all) > toastContentRows {
all = all[:toastContentRows]
last := all[len(all)-1]
if visibleLen(last) >= width {
last = clipRunes(last, width-1) + "…"
} else {
last = last + "…"
}
all[len(all)-1] = last
}
for i, l := range all {
out[i] = l
}
return out
}
// wrapToastWords is a small word-wrapper sized for toast bodies:
// greedy, breaks overlong words on rune boundaries, drops collapsing
// whitespace via strings.Fields.
func wrapToastWords(text string, width int) []string {
var lines []string
var cur string
flush := func() {
if cur != "" {
lines = append(lines, cur)
cur = ""
}
}
for _, word := range strings.Fields(text) {
for visibleLen(word) > width {
flush()
head := clipRunes(word, width)
lines = append(lines, head)
word = word[len(head):]
}
if word == "" {
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
flush()
cur = word
}
flush()
return lines
}
func toastIcon(kind toastKind) string { func toastIcon(kind toastKind) string {
switch kind { switch kind {
case toastError: case toastError:

View File

@@ -1,6 +1,9 @@
package app package app
import "testing" import (
"strings"
"testing"
)
func TestToastStackPushAndOrder(t *testing.T) { func TestToastStackPushAndOrder(t *testing.T) {
var s toastStack var s toastStack
@@ -98,3 +101,64 @@ func TestToastStackSnapshotIsCopy(t *testing.T) {
t.Fatalf("snapshot is not an independent copy: %#v", again) t.Fatalf("snapshot is not an independent copy: %#v", again)
} }
} }
func TestWrapToastBodyFixedHeight(t *testing.T) {
got := wrapToastBody("short", 20)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
if got[0] != "short" {
t.Fatalf("line 0 = %q, want \"short\"", got[0])
}
if got[1] != "" || got[2] != "" {
t.Fatalf("trailing pads not empty: %#v", got)
}
}
func TestWrapToastBodyWrapsOnWordBoundary(t *testing.T) {
got := wrapToastBody("the quick brown fox jumps over", 10)
// Expect greedy fill: "the quick" (9), "brown fox" (9), "jumps over" (10).
want := []string{"the quick", "brown fox", "jumps over"}
for i, w := range want {
if got[i] != w {
t.Fatalf("line %d = %q, want %q (full=%#v)", i, got[i], w, got)
}
}
}
func TestWrapToastBodyEllipsizesOverflow(t *testing.T) {
got := wrapToastBody("alpha beta gamma delta epsilon zeta eta theta", 6)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
last := got[toastContentRows-1]
if !strings.HasSuffix(last, "…") {
t.Fatalf("overflow should ellipsize last line, got %q (full=%#v)", last, got)
}
if visibleLen(last) > 6 {
t.Fatalf("last line %q exceeds width 6", last)
}
}
func TestWrapToastBodyBreaksOverlongWord(t *testing.T) {
got := wrapToastBody("supercalifragilistic", 6)
if got[0] != "superc" {
t.Fatalf("line 0 = %q, want \"superc\"", got[0])
}
if got[1] != "alifra" {
t.Fatalf("line 1 = %q, want \"alifra\"", got[1])
}
// Third line should hold the rest (possibly ellipsized).
if got[2] == "" {
t.Fatalf("line 2 unexpectedly empty: %#v", got)
}
}
func TestWrapToastBodyEmptyInput(t *testing.T) {
got := wrapToastBody("", 20)
for i, l := range got {
if l != "" {
t.Fatalf("line %d = %q, want \"\"", i, l)
}
}
}