@@ -3,6 +3,10 @@ package scheduler
33import (
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+ }
0 commit comments