on main help sign in
feat(notifications): migrate disabled and add picker
3fa7d3fKieran Klukas
internal/config/config.go+1−1
@@ -278,7 +278,7 @@ type Options struct {
278 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"`
278 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"`
279 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
279 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
280 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
280 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
281− DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
281+ DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Deprecated: Use notification_style instead. Disable desktop notifications,default=false"`
282 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"`
282 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"`
283 DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
283 DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
284}
284}
internal/config/load.go+69
@@ -26,6 +26,8 @@ import (
26 "github.com/charmbracelet/crush/internal/home"
26 "github.com/charmbracelet/crush/internal/home"
27 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
27 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
28 "github.com/qjebbs/go-jsons"
28 "github.com/qjebbs/go-jsons"
29+ "github.com/tidwall/gjson"
30+ "github.com/tidwall/sjson"
29)
31)
30
32
31const defaultCatwalkURL = "https://catwalk.charm.land"
33const defaultCatwalkURL = "https://catwalk.charm.land"
@@ -33,6 +35,9 @@ const defaultCatwalkURL = "https://catwalk.charm.land"
33// Load loads the configuration from the default paths and returns a
35// Load loads the configuration from the default paths and returns a
34// ConfigStore that owns both the pure-data Config and all runtime state.
36// ConfigStore that owns both the pure-data Config and all runtime state.
35func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
37func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
38+ // Migrate deprecated disable_notifications before loading config.
39+ migrateDisableNotifications()
40+
36 configPaths := lookupConfigs(workingDir)
41 configPaths := lookupConfigs(workingDir)
37
42
38 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
43 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
@@ -479,6 +484,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
479 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
484 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
480 }
485 }
481 }
486 }
487+
482 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
488 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
483}
489}
484
490
@@ -811,6 +817,69 @@ func hasAWSCredentials(env env.Env) bool {
811 return false
817 return false
812}
818}
813
819
820+// migrateDisableNotifications migrates the deprecated disable_notifications
821+// field to notification_style. It checks both the user config (~/.config) and
822+// data config (~/.local) files. If disable_notifications is true, it sets
823+// notification_style to "disabled" in the data file. Regardless of value, it
824+// removes disable_notifications from any file that contains it.
825+func migrateDisableNotifications() {
826+ globalConfig := GlobalConfig()
827+ dataConfig := GlobalConfigData()
828+
829+ var wasDisabled bool
830+ filesToClean := []string{}
831+
832+ for _, path := range []string{globalConfig, dataConfig} {
833+ data, err := os.ReadFile(path)
834+ if err != nil {
835+ continue
836+ }
837+ if gjson.Get(string(data), "options.disable_notifications").Exists() {
838+ filesToClean = append(filesToClean, path)
839+ if gjson.Get(string(data), "options.disable_notifications").Bool() {
840+ wasDisabled = true
841+ }
842+ }
843+ }
844+
845+ if len(filesToClean) == 0 {
846+ return
847+ }
848+
849+ // If notifications were disabled, persist the equivalent notification_style.
850+ if wasDisabled {
851+ data, err := os.ReadFile(dataConfig)
852+ if err == nil {
853+ if !gjson.Get(string(data), "options.notification_style").Exists() {
854+ updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
855+ if err == nil {
856+ if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
857+ slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
858+ } else {
859+ slog.Info("Migrated disable_notifications: true to notification_style: disabled")
860+ }
861+ }
862+ }
863+ }
864+ }
865+
866+ // Remove disable_notifications from all files that contain it.
867+ for _, path := range filesToClean {
868+ data, err := os.ReadFile(path)
869+ if err != nil {
870+ continue
871+ }
872+ updated, err := sjson.Delete(string(data), "options.disable_notifications")
873+ if err != nil {
874+ slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
875+ continue
876+ }
877+ if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
878+ slog.Warn("Failed to write migrated config", "path", path, "error", err)
879+ }
880+ }
881+}
882+
814// GlobalConfig returns the global configuration file path for the application.
883// GlobalConfig returns the global configuration file path for the application.
815func GlobalConfig() string {
884func GlobalConfig() string {
816 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
885 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
internal/config/store.go+3
@@ -649,6 +649,9 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
649 s.reloadInProgress = false
649 s.reloadInProgress = false
650 }()
650 }()
651
651
652+ // Migrate deprecated disable_notifications before reloading config.
653+ migrateDisableNotifications()
654+
652 configPaths := lookupConfigs(s.workingDir)
655 configPaths := lookupConfigs(s.workingDir)
653 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
656 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
654 if err != nil {
657 if err != nil {
internal/ui/dialog/actions.go+11−8
@@ -45,14 +45,17 @@ type ActionSelectModel struct {
45
45
46// Messages for commands
46// Messages for commands
47type (
47type (
48− ActionNewSession struct{}
48+ ActionNewSession struct{}
49− ActionToggleHelp struct{}
49+ ActionToggleHelp struct{}
50− ActionToggleCompactMode struct{}
50+ ActionToggleCompactMode struct{}
51− ActionToggleThinking struct{}
51+ ActionToggleThinking struct{}
52− ActionTogglePills struct{}
52+ ActionTogglePills struct{}
53− ActionExternalEditor struct{}
53+ ActionExternalEditor struct{}
54− ActionToggleYoloMode struct{}
54+ ActionToggleYoloMode struct{}
55− ActionToggleNotifications struct{}
55+ ActionToggleNotifications struct{}
56+ ActionSelectNotificationStyle struct {
57+ Style string
58+ }
56 ActionToggleTransparentBackground struct{}
59 ActionToggleTransparentBackground struct{}
57 ActionInitializeProject struct{}
60 ActionInitializeProject struct{}
58 ActionSummarize struct {
61 ActionSummarize struct {
internal/ui/dialog/commands.go+3−8
@@ -511,14 +511,9 @@ func (c *Commands) defaultCommands() []*CommandItem {
511 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
511 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
512 }
512 }
513
513
514− // Add a command for toggling notifications.
514+ // Add a command for selecting notification style via picker dialog.
515− cfg = c.com.Config()
516− notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
517− notificationLabel := "Disable Notifications"
515+ notificationLabel := "Notification Style"
518− if notificationsDisabled {
519− notificationLabel = "Enable Notifications"
520− }
521− commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
516+ commands = append(commands, NewCommandItem(c.com.Styles, "select_notifications", notificationLabel, "", ActionOpenDialog{DialogID: NotificationsID}))
522
517
523 commands = append(
518 commands = append(
524 commands,
519 commands,
internal/ui/dialog/notifications.gonew file+310
@@ -0,0 +1,310 @@
1+package dialog
2+
3+import (
4+ "charm.land/bubbles/v2/help"
5+ "charm.land/bubbles/v2/key"
6+ "charm.land/bubbles/v2/textinput"
7+ tea "charm.land/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/charmbracelet/crush/internal/ui/list"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "github.com/sahilm/fuzzy"
13+)
14+
15+const (
16+ // NotificationsID is the identifier for the notification style picker dialog.
17+ NotificationsID = "notifications"
18+ notificationsDialogMaxWidth = 50
19+ notificationsDialogMaxHeight = 12
20+)
21+
22+// NotificationStyle represents a notification backend option.
23+type NotificationStyle struct {
24+ ID string
25+ Title string
26+ Description string
27+}
28+
29+// AllNotificationStyles lists all available notification styles in order.
30+var AllNotificationStyles = []NotificationStyle{
31+ {ID: "auto", Title: "Auto", Description: "Automatically detect the best backend"},
32+ {ID: "native", Title: "Native", Description: "Use system notifications (macOS/Linux/Windows)"},
33+ {ID: "osc", Title: "OSC", Description: "Use terminal OSC escape sequences"},
34+ {ID: "bell", Title: "Bell", Description: "Use terminal bell character"},
35+ {ID: "disabled", Title: "Disabled", Description: "Turn off notifications"},
36+}
37+
38+// Notifications represents a dialog for selecting notification style.
39+type Notifications struct {
40+ com *common.Common
41+ help help.Model
42+ list *list.FilterableList
43+ input textinput.Model
44+
45+ keyMap struct {
46+ Select key.Binding
47+ Next key.Binding
48+ Previous key.Binding
49+ UpDown key.Binding
50+ Close key.Binding
51+ }
52+}
53+
54+// NotificationItem represents a notification style list item.
55+type NotificationItem struct {
56+ *list.Versioned
57+ style NotificationStyle
58+ isCurrent bool
59+ t *styles.Styles
60+ m fuzzy.Match
61+ cache map[int]string
62+ focused bool
63+}
64+
65+// Finished implements list.Item. Notification items are render-stable
66+// outside of explicit SetFocused / SetMatch.
67+func (n *NotificationItem) Finished() bool {
68+ return true
69+}
70+
71+var (
72+ _ Dialog = (*Notifications)(nil)
73+ _ ListItem = (*NotificationItem)(nil)
74+)
75+
76+// NewNotifications creates a new notification style picker dialog.
77+func NewNotifications(com *common.Common) *Notifications {
78+ n := &Notifications{com: com}
79+
80+ h := help.New()
81+ h.Styles = com.Styles.DialogHelpStyles()
82+ n.help = h
83+
84+ n.list = list.NewFilterableList()
85+ n.list.Focus()
86+
87+ n.input = textinput.New()
88+ n.input.SetVirtualCursor(false)
89+ n.input.Placeholder = "Type to filter"
90+ n.input.SetStyles(com.Styles.TextInput)
91+ n.input.Focus()
92+
93+ n.keyMap.Select = key.NewBinding(
94+ key.WithKeys("enter", "ctrl+y"),
95+ key.WithHelp("enter", "confirm"),
96+ )
97+ n.keyMap.Next = key.NewBinding(
98+ key.WithKeys("down", "ctrl+n"),
99+ key.WithHelp("↓", "next item"),
100+ )
101+ n.keyMap.Previous = key.NewBinding(
102+ key.WithKeys("up", "ctrl+p"),
103+ key.WithHelp("↑", "previous item"),
104+ )
105+ n.keyMap.UpDown = key.NewBinding(
106+ key.WithKeys("up", "down"),
107+ key.WithHelp("↑/↓", "choose"),
108+ )
109+ n.keyMap.Close = CloseKey
110+
111+ n.setItems()
112+ return n
113+}
114+
115+// ID implements Dialog.
116+func (n *Notifications) ID() string {
117+ return NotificationsID
118+}
119+
120+// HandleMsg implements [Dialog].
121+func (n *Notifications) HandleMsg(msg tea.Msg) Action {
122+ switch msg := msg.(type) {
123+ case tea.KeyPressMsg:
124+ switch {
125+ case key.Matches(msg, n.keyMap.Close):
126+ return ActionClose{}
127+ case key.Matches(msg, n.keyMap.Previous):
128+ n.list.Focus()
129+ if n.list.IsSelectedFirst() {
130+ n.list.SelectLast()
131+ n.list.ScrollToBottom()
132+ break
133+ }
134+ n.list.SelectPrev()
135+ n.list.ScrollToSelected()
136+ case key.Matches(msg, n.keyMap.Next):
137+ n.list.Focus()
138+ if n.list.IsSelectedLast() {
139+ n.list.SelectFirst()
140+ n.list.ScrollToTop()
141+ break
142+ }
143+ n.list.SelectNext()
144+ n.list.ScrollToSelected()
145+ case key.Matches(msg, n.keyMap.Select):
146+ selectedItem := n.list.SelectedItem()
147+ if selectedItem == nil {
148+ break
149+ }
150+ notifItem, ok := selectedItem.(*NotificationItem)
151+ if !ok {
152+ break
153+ }
154+ return ActionSelectNotificationStyle{Style: notifItem.style.ID}
155+ default:
156+ var cmd tea.Cmd
157+ n.input, cmd = n.input.Update(msg)
158+ value := n.input.Value()
159+ n.list.SetFilter(value)
160+ n.list.ScrollToTop()
161+ n.list.SetSelected(0)
162+ return ActionCmd{cmd}
163+ }
164+ }
165+ return nil
166+}
167+
168+// Cursor returns the cursor position relative to the dialog.
169+func (n *Notifications) Cursor() *tea.Cursor {
170+ return InputCursor(n.com.Styles, n.input.Cursor())
171+}
172+
173+// Draw implements [Dialog].
174+func (n *Notifications) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
175+ t := n.com.Styles
176+ width := max(0, min(notificationsDialogMaxWidth, area.Dx()))
177+ height := max(0, min(notificationsDialogMaxHeight, area.Dy()))
178+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
179+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
180+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
181+ t.Dialog.HelpView.GetVerticalFrameSize() +
182+ t.Dialog.View.GetVerticalFrameSize()
183+
184+ n.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
185+ n.list.SetSize(innerWidth, height-heightOffset)
186+ n.help.SetWidth(innerWidth)
187+
188+ rc := NewRenderContext(t, width)
189+ rc.Title = "Notification Style"
190+ inputView := t.Dialog.InputPrompt.Render(n.input.View())
191+ rc.AddPart(inputView)
192+
193+ visibleCount := len(n.list.FilteredItems())
194+ if n.list.Height() >= visibleCount {
195+ n.list.ScrollToTop()
196+ } else {
197+ n.list.ScrollToSelected()
198+ }
199+
200+ listView := t.Dialog.List.Height(n.list.Height()).Render(n.list.Render())
201+ rc.AddPart(listView)
202+ rc.Help = n.help.View(n)
203+
204+ view := rc.Render()
205+
206+ cur := n.Cursor()
207+ DrawCenterCursor(scr, area, view, cur)
208+ return cur
209+}
210+
211+// ShortHelp implements [help.KeyMap].
212+func (n *Notifications) ShortHelp() []key.Binding {
213+ return []key.Binding{
214+ n.keyMap.UpDown,
215+ n.keyMap.Select,
216+ n.keyMap.Close,
217+ }
218+}
219+
220+// FullHelp implements [help.KeyMap].
221+func (n *Notifications) FullHelp() [][]key.Binding {
222+ m := [][]key.Binding{}
223+ slice := []key.Binding{
224+ n.keyMap.Select,
225+ n.keyMap.Next,
226+ n.keyMap.Previous,
227+ n.keyMap.Close,
228+ }
229+ for i := 0; i < len(slice); i += 4 {
230+ end := min(i+4, len(slice))
231+ m = append(m, slice[i:end])
232+ }
233+ return m
234+}
235+
236+func (n *Notifications) setItems() {
237+ cfg := n.com.Config()
238+ currentStyle := "auto"
239+ if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle != "" {
240+ currentStyle = cfg.Options.NotificationStyle
241+ }
242+
243+ items := make([]list.FilterableItem, 0, len(AllNotificationStyles))
244+ selectedIndex := 0
245+ for i, style := range AllNotificationStyles {
246+ item := &NotificationItem{
247+ Versioned: list.NewVersioned(),
248+ style: style,
249+ isCurrent: style.ID == currentStyle,
250+ t: n.com.Styles,
251+ }
252+ items = append(items, item)
253+ if style.ID == currentStyle {
254+ selectedIndex = i
255+ }
256+ }
257+
258+ n.list.SetItems(items...)
259+ n.list.SetSelected(selectedIndex)
260+ n.list.ScrollToSelected()
261+}
262+
263+// Filter returns the filter value for the notification item.
264+func (n *NotificationItem) Filter() string {
265+ return n.style.Title
266+}
267+
268+// ID returns the unique identifier for the notification style.
269+func (n *NotificationItem) ID() string {
270+ return n.style.ID
271+}
272+
273+// SetFocused sets the focus state of the notification item.
274+func (n *NotificationItem) SetFocused(focused bool) {
275+ if n.focused == focused {
276+ return
277+ }
278+ n.cache = nil
279+ n.focused = focused
280+ if n.Versioned != nil {
281+ n.Bump()
282+ }
283+}
284+
285+// SetMatch sets the fuzzy match for the notification item.
286+func (n *NotificationItem) SetMatch(m fuzzy.Match) {
287+ if sameFuzzyMatch(n.m, m) {
288+ return
289+ }
290+ n.cache = nil
291+ n.m = m
292+ if n.Versioned != nil {
293+ n.Bump()
294+ }
295+}
296+
297+// Render returns the string representation of the notification item.
298+func (n *NotificationItem) Render(width int) string {
299+ info := ""
300+ if n.isCurrent {
301+ info = "current"
302+ }
303+ st := ListItemStyles{
304+ ItemBlurred: n.t.Dialog.NormalItem,
305+ ItemFocused: n.t.Dialog.SelectedItem,
306+ InfoTextBlurred: n.t.Dialog.ListItem.InfoBlurred,
307+ InfoTextFocused: n.t.Dialog.ListItem.InfoFocused,
308+ }
309+ return renderItem(st, n.style.Title, info, n.focused, width, n.cache, &n.m)
310+}
internal/ui/model/ui.go+25−16
@@ -498,14 +498,10 @@ func (m *UI) updateNotificationBackend() {
498}
498}
499
499
500// shouldSendNotification returns true if notifications should be sent based on
500// shouldSendNotification returns true if notifications should be sent based on
501−// current state. Focus reporting must be supported, window must not focused,
501+// current state. Focus reporting must be supported, window must not be
502−// and notifications must not be disabled in config.
502+// focused, and notifications must not be disabled in config.
503func (m *UI) shouldSendNotification() bool {
503func (m *UI) shouldSendNotification() bool {
504 cfg := m.com.Config()
504 cfg := m.com.Config()
505− if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
506− return false
507− }
508− // If the user explicitly set style to "disabled", skip sending.
509 if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
505 if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
510 return false
506 return false
511 }
507 }
@@ -1419,22 +1415,19 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1419 m.com.Workspace.PermissionSetSkipRequests(yolo)
1415 m.com.Workspace.PermissionSetSkipRequests(yolo)
1420 m.setEditorPrompt(yolo)
1416 m.setEditorPrompt(yolo)
1421 m.dialog.CloseDialog(dialog.CommandsID)
1417 m.dialog.CloseDialog(dialog.CommandsID)
1422− case dialog.ActionToggleNotifications:
1418+ case dialog.ActionSelectNotificationStyle:
1423 cfg := m.com.Config()
1419 cfg := m.com.Config()
1424 if cfg != nil && cfg.Options != nil {
1420 if cfg != nil && cfg.Options != nil {
1425− disabled := !cfg.Options.DisableNotifications
1421+ cfg.Options.NotificationStyle = msg.Style
1426− cfg.Options.DisableNotifications = disabled
1427− if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1422+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.notification_style", msg.Style); err != nil {
1428 cmds = append(cmds, util.ReportError(err))
1423 cmds = append(cmds, util.ReportError(err))
1429 } else {
1424 } else {
1430− status := "enabled"
1431− if disabled {
1432− status = "disabled"
1433− }
1434− cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1425+ cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications set to: "+msg.Style)))
1435 }
1426 }
1427+ // Reinitialize notification backend with new style.
1428+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
1436 }
1429 }
1437− m.dialog.CloseDialog(dialog.CommandsID)
1430+ m.dialog.CloseDialog(dialog.NotificationsID)
1438 case dialog.ActionNewSession:
1431 case dialog.ActionNewSession:
1439 if m.isAgentBusy() {
1432 if m.isAgentBusy() {
1440 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1433 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 {
3368 if cmd := m.openReasoningDialog(); cmd != nil {
3361 if cmd := m.openReasoningDialog(); cmd != nil {
3369 cmds = append(cmds, cmd)
3362 cmds = append(cmds, cmd)
3370 }
3363 }
3364+ case dialog.NotificationsID:
3365+ if cmd := m.openNotificationsDialog(); cmd != nil {
3366+ cmds = append(cmds, cmd)
3367+ }
3371 case dialog.FilePickerID:
3368 case dialog.FilePickerID:
3372 if cmd := m.openFilesDialog(); cmd != nil {
3369 if cmd := m.openFilesDialog(); cmd != nil {
3373 cmds = append(cmds, cmd)
3370 cmds = append(cmds, cmd)
@@ -3457,6 +3454,18 @@ func (m *UI) openReasoningDialog() tea.Cmd {
3457 return nil
3454 return nil
3458}
3455}
3459
3456
3457+// openNotificationsDialog opens the notification style picker dialog.
3458+func (m *UI) openNotificationsDialog() tea.Cmd {
3459+ if m.dialog.ContainsDialog(dialog.NotificationsID) {
3460+ m.dialog.BringToFront(dialog.NotificationsID)
3461+ return nil
3462+ }
3463+
3464+ notificationsDialog := dialog.NewNotifications(m.com)
3465+ m.dialog.OpenDialog(notificationsDialog)
3466+ return nil
3467+}
3468+
3460// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3469// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3461// it brings it to the front. Otherwise, it will list all the sessions and open
3470// it brings it to the front. Otherwise, it will list all the sessions and open
3462// the dialog.
3471// the dialog.
internal/ui/notification/osc.go+6−11
@@ -13,9 +13,6 @@ import (
13
13
14const osc99QueryID = "crush-osc99-query"
14const osc99QueryID = "crush-osc99-query"
15
15
16−// notifySeq is a counter for generating unique notification IDs.
17−var notifySeq uint64
18−
19// DetectOSC99Support parses an OSC response sequence and returns true if it
16// DetectOSC99Support parses an OSC response sequence and returns true if it
20// indicates OSC 99 notification support. This function should be called from
17// indicates OSC 99 notification support. This function should be called from
21// the capabilities detection layer to determine terminal support.
18// the capabilities detection layer to determine terminal support.
@@ -84,19 +81,17 @@ func OSC99QuerySequence() string {
84type OSCBackend struct {
81type OSCBackend struct {
85 icon []byte
82 icon []byte
86 supports99 bool
83 supports99 bool
84+ notifySeq uint64
87}
85}
88
86
89// NewOSCBackend creates a new OSC notification backend with automatic protocol
87// NewOSCBackend creates a new OSC notification backend with automatic protocol
90// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
88// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
91// OSC 777.
89// OSC 777.
92−func NewOSCBackend(icon any, supports99 bool) *OSCBackend {
90+func NewOSCBackend(icon []byte, supports99 bool) *OSCBackend {
93− b := &OSCBackend{
91+ return &OSCBackend{
92+ icon: icon,
94 supports99: supports99,
93 supports99: supports99,
95 }
94 }
96− if data, ok := icon.([]byte); ok && len(data) > 0 {
97− b.icon = data
98− }
99− return b
100}
95}
101
96
102// Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
97// 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 {
112 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
107 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
113
108
114 var sb strings.Builder
109 var sb strings.Builder
115− notifySeq++
110+ b.notifySeq++
116− id := fmt.Sprintf("crush-%d", notifySeq)
111+ id := fmt.Sprintf("crush-%d", b.notifySeq)
117
112
118 appName := "Crush"
113 appName := "Crush"
119 notificationType := "crush-notification"
114 notificationType := "crush-notification"