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.
362 lines
9.6 KiB
Go
362 lines
9.6 KiB
Go
package app
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// toastKind classifies a toast for styling and for migrating the
|
|
// pre-existing flashError / flashTransient / notifyAttention call
|
|
// sites onto the new stack.
|
|
type toastKind int
|
|
|
|
const (
|
|
toastInfo toastKind = iota
|
|
toastError
|
|
toastAttention
|
|
)
|
|
|
|
// toast is one entry in the host-level notification stack. Toasts
|
|
// persist until the user dismisses them with Ctrl-N or the
|
|
// "Clear notifications" palette command — there's no auto-expiry.
|
|
type toast struct {
|
|
id uint64
|
|
kind toastKind
|
|
text string
|
|
}
|
|
|
|
// toastStackCap caps how many toasts can be visible at once.
|
|
// Older entries drop off the bottom when a new push would exceed it.
|
|
const toastStackCap = 5
|
|
|
|
// toastBoxMaxWidth bounds the rendered box width so a wide pane
|
|
// doesn't produce huge toasts. Boxes shrink below this when the pane
|
|
// is narrow.
|
|
const toastBoxMaxWidth = 50
|
|
|
|
// toastBoxMinWidth is the floor below which we refuse to render —
|
|
// 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
|
|
// can be called from any goroutine without participating in the
|
|
// host's bigger lock-ordering rules.
|
|
type toastStack struct {
|
|
mu sync.Mutex
|
|
items []toast
|
|
next uint64
|
|
}
|
|
|
|
func (s *toastStack) push(kind toastKind, text string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.next++
|
|
s.items = append(s.items, toast{id: s.next, kind: kind, text: text})
|
|
if len(s.items) > toastStackCap {
|
|
s.items = s.items[len(s.items)-toastStackCap:]
|
|
}
|
|
}
|
|
|
|
// dismissTop pops the most recent toast (the one rendered at the
|
|
// top of the stack). Returns true if something was removed so
|
|
// callers can decide whether to repaint.
|
|
func (s *toastStack) dismissTop() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.items) == 0 {
|
|
return false
|
|
}
|
|
s.items = s.items[:len(s.items)-1]
|
|
return true
|
|
}
|
|
|
|
func (s *toastStack) clear() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.items) == 0 {
|
|
return false
|
|
}
|
|
s.items = s.items[:0]
|
|
return true
|
|
}
|
|
|
|
func (s *toastStack) snapshot() []toast {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.items) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]toast, len(s.items))
|
|
copy(out, s.items)
|
|
return out
|
|
}
|
|
|
|
func (s *toastStack) length() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.items)
|
|
}
|
|
|
|
// notifyToast is the single entry point that the former flash
|
|
// helpers now delegate to. It pushes onto the stack and triggers a
|
|
// repaint of the focused surface so the new toast appears
|
|
// immediately; the repaint path also re-renders the stack on top.
|
|
func (st *uiState) notifyToast(kind toastKind, text string) {
|
|
st.toasts.push(kind, text)
|
|
st.refreshToastSurface()
|
|
}
|
|
|
|
// refreshToastSurface re-renders whatever surface the toasts are
|
|
// drawn over (focused child, focused pad, or the empty-state
|
|
// 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
|
|
focusedID := st.focusedID
|
|
palOpen := st.palette != nil
|
|
st.mu.Unlock()
|
|
if palOpen {
|
|
// Palette owns the whole screen while it's open; toasts will
|
|
// repaint via closePalette's restore path.
|
|
return
|
|
}
|
|
switch {
|
|
case focusedPad != "":
|
|
st.repaintFocusedPad()
|
|
case focusedID != "":
|
|
st.repaintFocused()
|
|
default:
|
|
st.renderEmptyState()
|
|
}
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// renderToasts draws the toast stack over the top-right of the
|
|
// focused pane. Called from repaintFocused / repaintFocusedPad /
|
|
// renderEmptyState after they finish so toasts always sit on top of
|
|
// 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 nil
|
|
}
|
|
st.mu.Lock()
|
|
palOpen := st.palette != nil
|
|
st.mu.Unlock()
|
|
if palOpen {
|
|
return nil
|
|
}
|
|
layout := st.layoutSnapshot()
|
|
paneCols := int(layout.childCols())
|
|
paneRows := int(layout.childRows())
|
|
if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 {
|
|
return nil
|
|
}
|
|
boxWidth := toastBoxMaxWidth
|
|
if max := paneCols - 4; max < boxWidth {
|
|
boxWidth = max
|
|
}
|
|
if boxWidth < toastBoxMinWidth {
|
|
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
|
|
// 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
|
|
if col < int(layout.mainLeft) {
|
|
col = int(layout.mainLeft)
|
|
}
|
|
|
|
// Render newest first (visually on top), iterating items in
|
|
// reverse so the most recent push lands at the smallest row.
|
|
for idx := len(items) - 1; idx >= 0; idx-- {
|
|
t := items[idx]
|
|
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)
|
|
b.WriteString(border)
|
|
b.WriteString("╭")
|
|
b.WriteString(strings.Repeat("─", boxWidth-2))
|
|
b.WriteString("╮")
|
|
b.WriteString(styleReset)
|
|
row++
|
|
|
|
// 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(" ")
|
|
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("│")
|
|
b.WriteString(styleReset)
|
|
row++
|
|
}
|
|
|
|
// Bottom border.
|
|
moveTo(&b, row, col)
|
|
b.WriteString(border)
|
|
b.WriteString("╰")
|
|
b.WriteString(strings.Repeat("─", boxWidth-2))
|
|
b.WriteString("╯")
|
|
b.WriteString(styleReset)
|
|
row++
|
|
|
|
// 1-row gap between stacked toasts.
|
|
row++
|
|
}
|
|
|
|
b.WriteString("\x1b[?25h\x1b8\x1b[?2026l")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
func toastBorderStyle(kind toastKind) string {
|
|
switch kind {
|
|
case toastError:
|
|
return styleError
|
|
case toastAttention:
|
|
return styleAccent
|
|
default:
|
|
return styleBorder
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
return styleError + "✗ " + styleReset
|
|
case toastAttention:
|
|
return styleAccent + "! " + styleReset
|
|
default:
|
|
return styleHint + "• " + styleReset
|
|
}
|
|
}
|