From e6fbbbca8d282dc6c29670af03c0493363a39db9 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 25 Mar 2026 23:58:22 +0100 Subject: [PATCH] Make TUI warnings persist until manually dismissed Warning and error notifications no longer auto-dismiss after 3 seconds. They display an [x] close button and remain visible until the user clicks on them. Success and info notifications continue to auto-dismiss. - Add persistent() and style() methods on notification Type - Add render() method on notificationItem to centralize rendering - Add HandleClick() on Manager for mouse-based dismissal - Clamp maxWidth to prevent underflow on very narrow terminals Fixes #2247 Assisted-By: docker-agent --- .../components/notification/notification.go | 158 ++++++++++-------- pkg/tui/tui.go | 5 + 2 files changed, 97 insertions(+), 66 deletions(-) diff --git a/pkg/tui/components/notification/notification.go b/pkg/tui/components/notification/notification.go index c980a89ff..3216ec483 100644 --- a/pkg/tui/components/notification/notification.go +++ b/pkg/tui/components/notification/notification.go @@ -13,6 +13,7 @@ import ( ) const ( + closeButton = " [x]" defaultDuration = 3 * time.Second notificationPadding = 2 maxNotificationWidth = 80 // Maximum width to prevent covering too much screen @@ -30,6 +31,25 @@ const ( TypeError ) +// persistent returns true for notification types that stay until manually dismissed. +func (t Type) persistent() bool { + return t == TypeWarning || t == TypeError +} + +// style returns the lipgloss style for this notification type. +func (t Type) style() lipgloss.Style { + switch t { + case TypeError: + return styles.NotificationErrorStyle + case TypeWarning: + return styles.NotificationWarningStyle + case TypeInfo: + return styles.NotificationInfoStyle + default: + return styles.NotificationStyle + } +} + type ShowMsg struct { Text string Type Type // Defaults to TypeSuccess for backward compatibility @@ -40,39 +60,41 @@ type HideMsg struct { } func SuccessCmd(text string) tea.Cmd { - return core.CmdHandler(ShowMsg{ - Text: text, - Type: TypeSuccess, - }) + return core.CmdHandler(ShowMsg{Text: text, Type: TypeSuccess}) } func WarningCmd(text string) tea.Cmd { - return core.CmdHandler(ShowMsg{ - Text: text, - Type: TypeWarning, - }) + return core.CmdHandler(ShowMsg{Text: text, Type: TypeWarning}) } func InfoCmd(text string) tea.Cmd { - return core.CmdHandler(ShowMsg{ - Text: text, - Type: TypeInfo, - }) + return core.CmdHandler(ShowMsg{Text: text, Type: TypeInfo}) } func ErrorCmd(text string) tea.Cmd { - return core.CmdHandler(ShowMsg{ - Text: text, - Type: TypeError, - }) + return core.CmdHandler(ShowMsg{Text: text, Type: TypeError}) } // notificationItem represents a single notification type notificationItem struct { - ID uint64 - Text string - Type Type - TimerCmd tea.Cmd + ID uint64 + Text string + Type Type +} + +// render returns the styled view string for this notification item, +// including a close button for persistent notifications. +func (item notificationItem) render(maxWidth int) string { + text := item.Text + if item.Type.persistent() { + text += closeButton + } + + style := item.Type.style() + if lipgloss.Width(text) > maxWidth { + return style.Width(maxWidth).Render(text) + } + return style.Render(text) } // Manager represents a notification manager that displays @@ -110,19 +132,17 @@ func (n *Manager) Update(msg tea.Msg) (Manager, tea.Cmd) { notifType = TypeError } } - item := notificationItem{ - ID: id, - Text: msg.Text, - Type: notifType, - } - - item.TimerCmd = tea.Tick(defaultDuration, func(t time.Time) tea.Msg { - return HideMsg{ID: id} - }) + item := notificationItem{ID: id, Text: msg.Text, Type: notifType} n.items = append([]notificationItem{item}, n.items...) - return *n, item.TimerCmd + var cmd tea.Cmd + if !notifType.persistent() { + cmd = tea.Tick(defaultDuration, func(t time.Time) tea.Msg { + return HideMsg{ID: id} + }) + } + return *n, cmd case HideMsg: if msg.ID == 0 { @@ -143,49 +163,24 @@ func (n *Manager) Update(msg tea.Msg) (Manager, tea.Cmd) { return *n, nil } +// maxWidth returns the effective maximum width for notification text. +func (n *Manager) maxWidth() int { + if n.width > 0 { + return max(1, min(maxNotificationWidth, n.width-notificationPadding*2)) + } + return maxNotificationWidth +} + func (n *Manager) View() string { if len(n.items) == 0 { return "" } - var views []string + mw := n.maxWidth() + views := make([]string, 0, len(n.items)) for i := len(n.items) - 1; i >= 0; i-- { - item := n.items[i] - - // Select style based on notification type - var style lipgloss.Style - switch item.Type { - case TypeError: - style = styles.NotificationErrorStyle - case TypeWarning: - style = styles.NotificationWarningStyle - case TypeInfo: - style = styles.NotificationInfoStyle - default: - style = styles.NotificationStyle - } - - // Apply max width constraint and word wrapping - text := item.Text - maxWidth := maxNotificationWidth - if n.width > 0 { - // Use smaller of maxNotificationWidth or available width minus padding - maxWidth = min(maxNotificationWidth, n.width-notificationPadding*2) - } - - // Only constrain width if text actually exceeds maxWidth - textWidth := lipgloss.Width(text) - var view string - if textWidth > maxWidth { - // Wrap text using lipgloss Width style - lipgloss will automatically wrap - view = style.Width(maxWidth).Render(text) - } else { - // Use natural width for short text - view = style.Render(text) - } - views = append(views, view) + views = append(views, n.items[i].render(mw)) } - return lipgloss.JoinVertical(lipgloss.Right, views...) } @@ -215,3 +210,34 @@ func (n *Manager) position() (row, col int) { func (n *Manager) Open() bool { return len(n.items) > 0 } + +// HandleClick checks if the given screen coordinates hit a persistent +// notification and dismisses it. Returns a command if a notification +// was dismissed, nil otherwise. +func (n *Manager) HandleClick(x, y int) tea.Cmd { + if len(n.items) == 0 { + return nil + } + + row, col := n.position() + mw := n.maxWidth() + notifY := row + + // Walk items bottom-to-top (same render order as View) + for i := len(n.items) - 1; i >= 0; i-- { + item := n.items[i] + view := item.render(mw) + viewHeight := lipgloss.Height(view) + + if item.Type.persistent() { + viewWidth := lipgloss.Width(view) + if y >= notifY && y < notifY+viewHeight && x >= col && x < col+viewWidth { + return core.CmdHandler(HideMsg{ID: item.ID}) + } + } + + notifY += viewHeight + } + + return nil +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index a592a412c..f94d39747 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -1727,6 +1727,11 @@ func (m *appModel) switchFocus() (tea.Model, tea.Cmd) { // handleMouseClick routes mouse clicks to the appropriate component based on Y coordinate. func (m *appModel) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { + // Check if click hits a notification close button + if cmd := m.notification.HandleClick(msg.X, msg.Y); cmd != nil { + return m, cmd + } + // Dialogs use full-window coordinates (they're positioned over the entire screen) if m.dialogMgr.Open() { u, cmd := m.dialogMgr.Update(msg)