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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user