Skip to content

Commit 01e4dc0

Browse files
committed
Fix notifications more
1 parent 804525a commit 01e4dc0

3 files changed

Lines changed: 289 additions & 4 deletions

File tree

cmd/claude-code-ntfy/app.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,23 @@ func NewDependencies(cfg *config.Config) (*Dependencies, error) {
3131
// Create notification components
3232
baseNotifier := notification.NewNtfyClient(cfg.NtfyServer, cfg.NtfyTopic)
3333

34+
// Create output monitor with stdout notifier temporarily
35+
outputMonitor := monitor.NewOutputMonitor(cfg, notification.NewStdoutNotifier())
36+
37+
// Wrap with context notifier
38+
contextNotifier := notification.NewContextNotifier(baseNotifier, func() string {
39+
return outputMonitor.GetTerminalTitle()
40+
})
41+
3442
// Wrap with backstop notifier if configured
35-
var finalNotifier notification.Notifier = baseNotifier
43+
var finalNotifier notification.Notifier = contextNotifier
3644
if cfg.BackstopTimeout > 0 {
37-
finalNotifier = notification.NewBackstopNotifier(baseNotifier, cfg.BackstopTimeout)
45+
finalNotifier = notification.NewBackstopNotifier(contextNotifier, cfg.BackstopTimeout)
3846
}
3947
deps.Notifier = finalNotifier
4048

41-
// Create output monitor with the notifier
42-
outputMonitor := monitor.NewOutputMonitor(cfg, deps.Notifier)
49+
// Update the output monitor with the final notifier
50+
outputMonitor.SetNotifier(deps.Notifier)
4351
deps.OutputMonitor = outputMonitor
4452

4553
// Create input handler that disables backstop timer
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package notification
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// ContextNotifier wraps another notifier and adds context to notifications
10+
type ContextNotifier struct {
11+
underlying Notifier
12+
cwdBasename string
13+
terminalInfo func() string
14+
}
15+
16+
// NewContextNotifier creates a new context notifier
17+
func NewContextNotifier(underlying Notifier, terminalInfo func() string) *ContextNotifier {
18+
// Get CWD basename
19+
cwd, err := os.Getwd()
20+
cwdBasename := ""
21+
if err == nil {
22+
cwdBasename = filepath.Base(cwd)
23+
}
24+
25+
return &ContextNotifier{
26+
underlying: underlying,
27+
cwdBasename: cwdBasename,
28+
terminalInfo: terminalInfo,
29+
}
30+
}
31+
32+
// Send implements the Notifier interface
33+
func (cn *ContextNotifier) Send(notification Notification) error {
34+
// Add context to title
35+
context := cn.cwdBasename
36+
37+
// Get terminal title if available
38+
if cn.terminalInfo != nil {
39+
if title := cn.terminalInfo(); title != "" {
40+
// Parse out the Claude icon and clean up the title
41+
cleanTitle := cn.cleanTerminalTitle(title)
42+
if cleanTitle != "" && cleanTitle != "claude" {
43+
if context != "" {
44+
context = context + " - " + cleanTitle
45+
} else {
46+
context = cleanTitle
47+
}
48+
}
49+
}
50+
}
51+
52+
// Replace notification title with context if available
53+
if context != "" {
54+
notification.Title = "Claude Code: " + context
55+
}
56+
57+
// Forward to underlying notifier
58+
return cn.underlying.Send(notification)
59+
}
60+
61+
// cleanTerminalTitle removes the Claude icon and cleans up the title
62+
func (cn *ContextNotifier) cleanTerminalTitle(title string) string {
63+
// Common Claude icon patterns (various Unicode representations)
64+
claudeIcons := []string{
65+
"✅", // Checkmark
66+
"🤖", // Robot emoji sometimes used
67+
"⚡", // Lightning bolt
68+
"✨", // Sparkles
69+
"🔮", // Crystal ball
70+
"💫", // Dizzy symbol
71+
"☁️", // Cloud
72+
"🌟", // Star
73+
}
74+
75+
// Remove any of the Claude icons from the beginning
76+
cleaned := title
77+
for _, icon := range claudeIcons {
78+
cleaned = strings.TrimPrefix(cleaned, icon)
79+
cleaned = strings.TrimPrefix(cleaned, icon+" ")
80+
}
81+
82+
// Remove garbage/control characters at the beginning
83+
// This handles cases like "✳ Test Coverage"
84+
runes := []rune(cleaned)
85+
startIdx := 0
86+
87+
// Skip any non-printable or garbage characters at the start
88+
for startIdx < len(runes) {
89+
r := runes[startIdx]
90+
// Keep ASCII letters, numbers, and common punctuation
91+
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
92+
(r >= '0' && r <= '9') || r == ' ' || r == '-' ||
93+
r == '_' || r == '.' || r == '/' || r == '[' || r == ']' {
94+
break
95+
}
96+
startIdx++
97+
}
98+
99+
if startIdx < len(runes) {
100+
cleaned = string(runes[startIdx:])
101+
} else {
102+
cleaned = ""
103+
}
104+
105+
return strings.TrimSpace(cleaned)
106+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package notification
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestContextNotifier(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
terminalTitle string
11+
notification Notification
12+
expectedTitle string
13+
}{
14+
{
15+
name: "adds cwd and terminal title",
16+
terminalTitle: "My Project",
17+
notification: Notification{
18+
Title: "Test Notification",
19+
Message: "Test message",
20+
},
21+
expectedTitle: "Claude Code: claude-code-ntfy - My Project",
22+
},
23+
{
24+
name: "removes claude icon from title",
25+
terminalTitle: "✅ claude",
26+
notification: Notification{
27+
Title: "Test Notification",
28+
Message: "Test message",
29+
},
30+
expectedTitle: "Claude Code: claude-code-ntfy",
31+
},
32+
{
33+
name: "handles empty terminal title",
34+
terminalTitle: "",
35+
notification: Notification{
36+
Title: "Test Notification",
37+
Message: "Test message",
38+
},
39+
expectedTitle: "Claude Code: claude-code-ntfy",
40+
},
41+
{
42+
name: "ignores plain claude title",
43+
terminalTitle: "claude",
44+
notification: Notification{
45+
Title: "Test Notification",
46+
Message: "Test message",
47+
},
48+
expectedTitle: "Claude Code: claude-code-ntfy",
49+
},
50+
{
51+
name: "removes various claude icons",
52+
terminalTitle: "🤖 My Terminal",
53+
notification: Notification{
54+
Title: "Test Notification",
55+
Message: "Test message",
56+
},
57+
expectedTitle: "Claude Code: claude-code-ntfy - My Terminal",
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
// Create a mock notifier to capture the sent notification
64+
var sentNotification Notification
65+
mockNotifier := &mockNotifier{
66+
sendFunc: func(n Notification) error {
67+
sentNotification = n
68+
return nil
69+
},
70+
}
71+
72+
// Create context notifier
73+
cn := NewContextNotifier(mockNotifier, func() string {
74+
return tt.terminalTitle
75+
})
76+
77+
// The CWD basename will be "claude-code-ntfy" in tests
78+
// (based on the current directory structure)
79+
80+
// Send notification
81+
err := cn.Send(tt.notification)
82+
if err != nil {
83+
t.Errorf("unexpected error: %v", err)
84+
}
85+
86+
// Check if title was modified correctly
87+
// The title should start with "Claude Code:"
88+
if len(sentNotification.Title) < 12 || sentNotification.Title[:12] != "Claude Code:" {
89+
t.Errorf("expected title to start with 'Claude Code:', got %q", sentNotification.Title)
90+
}
91+
})
92+
}
93+
}
94+
95+
func TestCleanTerminalTitle(t *testing.T) {
96+
cn := &ContextNotifier{}
97+
98+
tests := []struct {
99+
name string
100+
input string
101+
expected string
102+
}{
103+
{
104+
name: "removes checkmark icon",
105+
input: "✅ claude",
106+
expected: "claude",
107+
},
108+
{
109+
name: "removes robot emoji",
110+
input: "🤖 My Project",
111+
expected: "My Project",
112+
},
113+
{
114+
name: "removes lightning bolt",
115+
input: "⚡ Terminal",
116+
expected: "Terminal",
117+
},
118+
{
119+
name: "handles no icon",
120+
input: "Plain Title",
121+
expected: "Plain Title",
122+
},
123+
{
124+
name: "removes generic unicode at start",
125+
input: "🎯 Something",
126+
expected: "Something",
127+
},
128+
{
129+
name: "preserves unicode elsewhere",
130+
input: "Title with 🎯 emoji",
131+
expected: "Title with 🎯 emoji",
132+
},
133+
{
134+
name: "removes garbage characters",
135+
input: "✳ Test Coverage 1",
136+
expected: "Test Coverage 1",
137+
},
138+
{
139+
name: "handles mixed garbage and valid",
140+
input: "∂ß婃∆ My Project",
141+
expected: "My Project",
142+
},
143+
{
144+
name: "preserves brackets and numbers",
145+
input: "[1] Development Server",
146+
expected: "[1] Development Server",
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
result := cn.cleanTerminalTitle(tt.input)
153+
if result != tt.expected {
154+
t.Errorf("cleanTerminalTitle(%q) = %q, want %q", tt.input, result, tt.expected)
155+
}
156+
})
157+
}
158+
}
159+
160+
// mockNotifier for testing
161+
type mockNotifier struct {
162+
sendFunc func(Notification) error
163+
}
164+
165+
func (m *mockNotifier) Send(n Notification) error {
166+
if m.sendFunc != nil {
167+
return m.sendFunc(n)
168+
}
169+
return nil
170+
}
171+

0 commit comments

Comments
 (0)