From 3fa7d3f270aef1d1cdd16bd20f626d72cca4db6d Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 26 May 2026 14:45:45 -0400 Subject: [PATCH] feat(notifications): migrate disabled and add picker --- 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(-) create mode 100644 internal/ui/dialog/notifications.go diff --git a/internal/config/config.go b/internal/config/config.go index 498385816127468f686913ee5d31f043cdb27159..fc3bab330231e22606109263d923073f70a00f41 100644 --- a/internal/config/config.go +++ b/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"` } diff --git a/internal/config/load.go b/internal/config/load.go index 08fa3cf2c7b24204146d9790c96e3936761933df..9f43dbebdd145c6bb2ae97c52d57ed0dad9b6940 100644 --- a/internal/config/load.go +++ b/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 != "" { diff --git a/internal/config/store.go b/internal/config/store.go index 3e55509b7132e38805830818fca1e5265b7e03f9..81b19a5926dcd80feb3ee3f24974596aa527aba4 100644 --- a/internal/config/store.go +++ b/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 { diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 09a3e5b5a0eb267727e67cdf06199e26ef63337c..0e96b06ad8005e54956dd6db49efee0043b2454d 100644 --- a/internal/ui/dialog/actions.go +++ b/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 { diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index b4c2606bfac3089adda8dd39a7161a7caa0e6ab7..6e17db70d04e30cd263d4d0ea0fd896e995e2a06 100644 --- a/internal/ui/dialog/commands.go +++ b/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, diff --git a/internal/ui/dialog/notifications.go b/internal/ui/dialog/notifications.go new file mode 100644 index 0000000000000000000000000000000000000000..67d2db19ace08ca4ac85d223f7dbb3d2cbcff7c5 --- /dev/null +++ b/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) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 33c2e7e028c247f4db8b1faca3c3d7a40c6c713c..a0883082efc653e5472c5cfaf79a8c387ef9c29e 100644 --- a/internal/ui/model/ui.go +++ b/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. diff --git a/internal/ui/notification/osc.go b/internal/ui/notification/osc.go index 973753becd8dccf11a347d08d14fa00147ee7a9b..d4b2778ffeefd92276b53223a6c80c9cc32d783c 100644 --- a/internal/ui/notification/osc.go +++ b/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"