Skip to content

Commit f442499

Browse files
Add click-to-focus tmux pane support for desktop notifications
Uses terminal-notifier on macOS to focus the clockr tmux pane when the notification is clicked. Detects terminal app and tmux target at scheduler startup. Falls back to zenity if terminal-notifier is not installed.
1 parent ee48d14 commit f442499

File tree

2 files changed

+114
-3
lines changed

2 files changed

+114
-3
lines changed

internal/scheduler/notify.go

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ package scheduler
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"os/exec"
8+
"runtime"
9+
"strings"
610

711
"github.com/ncruces/zenity"
812
)
@@ -22,6 +26,78 @@ type DialogResult struct {
2226
SnoozeMinutes int
2327
}
2428

29+
// TmuxTarget holds the tmux session/window/pane info for focusing.
30+
type TmuxTarget struct {
31+
Session string
32+
Window string
33+
PaneID string
34+
}
35+
36+
// DetectTmuxTarget returns the current tmux target if running inside tmux.
37+
func DetectTmuxTarget() *TmuxTarget {
38+
if os.Getenv("TMUX") == "" {
39+
return nil
40+
}
41+
paneID := os.Getenv("TMUX_PANE")
42+
if paneID == "" {
43+
return nil
44+
}
45+
46+
cmd := exec.Command("tmux", "display-message", "-p", "#{session_name}:#{window_index}")
47+
out, err := cmd.Output()
48+
if err != nil {
49+
return nil
50+
}
51+
52+
parts := strings.SplitN(strings.TrimSpace(string(out)), ":", 2)
53+
if len(parts) != 2 {
54+
return nil
55+
}
56+
57+
return &TmuxTarget{
58+
Session: parts[0],
59+
Window: parts[1],
60+
PaneID: paneID,
61+
}
62+
}
63+
64+
// FocusCommand returns a shell command that activates the terminal and
65+
// selects the tmux window/pane where clockr is running.
66+
func (t *TmuxTarget) FocusCommand() string {
67+
if t == nil {
68+
return ""
69+
}
70+
71+
var parts []string
72+
73+
if runtime.GOOS == "darwin" {
74+
bundleID := terminalBundleID()
75+
if bundleID != "" {
76+
parts = append(parts, fmt.Sprintf("open -b %s", bundleID))
77+
}
78+
}
79+
80+
target := fmt.Sprintf("%s:%s", t.Session, t.Window)
81+
parts = append(parts, fmt.Sprintf("tmux select-window -t '%s'", target))
82+
parts = append(parts, fmt.Sprintf("tmux select-pane -t '%s'", t.PaneID))
83+
84+
return strings.Join(parts, " && ")
85+
}
86+
87+
// terminalBundleID returns the macOS bundle identifier for the current terminal.
88+
func terminalBundleID() string {
89+
switch os.Getenv("TERM_PROGRAM") {
90+
case "iTerm.app":
91+
return "com.googlecode.iterm2"
92+
case "WezTerm":
93+
return "com.github.wez.wezterm"
94+
case "ghostty":
95+
return "com.mitchellh.ghostty"
96+
default:
97+
return "com.apple.Terminal"
98+
}
99+
}
100+
25101
// ShowPromptDialog displays a cross-platform dialog asking the user to log now,
26102
// snooze, or skip to the next timer tick. snoozeOptions contains durations in
27103
// minutes; if empty, only "Log Now" and "Next Timer" are shown.
@@ -61,7 +137,40 @@ func ShowPromptDialog(ctx context.Context, title, message string, snoozeOptions
61137
return DialogResult{Action: ActionLogNow}, nil
62138
}
63139

64-
// SendNotification sends a cross-platform desktop notification.
65-
func SendNotification(title, message string) error {
140+
// SendNotification sends a desktop notification. If tmuxTarget is provided and
141+
// terminal-notifier is available on macOS, clicking the notification will focus
142+
// the tmux pane where clockr is running.
143+
func SendNotification(title, message string, tmuxTarget *TmuxTarget) error {
144+
if runtime.GOOS == "darwin" {
145+
if notifierPath, err := exec.LookPath("terminal-notifier"); err == nil {
146+
return sendTerminalNotification(notifierPath, title, message, tmuxTarget)
147+
}
148+
}
66149
return zenity.Notify(message, zenity.Title(title), zenity.InfoIcon)
67150
}
151+
152+
// sendTerminalNotification uses terminal-notifier on macOS to show a
153+
// notification that focuses the clockr tmux pane when clicked.
154+
func sendTerminalNotification(notifierPath, title, message string, target *TmuxTarget) error {
155+
args := []string{"-title", title, "-message", message, "-sound", "default", "-group", "clockr"}
156+
157+
if focusCmd := target.FocusCommand(); focusCmd != "" {
158+
args = append(args, "-execute", focusCmd)
159+
} else {
160+
// No tmux target — just activate the terminal on click.
161+
bundleID := terminalBundleID()
162+
if bundleID != "" {
163+
args = append(args, "-activate", bundleID)
164+
}
165+
}
166+
167+
cmd := exec.Command(notifierPath, args...)
168+
// Start without blocking — terminal-notifier waits for user interaction
169+
// and will run the -execute command when the notification is clicked.
170+
if err := cmd.Start(); err != nil {
171+
return err
172+
}
173+
// Reap the process in the background to avoid zombies.
174+
go cmd.Wait()
175+
return nil
176+
}

internal/scheduler/ticker.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Scheduler struct {
2424
provider ai.Provider
2525
workspaceID string
2626
skipWorkTimeCheck bool
27+
tmuxTarget *TmuxTarget
2728
}
2829

2930
func New(cfg *config.Config, client *clockify.Client, db *store.DB, provider ai.Provider, workspaceID string) *Scheduler {
@@ -33,6 +34,7 @@ func New(cfg *config.Config, client *clockify.Client, db *store.DB, provider ai.
3334
db: db,
3435
provider: provider,
3536
workspaceID: workspaceID,
37+
tmuxTarget: DetectTmuxTarget(),
3638
}
3739
}
3840

@@ -113,7 +115,7 @@ func (s *Scheduler) prompt(ctx context.Context, tickTime time.Time, interval tim
113115
if s.cfg.Notifications.Enabled {
114116
// Send a system notification first so the user gets a banner + sound
115117
// even if the interactive dialog appears behind other windows.
116-
_ = SendNotification("clockr", "Time to log your work!")
118+
_ = SendNotification("clockr", "Time to log your work!", s.tmuxTarget)
117119

118120
action := s.showDialogWithSnooze(ctx)
119121
if action == ActionNextTimer {

0 commit comments

Comments
 (0)