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 } }