Detailed changes
@@ -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"`
}
@@ -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 != "" {
@@ -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 {
@@ -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 {
@@ -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,
@@ -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)
+}
@@ -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.
@@ -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"