4 Commits

Author SHA1 Message Date
ef9b8e71c6 Release v0.0.5
All checks were successful
release / build-linux-amd64 (push) Successful in 11m51s
2026-05-15 20:56:38 +01:00
e64060e40f Calm down the focused-section labels in the command palette
Focused-section rows are now bare verbs (Rename, Close, Stop, Restart,
Delete, Edit) instead of repeating the focused name. The title bar
already carries the subject, and the row hint preserves fuzzy-search
matches like "close codex". Section banners are replaced by a single
blank spacer row so the verbs themselves carry the visual weight,
and the Open section no longer lists "Switch to <current>" for the
pane that's already focused.
2026-05-15 20:30:31 +01:00
e4ab8c2136 Merge pull request 'Add stackable toast notifications' (#5) from worktree-toast-notifications into main 2026-05-15 20:28:10 +01:00
f312b6d345 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.
2026-05-15 20:26:35 +01:00
9 changed files with 591 additions and 117 deletions

View File

@@ -6,6 +6,32 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [0.0.5] - 2026-05-15
### 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.
- Command palette is calmer when something is focused. Focused-section
rows now read as bare verbs (`Rename`, `Close`, `Stop`, `Restart`,
`Delete`, `Edit`) instead of repeating the focused name (`Close
agent: codex`); the title bar's `on: codex` / `pad: notes.md`
carries the subject. Fuzzy queries still match the dropped context
through the row hint (e.g. typing `close codex` still finds the
Close row).
- Dashed `── Focused ──` / `── Open ──` / `── Spawn ──` section
banners are gone. Sections are separated by a single blank spacer
row, so the action labels themselves carry the visual weight.
- The Open section no longer lists a `Switch to <current>` row for
the pane you're already focused on.
## [0.0.4] - 2026-05-15 ## [0.0.4] - 2026-05-15
### Changed ### Changed

View File

@@ -1,3 +0,0 @@
The close action in the command palette should just be "Close current agent" rather than "Close codex"
Same with the other "focused" parts. It seems a bit clunky right now. "Close current agent"
In general I think while the feature set has grown, the actual refinement of it isn't great, it feels a bit cluttered.

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

@@ -54,14 +54,6 @@ const (
groupQuit groupQuit
) )
var groupLabels = map[int]string{
groupFocused: "Focused",
groupOpen: "Open",
groupSpawn: "Spawn",
groupSettings: "Settings",
groupQuit: "Quit",
}
type paletteItem struct { type paletteItem struct {
label string label string
hint string hint string
@@ -205,8 +197,10 @@ func (p *paletteState) rebuild() {
all := p.buildItems(macro) all := p.buildItems(macro)
if rest == "" { if rest == "" {
// No textual filter: render with section headers between groups. // No textual filter: render with blank spacer rows between
p.items = itemsWithHeaders(all) // groups so sections read as scannable bands without dashed
// headers stealing visual weight.
p.items = itemsWithSpacers(all)
p.clampCursor() p.clampCursor()
return return
} }
@@ -243,25 +237,28 @@ func (p *paletteState) rebuild() {
} }
// buildItems assembles every selectable row in fixed group order // buildItems assembles every selectable row in fixed group order
// (Focused → Open → Spawn → Quit). Headers are added by // (Focused → Open → Spawn → Quit). Blank spacer rows are added by
// itemsWithHeaders for the no-query case; scored mode drops them. // itemsWithSpacers for the no-query case; scored mode drops them.
// When macro is non-empty the result is filtered down to the kinds // When macro is non-empty the result is filtered down to the kinds
// that macro retains. // that macro retains.
func (p *paletteState) buildItems(macro string) []paletteItem { func (p *paletteState) buildItems(macro string) []paletteItem {
var out []paletteItem var out []paletteItem
// Group 0: Focused — context-aware actions for whatever owns focus. // Group 0: Focused — context-aware actions for whatever owns focus.
// A focused scratchpad shadows any focused child. // A focused scratchpad shadows any focused child. Labels are bare
// verbs because the title bar already carries the subject ("on:
// codex" / "pad: notes.md"); the noun + name move into the hint so
// fuzzy queries like "close codex" still surface the row.
switch { switch {
case p.focusedPad != "": case p.focusedPad != "":
name := p.focusedPad name := p.focusedPad
out = append(out, out = append(out,
paletteItem{label: "Delete scratchpad: " + name, hint: "remove the file from disk", paletteItem{label: "Edit", hint: "edit scratchpad · " + name + " (opens $EDITOR)",
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
paletteItem{label: "Rename scratchpad: " + name, hint: "inline rename · enter to commit",
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Edit scratchpad: " + name, hint: "open in external editor (zed)",
action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused}, action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused},
paletteItem{label: "Rename", hint: "rename scratchpad · " + name,
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete scratchpad · " + name,
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
) )
case p.focused != "": case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil { if c := findChildByID(p.children, p.focused); c != nil {
@@ -269,40 +266,39 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
switch c.Kind { switch c.Kind {
case KindAgent: case KindAgent:
out = append(out, out = append(out,
paletteItem{label: "Rename agent: " + name, hint: "inline rename · enter to commit", paletteItem{label: "Rename", hint: "rename agent · " + name,
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close agent: " + name, hint: "SIGTERM " + strings.Join(c.Argv, " "), paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
) )
default: default:
out = append(out, out = append(out,
paletteItem{label: "Rename process: " + name, hint: "inline rename · enter to commit", paletteItem{label: "Rename", hint: "rename process · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive", paletteItem{label: "Stop", hint: "stop process · " + name + " (SIGTERM, keeps entry)",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
paletteItem{label: "Stop process: " + name, hint: "SIGTERM · keep entry for restart",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart process: " + name, hint: "SIGTERM then start with same argv", paletteItem{label: "Restart", hint: "restart process · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete process · " + name + " (SIGKILL if alive)",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
) )
} }
} }
} }
// Group 1: Open — switch entries for every running child. Dead // Group 1: Open — switch entries for every running child *other than*
// the one already focused (no point offering a no-op switch). Dead
// agents are filtered out (no restart path); dead command processes // agents are filtered out (no restart path); dead command processes
// remain so they can be restarted. The currently-focused child is // remain so they can be restarted.
// marked with a leading ▶ instead of the older "• … (current)" suffix
// so the row reads cleaner.
for _, c := range p.children { for _, c := range p.children {
if c.ID == p.focused {
continue
}
if c.Kind == KindAgent && c.Status() != StatusRunning { if c.Kind == KindAgent && c.Status() != StatusRunning {
continue continue
} }
label := "Switch to " + c.DisplayName() label := "Switch to " + c.DisplayName()
hint := strings.Join(c.Argv, " ") hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "▶ " + label
}
if c.Status() != StatusRunning { if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]" label = label + " [" + string(c.Status()) + "]"
} }
@@ -353,6 +349,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{
@@ -378,9 +380,11 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
return out return out
} }
// itemsWithHeaders splices a non-selectable header row in front of // itemsWithSpacers splices a non-selectable blank row between groups
// each new group so the (unfiltered) list reads as scannable bands. // so the (unfiltered) list reads as scannable bands without dashed
func itemsWithHeaders(items []paletteItem) []paletteItem { // section headers stealing weight from the actions themselves. The
// first group never gets a leading spacer.
func itemsWithSpacers(items []paletteItem) []paletteItem {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
@@ -388,16 +392,13 @@ func itemsWithHeaders(items []paletteItem) []paletteItem {
currentGroup := -1 currentGroup := -1
for _, it := range items { for _, it := range items {
if it.group != currentGroup { if it.group != currentGroup {
currentGroup = it.group if currentGroup != -1 {
label, ok := groupLabels[it.group] result = append(result, paletteItem{
if !ok { action: paletteAction{kind: "header"},
label = "" group: it.group,
})
} }
result = append(result, paletteItem{ currentGroup = it.group
label: "── " + label + " ──",
action: paletteAction{kind: "header"},
group: it.group,
})
} }
result = append(result, it) result = append(result, it)
} }

View File

@@ -31,16 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) { func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{}) p := newPalette(nil, "", "notes.md", preset.Set{})
// pad-delete is the first selectable row; the Focused section header // With the dashed section header gone, pad-edit is the first row;
// (a non-selectable row) sits above it. // pad-rename-form follows, with destructive pad-delete last in the
if i, _ := findItem(p, "pad-delete"); i != 1 { // Focused section.
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i) if i, _ := findItem(p, "pad-edit"); i != 0 {
t.Fatalf("pad-edit at %d; want 0", i)
} }
if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" { if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" {
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it) t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
} }
if _, it := findItem(p, "pad-edit"); it == nil { if i, _ := findItem(p, "pad-delete"); i < 0 {
t.Fatalf("pad-edit missing") t.Fatalf("pad-delete missing")
} }
// No focused child → no agent/proc context items. // No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 { if i, _ := findItem(p, "agent-rename-form"); i != -1 {
@@ -83,8 +84,11 @@ func TestContextItemsProcess(t *testing.T) {
} }
func TestContextItemsAppearAboveSwitch(t *testing.T) { func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand) // Two children so there's still a non-focused switch entry to compare
p := newPalette([]*Child{c}, "pid", "", preset.Set{}) // against (the focused child is suppressed from the Open section).
focused := makeFakeChild("pid", "devserver", KindCommand)
other := makeFakeChild("oid", "worker", KindCommand)
p := newPalette([]*Child{focused, other}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form") procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch") switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 { if procIdx < 0 || switchIdx < 0 {

View File

@@ -57,22 +57,45 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
// -- Phase 2: section headers and cursor skip ------------------------ // -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionHeadersPresent(t *testing.T) { func TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
// Section-named dashed headers are gone; groups are visually
// separated by a single non-selectable blank row. Verify that the
// build emits one such spacer between every pair of adjacent groups
// and never a leading spacer.
c := makeFakeChild("a", "claude", KindAgent) c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}}) other := makeFakeChild("b", "worker", KindCommand)
wantSections := []string{"Focused", "Open", "Spawn", "Quit"} p := newPalette([]*Child{c, other}, "a", "",
for _, w := range wantSections { preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
found := false
for _, it := range p.items { if len(p.items) == 0 {
if it.action.kind == "header" && strings.Contains(it.label, w) { t.Fatalf("palette built no items")
found = true }
break if p.items[0].action.kind == "header" {
t.Fatalf("first row is a spacer; should be a selectable item")
}
transitions := 0
prevGroup := p.items[0].group
for i := 1; i < len(p.items); i++ {
it := p.items[i]
if it.group != prevGroup {
if it.action.kind != "header" || it.label != "" {
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
} }
transitions++
// The row immediately after the spacer must be selectable.
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
t.Fatalf("spacer at %d not followed by selectable row", i)
}
prevGroup = p.items[i+1].group
} }
if !found { // No dashed banners anywhere.
t.Errorf("section header %q missing from items", w) if it.action.kind == "header" && strings.Contains(it.label, "──") {
t.Errorf("dashed section header still present at %d: %q", i, it.label)
} }
} }
if transitions == 0 {
t.Fatalf("no section transitions found in palette items")
}
} }
func TestPaletteCursorSkipsHeaders(t *testing.T) { func TestPaletteCursorSkipsHeaders(t *testing.T) {

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