From f312b6d3455923b3b3d0dfce6a5f77f016a79d59 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Fri, 15 May 2026 20:26:35 +0100 Subject: [PATCH] Add stackable toast notifications Replaces the single-slot status-line flash with a top-right toast stack over the focused pane. flashError, flashTransient, and notifyAttention all push onto the same stack (cap 5, FIFO drop). Ctrl-N dismisses the most recent toast; empty stack falls through to the focused PTY so readline / nano / emacs / opencode bindings keep working. A new "Clear notifications" palette item empties the stack. --- CHANGELOG.md | 12 + internal/app/app.go | 105 +++---- internal/app/palette.go | 6 + internal/app/toast.go | 288 ++++++++++++++++++ internal/app/toast_test.go | 100 ++++++ internal/harness/scenarios/toast_dismiss.json | 32 ++ 6 files changed, 492 insertions(+), 51 deletions(-) create mode 100644 internal/app/toast.go create mode 100644 internal/app/toast_test.go create mode 100644 internal/harness/scenarios/toast_dismiss.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbdb7b..ac85cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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 ### Changed diff --git a/internal/app/app.go b/internal/app/app.go index 4d085a9..c9c8171 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -432,10 +432,11 @@ type uiState struct { repaintNextPTY string repaintNextPTYBudget int - // attention is the latest request_human_attention surfaced via MCP; - // rendered in the status line until cleared. - attentionText string - attentionAt string + // toasts is the stackable notification surface. flashError, + // flashTransient, and notifyAttention all push onto it; the user + // dismisses entries with Ctrl-N or the "Clear notifications" + // palette command. + toasts toastStack // pendingTrust is the most recent trust prompt — surfaced in 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 -// surface a one-line toast in the status row and remember the most -// recent ask so the status line keeps showing it. The sidebar-blink is -// deferred until the §4 chrome lands. +// push a toast onto the stack; the focused-pane render path picks it +// up. The sidebar-blink is deferred until the §4 chrome lands. func (st *uiState) notifyAttention(childID, reason string) { c := st.sess.FindChild(childID) name := childID if c != nil { name = c.DisplayName() } - st.mu.Lock() - st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason) - st.attentionAt = childID - st.mu.Unlock() - st.drawStatusLine() + st.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason)) } func (st *uiState) scratchpadsChanged() { @@ -1167,8 +1163,6 @@ func (st *uiState) drawStatusLine() { palOpen := st.palette != nil focusID := st.focusedID focusName := st.focusedName - attention := st.attentionText - attentionAt := st.attentionAt var trustMsg string if st.pendingTrust != nil { trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) @@ -1208,13 +1202,6 @@ func (st *uiState) drawStatusLine() { left = owner } } - if attention != "" && attentionAt == focusID { - left = "[!] " + attention - } - if attention != "" && attentionAt == "" { - // Sticky attention/flash from somewhere outside the focused pane. - left = "[!] " + attention - } if trustMsg != "" { left = "[trust] " + trustMsg } @@ -1270,8 +1257,6 @@ func (st *uiState) drawStatusLine() { // child is focused. func (st *uiState) renderEmptyState() { layout := st.layoutSnapshot() - st.outMu.Lock() - defer st.outMu.Unlock() line := "Press Ctrl-K to spawn an agent or process" row := int(layout.mainTop) + (int(layout.childRows()) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) @@ -1281,7 +1266,10 @@ func (st *uiState) renderEmptyState() { if 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) + st.outMu.Unlock() + st.renderToasts() } func (st *uiState) hostSizeSnapshot() (uint16, uint16) { @@ -1412,6 +1400,7 @@ func (st *uiState) processStdin(chunk []byte) { var pendingViewportBottom bool var pendingPadStep int var pendingPadExit bool + var pendingDismissToast bool flushForward := func() { 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, 'w'); 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 { i++ continue @@ -1696,6 +1690,22 @@ func (st *uiState) processStdin(chunk []byte) { 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 // active area. Use this as the escape hatch from a scrolled-up // state — wheel scrolls move the viewport into the libghostty @@ -1777,6 +1787,11 @@ func (st *uiState) processStdin(chunk []byte) { if pendingPadExit { st.exitPadView() } + if pendingDismissToast { + if st.toasts.dismissTop() { + st.refreshToastSurface() + } + } } // scrollFocusedViewport scrolls the focused child's emulator viewport by @@ -1985,6 +2000,11 @@ func (st *uiState) closePalette(action paletteAction) { case "quit": st.requestExit() + case "toasts-clear": + if st.toasts.clear() { + st.refreshToastSurface() + } + case "pad-delete": st.handlePadDelete(action.padName) @@ -2261,37 +2281,18 @@ func (st *uiState) handleProcRestart(childID string) { st.drawStatusLine() } -// flashError surfaces a spawn/etc. failure in the status line until the -// next attention update overwrites it. stderr is hidden under the alt -// screen so we can't rely on Fprintln(os.Stderr). +// flashError surfaces a spawn/etc. failure as an error toast over the +// focused pane. stderr is hidden under the alt screen so we can't rely +// on Fprintln(os.Stderr). func (st *uiState) flashError(msg string) { - st.mu.Lock() - 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() + st.notifyToast(toastError, msg) } // 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) { - st.mu.Lock() - st.attentionText = msg - st.attentionAt = "" - st.mu.Unlock() - st.drawStatusLine() + st.notifyToast(toastInfo, msg) } // repaintFocused redraws the current focused child's screen snapshot. @@ -2335,8 +2336,9 @@ func (st *uiState) repaintFocused() { } st.mu.Unlock() st.outMu.Lock() - defer st.outMu.Unlock() _, _ = os.Stdout.Write(out) + st.outMu.Unlock() + st.renderToasts() } // repaintFocusedPad paints the focused scratchpad's content into the @@ -2360,8 +2362,9 @@ func (st *uiState) repaintFocusedPad() { return } st.outMu.Lock() - defer st.outMu.Unlock() _, _ = os.Stdout.Write(out) + st.outMu.Unlock() + st.renderToasts() } // renderPadView builds the bytes that paint a scratchpad's content diff --git a/internal/app/palette.go b/internal/app/palette.go index 63b05bb..6988d9c 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -353,6 +353,12 @@ func (p *paletteState) buildItems(macro string) []paletteItem { action: paletteAction{kind: "settings-open"}, 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. out = append(out, paletteItem{ diff --git a/internal/app/toast.go b/internal/app/toast.go new file mode 100644 index 0000000..2e80a1b --- /dev/null +++ b/internal/app/toast.go @@ -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 + } +} diff --git a/internal/app/toast_test.go b/internal/app/toast_test.go new file mode 100644 index 0000000..764bab5 --- /dev/null +++ b/internal/app/toast_test.go @@ -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) + } +} diff --git a/internal/harness/scenarios/toast_dismiss.json b/internal/harness/scenarios/toast_dismiss.json new file mode 100644 index 0000000..e31fdfd --- /dev/null +++ b/internal/harness/scenarios/toast_dismiss.json @@ -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" } + ] +} -- 2.49.1