diff --git a/CHANGELOG.md b/CHANGELOG.md index cb32ebf..994a74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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 ### Changed diff --git a/internal/app/app.go b/internal/app/app.go index c9c8171..17e0054 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -969,14 +969,19 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) { st.metrics.recordRender(time.Since(rstart)) } } - // One write covers the autowrap-disable prelude, the chunk, and the - // autowrap-restore postlude — three syscalls collapsed into one - // under outMu. The three sequences were already emitted atomically - // under the lock; coalescing just halves the syscall count. - wrapped := make([]byte, 0, len(out)+10) + // One write covers the autowrap-disable prelude, the chunk, the + // autowrap-restore postlude, and (when a toast is up) the toast + // overlay — four syscalls collapsed into one under outMu. The + // sequences were already emitted atomically under the lock; + // 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, out...) wrapped = append(wrapped, "\x1b[?7h"...) + wrapped = append(wrapped, overlay...) var wstart time.Time if st.metrics != nil { wstart = time.Now() @@ -1219,6 +1224,12 @@ func (st *uiState) drawStatusLine() { 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, " · ") for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 { hints = hints[1:] diff --git a/internal/app/toast.go b/internal/app/toast.go index 2e80a1b..a564b6e 100644 --- a/internal/app/toast.go +++ b/internal/app/toast.go @@ -1,7 +1,6 @@ package app import ( - "fmt" "os" "strings" "sync" @@ -40,6 +39,11 @@ const toastBoxMaxWidth = 50 // any narrower and there's not enough room for borders + content. 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 // index 0, newest (visually topmost) at the end. The stack's own // 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 // the toast layer is always reapplied on top of a freshly-drawn // 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() { st.mu.Lock() focusedPad := st.focusedPad @@ -134,6 +142,7 @@ func (st *uiState) refreshToastSurface() { default: st.renderEmptyState() } + st.drawStatusLine() } // 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 // empty (no-op). 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() if len(items) == 0 { - return + return nil } st.mu.Lock() palOpen := st.palette != nil st.mu.Unlock() if palOpen { - return + return nil } layout := st.layoutSnapshot() paneCols := int(layout.childCols()) paneRows := int(layout.childRows()) - if paneCols < toastBoxMinWidth+2 || paneRows < 3 { - return + if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 { + return nil } boxWidth := toastBoxMaxWidth if max := paneCols - 4; max < boxWidth { boxWidth = max } if boxWidth < toastBoxMinWidth { - return + return nil } 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 - 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 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. for idx := len(items) - 1; idx >= 0; idx-- { t := items[idx] - isTopmost := idx == len(items)-1 - hintLine := "" - if isTopmost && len(items) > 1 { - hintLine = fmt.Sprintf("Ctrl-N · %d more", len(items)-1) - } - height := 3 - if hintLine != "" { - height++ - } + height := toastContentRows + 2 // Stop if we'd run off the bottom of the pane. if row+height > int(layout.mainTop)+paneRows { break } border := toastBorderStyle(t.kind) + wrapped := wrapToastBody(t.text, bodyRoom) // Top border. moveTo(&b, row, col) @@ -204,40 +235,22 @@ func (st *uiState) renderToasts() { b.WriteString(styleReset) row++ - // Content row. - moveTo(&b, row, col) - b.WriteString(border) - 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) + "…" - } + // Content rows. Row 0 carries the kind glyph; rows 1..N indent + // by iconCols spaces so wrapped text lines up under the body. + for i := 0; i < toastContentRows; i++ { moveTo(&b, row, col) b.WriteString(border) b.WriteString("│") b.WriteString(styleReset) b.WriteString(" ") - b.WriteString(styleHint) - b.WriteString(hintLine) - b.WriteString(styleReset) - b.WriteString(strings.Repeat(" ", max(0, contentWidth-visibleLen(hintLine)))) + if i == 0 { + b.WriteString(toastIcon(t.kind)) + } else { + b.WriteString(strings.Repeat(" ", iconCols)) + } + line := wrapped[i] + b.WriteString(line) + b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(line)))) b.WriteString(" ") b.WriteString(border) b.WriteString("│") @@ -258,11 +271,8 @@ func (st *uiState) renderToasts() { row++ } - b.WriteString("\x1b[?25h\x1b8") - - st.outMu.Lock() - defer st.outMu.Unlock() - _, _ = os.Stdout.WriteString(b.String()) + b.WriteString("\x1b[?25h\x1b8\x1b[?2026l") + return []byte(b.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 { switch kind { case toastError: diff --git a/internal/app/toast_test.go b/internal/app/toast_test.go index 764bab5..d0ee735 100644 --- a/internal/app/toast_test.go +++ b/internal/app/toast_test.go @@ -1,6 +1,9 @@ package app -import "testing" +import ( + "strings" + "testing" +) func TestToastStackPushAndOrder(t *testing.T) { var s toastStack @@ -98,3 +101,64 @@ func TestToastStackSnapshotIsCopy(t *testing.T) { 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) + } + } +}