Last active
March 8, 2026 08:38
-
-
Save ken-morel/f1935adfe6301ec52d5e9e26ff146ffe to your computer and use it in GitHub Desktop.
vibe-coded drop-in replacement for notify-send using tmux popup windows, with actions/inline-replies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env fish | |
| # --- Internal Popup UI Logic --- | |
| if set -q argv[1]; and test "$argv[1]" = --internal-popup | |
| set -l tmp_file $argv[2] | |
| set -l timeout $argv[3] | |
| set -l summary $argv[4] | |
| set -l body $argv[5] | |
| set -l reply_prompt $argv[6] | |
| set -l actions $argv[7..-1] # Flat list: key1, label1, key2, label2... | |
| # Catch-all trap for Ctrl-C / sudden kills | |
| function _tmux_notify_sig --on-signal INT --on-signal TERM --on-signal HUP | |
| if not test -s "$tmp_file" | |
| echo -n dismissed >"$tmp_file" | |
| end | |
| exit 0 | |
| end | |
| # Build Markdown content | |
| set -l content "# $summary" | |
| if test -n "$body" | |
| set content "$content\n\n$body" | |
| end | |
| # Display content using bat if available | |
| if command -v bat >/dev/null | |
| printf "%b" "$content" | bat --color=always --style=plain --paging=never -l md | |
| else if command -v batcat >/dev/null | |
| printf "%b" "$content" | batcat --color=always --style=plain --paging=never -l md | |
| else | |
| printf "%b\n" "$content" | |
| end | |
| echo "" | |
| if test -n "$reply_prompt" | |
| # Reply Mode: Capture full line of text | |
| set -l reply | |
| if read -P "$reply_prompt: " reply | |
| echo -n "$reply" >"$tmp_file" | |
| else | |
| echo -n dismissed >"$tmp_file" | |
| end | |
| else if test (count $actions) -gt 0 | |
| # Interaction Mode: Show numbered list | |
| set -l keys | |
| set -l labels | |
| for i in (seq 1 2 (count $actions)) | |
| set -a keys $actions[$i] | |
| set -a labels $actions[(math $i + 1)] | |
| end | |
| for i in (seq (count $labels)) | |
| echo "[$i] $labels[$i]" | |
| end | |
| echo "" | |
| # Flush stdin using bash because fish read might lack timeout | |
| bash -c "read -t 0.01 -n 1000 _unused" 2>/dev/null | |
| # Input Loop: Keep asking until valid choice or explicit dismiss | |
| while true | |
| set -l choice | |
| # read returns non-zero on EOF (Ctrl-D) | |
| if not read -n 1 -P "> " choice | |
| echo -n dismissed >"$tmp_file" | |
| break | |
| end | |
| if string match -qr '^[0-9]+$' "$choice" | |
| if test "$choice" -ge 1; and test "$choice" -le (count $keys) | |
| # Write key explicitly and break | |
| echo -n "$keys[$choice]" >"$tmp_file" | |
| break | |
| end | |
| end | |
| # Ignore all other keys to prevent accidental dismissal while typing | |
| end | |
| else | |
| # Passive Mode (No actions) | |
| set -l start_time (date +%s) | |
| set -l timeout_s 0 | |
| if test "$timeout" -gt 0 | |
| set timeout_s (math -s 0 "$timeout / 1000") | |
| if test "$timeout_s" -le 0 | |
| set timeout_s 1 | |
| end | |
| end | |
| while true | |
| if test "$timeout" -eq 0 | |
| # Always show prompt and only break on Ctrl-D | |
| if not read -n 1 -P "> " _unused | |
| break | |
| end | |
| else | |
| set -l now (date +%s) | |
| set -l rem (math $timeout_s - ($now - $start_time)) | |
| if test $rem -le 0 | |
| break | |
| end | |
| # Use bash to capture a single character with a timeout and prompt | |
| bash -c "read -r -p '> ' -n 1 -t $rem" 2>/dev/null | |
| set -l s $status | |
| if test $s -gt 128 | |
| break | |
| end # Timeout reached | |
| if test $s -eq 1 | |
| break | |
| end # EOF (Ctrl-D) | |
| end | |
| # Ignore all other keys | |
| end | |
| echo -n dismissed >"$tmp_file" | |
| end | |
| # Final safety net just in case something bypassed the writes | |
| if not test -s "$tmp_file" | |
| echo -n dismissed >"$tmp_file" | |
| end | |
| exit 0 | |
| end | |
| # --- Main Script Logic (CLI) --- | |
| function show_help | |
| echo "tmux-notify 0.2.0 - A drop-in replacement for notify-send for the Tmux terminal." | |
| echo "" | |
| echo "USAGE:" | |
| echo " tmux-notify [OPTIONS] <SUMMARY> [BODY]" | |
| echo "" | |
| echo "DESCRIPTION:" | |
| echo " Displays a notification in a Tmux popup window at the top-right of the screen." | |
| echo " It mimics the behavior and arguments of the standard 'notify-send' utility." | |
| echo "" | |
| echo "OPTIONS:" | |
| echo " -a, --app-name=NAME Sets the application name in the popup title." | |
| echo " -A, --action=KEY=LABEL Defines an action. KEY is printed to stdout if LABEL is selected." | |
| echo " -I, --reply[=PROMPT] Enables inline reply mode. Prints the response to stdout." | |
| echo " (Default prompt: 'Reply')" | |
| echo " -t, --expire-time=TIME Timeout in milliseconds. (Default: 0)" | |
| echo " -w, --wait Waits for the notification to be closed or an action." | |
| echo " -h, --help Shows this help message." | |
| echo "" | |
| echo " Accepted but ignored notify-send arguments:" | |
| echo " -u, --urgency=LEVEL, -i, --icon=ICON, -c, --category=TYPE, --hint=TYPE:NAME:VALUE" | |
| exit 0 | |
| end | |
| # 1. Parse notify-send arguments | |
| set -l original_argv $argv | |
| set -l options h/help 'u/urgency=' 't/expire-time=' 'i/icon=' 'a/app-name=' 'A/action=+' p/print-id 'r/replace-id=' w/wait 'hint=+' 'c/category=' 'I/reply=?' | |
| argparse $options -- $argv; or exit 1 | |
| if set -q _flag_help | |
| show_help | |
| end | |
| # If this is a fire-and-forget call (no -w, -A, or -I), relaunch with -w in the | |
| # background and exit. This effectively queues notifications. | |
| if not set -q _flag_w; and not set -q _flag_A; and not set -q _flag_I | |
| set -l script_path (realpath (status filename)) | |
| set -l cmd_list | |
| set -a cmd_list (string escape -- $script_path) | |
| # Important: Place -w FIRST so it's always parsed by the worker's argparse | |
| set -a cmd_list -w | |
| for arg in $original_argv | |
| set -a cmd_list (string escape -- "$arg") | |
| end | |
| set -l cmd_string (string join " " -- $cmd_list) | |
| # Run in the background, fully detached and silenced. | |
| fish -c "$cmd_string >/dev/null 2>&1 &; disown" | |
| exit 0 | |
| end | |
| # From here on, we are in a "worker" instance which will show a notification. | |
| # We must acquire a lock to ensure only one popup is displayed at a time. | |
| # But first, check for TMUX. | |
| if not set -q TMUX | |
| echo "Error: tmux-notify must be run inside a tmux session." >&2 | |
| exit 1 | |
| end | |
| set -l LOCK_DIR /tmp/tmux-notify-$USER.lock | |
| set -l PID_FILE "$LOCK_DIR/pid" | |
| # Ensure lock is released even if script is interrupted | |
| function _cleanup_lock --on-event fish_exit | |
| if test -f "$PID_FILE" | |
| if test (cat "$PID_FILE" 2>/dev/null) = "$fish_pid" | |
| rm -rf "$LOCK_DIR" >/dev/null 2>&1 | |
| end | |
| end | |
| end | |
| # Wait until we can acquire the lock. `mkdir` is an atomic operation. | |
| set -l lock_start_time (date +%s) | |
| set -l lock_timeout 30 | |
| while ! mkdir "$LOCK_DIR" >/dev/null 2>&1 | |
| if test -d "$LOCK_DIR" | |
| if test -f "$PID_FILE" | |
| set -l other_pid (cat "$PID_FILE" 2>/dev/null) | |
| if test -n "$other_pid" | |
| if not kill -0 "$other_pid" >/dev/null 2>&1 | |
| rm -rf "$LOCK_DIR" >/dev/null 2>&1 | |
| continue | |
| end | |
| end | |
| else | |
| set -l lock_age (math (date +%s) - (stat -c %Y "$LOCK_DIR" 2>/dev/null; or date +%s)) | |
| if test "$lock_age" -gt 3 | |
| rm -rf "$LOCK_DIR" >/dev/null 2>&1 | |
| continue | |
| end | |
| end | |
| end | |
| if test (math (date +%s) - $lock_start_time) -gt $lock_timeout | |
| echo "Error: Timed out waiting for tmux-notify lock." >&2 | |
| exit 1 | |
| end | |
| sleep 0.2 | |
| end | |
| echo "$fish_pid" >"$PID_FILE" | |
| # 2. Extract Summary, Body and Actions | |
| set -l summary "" | |
| set -l body "" | |
| if test (count $argv) -ge 1 | |
| set summary $argv[1] | |
| end | |
| if test (count $argv) -ge 2 | |
| # Join everything from the second argument onwards into the body | |
| set body (string join " " -- $argv[2..-1]) | |
| end | |
| # Default timeout is 0 (never expire) | |
| set -l timeout 0 | |
| if set -q _flag_t | |
| set timeout $_flag_t | |
| end | |
| # Default title if app-name isn't specified | |
| set -l title " Notification " | |
| if set -q _flag_a | |
| set title " $_flag_a " | |
| end | |
| set -l reply_prompt "" | |
| if set -q _flag_I | |
| set reply_prompt $_flag_I | |
| if test -z "$reply_prompt" | |
| set reply_prompt Reply | |
| end | |
| end | |
| set -l action_args | |
| if set -q _flag_A | |
| for a in $_flag_A | |
| if string match -q "*=*" -- "$a" | |
| set -l parts (string split -m 1 "=" -- "$a") | |
| set -a action_args $parts[1] $parts[2] | |
| else | |
| set -a action_args "$a" "$a" | |
| end | |
| end | |
| end | |
| # 3. Prepare Communication | |
| set -l tmp_file (mktemp -t tmux-notify.XXXXXX) | |
| # 4. Launch the Popup | |
| set -l script_path (realpath (status filename)) | |
| # Safely escape quotes in arguments for the internal popup call | |
| set -l safe_args | |
| # We explicitly pass empty strings to ensure positional arguments 4 and 5 are always present | |
| for arg in "$script_path" --internal-popup "$tmp_file" "$timeout" "$summary" "$body" "$reply_prompt" $action_args | |
| set -a safe_args (string escape -- "$arg") | |
| end | |
| set -l tmux_cmd fish | |
| for arg in $safe_args | |
| set tmux_cmd "$tmux_cmd $arg" | |
| end | |
| # Run the command in a subshell so fish/tmux correctly handle the absolute path | |
| tmux display-popup \ | |
| -x R -y 0 \ | |
| -w 60 -h 20 \ | |
| -b rounded \ | |
| -E -E \ | |
| -T "$title" \ | |
| "$tmux_cmd" | |
| # 5. Polling Logic | |
| if set -q _flag_w; or set -q _flag_A; or set -q _flag_I | |
| while not test -s "$tmp_file" | |
| if not test -f "$tmp_file" | |
| break | |
| end | |
| sleep 0.1 | |
| end | |
| sleep 0.05 | |
| if test -f "$tmp_file" | |
| set -l result (cat "$tmp_file") | |
| if test "$result" != dismissed; and test "$result" != "" | |
| # Print result for Actions (-A) or Inline Replies (-I) | |
| if set -q _flag_A; or set -q _flag_I | |
| echo "$result" | |
| end | |
| end | |
| rm -f "$tmp_file" | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment