Merge pull request 'Add stackable toast notifications' (#5) from worktree-toast-notifications into main

This commit was merged in pull request #5.
This commit is contained in:
2026-05-15 20:28:10 +01:00
6 changed files with 492 additions and 51 deletions

View File

@@ -6,6 +6,18 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- Replaced the single-slot status-line "flash" with a stackable toast
surface anchored at the top-right of the focused pane. `flashError`,
`flashTransient`, and MCP `request_human_attention` now push onto
the toast stack (cap 5, oldest drops). Toasts persist until
dismissed with `Ctrl-N`, or cleared via the new
"Clear notifications" palette command. The status line no longer
shows the `[!]` prefix.
- `Ctrl-N` is consumed by the host only when there is a toast to
dismiss; an empty stack lets `Ctrl-N` pass through to the focused
child so readline / nano / emacs / opencode keep their bindings.
## [0.0.4] - 2026-05-15 ## [0.0.4] - 2026-05-15
### Changed ### Changed

View File

@@ -432,10 +432,11 @@ type uiState struct {
repaintNextPTY string repaintNextPTY string
repaintNextPTYBudget int repaintNextPTYBudget int
// attention is the latest request_human_attention surfaced via MCP; // toasts is the stackable notification surface. flashError,
// rendered in the status line until cleared. // flashTransient, and notifyAttention all push onto it; the user
attentionText string // dismisses entries with Ctrl-N or the "Clear notifications"
attentionAt string // palette command.
toasts toastStack
// pendingTrust is the most recent trust prompt — surfaced in the // pendingTrust is the most recent trust prompt — surfaced in the
// status line until the user resolves it with Ctrl-K. v1 keeps the // status line until the user resolves it with Ctrl-K. v1 keeps the
@@ -722,20 +723,15 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
// notifyAttention is the request_human_attention sink (SPEC §7). We // notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most // push a toast onto the stack; the focused-pane render path picks it
// recent ask so the status line keeps showing it. The sidebar-blink is // up. The sidebar-blink is deferred until the §4 chrome lands.
// deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) { func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID) c := st.sess.FindChild(childID)
name := childID name := childID
if c != nil { if c != nil {
name = c.DisplayName() name = c.DisplayName()
} }
st.mu.Lock() st.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason))
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
} }
func (st *uiState) scratchpadsChanged() { func (st *uiState) scratchpadsChanged() {
@@ -1167,8 +1163,6 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil palOpen := st.palette != nil
focusID := st.focusedID focusID := st.focusedID
focusName := st.focusedName focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
var trustMsg string var trustMsg string
if st.pendingTrust != nil { if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
@@ -1208,13 +1202,6 @@ func (st *uiState) drawStatusLine() {
left = owner left = owner
} }
} }
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
if attention != "" && attentionAt == "" {
// Sticky attention/flash from somewhere outside the focused pane.
left = "[!] " + attention
}
if trustMsg != "" { if trustMsg != "" {
left = "[trust] " + trustMsg left = "[trust] " + trustMsg
} }
@@ -1270,8 +1257,6 @@ func (st *uiState) drawStatusLine() {
// child is focused. // child is focused.
func (st *uiState) renderEmptyState() { func (st *uiState) renderEmptyState() {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
line := "Press Ctrl-K to spawn an agent or process" line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2) row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
@@ -1281,7 +1266,10 @@ func (st *uiState) renderEmptyState() {
if col < int(layout.mainLeft) { if col < int(layout.mainLeft) {
col = int(layout.mainLeft) col = int(layout.mainLeft)
} }
st.outMu.Lock()
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line) fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
st.outMu.Unlock()
st.renderToasts()
} }
func (st *uiState) hostSizeSnapshot() (uint16, uint16) { func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
@@ -1412,6 +1400,7 @@ func (st *uiState) processStdin(chunk []byte) {
var pendingViewportBottom bool var pendingViewportBottom bool
var pendingPadStep int var pendingPadStep int
var pendingPadExit bool var pendingPadExit bool
var pendingDismissToast bool
flushForward := func() { flushForward := func() {
if len(forward) == 0 { if len(forward) == 0 {
@@ -1598,6 +1587,11 @@ func (st *uiState) processStdin(chunk []byte) {
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'n'); hit {
// Ctrl-N is the toast dismiss key. In pad view we
// allow it through the chord block so the handler
// below can fire even though pads otherwise swallow
// bytes.
} else { } else {
i++ i++
continue continue
@@ -1696,6 +1690,22 @@ func (st *uiState) processStdin(chunk []byte) {
break break
} }
} }
// Ctrl-N dismisses the most recent toast. We only consume the
// chord when there's actually a toast to dismiss; otherwise the
// bytes fall through to the focused PTY so readline /
// nano / emacs / opencode keep working in shells and editors.
if hit, adv := matchCtrlChar(chunk, i, 'n'); hit {
if st.toasts.length() > 0 {
flushForward()
pendingDismissToast = true
i += adv
continue
}
forward = append(forward, chunk[i:i+adv]...)
i += adv
continue
}
// Ctrl-B snaps the focused child's emulator viewport back to the // Ctrl-B snaps the focused child's emulator viewport back to the
// active area. Use this as the escape hatch from a scrolled-up // active area. Use this as the escape hatch from a scrolled-up
// state — wheel scrolls move the viewport into the libghostty // state — wheel scrolls move the viewport into the libghostty
@@ -1777,6 +1787,11 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingPadExit { if pendingPadExit {
st.exitPadView() st.exitPadView()
} }
if pendingDismissToast {
if st.toasts.dismissTop() {
st.refreshToastSurface()
}
}
} }
// scrollFocusedViewport scrolls the focused child's emulator viewport by // scrollFocusedViewport scrolls the focused child's emulator viewport by
@@ -1985,6 +2000,11 @@ func (st *uiState) closePalette(action paletteAction) {
case "quit": case "quit":
st.requestExit() st.requestExit()
case "toasts-clear":
if st.toasts.clear() {
st.refreshToastSurface()
}
case "pad-delete": case "pad-delete":
st.handlePadDelete(action.padName) st.handlePadDelete(action.padName)
@@ -2261,37 +2281,18 @@ func (st *uiState) handleProcRestart(childID string) {
st.drawStatusLine() st.drawStatusLine()
} }
// flashError surfaces a spawn/etc. failure in the status line until the // flashError surfaces a spawn/etc. failure as an error toast over the
// next attention update overwrites it. stderr is hidden under the alt // focused pane. stderr is hidden under the alt screen so we can't rely
// screen so we can't rely on Fprintln(os.Stderr). // on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) { func (st *uiState) flashError(msg string) {
st.mu.Lock() st.notifyToast(toastError, msg)
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
focusedPad := st.focusedPad
focusedID := st.focusedID
st.mu.Unlock()
switch {
case focusedPad != "":
st.repaintFocusedPad()
case focusedID != "":
st.repaintFocused()
default:
st.renderEmptyState()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
} }
// flashTransient is the softer cousin of flashError used for // flashTransient is the softer cousin of flashError used for
// trust-prompt resolutions. Same status-line surface; the prefix differs. // trust-prompt resolutions and other ack-style notices. Same
// stackable surface, info styling.
func (st *uiState) flashTransient(msg string) { func (st *uiState) flashTransient(msg string) {
st.mu.Lock() st.notifyToast(toastInfo, msg)
st.attentionText = msg
st.attentionAt = ""
st.mu.Unlock()
st.drawStatusLine()
} }
// repaintFocused redraws the current focused child's screen snapshot. // repaintFocused redraws the current focused child's screen snapshot.
@@ -2335,8 +2336,9 @@ func (st *uiState) repaintFocused() {
} }
st.mu.Unlock() st.mu.Unlock()
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
} }
// repaintFocusedPad paints the focused scratchpad's content into the // repaintFocusedPad paints the focused scratchpad's content into the
@@ -2360,8 +2362,9 @@ func (st *uiState) repaintFocusedPad() {
return return
} }
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
} }
// renderPadView builds the bytes that paint a scratchpad's content // renderPadView builds the bytes that paint a scratchpad's content

View File

@@ -353,6 +353,12 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
action: paletteAction{kind: "settings-open"}, action: paletteAction{kind: "settings-open"},
group: groupSettings, group: groupSettings,
}) })
out = append(out, paletteItem{
label: "Clear notifications",
hint: "dismiss all toasts in the top-right of the focused pane",
action: paletteAction{kind: "toasts-clear"},
group: groupSettings,
})
// Group 4: Quit. // Group 4: Quit.
out = append(out, paletteItem{ out = append(out, paletteItem{

288
internal/app/toast.go Normal file
View File

@@ -0,0 +1,288 @@
package app
import (
"fmt"
"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
// 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.
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()
}
}
// 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() {
items := st.toasts.snapshot()
if len(items) == 0 {
return
}
st.mu.Lock()
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
paneCols := int(layout.childCols())
paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < 3 {
return
}
boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth {
boxWidth = max
}
if boxWidth < toastBoxMinWidth {
return
}
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
var b strings.Builder
b.WriteString("\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]
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++
}
// Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows {
break
}
border := toastBorderStyle(t.kind)
// Top border.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("╭")
b.WriteString(strings.Repeat("─", boxWidth-2))
b.WriteString("╮")
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) + "…"
}
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))))
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")
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
}
func toastBorderStyle(kind toastKind) string {
switch kind {
case toastError:
return styleError
case toastAttention:
return styleAccent
default:
return styleBorder
}
}
func toastIcon(kind toastKind) string {
switch kind {
case toastError:
return styleError + "✗ " + styleReset
case toastAttention:
return styleAccent + "! " + styleReset
default:
return styleHint + "• " + styleReset
}
}

100
internal/app/toast_test.go Normal file
View File

@@ -0,0 +1,100 @@
package app
import "testing"
func TestToastStackPushAndOrder(t *testing.T) {
var s toastStack
s.push(toastInfo, "one")
s.push(toastError, "two")
s.push(toastAttention, "three")
snap := s.snapshot()
if len(snap) != 3 {
t.Fatalf("snapshot len = %d, want 3", len(snap))
}
if snap[0].text != "one" || snap[1].text != "two" || snap[2].text != "three" {
t.Fatalf("snapshot order wrong: %#v", snap)
}
if snap[0].kind != toastInfo || snap[1].kind != toastError || snap[2].kind != toastAttention {
t.Fatalf("snapshot kinds wrong: %#v", snap)
}
// IDs strictly increase.
if !(snap[0].id < snap[1].id && snap[1].id < snap[2].id) {
t.Fatalf("ids not increasing: %#v", snap)
}
}
func TestToastStackCapDropsOldest(t *testing.T) {
var s toastStack
for i := 0; i < toastStackCap+3; i++ {
s.push(toastInfo, "msg")
}
snap := s.snapshot()
if len(snap) != toastStackCap {
t.Fatalf("len = %d, want %d", len(snap), toastStackCap)
}
// The earliest IDs should have been dropped, leaving the highest
// toastStackCap IDs.
for i := 1; i < len(snap); i++ {
if snap[i].id <= snap[i-1].id {
t.Fatalf("ordering broken after cap: %#v", snap)
}
}
// First retained id should be 4 (1,2,3 dropped; cap=5 leaves 4..8).
want := uint64(toastStackCap + 3 - toastStackCap + 1)
if snap[0].id != want {
t.Fatalf("first retained id = %d, want %d", snap[0].id, want)
}
}
func TestToastStackDismissTop(t *testing.T) {
var s toastStack
if s.dismissTop() {
t.Fatalf("dismissTop on empty stack returned true")
}
s.push(toastInfo, "a")
s.push(toastError, "b")
if !s.dismissTop() {
t.Fatalf("dismissTop returned false with items present")
}
snap := s.snapshot()
if len(snap) != 1 || snap[0].text != "a" {
t.Fatalf("after dismissTop: %#v", snap)
}
if !s.dismissTop() {
t.Fatalf("dismissTop on last item returned false")
}
if s.length() != 0 {
t.Fatalf("length after final dismiss = %d, want 0", s.length())
}
}
func TestToastStackClear(t *testing.T) {
var s toastStack
if s.clear() {
t.Fatalf("clear on empty returned true")
}
s.push(toastInfo, "a")
s.push(toastError, "b")
s.push(toastAttention, "c")
if !s.clear() {
t.Fatalf("clear returned false with items present")
}
if s.length() != 0 {
t.Fatalf("length after clear = %d, want 0", s.length())
}
if snap := s.snapshot(); snap != nil {
t.Fatalf("snapshot after clear = %#v, want nil", snap)
}
}
func TestToastStackSnapshotIsCopy(t *testing.T) {
var s toastStack
s.push(toastInfo, "a")
snap := s.snapshot()
snap[0].text = "mutated"
again := s.snapshot()
if again[0].text != "a" {
t.Fatalf("snapshot is not an independent copy: %#v", again)
}
}

View File

@@ -0,0 +1,32 @@
{
"name": "toast_dismiss",
"presets": {
"processes": [
{
"name": "steady",
"argv": ["sh", "-lc", "printf 'STEADY READY\\n'; sleep 30"]
}
]
},
"trust": ["steady"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "steady", "name": "steady"},
"save_as": "proc"
},
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{
"type": "mcp_call",
"method": "request_human_attention",
"params": {"process_id": "{{proc.process_id}}", "reason": "needs eyes on the deploy"}
},
{ "type": "wait_text", "contains": "needs eyes on the deploy", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "assert_not_contains", "contains": "needs eyes on the deploy" }
]
}