on main help sign in
feat(notifications): migrate disabled and add picker
commit3fa7d3f270aef1d1cdd16bd20f626d72cca4db6d authorKieran Klukas <kieran@dunkirk.sh> dateTue 26 May 2026, 14:45 (5 months ago) parente7f5c68
8 files changed+428−44
internal/config/config.go+1−1 internal/config/load.go+69 internal/config/store.go+3 internal/ui/dialog/actions.go+11−8 internal/ui/dialog/commands.go+3−8 internal/ui/dialog/notifications.go+310 internal/ui/model/ui.go+25−16 internal/ui/notification/osc.go+6−11
internal/config/config.go+1−1
@@ -278,7 +278,7 @@ type Options struct {
278278 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"`
279279 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
280280 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"`
282282 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"`
283283 DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
284284}
internal/config/load.go+69
@@ -26,6 +26,8 @@ import (
2626 "github.com/charmbracelet/crush/internal/home"
2727 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
2828 "github.com/qjebbs/go-jsons"
29+ "github.com/tidwall/gjson"
30+ "github.com/tidwall/sjson"
2931)
3032
3133const defaultCatwalkURL = "https://catwalk.charm.land"
@@ -33,6 +35,9 @@ const defaultCatwalkURL = "https://catwalk.charm.land"
3335// Load loads the configuration from the default paths and returns a
3436// ConfigStore that owns both the pure-data Config and all runtime state.
3537func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
38+ // Migrate deprecated disable_notifications before loading config.
39+ migrateDisableNotifications()
40+
3641 configPaths := lookupConfigs(workingDir)
3742
3843 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
@@ -479,6 +484,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
479484 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
480485 }
481486 }
487+
482488 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
483489}
484490
@@ -811,6 +817,69 @@ func hasAWSCredentials(env env.Env) bool {
811817 return false
812818}
813819
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+
814883// GlobalConfig returns the global configuration file path for the application.
815884func GlobalConfig() string {
816885 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 {
649649 s.reloadInProgress = false
650650 }()
651651
652+ // Migrate deprecated disable_notifications before reloading config.
653+ migrateDisableNotifications()
654+
652655 configPaths := lookupConfigs(s.workingDir)
653656 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
654657 if err != nil {
internal/ui/dialog/actions.go+11−8
@@ -45,14 +45,17 @@ type ActionSelectModel struct {
4545
4646// Messages for commands
4747type (
48− ActionNewSession struct{}
49− ActionToggleHelp struct{}
50− ActionToggleCompactMode struct{}
51− ActionToggleThinking struct{}
52− ActionTogglePills struct{}
53− ActionExternalEditor struct{}
54− ActionToggleYoloMode struct{}
55− ActionToggleNotifications struct{}
48+ ActionNewSession struct{}
49+ ActionToggleHelp struct{}
50+ ActionToggleCompactMode struct{}
51+ ActionToggleThinking struct{}
52+ ActionTogglePills struct{}
53+ ActionExternalEditor struct{}
54+ ActionToggleYoloMode struct{}
55+ ActionToggleNotifications struct{}
56+ ActionSelectNotificationStyle struct {
57+ Style string
58+ }
5659 ActionToggleTransparentBackground struct{}
5760 ActionInitializeProject struct{}
5861 ActionSummarize struct {
internal/ui/dialog/commands.go+3−8
@@ -511,14 +511,9 @@ func (c *Commands) defaultCommands() []*CommandItem {
511511 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
512512 }
513513
514− // Add a command for toggling notifications.
515− cfg = c.com.Config()
516− notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
517− notificationLabel := "Disable Notifications"
518− if notificationsDisabled {
519− notificationLabel = "Enable Notifications"
520− }
521− commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
514+ // Add a command for selecting notification style via picker dialog.
515+ notificationLabel := "Notification Style"
516+ commands = append(commands, NewCommandItem(c.com.Styles, "select_notifications", notificationLabel, "", ActionOpenDialog{DialogID: NotificationsID}))
522517
523518 commands = append(
524519 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() {
498498}
499499
500500// shouldSendNotification returns true if notifications should be sent based on
501−// current state. Focus reporting must be supported, window must not focused,
502−// and notifications must not be disabled in config.
501+// current state. Focus reporting must be supported, window must not be
502+// focused, and notifications must not be disabled in config.
503503func (m *UI) shouldSendNotification() bool {
504504 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.
509505 if cfg != nil && cfg.Options != nil && cfg.Options.NotificationStyle == "disabled" {
510506 return false
511507 }
@@ -1419,22 +1415,19 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14191415 m.com.Workspace.PermissionSetSkipRequests(yolo)
14201416 m.setEditorPrompt(yolo)
14211417 m.dialog.CloseDialog(dialog.CommandsID)
1422− case dialog.ActionToggleNotifications:
1418+ case dialog.ActionSelectNotificationStyle:
14231419 cfg := m.com.Config()
14241420 if cfg != nil && cfg.Options != nil {
1425− disabled := !cfg.Options.DisableNotifications
1426− cfg.Options.DisableNotifications = disabled
1427− if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1421+ cfg.Options.NotificationStyle = msg.Style
1422+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.notification_style", msg.Style); err != nil {
14281423 cmds = append(cmds, util.ReportError(err))
14291424 } 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)))
14351426 }
1427+ // Reinitialize notification backend with new style.
1428+ m.notifyBackend = selectNotificationBackend(m.caps, cfg)
14361429 }
1437− m.dialog.CloseDialog(dialog.CommandsID)
1430+ m.dialog.CloseDialog(dialog.NotificationsID)
14381431 case dialog.ActionNewSession:
14391432 if m.isAgentBusy() {
14401433 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 {
33683361 if cmd := m.openReasoningDialog(); cmd != nil {
33693362 cmds = append(cmds, cmd)
33703363 }
3364+ case dialog.NotificationsID:
3365+ if cmd := m.openNotificationsDialog(); cmd != nil {
3366+ cmds = append(cmds, cmd)
3367+ }
33713368 case dialog.FilePickerID:
33723369 if cmd := m.openFilesDialog(); cmd != nil {
33733370 cmds = append(cmds, cmd)
@@ -3457,6 +3454,18 @@ func (m *UI) openReasoningDialog() tea.Cmd {
34573454 return nil
34583455}
34593456
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+
34603469// openSessionsDialog opens the sessions dialog. If the dialog is already open,
34613470// it brings it to the front. Otherwise, it will list all the sessions and open
34623471// the dialog.
internal/ui/notification/osc.go+6−11
@@ -13,9 +13,6 @@ import (
1313
1414const osc99QueryID = "crush-osc99-query"
1515
16−// notifySeq is a counter for generating unique notification IDs.
17−var notifySeq uint64
18−
1916// DetectOSC99Support parses an OSC response sequence and returns true if it
2017// indicates OSC 99 notification support. This function should be called from
2118// the capabilities detection layer to determine terminal support.
@@ -84,19 +81,17 @@ func OSC99QuerySequence() string {
8481type OSCBackend struct {
8582 icon []byte
8683 supports99 bool
84+ notifySeq uint64
8785}
8886
8987// NewOSCBackend creates a new OSC notification backend with automatic protocol
9088// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
9189// OSC 777.
92−func NewOSCBackend(icon any, supports99 bool) *OSCBackend {
93− b := &OSCBackend{
90+func NewOSCBackend(icon []byte, supports99 bool) *OSCBackend {
91+ return &OSCBackend{
92+ icon: icon,
9493 supports99: supports99,
9594 }
96− if data, ok := icon.([]byte); ok && len(data) > 0 {
97− b.icon = data
98− }
99− return b
10095}
10196
10297// 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 {
112107 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
113108
114109 var sb strings.Builder
115− notifySeq++
116− id := fmt.Sprintf("crush-%d", notifySeq)
110+ b.notifySeq++
111+ id := fmt.Sprintf("crush-%d", b.notifySeq)
117112
118113 appName := "Crush"
119114 notificationType := "crush-notification"