Compare commits
2 Commits
e6f5a94fae
...
e4ab8c2136
| Author | SHA1 | Date | |
|---|---|---|---|
| e4ab8c2136 | |||
| f312b6d345 |
12
CHANGELOG.md
12
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
288
internal/app/toast.go
Normal file
288
internal/app/toast.go
Normal 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
100
internal/app/toast_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
internal/harness/scenarios/toast_dismiss.json
Normal file
32
internal/harness/scenarios/toast_dismiss.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user