feat(notifications): migrate disabled and add picker

Kieran Klukas created

Change summary

internal/config/config.go           |   2 
internal/config/load.go             |  69 ++++++
internal/config/store.go            |   3 
internal/ui/dialog/actions.go       |  19 +
internal/ui/dialog/commands.go      |  11 
internal/ui/dialog/notifications.go | 310 +++++++++++++++++++++++++++++++
internal/ui/model/ui.go             |  41 ++-
internal/ui/notification/osc.go     |  17 -
8 files changed, 428 insertions(+), 44 deletions(-)

Detailed changes

internal/config/config.go 🔗

@@ -278,7 +278,7 @@ type Options struct {
 	InitializeAs              string       `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
 	AutoLSP                   *bool        `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
 	Progress                  *bool        `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
-	DisableNotifications      bool         `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
+	DisableNotifications      bool         `json:"disable_notifications,omitempty" jsonschema:"description=Deprecated: Use notification_style instead. Disable desktop notifications,default=false"`
 	NotificationStyle         string       `json:"notification_style,omitempty" jsonschema:"description=Notification style to use. Options: auto (default), native, osc, bell, disabled. Auto selects based on environment: native for local sessions, osc for SSH (with automatic OSC 99/777 detection).,enum=auto,enum=native,enum=osc,enum=bell,enum=disabled,default=auto"`
 	DisabledSkills            []string     `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
 }

internal/config/load.go 🔗

@@ -26,6 +26,8 @@ import (
 	"github.com/charmbracelet/crush/internal/home"
 	powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
 	"github.com/qjebbs/go-jsons"
+	"github.com/tidwall/gjson"
+	"github.com/tidwall/sjson"
 )
 
 const defaultCatwalkURL = "https://catwalk.charm.land"
@@ -33,6 +35,9 @@ const defaultCatwalkURL = "https://catwalk.charm.land"
 // Load loads the configuration from the default paths and returns a
 // ConfigStore that owns both the pure-data Config and all runtime state.
 func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
+	// Migrate deprecated disable_notifications before loading config.
+	migrateDisableNotifications()
+
 	configPaths := lookupConfigs(workingDir)
 
 	cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
@@ -479,6 +484,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 			c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
 		}
 	}
+
 	c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
 }
 
@@ -811,6 +817,69 @@ func hasAWSCredentials(env env.Env) bool {
 	return false
 }
 
+// migrateDisableNotifications migrates the deprecated disable_notifications
+// field to notification_style. It checks both the user config (~/.config) and
+// data config (~/.local) files. If disable_notifications is true, it sets
+// notification_style to "disabled" in the data file. Regardless of value, it
+// removes disable_notifications from any file that contains it.
+func migrateDisableNotifications() {
+	globalConfig := GlobalConfig()
+	dataConfig := GlobalConfigData()
+
+	var wasDisabled bool
+	filesToClean := []string{}
+
+	for _, path := range []string{globalConfig, dataConfig} {
+		data, err := os.ReadFile(path)
+		if err != nil {
+			continue
+		}
+		if gjson.Get(string(data), "options.disable_notifications").Exists() {
+			filesToClean = append(filesToClean, path)
+			if gjson.Get(string(data), "options.disable_notifications").Bool() {
+				wasDisabled = true
+			}
+		}
+	}
+
+	if len(filesToClean) == 0 {
+		return
+	}
+
+	// If notifications were disabled, persist the equivalent notification_style.
+	if wasDisabled {
+		data, err := os.ReadFile(dataConfig)
+		if err == nil {
+			if !gjson.Get(string(data), "options.notification_style").Exists() {
+				updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
+				if err == nil {
+					if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
+						slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
+					} else {
+						slog.Info("Migrated disable_notifications: true to notification_style: disabled")
+					}
+				}
+			}
+		}
+	}
+
+	// Remove disable_notifications from all files that contain it.
+	for _, path := range filesToClean {
+		data, err := os.ReadFile(path)
+		if err != nil {
+			continue
+		}
+		updated, err := sjson.Delete(string(data), "options.disable_notifications")
+		if err != nil {
+			slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
+			continue
+		}
+		if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
+			slog.Warn("Failed to write migrated config", "path", path, "error", err)
+		}
+	}
+}
+
 // GlobalConfig returns the global configuration file path for the application.
 func GlobalConfig() string {
 	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {

internal/config/store.go 🔗

@@ -649,6 +649,9 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
 		s.reloadInProgress = false
 	}()
 
+	// Migrate deprecated disable_notifications before reloading config.
+	migrateDisableNotifications()
+
 	configPaths := lookupConfigs(s.workingDir)
 	cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
 	if err != nil {

internal/ui/dialog/actions.go 🔗

@@ -45,14 +45,17 @@ type ActionSelectModel struct {
 
 // Messages for commands
 type (
-	ActionNewSession                  struct{}
-	ActionToggleHelp                  struct{}
-	ActionToggleCompactMode           struct{}
-	ActionToggleThinking              struct{}
-	ActionTogglePills                 struct{}
-	ActionExternalEditor              struct{}
-	ActionToggleYoloMode              struct{}
-	ActionToggleNotifications         struct{}
+	ActionNewSession              struct{}
+	ActionToggleHelp              struct{}
+	ActionToggleCompactMode       struct{}
+	ActionToggleThinking          struct{}
+	ActionTogglePills             struct{}
+	ActionExternalEditor          struct{}
+	ActionToggleYoloMode          struct{}
+	ActionToggleNotifications     struct{}
+	ActionSelectNotificationStyle struct {
+		Style string
+	}
 	ActionToggleTransparentBackground struct{}
 	ActionInitializeProject           struct{}
 	ActionSummarize                   struct {

internal/ui/dialog/commands.go 🔗

@@ -511,14 +511,9 @@ func (c *Commands) defaultCommands() []*CommandItem {
 		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
 	}
 
-	// Add a command for toggling notifications.
-	cfg = c.com.Config()
-	notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
-	notificationLabel := "Disable Notifications"
-	if notificationsDisabled {
-		notificationLabel = "Enable Notifications"
-	}
-	commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
+	// Add a command for selecting notification style via picker dialog.
+	notificationLabel := "Notification Style"
+	commands = append(commands, NewCommandItem(c.com.Styles, "select_notifications", notificationLabel, "", ActionOpenDialog{DialogID: NotificationsID}))
 
 	commands = append(
 		commands,

internal/ui/dialog/notifications.go 🔗

@@ -0,0 +1,310 @@
+package dialog
+
+import (
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/sahilm/fuzzy"
+)
+
+const (
+	// NotificationsID is the identifier for the notification style picker dialog.
+	NotificationsID              = "notifications"
+	notificationsDialogMaxWidth  = 50
+	notificationsDialogMaxHeight = 12
+)
+
+// NotificationStyle represents a notification backend option.
+type NotificationStyle struct {
+	ID          string
+	Title       string
+	Description string
+}
+
+// AllNotificationStyles lists all available notification styles in order.
+var AllNotificationStyles = []NotificationStyle{
+	{ID: "auto", Title: "Auto", Description: "Automatically detect the best backend"},
+	{ID: "native", Title: "Native", Description: "Use system notifications (macOS/Linux/Windows)"},
+	{ID: "osc", Title: "OSC", Description: "Use terminal OSC escape sequences"},
+	{ID: "bell", Title: "Bell", Description: "Use terminal bell character"},
+	{ID: "disabled", Title: "Disabled", Description: "Turn off notifications"},
+}
+
+// Notifications represents a dialog for selecting notification style.
+type Notifications struct {
+	com   *common.Common
+	help  help.Model
+	list  *list.FilterableList
+	input textinput.Model
+
+	keyMap struct {
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		UpDown   key.Binding
+		Close    key.Binding
+	}
+}
+
+// NotificationItem represents a notification style list item.
+type NotificationItem struct {
+	*list.Versioned
+	style     NotificationStyle
+	isCurrent bool
+	t         *styles.Styles
+	m         fuzzy.Match
+	cache     map[int]string
+	focused   bool
+}
+
+// Finished implements list.Item. Notification items are render-stable
+// outside of explicit SetFocused / SetMatch.
+func (n *NotificationItem) Finished() bool {
+	return true
+}
+
+var (
+	_ Dialog   = (*Notifications)(nil)
+	_ ListItem = (*NotificationItem)(nil)
+)
+
+// NewNotifications creates a new notification style picker dialog.
+func NewNotifications(com *common.Common) *Notifications {
+	n := &Notifications{com: com}
+
+	h := help.New()
+	h.Styles = com.Styles.DialogHelpStyles()
+	n.help = h
+
+	n.list = list.NewFilterableList()
+	n.list.Focus()
+
+	n.input = textinput.New()
+	n.input.SetVirtualCursor(false)
+	n.input.Placeholder = "Type to filter"
+	n.input.SetStyles(com.Styles.TextInput)
+	n.input.Focus()
+
+	n.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	n.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	n.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	n.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	n.keyMap.Close = CloseKey
+
+	n.setItems()
+	return n
+}
+
+// ID implements Dialog.
+func (n *Notifications) ID() string {
+	return NotificationsID
+}
+
+// HandleMsg implements [Dialog].
+func (n *Notifications) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, n.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, n.keyMap.Previous):
+			n.list.Focus()
+			if n.list.IsSelectedFirst() {
+				n.list.SelectLast()
+				n.list.ScrollToBottom()
+				break
+			}
+			n.list.SelectPrev()
+			n.list.ScrollToSelected()
+		case key.Matches(msg, n.keyMap.Next):
+			n.list.Focus()
+			if n.list.IsSelectedLast() {
+				n.list.SelectFirst()
+				n.list.ScrollToTop()
+				break
+			}
+			n.list.SelectNext()
+			n.list.ScrollToSelected()
+		case key.Matches(msg, n.keyMap.Select):
+			selectedItem := n.list.SelectedItem()
+			if selectedItem == nil {
+				break
+			}
+			notifItem, ok := selectedItem.(*NotificationItem)
+			if !ok {
+				break
+			}
+			return ActionSelectNotificationStyle{Style: notifItem.style.ID}
+		default:
+			var cmd tea.Cmd
+			n.input, cmd = n.input.Update(msg)
+			value := n.input.Value()
+			n.list.SetFilter(value)
+			n.list.ScrollToTop()
+			n.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (n *Notifications) Cursor() *tea.Cursor {
+	return InputCursor(n.com.Styles, n.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (n *Notifications) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := n.com.Styles
+	width := max(0, min(notificationsDialogMaxWidth, area.Dx()))
+	height := max(0, min(notificationsDialogMaxHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+
+	n.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+	n.list.SetSize(innerWidth, height-heightOffset)
+	n.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Notification Style"
+	inputView := t.Dialog.InputPrompt.Render(n.input.View())
+	rc.AddPart(inputView)
+
+	visibleCount := len(n.list.FilteredItems())
+	if n.list.Height() >= visibleCount {
+		n.list.ScrollToTop()
+	} else {
+		n.list.ScrollToSelected()
+	}
+
+	listView := t.Dialog.List.Height(n.list.Height()).Render(n.list.Render())
+	rc.AddPart(listView)
+	rc.Help = n.help.View(n)
+
+	view := rc.Render()
+
+	cur := n.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (n *Notifications) ShortHelp() []key.Binding {
+	return []key.Binding{
+		n.keyMap.UpDown,
+		n.keyMap.Select,
+		n.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (n *Notifications) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := []key.Binding{
+		n.keyMap.Select,
+		n.keyMap.Next,
+		n.keyMap.Previous,
+		n.keyMap.Close,
+	}
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+func (n *Notifications) setItems() {
+	cfg := n.com.Config()
+	currentStyle := "auto"
+	if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
+		currentStyle = cfg.Options.NotificationStyle
+	}
+
+	items := make([]list.FilterableItem, 0, len(AllNotificationStyles))
+	selectedIndex := 0
+	for i, style := range AllNotificationStyles {
+		item := &NotificationItem{
+			Versioned: list.NewVersioned(),
+			style:     style,
+			isCurrent: style.ID == currentStyle,
+			t:         n.com.Styles,
+		}
+		items = append(items, item)
+		if style.ID == currentStyle {
+			selectedIndex = i
+		}
+	}
+
+	n.list.SetItems(items...)
+	n.list.SetSelected(selectedIndex)
+	n.list.ScrollToSelected()
+}
+
+// Filter returns the filter value for the notification item.
+func (n *NotificationItem) Filter() string {
+	return n.style.Title
+}
+
+// ID returns the unique identifier for the notification style.
+func (n *NotificationItem) ID() string {
+	return n.style.ID
+}
+
+// SetFocused sets the focus state of the notification item.
+func (n *NotificationItem) SetFocused(focused bool) {
+	if n.focused == focused {
+		return
+	}
+	n.cache = nil
+	n.focused = focused
+	if n.Versioned != nil {
+		n.Bump()
+	}
+}
+
+// SetMatch sets the fuzzy match for the notification item.
+func (n *NotificationItem) SetMatch(m fuzzy.Match) {
+	if sameFuzzyMatch(n.m, m) {
+		return
+	}
+	n.cache = nil
+	n.m = m
+	if n.Versioned != nil {
+		n.Bump()
+	}
+}
+
+// Render returns the string representation of the notification item.
+func (n *NotificationItem) Render(width int) string {
+	info := ""
+	if n.isCurrent {
+		info = "current"
+	}
+	st := ListItemStyles{
+		ItemBlurred:     n.t.Dialog.NormalItem,
+		ItemFocused:     n.t.Dialog.SelectedItem,
+		InfoTextBlurred: n.t.Dialog.ListItem.InfoBlurred,
+		InfoTextFocused: n.t.Dialog.ListItem.InfoFocused,
+	}
+	return renderItem(st, n.style.Title, info, n.focused, width, n.cache, &n.m)
+}

internal/ui/model/ui.go 🔗

@@ -498,14 +498,10 @@ func (m *UI) updateNotificationBackend() {
 }
 
 // shouldSendNotification returns true if notifications should be sent based on
-// current state. Focus reporting must be supported, window must not focused,
-// and notifications must not be disabled in config.
+// current state. Focus reporting must be supported, window must not be
+// focused, and notifications must not be disabled in config.
 func (m *UI) shouldSendNotification() bool {
 	cfg := m.com.Config()
-	if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
-		return false
-	}
-	// If the user explicitly set style to "disabled", skip sending.
 	if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
 		return false
 	}
@@ -1419,22 +1415,19 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.com.Workspace.PermissionSetSkipRequests(yolo)
 		m.setEditorPrompt(yolo)
 		m.dialog.CloseDialog(dialog.CommandsID)
-	case dialog.ActionToggleNotifications:
+	case dialog.ActionSelectNotificationStyle:
 		cfg := m.com.Config()
 		if cfg != nil && cfg.Options != nil {
-			disabled := !cfg.Options.DisableNotifications
-			cfg.Options.DisableNotifications = disabled
-			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
+			cfg.Options.NotificationStyle = msg.Style
+			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.notification_style", msg.Style); err != nil {
 				cmds = append(cmds, util.ReportError(err))
 			} else {
-				status := "enabled"
-				if disabled {
-					status = "disabled"
-				}
-				cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
+				cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications set to: "+msg.Style)))
 			}
+			// Reinitialize notification backend with new style.
+			m.notifyBackend = selectNotificationBackend(m.caps, cfg)
 		}
-		m.dialog.CloseDialog(dialog.CommandsID)
+		m.dialog.CloseDialog(dialog.NotificationsID)
 	case dialog.ActionNewSession:
 		if m.isAgentBusy() {
 			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
@@ -3368,6 +3361,10 @@ func (m *UI) openDialog(id string) tea.Cmd {
 		if cmd := m.openReasoningDialog(); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+	case dialog.NotificationsID:
+		if cmd := m.openNotificationsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case dialog.FilePickerID:
 		if cmd := m.openFilesDialog(); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -3457,6 +3454,18 @@ func (m *UI) openReasoningDialog() tea.Cmd {
 	return nil
 }
 
+// openNotificationsDialog opens the notification style picker dialog.
+func (m *UI) openNotificationsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.NotificationsID) {
+		m.dialog.BringToFront(dialog.NotificationsID)
+		return nil
+	}
+
+	notificationsDialog := dialog.NewNotifications(m.com)
+	m.dialog.OpenDialog(notificationsDialog)
+	return nil
+}
+
 // openSessionsDialog opens the sessions dialog. If the dialog is already open,
 // it brings it to the front. Otherwise, it will list all the sessions and open
 // the dialog.

internal/ui/notification/osc.go 🔗

@@ -13,9 +13,6 @@ import (
 
 const osc99QueryID = "crush-osc99-query"
 
-// notifySeq is a counter for generating unique notification IDs.
-var notifySeq uint64
-
 // DetectOSC99Support parses an OSC response sequence and returns true if it
 // indicates OSC 99 notification support. This function should be called from
 // the capabilities detection layer to determine terminal support.
@@ -84,19 +81,17 @@ func OSC99QuerySequence() string {
 type OSCBackend struct {
 	icon       []byte
 	supports99 bool
+	notifySeq  uint64
 }
 
 // NewOSCBackend creates a new OSC notification backend with automatic protocol
 // detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
 // OSC 777.
-func NewOSCBackend(icon any, supports99 bool) *OSCBackend {
-	b := &OSCBackend{
+func NewOSCBackend(icon []byte, supports99 bool) *OSCBackend {
+	return &OSCBackend{
+		icon:       icon,
 		supports99: supports99,
 	}
-	if data, ok := icon.([]byte); ok && len(data) > 0 {
-		b.icon = data
-	}
-	return b
 }
 
 // Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
@@ -112,8 +107,8 @@ func (b *OSCBackend) sendOSC99(n Notification) tea.Cmd {
 	slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
 
 	var sb strings.Builder
-	notifySeq++
-	id := fmt.Sprintf("crush-%d", notifySeq)
+	b.notifySeq++
+	id := fmt.Sprintf("crush-%d", b.notifySeq)
 
 	appName := "Crush"
 	notificationType := "crush-notification"