From c8d425ff07c06d47fe2904713bd35e4f8875c8ba Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:40:17 +0800 Subject: [PATCH 01/41] refactor: add terminal-native notification routing --- .../plugins/terminal-notify.plugin.zsh | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 home/.config/sheldon/plugins/terminal-notify.plugin.zsh diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh new file mode 100644 index 00000000..b47f752f --- /dev/null +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -0,0 +1,136 @@ +# Route long-running command notifications through the hosting terminal when possible. +# bgnotify remains the command-lifecycle source; this file only chooses delivery. + +bgnotify_bell=false +bgnotify_threshold=5 + +typeset -g _terminal_notify_protocol='' + +_terminal_notify_disable_bgnotify() { + autoload -Uz add-zsh-hook + + if (( ${+functions[bgnotify_begin]} )); then + add-zsh-hook -d preexec bgnotify_begin + fi + if (( ${+functions[bgnotify_end]} )); then + add-zsh-hook -d precmd bgnotify_end + fi +} + +_terminal_notify_clean() { + local value=$1 + value=${value//$'\e'/} + value=${value//$'\a'/} + value=${value//$'\n'/ } + value=${value//$'\r'/ } + value=${value//$'\t'/ } + value=${value//;/,} + REPLY=$value +} + +_terminal_notify_write() { + local sequence=$1 + + if [[ -n ${TMUX:-} ]]; then + # tmux DCS passthrough requires every ESC in the nested sequence to be + # doubled. `allow-passthrough` must be enabled in tmux for this to pass. + sequence=${sequence//$'\e'/$'\e\e'} + print -rn -- $'\ePtmux;' "$sequence" $'\e\\' + else + # Zellij understands notification OSC directly, so no extra envelope is + # needed there. + print -rn -- "$sequence" + fi +} + +_terminal_notify_osc777() { + local title=$1 + local message=$2 + + _terminal_notify_clean "$title" + title=$REPLY + _terminal_notify_clean "$message" + message=$REPLY + + _terminal_notify_write $'\e]777;notify;'"$title;$message"$'\e\\' +} + +_terminal_notify_osc9() { + local title=$1 + local message=$2 + + _terminal_notify_clean "$title" + title=$REPLY + _terminal_notify_clean "$message" + message=$REPLY + + _terminal_notify_write $'\e]9;'"$title — $message"$'\e\\' +} + +_terminal_notify_osc99() { + local title=$1 + local message=$2 + local id=bgnotify + + _terminal_notify_clean "$title" + title=$REPLY + _terminal_notify_clean "$message" + message=$REPLY + + # OSC 99 is chunked: hold the notification after the title, then complete it + # with the body. Reusing the id updates the previous command notification. + _terminal_notify_write $'\e]99;i='"$id"$':d=0;'"$title"$'\e\\' + _terminal_notify_write $'\e]99;i='"$id"$':p=body:d=1;'"$message"$'\e\\' +} + +if [[ -n ${WT_SESSION:-} ]]; then + _terminal_notify_protocol=osc777 + + # Windows Terminal already suppresses OSC 777 from its focused active pane. + # Always let it make that decision instead of spawning a Windows focus probe. + bgnotify_appid() { + print -r -- '__terminal_notify_dispatch__' + } + bgnotify_termid='__terminal_notify_host_focus__' +elif [[ -n ${KITTY_WINDOW_ID:-} || ${TERM:-} == xterm-kitty ]]; then + _terminal_notify_protocol=osc99 +else + case ${TERM_PROGRAM:-} in + ghostty) + if [[ -z ${TMUX:-} && -z ${ZELLIJ:-} && -z ${ZELLIJ_SESSION_NAME:-} ]]; then + # Direct Ghostty sessions have more accurate native OSC 133 command + # tracking, including focus-aware command-finished notifications. + _terminal_notify_disable_bgnotify + return + fi + # Multiplexers can consume OSC 133 before Ghostty sees it. Emit an + # explicit notification instead; tmux is wrapped by _terminal_notify_write. + _terminal_notify_protocol=osc777 + ;; + WezTerm|rio) + _terminal_notify_protocol=osc777 + ;; + iTerm.app) + _terminal_notify_protocol=osc9 + ;; + WarpTerminal) + # Warp already tracks command lifecycle and notifies only when away. + _terminal_notify_disable_bgnotify + return + ;; + esac +fi + +# Unknown terminals keep upstream bgnotify's native OS fallbacks unchanged. +[[ -n $_terminal_notify_protocol ]] || return + +bgnotify() { + local title=$1 + local message=$2 + + case $_terminal_notify_protocol in + osc99) _terminal_notify_osc99 "$title" "$message" ;; + osc777) _terminal_notify_osc777 "$title" "$message" ;; + osc9) _terminal_notify_osc9 "$title" "$message" ;; + esac +} From 440afb7520727d0b8a55b7c71440dc68b71f16ce Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:40:30 +0800 Subject: [PATCH 02/41] refactor: load command notification routing everywhere --- home/.config/sheldon/plugins.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/home/.config/sheldon/plugins.toml b/home/.config/sheldon/plugins.toml index 6259a45a..a1d64950 100644 --- a/home/.config/sheldon/plugins.toml +++ b/home/.config/sheldon/plugins.toml @@ -85,11 +85,9 @@ use = [ [plugins.bgnotify] github = "ohmyzsh/ohmyzsh" use = ["plugins/bgnotify/bgnotify.plugin.zsh"] -profiles = ["wsl"] -[plugins.wsl-notify] +[plugins.terminal-notify] local = "~/.config/sheldon/plugins" -profiles = ["wsl"] # Keep syntax highlighting last among plugins. [plugins.zsh-syntax-highlighting] From 0604437c102a6c2588a0c5176cb6f2720c5c61dc Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:40:38 +0800 Subject: [PATCH 03/41] refactor: replace WSL-specific notification shim --- .../sheldon/plugins/wsl-notify.plugin.zsh | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 home/.config/sheldon/plugins/wsl-notify.plugin.zsh diff --git a/home/.config/sheldon/plugins/wsl-notify.plugin.zsh b/home/.config/sheldon/plugins/wsl-notify.plugin.zsh deleted file mode 100644 index 6447ff81..00000000 --- a/home/.config/sheldon/plugins/wsl-notify.plugin.zsh +++ /dev/null @@ -1,74 +0,0 @@ -# Configure bgnotify for terminals hosted by Windows. -[[ -n ${WSL_DISTRO_NAME:-} || -n ${WSL_INTEROP:-} ]] || return - -bgnotify_bell=false -bgnotify_threshold=5 - -if [[ ${TERM_PROGRAM:-} == ghostty ]]; then - # Ghostty provides accurate command-finished notifications itself. - autoload -Uz add-zsh-hook - add-zsh-hook -d preexec bgnotify_begin - add-zsh-hook -d precmd bgnotify_end - return -fi - -if [[ -n ${WT_SESSION:-} ]]; then - if [[ -z ${commands[powershell.exe]:-} || -z ${commands[wslpath]:-} ]]; then - # Without the Windows-native helper path we cannot reliably distinguish a - # focused terminal from a background one, so fail closed. - autoload -Uz add-zsh-hook - add-zsh-hook -d preexec bgnotify_begin - add-zsh-hook -d precmd bgnotify_end - return - fi - - # Keep upstream bgnotify's timing and command formatting, but defer the - # Windows foreground check to wsl-notify.ps1. These fixed, unequal IDs make - # bgnotify_end dispatch without spawning a separate PowerShell probe. - function bgnotify_appid { - print -r -- '__wsl_notify_dispatch__' - } - bgnotify_termid='__wsl_notify_terminal_foreground__' -fi - -function bgnotify { - local title="$1" - local message="$2" - local icon="$3" - - if [[ -n ${WT_SESSION:-} ]]; then - local notify_script="${MISE_CONFIG_DIR:-$HOME/.config/mise}/scripts/wsl-notify.ps1" - local windows_notify_script - local notify_status - - # If the helper cannot be resolved, suppress the notification rather than - # guessing whether Windows Terminal is focused. - [[ -r $notify_script ]] || return 0 - windows_notify_script=$(command wslpath -w "$notify_script") || return 0 - [[ -n $windows_notify_script ]] || return 0 - - if powershell.exe -NoLogo -NoProfile -NonInteractive \ - -ExecutionPolicy Bypass -File "$windows_notify_script" \ - -Title "$title" -Message "$message" \ - -DistroName "${WSL_DISTRO_NAME:-}" >/dev/null 2>&1; then - notify_status=0 - else - notify_status=$? - fi - - case $notify_status in - 0|10|11) - # Toast delivered, intentionally suppressed while focused, or focus - # state was indeterminate and the helper failed closed. - return 0 - ;; - *) - # Native delivery failed after dispatch; retain an audible fallback. - print -rn -- $'\a' - ;; - esac - elif (( ${+commands[notify-send]} )); then - command notify-send "$title" "$message" \ - ${=icon:+--icon "$icon"} ${=bgnotify_extraargs:-} - fi -} From 5ceff259c41f95b208ca955d22427dd8d5369ee3 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:40:48 +0800 Subject: [PATCH 04/41] feat: enable OSC 777 notifications in Windows Terminal --- config/windows-terminal/notification.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 config/windows-terminal/notification.json diff --git a/config/windows-terminal/notification.json b/config/windows-terminal/notification.json new file mode 100644 index 00000000..b53bcaf8 --- /dev/null +++ b/config/windows-terminal/notification.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://aka.ms/terminal-profiles-schema", + "profiles": { + "defaults": { + "compatibility.allowOSC777": true + } + } +} From 7e58ccddb9a8153b318d461d63e5f0156b097b01 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:41:01 +0800 Subject: [PATCH 05/41] refactor: install all Windows Terminal integration fragments --- config/scripts/bootstrap-windows-terminal.sh | 25 ++++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/config/scripts/bootstrap-windows-terminal.sh b/config/scripts/bootstrap-windows-terminal.sh index fb788928..e8d9899c 100644 --- a/config/scripts/bootstrap-windows-terminal.sh +++ b/config/scripts/bootstrap-windows-terminal.sh @@ -3,14 +3,16 @@ set -eu jsonc_parser_version=3.3.1 config_dir=${MISE_CONFIG_DIR:-$HOME/.config/mise} -fragment="$config_dir/windows-terminal/keybind.json" editor_script="$config_dir/scripts/ensure-windows-terminal-import.cjs" -import_name=keybind.json +import_names='keybind.json notification.json' -if [ ! -f "$fragment" ]; then - printf 'windows terminal bootstrap: keybind fragment was not found: %s\n' "$fragment" >&2 - exit 1 -fi +for import_name in $import_names; do + fragment="$config_dir/windows-terminal/$import_name" + if [ ! -f "$fragment" ]; then + printf 'windows terminal bootstrap: fragment was not found: %s\n' "$fragment" >&2 + exit 1 + fi +done if [ ! -f "$editor_script" ]; then printf 'windows terminal bootstrap: JSONC editor was not found: %s\n' "$editor_script" >&2 @@ -85,14 +87,17 @@ do if [ "$found" = false ]; then if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then - printf 'windows terminal bootstrap: node and npm are required to register %s\n' "$import_name" >&2 + printf 'windows terminal bootstrap: node and npm are required to register terminal fragments\n' >&2 exit 1 fi ensure_jsonc_parser found=true fi - cp -f -- "$fragment" "$state_directory/$import_name" - NODE_PATH="$jsonc_node_path${NODE_PATH:+:$NODE_PATH}" \ - node "$editor_script" "$settings_path" "$import_name" + for import_name in $import_names; do + fragment="$config_dir/windows-terminal/$import_name" + cp -f -- "$fragment" "$state_directory/$import_name" + NODE_PATH="$jsonc_node_path${NODE_PATH:+:$NODE_PATH}" \ + node "$editor_script" "$settings_path" "$import_name" + done done From 2b87e153f9d46f839d1233f93b3e060e22ead708 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:41:07 +0800 Subject: [PATCH 06/41] docs: describe Windows Terminal integration broadly --- config/conf.d/platform.wsl.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/conf.d/platform.wsl.toml b/config/conf.d/platform.wsl.toml index 56748fb2..b6eabd92 100644 --- a/config/conf.d/platform.wsl.toml +++ b/config/conf.d/platform.wsl.toml @@ -5,7 +5,7 @@ SHELDON_PROFILE = "wsl" [tasks."bootstrap:windows"] -description = "Configure Windows font and Terminal keybinds" +description = "Configure Windows font and Terminal integration" hide = false run = [ "sh \"${MISE_CONFIG_DIR:-$HOME/.config/mise}/scripts/bootstrap-windows.sh\"", From 97559f775cb4c25c9d9f77039ef464bbd122fdf5 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:41:13 +0800 Subject: [PATCH 07/41] refactor: drop obsolete WinRT notification helper --- config/scripts/wsl-notify.ps1 | 252 ---------------------------------- 1 file changed, 252 deletions(-) delete mode 100644 config/scripts/wsl-notify.ps1 diff --git a/config/scripts/wsl-notify.ps1 b/config/scripts/wsl-notify.ps1 deleted file mode 100644 index 2fb5e485..00000000 --- a/config/scripts/wsl-notify.ps1 +++ /dev/null @@ -1,252 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string] $Title, - [Parameter(Mandatory = $true)][string] $Message, - [string] $DistroName = '' -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -# Exit codes consumed by wsl-notify.plugin.zsh: -# 0 toast displayed -# 10 Windows Terminal is focused; notification intentionally suppressed -# 11 foreground state could not be determined; fail closed -# 20 native toast delivery failed; the shell may fall back to BEL -$ExitToastShown = 0 -$ExitFocused = 10 -$ExitFocusUnknown = 11 -$ExitDeliveryFailed = 20 - -function Get-ForegroundProcessName { - try { - Add-Type -TypeDefinition @" -using System; -using System.Runtime.InteropServices; - -public static class ForegroundWindow { - [DllImport("user32.dll", SetLastError = true)] - public static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll", SetLastError = true)] - public static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); -} -"@ - - $window = [ForegroundWindow]::GetForegroundWindow() - if ($window -eq [IntPtr]::Zero) { - return $null - } - - $foregroundProcessId = [uint32]0 - $threadId = [ForegroundWindow]::GetWindowThreadProcessId($window, [ref] $foregroundProcessId) - if ($threadId -eq 0 -or $foregroundProcessId -eq 0) { - return $null - } - - return (Get-Process -Id $foregroundProcessId -ErrorAction Stop).ProcessName - } - catch { - return $null - } -} - -function Get-NotificationAppId { - $startApps = @(Get-StartApps) - - foreach ($candidate in @( - 'Microsoft.WindowsTerminal_8wekyb3d8bbwe!App', - 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe!App' - )) { - if ($startApps.AppID -contains $candidate) { - return $candidate - } - } - - $appId = $startApps | - Where-Object { $_.AppID -match '^Microsoft\.WindowsTerminal.*!App$' } | - Select-Object -First 1 -ExpandProperty AppID - - if (-not [string]::IsNullOrWhiteSpace($appId)) { - return $appId - } - - return $startApps | - Where-Object { $_.AppID -match 'PowerShell' } | - Select-Object -First 1 -ExpandProperty AppID -} - -function Get-WslDistroIconPath { - param([string] $Name) - - if ([string]::IsNullOrWhiteSpace($Name)) { - return $null - } - - $roots = @( - ('\\wsl.localhost\{0}' -f $Name), - ('\\wsl$\{0}' -f $Name) - ) - - foreach ($root in $roots) { - $configPath = Join-Path -Path $root -ChildPath 'etc\wsl-distribution.conf' - if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { - continue - } - - try { - $section = '' - $linuxIconPath = $null - - foreach ($rawLine in Get-Content -LiteralPath $configPath -ErrorAction Stop) { - $line = ($rawLine -split '[#;]', 2)[0].Trim() - if ($line.Length -eq 0) { - continue - } - - if ($line -match '^\[(?
[^\]]+)\]$') { - $section = $Matches['Section'].Trim() - continue - } - - if ($section -ieq 'shortcut' -and $line -match '^(?i:icon)\s*=\s*(?.+)$') { - $linuxIconPath = $Matches['Icon'].Trim() - if (($linuxIconPath.StartsWith('"') -and $linuxIconPath.EndsWith('"')) -or - ($linuxIconPath.StartsWith("'") -and $linuxIconPath.EndsWith("'"))) { - $linuxIconPath = $linuxIconPath.Substring(1, $linuxIconPath.Length - 2) - } - break - } - } - - if ([string]::IsNullOrWhiteSpace($linuxIconPath) -or -not $linuxIconPath.StartsWith('/')) { - continue - } - - $relativePath = $linuxIconPath.TrimStart('/').Replace('/', '\') - $candidate = Join-Path -Path $root -ChildPath $relativePath - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - return $candidate - } - } - catch { - continue - } - } - - return $null -} - -function Get-WslDistroIconUri { - param([string] $Name) - - $sourcePath = Get-WslDistroIconPath -Name $Name - if ([string]::IsNullOrWhiteSpace($sourcePath)) { - return $null - } - - $extension = [IO.Path]::GetExtension($sourcePath).ToLowerInvariant() - if ($extension -in @('.png', '.jpg', '.jpeg', '.svg')) { - return ([Uri] $sourcePath).AbsoluteUri - } - - if ($extension -ne '.ico') { - return $null - } - - try { - Add-Type -AssemblyName System.Drawing - - $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) - if ([string]::IsNullOrWhiteSpace($localAppData)) { - return $null - } - - $cacheDirectory = Join-Path -Path $localAppData -ChildPath 'wsl-notify\icons' - [void] [IO.Directory]::CreateDirectory($cacheDirectory) - - $sha256 = [Security.Cryptography.SHA256]::Create() - try { - $cacheKey = [Text.Encoding]::UTF8.GetBytes("$Name`0$sourcePath") - $hash = [BitConverter]::ToString($sha256.ComputeHash($cacheKey)).Replace('-', '').ToLowerInvariant() - } - finally { - $sha256.Dispose() - } - - $cachePath = Join-Path -Path $cacheDirectory -ChildPath "$hash.png" - $sourceInfo = Get-Item -LiteralPath $sourcePath -ErrorAction Stop - $refreshCache = -not (Test-Path -LiteralPath $cachePath -PathType Leaf) - if (-not $refreshCache) { - $cacheInfo = Get-Item -LiteralPath $cachePath -ErrorAction Stop - $refreshCache = $cacheInfo.LastWriteTimeUtc -lt $sourceInfo.LastWriteTimeUtc - } - - if ($refreshCache) { - $icon = New-Object -TypeName System.Drawing.Icon -ArgumentList @($sourcePath, 48, 48) - try { - $bitmap = $icon.ToBitmap() - try { - $bitmap.Save($cachePath, [System.Drawing.Imaging.ImageFormat]::Png) - } - finally { - $bitmap.Dispose() - } - } - finally { - $icon.Dispose() - } - } - - return ([Uri] $cachePath).AbsoluteUri - } - catch { - return $null - } -} - -$foregroundProcess = Get-ForegroundProcessName -if ([string]::IsNullOrWhiteSpace($foregroundProcess)) { - exit $ExitFocusUnknown -} - -if ($foregroundProcess -like 'WindowsTerminal*') { - exit $ExitFocused -} - -try { - Add-Type -AssemblyName System.Runtime.WindowsRuntime - $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] - $null = [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] - $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] - - $appId = Get-NotificationAppId - if ([string]::IsNullOrWhiteSpace($appId)) { - throw 'No Start-menu AUMID suitable for WSL toast notifications was found.' - } - - $document = [Windows.Data.Xml.Dom.XmlDocument]::new() - $document.LoadXml('') - $textNodes = $document.GetElementsByTagName('text') - [void] $textNodes.Item(0).AppendChild($document.CreateTextNode($Title)) - [void] $textNodes.Item(1).AppendChild($document.CreateTextNode($Message)) - - $iconUri = Get-WslDistroIconUri -Name $DistroName - if (-not [string]::IsNullOrWhiteSpace($iconUri)) { - $binding = $document.SelectSingleNode('/toast/visual/binding') - $image = $document.CreateElement('image') - $image.SetAttribute('placement', 'appLogoOverride') - $image.SetAttribute('src', $iconUri) - if (-not [string]::IsNullOrWhiteSpace($DistroName)) { - $image.SetAttribute('alt', $DistroName) - } - [void] $binding.AppendChild($image) - } - - $toast = [Windows.UI.Notifications.ToastNotification]::new($document) - [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) - exit $ExitToastShown -} -catch { - [Console]::Error.WriteLine("WSL notification failed: $($_.Exception.Message)") - exit $ExitDeliveryFailed -} From de340370db5112bcce00e5fdd5851cf9f2f84b73 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:41:48 +0800 Subject: [PATCH 08/41] test: cover terminal-native notification routing --- config/tests/terminal-notify.zsh | 122 +++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 config/tests/terminal-notify.zsh diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh new file mode 100644 index 00000000..60ea7358 --- /dev/null +++ b/config/tests/terminal-notify.zsh @@ -0,0 +1,122 @@ +#!/usr/bin/env zsh +set -eu + +repo_root=${0:A:h:h:h} +plugin="$repo_root/home/.config/sheldon/plugins/terminal-notify.plugin.zsh" +test_root=$(mktemp -d) +trap 'rm -rf "$test_root"' EXIT + +# Windows Terminal uses OSC 777 and delegates focused-pane suppression to the +# terminal itself rather than probing Windows from WSL. +zsh -f -c ' + unset TERM_PROGRAM KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + export TERM=xterm-256color + export WT_SESSION=ci + function bgnotify_appid { print -r -- fallback; } + bgnotify_termid=fallback + source "$1" + + [[ $_terminal_notify_protocol == osc777 ]] + [[ "$(bgnotify_appid)" == __terminal_notify_dispatch__ ]] + [[ $bgnotify_termid == __terminal_notify_host_focus__ ]] + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'"'"'\e]777;notify;build finished;command completed\e\\'"'"' ]] +' zsh "$plugin" + +# tmux receives the same terminal protocol through DCS passthrough with nested +# ESC bytes escaped as required by tmux. +zsh -f -c ' + unset TERM_PROGRAM KITTY_WINDOW_ID ZELLIJ ZELLIJ_SESSION_NAME + export TERM=xterm-256color + export WT_SESSION=ci + export TMUX=/tmp/tmux-test + function bgnotify_appid { print -r -- fallback; } + bgnotify_termid=fallback + source "$1" + + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'"'"'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\'"'"' ]] +' zsh "$plugin" + +# kitty gets its richer OSC 99 protocol with one logical notification split +# into title and body chunks. +zsh -f -c ' + unset TERM_PROGRAM WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME + export TERM=xterm-kitty + export KITTY_WINDOW_ID=1 + function bgnotify { print -r -- fallback; } + source "$1" + + [[ $_terminal_notify_protocol == osc99 ]] + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'"'"'\e]99;i=bgnotify:d=0;build finished\e\\\e]99;i=bgnotify:p=body:d=1;command completed\e\\'"'"' ]] +' zsh "$plugin" + +# iTerm2 keeps its native OSC 9 notification path. +zsh -f -c ' + unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + export TERM=xterm-256color + export TERM_PROGRAM=iTerm.app + function bgnotify { print -r -- fallback; } + source "$1" + + [[ $_terminal_notify_protocol == osc9 ]] + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'"'"'\e]9;build finished — command completed\e\\'"'"' ]] +' zsh "$plugin" + +# Unknown terminals retain upstream bgnotify unchanged, preserving its native +# OS notification fallback instead of forcing an unsupported escape sequence. +zsh -f -c ' + unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + export TERM=xterm-256color + export TERM_PROGRAM=unknown-terminal + function bgnotify { print -r -- fallback; } + source "$1" + + [[ -z $_terminal_notify_protocol ]] + [[ "$(bgnotify one two three)" == fallback ]] +' zsh "$plugin" + +# Exercise direct Ghostty hook removal using a fixture loaded through zsh's +# normal autoload mechanism. Direct Ghostty owns command completion via OSC 133. +mkdir -p "$test_root/fpath" +cat >"$test_root/fpath/add-zsh-hook" <<'EOF' +local mode=$1 hook=$2 callback=$3 +[[ $mode == -d ]] || return 2 + +case $hook in + preexec) preexec_functions=(${preexec_functions:#$callback}) ;; + precmd) precmd_functions=(${precmd_functions:#$callback}) ;; + *) return 2 ;; +esac +EOF + +zsh -f -c ' + typeset -ga preexec_functions precmd_functions + preexec_functions=(bgnotify_begin) + precmd_functions=(bgnotify_end) + function bgnotify_begin {} + function bgnotify_end {} + fpath=("$2" $fpath) + unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + export TERM_PROGRAM=ghostty + source "$1" + + [[ -z ${preexec_functions[(r)bgnotify_begin]-} ]] + [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] +' zsh "$plugin" "$test_root/fpath" + +# Inside tmux Ghostty cannot rely on OSC 133 reaching the outer terminal, so it +# falls back to an explicit OSC 777 notification through passthrough. +zsh -f -c ' + unset WT_SESSION KITTY_WINDOW_ID ZELLIJ ZELLIJ_SESSION_NAME + export TERM_PROGRAM=ghostty + export TMUX=/tmp/tmux-test + function bgnotify { print -r -- fallback; } + source "$1" + + [[ $_terminal_notify_protocol == osc777 ]] + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'"'"'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\'"'"' ]] +' zsh "$plugin" From a00c26fa09ea35692d371a1a9fc6702ea2c0d934 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:41:56 +0800 Subject: [PATCH 09/41] test: replace WSL toast tests with terminal routing tests --- config/tests/wsl-notify.zsh | 76 ------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 config/tests/wsl-notify.zsh diff --git a/config/tests/wsl-notify.zsh b/config/tests/wsl-notify.zsh deleted file mode 100644 index d92e0cbf..00000000 --- a/config/tests/wsl-notify.zsh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env zsh -set -eu - -repo_root=${0:A:h:h:h} -plugin="$repo_root/home/.config/sheldon/plugins/wsl-notify.plugin.zsh" -test_root=$(mktemp -d) -trap 'rm -rf "$test_root"' EXIT -mkdir -p "$test_root/bin" - -cat >"$test_root/bin/wslpath" <<'EOF' -#!/bin/sh -printf '%s\n' 'C:\\wsl-notify.ps1' -EOF - -cat >"$test_root/bin/powershell.exe" <<'EOF' -#!/bin/sh -printf '%s\n' "$*" >>"$WSL_NOTIFY_TEST_LOG" -exit "${WSL_NOTIFY_TEST_EXIT:-0}" -EOF - -chmod +x "$test_root/bin/wslpath" "$test_root/bin/powershell.exe" - -export PATH="$test_root/bin:$PATH" -export MISE_CONFIG_DIR="$repo_root/config" -export WSL_DISTRO_NAME=ci -export WT_SESSION=ci -export WSL_NOTIFY_TEST_LOG="$test_root/powershell.log" -unset TERM_PROGRAM -rehash - -source "$plugin" - -[[ "$(bgnotify_appid)" == '__wsl_notify_dispatch__' ]] -[[ "$bgnotify_termid" == '__wsl_notify_terminal_foreground__' ]] -[[ ! -e "$WSL_NOTIFY_TEST_LOG" ]] - -for exit_code in 0 10 11; do - export WSL_NOTIFY_TEST_EXIT=$exit_code - output=$(bgnotify 'build finished' 'command completed' '') - [[ -z $output ]] -done - -export WSL_NOTIFY_TEST_EXIT=20 -output=$(bgnotify 'build finished' 'command completed' '') -[[ "$output" == $'\a' ]] -[[ "$(wc -l <"$WSL_NOTIFY_TEST_LOG")" -eq 4 ]] -[[ "$(grep -c -- '-DistroName ci' "$WSL_NOTIFY_TEST_LOG")" -eq 4 ]] - -# Exercise the Ghostty hook-removal path without depending on the runner's zsh -# function installation. The fixture is loaded through zsh's real autoload -# mechanism, just like add-zsh-hook is in an interactive shell. -mkdir -p "$test_root/fpath" -cat >"$test_root/fpath/add-zsh-hook" <<'EOF' -local mode=$1 hook=$2 callback=$3 -[[ $mode == -d ]] || return 2 - -case $hook in - preexec) preexec_functions=(${preexec_functions:#$callback}) ;; - precmd) precmd_functions=(${precmd_functions:#$callback}) ;; - *) return 2 ;; -esac -EOF - -zsh -f -c ' - typeset -ga preexec_functions precmd_functions - preexec_functions=(bgnotify_begin) - precmd_functions=(bgnotify_end) - function bgnotify_begin {} - function bgnotify_end {} - fpath=("$2" $fpath) - export WSL_DISTRO_NAME=ci - export TERM_PROGRAM=ghostty - source "$1" - [[ -z ${preexec_functions[(r)bgnotify_begin]-} ]] - [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] -' zsh "$plugin" "$test_root/fpath" From c6178fa7dc1510b04d92b378fc899b40080fd89f Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:42:10 +0800 Subject: [PATCH 10/41] test: validate terminal notification routing --- config/hk.pkl | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/config/hk.pkl b/config/hk.pkl index 5612e9a3..c4296b38 100644 --- a/config/hk.pkl +++ b/config/hk.pkl @@ -70,13 +70,12 @@ local checks = new Mapping { ) check = "zsh -n {{files}}" } - ["wsl-notify"] { + ["terminal-notify"] { glob = List( - "../home/.config/sheldon/plugins/wsl-notify.plugin.zsh", - "scripts/wsl-notify.ps1", - "tests/wsl-notify.zsh" + "../home/.config/sheldon/plugins/terminal-notify.plugin.zsh", + "tests/terminal-notify.zsh" ) - check = "zsh tests/wsl-notify.zsh" + check = "zsh tests/terminal-notify.zsh" } } From 49035d852bd956c2c300311c6aceafa878542182 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:42:23 +0800 Subject: [PATCH 11/41] docs: describe terminal-native command notifications --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9a3f62d..431c9fc4 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,14 @@ Omarchy 環境則直接使用 Omarchy 的主題系統,不需要另外使用 Ti ## Windows / WSL -在 WSL bootstrap 時會一併安裝 Windows 端的 JetBrainsMono Nerd Font,並套用 Windows Terminal 快捷鍵;`Ctrl+Shift+Z` 會送出 Zsh redo 所需的 escape sequence。需要再次執行 Windows 整合 bootstrap 時可使用: +在 WSL bootstrap 時會一併安裝 Windows 端的 JetBrainsMono Nerd Font,並套用 Windows Terminal 整合:`Ctrl+Shift+Z` 會送出 Zsh redo 所需的 escape sequence,並啟用 OSC 777 terminal-native desktop notifications。需要再次執行 Windows 整合 bootstrap 時可使用: ```bash mise run bootstrap:windows ``` +長時間 command 的通知優先交給 terminal 本身處理。Ghostty 直接使用 OSC 133 command-finished notifications;Windows Terminal、Rio、WezTerm 使用 OSC 777,kitty 使用 OSC 99,iTerm2 使用 OSC 9。tmux 會使用 DCS passthrough(需啟用 `allow-passthrough`),Zellij 則直接接收 notification OSC;不支援的 terminal 仍保留 bgnotify 原本的 OS notification fallback。 + ## 常用指令 | 用途 | 指令 | From 5a4c387486071030116d3f991fc83741f696a01a Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:43:22 +0800 Subject: [PATCH 12/41] test: validate Windows Terminal notification capability --- config/hk.pkl | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/config/hk.pkl b/config/hk.pkl index c4296b38..1b99a543 100644 --- a/config/hk.pkl +++ b/config/hk.pkl @@ -73,9 +73,13 @@ local checks = new Mapping { ["terminal-notify"] { glob = List( "../home/.config/sheldon/plugins/terminal-notify.plugin.zsh", - "tests/terminal-notify.zsh" + "tests/terminal-notify.zsh", + "windows-terminal/notification.json" ) - check = "zsh tests/terminal-notify.zsh" + check = """ + zsh tests/terminal-notify.zsh + jq -e '.profiles.defaults["compatibility.allowOSC777"] == true' windows-terminal/notification.json >/dev/null + """ } } From 1156571542df4ab9075281cce86b2c06b70990af Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:44:06 +0800 Subject: [PATCH 13/41] fix: keep Windows Terminal bootstrap shellcheck-clean --- config/scripts/bootstrap-windows-terminal.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/scripts/bootstrap-windows-terminal.sh b/config/scripts/bootstrap-windows-terminal.sh index e8d9899c..628b0013 100644 --- a/config/scripts/bootstrap-windows-terminal.sh +++ b/config/scripts/bootstrap-windows-terminal.sh @@ -4,9 +4,8 @@ set -eu jsonc_parser_version=3.3.1 config_dir=${MISE_CONFIG_DIR:-$HOME/.config/mise} editor_script="$config_dir/scripts/ensure-windows-terminal-import.cjs" -import_names='keybind.json notification.json' -for import_name in $import_names; do +for import_name in keybind.json notification.json; do fragment="$config_dir/windows-terminal/$import_name" if [ ! -f "$fragment" ]; then printf 'windows terminal bootstrap: fragment was not found: %s\n' "$fragment" >&2 @@ -94,7 +93,7 @@ do found=true fi - for import_name in $import_names; do + for import_name in keybind.json notification.json; do fragment="$config_dir/windows-terminal/$import_name" cp -f -- "$fragment" "$state_directory/$import_name" NODE_PATH="$jsonc_node_path${NODE_PATH:+:$NODE_PATH}" \ From 43a98719dc5bfd163af9c24242ae6001ee9b6a83 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:45:58 +0800 Subject: [PATCH 14/41] fix: avoid spaces in tmux passthrough sequences --- home/.config/sheldon/plugins/terminal-notify.plugin.zsh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index b47f752f..9fb8f49c 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -35,7 +35,7 @@ _terminal_notify_write() { # tmux DCS passthrough requires every ESC in the nested sequence to be # doubled. `allow-passthrough` must be enabled in tmux for this to pass. sequence=${sequence//$'\e'/$'\e\e'} - print -rn -- $'\ePtmux;' "$sequence" $'\e\\' + print -rn -- $'\ePtmux;'"$sequence"$'\e\\' else # Zellij understands notification OSC directly, so no extra envelope is # needed there. From db3282294c65f6c40e0875844580fc623474dd41 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:48:23 +0800 Subject: [PATCH 15/41] test: simplify terminal notification protocol coverage --- config/tests/terminal-notify.zsh | 99 +++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index 60ea7358..df24f607 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -8,75 +8,90 @@ trap 'rm -rf "$test_root"' EXIT # Windows Terminal uses OSC 777 and delegates focused-pane suppression to the # terminal itself rather than probing Windows from WSL. -zsh -f -c ' +( unset TERM_PROGRAM KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export WT_SESSION=ci - function bgnotify_appid { print -r -- fallback; } + bgnotify_appid() { print -r -- fallback; } bgnotify_termid=fallback - source "$1" + source "$plugin" [[ $_terminal_notify_protocol == osc777 ]] [[ "$(bgnotify_appid)" == __terminal_notify_dispatch__ ]] [[ $bgnotify_termid == __terminal_notify_host_focus__ ]] output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'"'"'\e]777;notify;build finished;command completed\e\\'"'"' ]] -' zsh "$plugin" + [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] +) # tmux receives the same terminal protocol through DCS passthrough with nested # ESC bytes escaped as required by tmux. -zsh -f -c ' +( unset TERM_PROGRAM KITTY_WINDOW_ID ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export WT_SESSION=ci export TMUX=/tmp/tmux-test - function bgnotify_appid { print -r -- fallback; } + bgnotify_appid() { print -r -- fallback; } bgnotify_termid=fallback - source "$1" + source "$plugin" output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'"'"'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\'"'"' ]] -' zsh "$plugin" + [[ $output == $'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\' ]] +) # kitty gets its richer OSC 99 protocol with one logical notification split # into title and body chunks. -zsh -f -c ' +( unset TERM_PROGRAM WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-kitty export KITTY_WINDOW_ID=1 - function bgnotify { print -r -- fallback; } - source "$1" + bgnotify() { print -r -- fallback; } + source "$plugin" [[ $_terminal_notify_protocol == osc99 ]] output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'"'"'\e]99;i=bgnotify:d=0;build finished\e\\\e]99;i=bgnotify:p=body:d=1;command completed\e\\'"'"' ]] -' zsh "$plugin" + [[ $output == $'\e]99;i=bgnotify:d=0;build finished\e\\\e]99;i=bgnotify:p=body:d=1;command completed\e\\' ]] +) # iTerm2 keeps its native OSC 9 notification path. -zsh -f -c ' +( unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export TERM_PROGRAM=iTerm.app - function bgnotify { print -r -- fallback; } - source "$1" + bgnotify() { print -r -- fallback; } + source "$plugin" [[ $_terminal_notify_protocol == osc9 ]] output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'"'"'\e]9;build finished — command completed\e\\'"'"' ]] -' zsh "$plugin" + [[ $output == $'\e]9;build finished — command completed\e\\' ]] +) + +# Rio and WezTerm both use OSC 777. +for terminal_program in rio WezTerm; do + ( + unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + export TERM=xterm-256color + export TERM_PROGRAM=$terminal_program + bgnotify() { print -r -- fallback; } + source "$plugin" + + [[ $_terminal_notify_protocol == osc777 ]] + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] + ) +done # Unknown terminals retain upstream bgnotify unchanged, preserving its native # OS notification fallback instead of forcing an unsupported escape sequence. -zsh -f -c ' +( unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export TERM_PROGRAM=unknown-terminal - function bgnotify { print -r -- fallback; } - source "$1" + bgnotify() { print -r -- fallback; } + source "$plugin" [[ -z $_terminal_notify_protocol ]] [[ "$(bgnotify one two three)" == fallback ]] -' zsh "$plugin" +) # Exercise direct Ghostty hook removal using a fixture loaded through zsh's # normal autoload mechanism. Direct Ghostty owns command completion via OSC 133. @@ -92,31 +107,45 @@ case $hook in esac EOF -zsh -f -c ' +( typeset -ga preexec_functions precmd_functions preexec_functions=(bgnotify_begin) precmd_functions=(bgnotify_end) - function bgnotify_begin {} - function bgnotify_end {} - fpath=("$2" $fpath) + bgnotify_begin() {} + bgnotify_end() {} + fpath=("$test_root/fpath" $fpath) unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM_PROGRAM=ghostty - source "$1" + source "$plugin" [[ -z ${preexec_functions[(r)bgnotify_begin]-} ]] [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] -' zsh "$plugin" "$test_root/fpath" +) # Inside tmux Ghostty cannot rely on OSC 133 reaching the outer terminal, so it # falls back to an explicit OSC 777 notification through passthrough. -zsh -f -c ' +( unset WT_SESSION KITTY_WINDOW_ID ZELLIJ ZELLIJ_SESSION_NAME export TERM_PROGRAM=ghostty export TMUX=/tmp/tmux-test - function bgnotify { print -r -- fallback; } - source "$1" + bgnotify() { print -r -- fallback; } + source "$plugin" [[ $_terminal_notify_protocol == osc777 ]] output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'"'"'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\'"'"' ]] -' zsh "$plugin" + [[ $output == $'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\' ]] +) + +# Zellij can consume/forward notification OSC directly, so Ghostty sessions +# inside Zellij use OSC 777 without a tmux DCS envelope. +( + unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ_SESSION_NAME + export TERM_PROGRAM=ghostty + export ZELLIJ=1 + bgnotify() { print -r -- fallback; } + source "$plugin" + + [[ $_terminal_notify_protocol == osc777 ]] + output=$(bgnotify "build finished" "command completed" "") + [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] +) From 80f16079b0ad091a580b177412928cdd53124443 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:50:20 +0800 Subject: [PATCH 16/41] fix: source notification routing successfully when inactive --- home/.config/sheldon/plugins/terminal-notify.plugin.zsh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index 9fb8f49c..85786b2f 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -101,7 +101,7 @@ else # Direct Ghostty sessions have more accurate native OSC 133 command # tracking, including focus-aware command-finished notifications. _terminal_notify_disable_bgnotify - return + return 0 fi # Multiplexers can consume OSC 133 before Ghostty sees it. Emit an # explicit notification instead; tmux is wrapped by _terminal_notify_write. @@ -116,13 +116,13 @@ else WarpTerminal) # Warp already tracks command lifecycle and notifies only when away. _terminal_notify_disable_bgnotify - return + return 0 ;; esac fi # Unknown terminals keep upstream bgnotify's native OS fallbacks unchanged. -[[ -n $_terminal_notify_protocol ]] || return +[[ -n $_terminal_notify_protocol ]] || return 0 bgnotify() { local title=$1 From 81c437c2e5a4d59773029435af0f23587be943d8 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:51:29 +0800 Subject: [PATCH 17/41] refactor: drop obsolete Sheldon WSL profile --- config/conf.d/platform.wsl.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/conf.d/platform.wsl.toml b/config/conf.d/platform.wsl.toml index b6eabd92..4bb7ac61 100644 --- a/config/conf.d/platform.wsl.toml +++ b/config/conf.d/platform.wsl.toml @@ -1,9 +1,6 @@ # WSL-specific mise configuration. # This fragment is loaded only when the `wsl` environment is active. -[env] -SHELDON_PROFILE = "wsl" - [tasks."bootstrap:windows"] description = "Configure Windows font and Terminal integration" hide = false From b38c03ea7a416298b1b591d7aec83eaf4993f7e5 Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 15:59:55 +0800 Subject: [PATCH 18/41] refactor: limit terminal notifications to Ghostty WT and Rio --- .../plugins/terminal-notify.plugin.zsh | 55 ++----------------- 1 file changed, 5 insertions(+), 50 deletions(-) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index 85786b2f..098a5b6a 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -1,4 +1,4 @@ -# Route long-running command notifications through the hosting terminal when possible. +# Route long-running command notifications through supported terminals. # bgnotify remains the command-lifecycle source; this file only chooses delivery. bgnotify_bell=false @@ -55,34 +55,6 @@ _terminal_notify_osc777() { _terminal_notify_write $'\e]777;notify;'"$title;$message"$'\e\\' } -_terminal_notify_osc9() { - local title=$1 - local message=$2 - - _terminal_notify_clean "$title" - title=$REPLY - _terminal_notify_clean "$message" - message=$REPLY - - _terminal_notify_write $'\e]9;'"$title — $message"$'\e\\' -} - -_terminal_notify_osc99() { - local title=$1 - local message=$2 - local id=bgnotify - - _terminal_notify_clean "$title" - title=$REPLY - _terminal_notify_clean "$message" - message=$REPLY - - # OSC 99 is chunked: hold the notification after the title, then complete it - # with the body. Reusing the id updates the previous command notification. - _terminal_notify_write $'\e]99;i='"$id"$':d=0;'"$title"$'\e\\' - _terminal_notify_write $'\e]99;i='"$id"$':p=body:d=1;'"$message"$'\e\\' -} - if [[ -n ${WT_SESSION:-} ]]; then _terminal_notify_protocol=osc777 @@ -92,8 +64,6 @@ if [[ -n ${WT_SESSION:-} ]]; then print -r -- '__terminal_notify_dispatch__' } bgnotify_termid='__terminal_notify_host_focus__' -elif [[ -n ${KITTY_WINDOW_ID:-} || ${TERM:-} == xterm-kitty ]]; then - _terminal_notify_protocol=osc99 else case ${TERM_PROGRAM:-} in ghostty) @@ -104,33 +74,18 @@ else return 0 fi # Multiplexers can consume OSC 133 before Ghostty sees it. Emit an - # explicit notification instead; tmux is wrapped by _terminal_notify_write. + # explicit OSC 777 notification instead; tmux is wrapped above. _terminal_notify_protocol=osc777 ;; - WezTerm|rio) + rio) _terminal_notify_protocol=osc777 ;; - iTerm.app) - _terminal_notify_protocol=osc9 - ;; - WarpTerminal) - # Warp already tracks command lifecycle and notifies only when away. - _terminal_notify_disable_bgnotify - return 0 - ;; esac fi -# Unknown terminals keep upstream bgnotify's native OS fallbacks unchanged. +# Unsupported terminals keep upstream bgnotify's native OS fallbacks unchanged. [[ -n $_terminal_notify_protocol ]] || return 0 bgnotify() { - local title=$1 - local message=$2 - - case $_terminal_notify_protocol in - osc99) _terminal_notify_osc99 "$title" "$message" ;; - osc777) _terminal_notify_osc777 "$title" "$message" ;; - osc9) _terminal_notify_osc9 "$title" "$message" ;; - esac + _terminal_notify_osc777 "$1" "$2" } From 99ea530af710496fcd271f1e22b0b8ec54dbb3cd Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 16:00:09 +0800 Subject: [PATCH 19/41] test: scope terminal routing to Ghostty WT and Rio --- config/tests/terminal-notify.zsh | 68 +++++++++----------------------- 1 file changed, 18 insertions(+), 50 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index df24f607..612b82f7 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -9,7 +9,7 @@ trap 'rm -rf "$test_root"' EXIT # Windows Terminal uses OSC 777 and delegates focused-pane suppression to the # terminal itself rather than probing Windows from WSL. ( - unset TERM_PROGRAM KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset TERM_PROGRAM TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export WT_SESSION=ci bgnotify_appid() { print -r -- fallback; } @@ -23,10 +23,9 @@ trap 'rm -rf "$test_root"' EXIT [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] ) -# tmux receives the same terminal protocol through DCS passthrough with nested -# ESC bytes escaped as required by tmux. +# Windows Terminal inside tmux receives OSC 777 through DCS passthrough. ( - unset TERM_PROGRAM KITTY_WINDOW_ID ZELLIJ ZELLIJ_SESSION_NAME + unset TERM_PROGRAM ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export WT_SESSION=ci export TMUX=/tmp/tmux-test @@ -38,52 +37,22 @@ trap 'rm -rf "$test_root"' EXIT [[ $output == $'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\' ]] ) -# kitty gets its richer OSC 99 protocol with one logical notification split -# into title and body chunks. +# Rio uses OSC 777 directly. ( - unset TERM_PROGRAM WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME - export TERM=xterm-kitty - export KITTY_WINDOW_ID=1 - bgnotify() { print -r -- fallback; } - source "$plugin" - - [[ $_terminal_notify_protocol == osc99 ]] - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\e]99;i=bgnotify:d=0;build finished\e\\\e]99;i=bgnotify:p=body:d=1;command completed\e\\' ]] -) - -# iTerm2 keeps its native OSC 9 notification path. -( - unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color - export TERM_PROGRAM=iTerm.app + export TERM_PROGRAM=rio bgnotify() { print -r -- fallback; } source "$plugin" - [[ $_terminal_notify_protocol == osc9 ]] + [[ $_terminal_notify_protocol == osc777 ]] output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\e]9;build finished — command completed\e\\' ]] + [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] ) -# Rio and WezTerm both use OSC 777. -for terminal_program in rio WezTerm; do - ( - unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME - export TERM=xterm-256color - export TERM_PROGRAM=$terminal_program - bgnotify() { print -r -- fallback; } - source "$plugin" - - [[ $_terminal_notify_protocol == osc777 ]] - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] - ) -done - -# Unknown terminals retain upstream bgnotify unchanged, preserving its native -# OS notification fallback instead of forcing an unsupported escape sequence. +# Unsupported terminals retain upstream bgnotify unchanged. ( - unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM=xterm-256color export TERM_PROGRAM=unknown-terminal bgnotify() { print -r -- fallback; } @@ -93,8 +62,8 @@ done [[ "$(bgnotify one two three)" == fallback ]] ) -# Exercise direct Ghostty hook removal using a fixture loaded through zsh's -# normal autoload mechanism. Direct Ghostty owns command completion via OSC 133. +# Direct Ghostty owns command completion via OSC 133, so bgnotify hooks are +# removed instead of producing duplicate notifications. mkdir -p "$test_root/fpath" cat >"$test_root/fpath/add-zsh-hook" <<'EOF' local mode=$1 hook=$2 callback=$3 @@ -114,7 +83,7 @@ EOF bgnotify_begin() {} bgnotify_end() {} fpath=("$test_root/fpath" $fpath) - unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME export TERM_PROGRAM=ghostty source "$plugin" @@ -122,10 +91,10 @@ EOF [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] ) -# Inside tmux Ghostty cannot rely on OSC 133 reaching the outer terminal, so it -# falls back to an explicit OSC 777 notification through passthrough. +# Ghostty inside tmux cannot rely on OSC 133 reaching the outer terminal, so it +# falls back to explicit OSC 777 through passthrough. ( - unset WT_SESSION KITTY_WINDOW_ID ZELLIJ ZELLIJ_SESSION_NAME + unset WT_SESSION ZELLIJ ZELLIJ_SESSION_NAME export TERM_PROGRAM=ghostty export TMUX=/tmp/tmux-test bgnotify() { print -r -- fallback; } @@ -136,10 +105,9 @@ EOF [[ $output == $'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\' ]] ) -# Zellij can consume/forward notification OSC directly, so Ghostty sessions -# inside Zellij use OSC 777 without a tmux DCS envelope. +# Ghostty inside Zellij emits OSC 777 directly for Zellij to forward. ( - unset WT_SESSION KITTY_WINDOW_ID TMUX ZELLIJ_SESSION_NAME + unset WT_SESSION TMUX ZELLIJ_SESSION_NAME export TERM_PROGRAM=ghostty export ZELLIJ=1 bgnotify() { print -r -- fallback; } From 59cfb8945ad4f80265100c7c6c3c943a312d776a Mon Sep 17 00:00:00 2001 From: Elliot Date: Mon, 14 Sep 2026 16:01:48 +0800 Subject: [PATCH 20/41] docs: limit notification support to Ghostty WT and Rio --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 431c9fc4..e44d1aa4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Omarchy 環境則直接使用 Omarchy 的主題系統,不需要另外使用 Ti mise run bootstrap:windows ``` -長時間 command 的通知優先交給 terminal 本身處理。Ghostty 直接使用 OSC 133 command-finished notifications;Windows Terminal、Rio、WezTerm 使用 OSC 777,kitty 使用 OSC 99,iTerm2 使用 OSC 9。tmux 會使用 DCS passthrough(需啟用 `allow-passthrough`),Zellij 則直接接收 notification OSC;不支援的 terminal 仍保留 bgnotify 原本的 OS notification fallback。 +長時間 command 的通知針對 Ghostty、Windows Terminal 與 Rio 使用 terminal-native 路徑。Ghostty 直連時使用 OSC 133 command-finished notifications;Windows Terminal 與 Rio 使用 OSC 777。Ghostty 或 Windows Terminal 位於 tmux 內時會使用 DCS passthrough(需啟用 `allow-passthrough`),Zellij 則直接接收 notification OSC;其他 terminal 保留 bgnotify 原本的 OS notification fallback。 ## 常用指令 From 42a35cb81b8a8d345302c51135370894d0210b88 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:41:07 +0800 Subject: [PATCH 21/41] refactor: limit terminal notifications to Ghostty and WT --- .../plugins/terminal-notify.plugin.zsh | 50 ++++--------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index 098a5b6a..dd1844bc 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -1,9 +1,6 @@ # Route long-running command notifications through supported terminals. # bgnotify remains the command-lifecycle source; this file only chooses delivery. -bgnotify_bell=false -bgnotify_threshold=5 - typeset -g _terminal_notify_protocol='' _terminal_notify_disable_bgnotify() { @@ -28,21 +25,6 @@ _terminal_notify_clean() { REPLY=$value } -_terminal_notify_write() { - local sequence=$1 - - if [[ -n ${TMUX:-} ]]; then - # tmux DCS passthrough requires every ESC in the nested sequence to be - # doubled. `allow-passthrough` must be enabled in tmux for this to pass. - sequence=${sequence//$'\e'/$'\e\e'} - print -rn -- $'\ePtmux;'"$sequence"$'\e\\' - else - # Zellij understands notification OSC directly, so no extra envelope is - # needed there. - print -rn -- "$sequence" - fi -} - _terminal_notify_osc777() { local title=$1 local message=$2 @@ -52,40 +34,28 @@ _terminal_notify_osc777() { _terminal_notify_clean "$message" message=$REPLY - _terminal_notify_write $'\e]777;notify;'"$title;$message"$'\e\\' + print -rn -- $'\e]777;notify;'"$title;$message"$'\e\\' } if [[ -n ${WT_SESSION:-} ]]; then _terminal_notify_protocol=osc777 + bgnotify_bell=false - # Windows Terminal already suppresses OSC 777 from its focused active pane. - # Always let it make that decision instead of spawning a Windows focus probe. + # Windows Terminal suppresses OSC 777 from its focused active pane, so let + # the terminal make the focus decision instead of probing Windows from WSL. bgnotify_appid() { print -r -- '__terminal_notify_dispatch__' } bgnotify_termid='__terminal_notify_host_focus__' +elif [[ ${TERM_PROGRAM:-} == ghostty ]]; then + # Ghostty owns command completion through its OSC 133 shell integration. + _terminal_notify_disable_bgnotify + return 0 else - case ${TERM_PROGRAM:-} in - ghostty) - if [[ -z ${TMUX:-} && -z ${ZELLIJ:-} && -z ${ZELLIJ_SESSION_NAME:-} ]]; then - # Direct Ghostty sessions have more accurate native OSC 133 command - # tracking, including focus-aware command-finished notifications. - _terminal_notify_disable_bgnotify - return 0 - fi - # Multiplexers can consume OSC 133 before Ghostty sees it. Emit an - # explicit OSC 777 notification instead; tmux is wrapped above. - _terminal_notify_protocol=osc777 - ;; - rio) - _terminal_notify_protocol=osc777 - ;; - esac + # Unsupported terminals keep upstream bgnotify unchanged. + return 0 fi -# Unsupported terminals keep upstream bgnotify's native OS fallbacks unchanged. -[[ -n $_terminal_notify_protocol ]] || return 0 - bgnotify() { _terminal_notify_osc777 "$1" "$2" } From 246234aeae6ea17d4eb352dc1ca8e7ba99c644f8 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:41:17 +0800 Subject: [PATCH 22/41] test: narrow terminal notification coverage --- config/tests/terminal-notify.zsh | 70 +++++--------------------------- 1 file changed, 10 insertions(+), 60 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index 612b82f7..c3fc579a 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -9,56 +9,33 @@ trap 'rm -rf "$test_root"' EXIT # Windows Terminal uses OSC 777 and delegates focused-pane suppression to the # terminal itself rather than probing Windows from WSL. ( - unset TERM_PROGRAM TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset TERM_PROGRAM export TERM=xterm-256color export WT_SESSION=ci bgnotify_appid() { print -r -- fallback; } bgnotify_termid=fallback + bgnotify_bell=true source "$plugin" [[ $_terminal_notify_protocol == osc777 ]] + [[ $bgnotify_bell == false ]] [[ "$(bgnotify_appid)" == __terminal_notify_dispatch__ ]] [[ $bgnotify_termid == __terminal_notify_host_focus__ ]] - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] + output=$(bgnotify "build; finished" $'command\ncompleted' '') + [[ $output == $'\e]777;notify;build, finished;command completed\e\\' ]] ) -# Windows Terminal inside tmux receives OSC 777 through DCS passthrough. +# Unsupported terminals retain upstream bgnotify completely unchanged. ( - unset TERM_PROGRAM ZELLIJ ZELLIJ_SESSION_NAME - export TERM=xterm-256color - export WT_SESSION=ci - export TMUX=/tmp/tmux-test - bgnotify_appid() { print -r -- fallback; } - bgnotify_termid=fallback - source "$plugin" - - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\' ]] -) - -# Rio uses OSC 777 directly. -( - unset WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME - export TERM=xterm-256color - export TERM_PROGRAM=rio - bgnotify() { print -r -- fallback; } - source "$plugin" - - [[ $_terminal_notify_protocol == osc777 ]] - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] -) - -# Unsupported terminals retain upstream bgnotify unchanged. -( - unset WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset WT_SESSION export TERM=xterm-256color export TERM_PROGRAM=unknown-terminal bgnotify() { print -r -- fallback; } + bgnotify_bell=true source "$plugin" [[ -z $_terminal_notify_protocol ]] + [[ $bgnotify_bell == true ]] [[ "$(bgnotify one two three)" == fallback ]] ) @@ -83,37 +60,10 @@ EOF bgnotify_begin() {} bgnotify_end() {} fpath=("$test_root/fpath" $fpath) - unset WT_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME + unset WT_SESSION export TERM_PROGRAM=ghostty source "$plugin" [[ -z ${preexec_functions[(r)bgnotify_begin]-} ]] [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] ) - -# Ghostty inside tmux cannot rely on OSC 133 reaching the outer terminal, so it -# falls back to explicit OSC 777 through passthrough. -( - unset WT_SESSION ZELLIJ ZELLIJ_SESSION_NAME - export TERM_PROGRAM=ghostty - export TMUX=/tmp/tmux-test - bgnotify() { print -r -- fallback; } - source "$plugin" - - [[ $_terminal_notify_protocol == osc777 ]] - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\ePtmux;\e\e]777;notify;build finished;command completed\e\e\\\e\\' ]] -) - -# Ghostty inside Zellij emits OSC 777 directly for Zellij to forward. -( - unset WT_SESSION TMUX ZELLIJ_SESSION_NAME - export TERM_PROGRAM=ghostty - export ZELLIJ=1 - bgnotify() { print -r -- fallback; } - source "$plugin" - - [[ $_terminal_notify_protocol == osc777 ]] - output=$(bgnotify "build finished" "command completed" "") - [[ $output == $'\e]777;notify;build finished;command completed\e\\' ]] -) From fc1b83ea636507cdc3c5079b61c30f6442755a90 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:41:49 +0800 Subject: [PATCH 23/41] feat: route Herdr notifications through terminal --- home/.config/herdr/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 home/.config/herdr/config.toml diff --git a/home/.config/herdr/config.toml b/home/.config/herdr/config.toml new file mode 100644 index 00000000..516f87d3 --- /dev/null +++ b/home/.config/herdr/config.toml @@ -0,0 +1,2 @@ +[ui.toast] +delivery = "terminal" From 3c4f3d163bac4dfb7b58da5d3065fe9da73a999a Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:42:16 +0800 Subject: [PATCH 24/41] chore: track Herdr terminal notification config --- config/config.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.toml b/config/config.toml index 0d13bf4b..3b9c68a6 100644 --- a/config/config.toml +++ b/config/config.toml @@ -120,6 +120,7 @@ fc-cache -f "$font_dir" "~/.config/topgrade.toml" = { mode = "track" } "~/.config/topgrade.systemd.toml" = { mode = "track" } "~/.config/ghostty/config" = { mode = "track" } +"~/.config/herdr/config.toml" = { mode = "track" } "~/.config/xdg-terminals.list" = { mode = "track" } "~/.config/fcitx5/profile" = { mode = "track" } "~/.config/environment.d/90-fcitx5.conf" = { mode = "track" } From f65c9834d412ca143b576ba31e87b0a1a8606711 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:42:28 +0800 Subject: [PATCH 25/41] chore: enroll Herdr config in setup history --- .mise-history/manifest.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.mise-history/manifest.json b/.mise-history/manifest.json index aa35cb6e..93d7e9dc 100644 --- a/.mise-history/manifest.json +++ b/.mise-history/manifest.json @@ -67,6 +67,12 @@ "encrypt": false, "variants": [] }, + { + "path": "home/.config/herdr/config.toml", + "autosave": true, + "encrypt": false, + "variants": [] + }, { "path": "home/.config/omarchy/hooks/theme-set.d/50-themed-links", "autosave": true, From fca38a09ae14fc33df290ae9a032943a6b2a55e1 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:42:48 +0800 Subject: [PATCH 26/41] docs: narrow notification support scope --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e44d1aa4..12e13b7e 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Omarchy 環境則直接使用 Omarchy 的主題系統,不需要另外使用 Ti mise run bootstrap:windows ``` -長時間 command 的通知針對 Ghostty、Windows Terminal 與 Rio 使用 terminal-native 路徑。Ghostty 直連時使用 OSC 133 command-finished notifications;Windows Terminal 與 Rio 使用 OSC 777。Ghostty 或 Windows Terminal 位於 tmux 內時會使用 DCS passthrough(需啟用 `allow-passthrough`),Zellij 則直接接收 notification OSC;其他 terminal 保留 bgnotify 原本的 OS notification fallback。 +長時間 command 的通知只針對 Ghostty 與 Windows Terminal 使用 terminal-native 路徑:Ghostty 直接使用 OSC 133 command-finished notifications;Windows Terminal 使用 OSC 777。其他 terminal 保留 bgnotify 原本的 OS notification fallback。Herdr 則使用 `delivery = "terminal"`,由 Herdr 自己在 agent 完成或等待輸入時請外層 terminal 顯示通知,不經 shell command-completion hook。 ## 常用指令 From f00f571498d6818a85a5d902f001d2ce5d300f5c Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:43:03 +0800 Subject: [PATCH 27/41] docs: document Herdr terminal notification config --- .../skills/elliot-dotfiles/references/repository-map.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.agents/skills/elliot-dotfiles/references/repository-map.md b/.agents/skills/elliot-dotfiles/references/repository-map.md index f610bfec..64b72c1c 100644 --- a/.agents/skills/elliot-dotfiles/references/repository-map.md +++ b/.agents/skills/elliot-dotfiles/references/repository-map.md @@ -31,7 +31,7 @@ Environment selector restored to `~/.miserc.toml`. It detects Arch, Fedora, RHEL - `config/conf.d/packages.dnf.toml`: DNF-family packages/settings, including Fcitx5 packages. - `config/conf.d/packages.pacman.toml`: Pacman/Arch packages/settings, including Fcitx5 packages. - `config/conf.d/platform.omarchy.toml`: Omarchy-specific tracked theme files and theme bootstrap behavior. -- `config/conf.d/platform.wsl.toml`: WSL behavior and Windows font bootstrap override. +- `config/conf.d/platform.wsl.toml`: WSL behavior and Windows font/bootstrap integration override. - `config/conf.d/platform.wslg.toml`: WSLg-specific configuration. - `config/config.flatpak.toml`: Flatpak-specific bootstrap/update behavior. @@ -44,13 +44,14 @@ These files are restored directly to their application paths and use `mode = "tr - `home/.config/yazi/`; - `home/.config/topgrade.toml` and `home/.config/topgrade.systemd.toml`; - `home/.config/ghostty/config`; +- `home/.config/herdr/config.toml`; - `home/.config/omarchy/themed/gomi.yaml.tpl`, `home/.config/omarchy/themed-links.toml`, and `home/.config/omarchy/hooks/theme-set.d/50-themed-links`; - `home/.config/xdg-terminals.list`; - `home/.config/fcitx5/profile`; - `home/.config/environment.d/90-fcitx5.conf`; - `home/.local/share/mise-completions-sync/registry.toml`. -Fcitx5 user configuration is shared across APT, DNF, and Pacman systems; only package installation stays package-manager-specific. `~/.gitconfig` is managed only through a block edit so machine-local identity is not synchronized. +Herdr's tracked config routes agent completion/input notifications through the outer terminal with `[ui.toast] delivery = "terminal"`. Fcitx5 user configuration is shared across APT, DNF, and Pacman systems; only package installation stays package-manager-specific. `~/.gitconfig` is managed only through a block edit so machine-local identity is not synchronized. ## Bundled assets and scripts @@ -59,8 +60,9 @@ Fcitx5 user configuration is shared across APT, DNF, and Pacman systems; only pa - `config/scripts/bootstrap-ghostty-fedora.sh`: Fedora Ghostty helper. - `config/scripts/bootstrap-windows.sh`: WSL-side wrapper that installs the Windows JetBrainsMono Nerd Font only. - `config/scripts/bootstrap-windows.ps1`: Windows-native WinGet font installer. +- `config/scripts/bootstrap-windows-terminal.sh`: WSL-side Windows Terminal integration for keybindings and terminal-native notifications. - `config/scripts/tinty-windows-terminal.sh`: Tinty-side Windows Terminal integration; writes `tinty.json`, registers its JSONC import, and removes legacy `palette.json` state. -- `config/scripts/ensure-windows-terminal-import.cjs`: JSONC editor used by the Tinty Windows Terminal helper. +- `config/scripts/ensure-windows-terminal-import.cjs`: JSONC editor used by Windows Terminal integration helpers. - `config/scripts/tinty-theme-picker.sh`: platform-independent Gum-based Tinty scheme picker with apply/restore preview flow. - `config/scripts/login-github.sh`: GitHub login/configuration workflow. From cc4633e7609d4477eba5c1132935cd2bfe84949d Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 00:43:37 +0800 Subject: [PATCH 28/41] test: validate Herdr terminal notification delivery --- config/hk.pkl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/hk.pkl b/config/hk.pkl index 1b99a543..662fe46d 100644 --- a/config/hk.pkl +++ b/config/hk.pkl @@ -72,6 +72,7 @@ local checks = new Mapping { } ["terminal-notify"] { glob = List( + "../home/.config/herdr/config.toml", "../home/.config/sheldon/plugins/terminal-notify.plugin.zsh", "tests/terminal-notify.zsh", "windows-terminal/notification.json" @@ -79,6 +80,7 @@ local checks = new Mapping { check = """ zsh tests/terminal-notify.zsh jq -e '.profiles.defaults["compatibility.allowOSC777"] == true' windows-terminal/notification.json >/dev/null + grep -Fxq 'delivery = "terminal"' ../home/.config/herdr/config.toml """ } } From 5592318c408467cca7235543ce4a09b6392c6937 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 22:05:26 +0800 Subject: [PATCH 29/41] refactor: make terminal notifications self-contained --- .../plugins/terminal-notify.plugin.zsh | 87 ++++++++++++------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index dd1844bc..5e4d9c84 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -1,18 +1,16 @@ -# Route long-running command notifications through supported terminals. -# bgnotify remains the command-lifecycle source; this file only chooses delivery. +# Notify on long-running commands in Windows Terminal using OSC 777. +# Ghostty handles command completion itself through OSC 133 shell integration, +# while Herdr emits its own terminal notifications. -typeset -g _terminal_notify_protocol='' +[[ -o interactive ]] || return 0 +[[ -n ${WT_SESSION:-} ]] || return 0 -_terminal_notify_disable_bgnotify() { - autoload -Uz add-zsh-hook +zmodload zsh/datetime +autoload -Uz add-zsh-hook - if (( ${+functions[bgnotify_begin]} )); then - add-zsh-hook -d preexec bgnotify_begin - fi - if (( ${+functions[bgnotify_end]} )); then - add-zsh-hook -d precmd bgnotify_end - fi -} +typeset -g _terminal_notify_threshold=5 +typeset -g _terminal_notify_started=0 +typeset -g _terminal_notify_command='' _terminal_notify_clean() { local value=$1 @@ -25,6 +23,18 @@ _terminal_notify_clean() { REPLY=$value } +_terminal_notify_elapsed() { + local elapsed=$1 + + if (( elapsed < 60 )); then + REPLY="${elapsed}s" + elif (( elapsed < 3600 )); then + REPLY="$(( elapsed / 60 ))m $(( elapsed % 60 ))s" + else + REPLY="$(( elapsed / 3600 ))h $(( (elapsed % 3600) / 60 ))m $(( elapsed % 60 ))s" + fi +} + _terminal_notify_osc777() { local title=$1 local message=$2 @@ -37,25 +47,36 @@ _terminal_notify_osc777() { print -rn -- $'\e]777;notify;'"$title;$message"$'\e\\' } -if [[ -n ${WT_SESSION:-} ]]; then - _terminal_notify_protocol=osc777 - bgnotify_bell=false - - # Windows Terminal suppresses OSC 777 from its focused active pane, so let - # the terminal make the focus decision instead of probing Windows from WSL. - bgnotify_appid() { - print -r -- '__terminal_notify_dispatch__' - } - bgnotify_termid='__terminal_notify_host_focus__' -elif [[ ${TERM_PROGRAM:-} == ghostty ]]; then - # Ghostty owns command completion through its OSC 133 shell integration. - _terminal_notify_disable_bgnotify - return 0 -else - # Unsupported terminals keep upstream bgnotify unchanged. - return 0 -fi - -bgnotify() { - _terminal_notify_osc777 "$1" "$2" +_terminal_notify_preexec() { + _terminal_notify_started=$EPOCHSECONDS + _terminal_notify_command=${1:-$2} } + +_terminal_notify_precmd() { + local exit_status=$? + local started=$_terminal_notify_started + local command=$_terminal_notify_command + + _terminal_notify_started=0 + _terminal_notify_command='' + + (( started > 0 )) || return 0 + + local elapsed=$(( EPOCHSECONDS - started )) + (( elapsed >= _terminal_notify_threshold )) || return 0 + + _terminal_notify_elapsed "$elapsed" + local duration=$REPLY + local title + + if (( exit_status == 0 )); then + title="Command finished · $duration" + else + title="Command failed · $duration" + fi + + _terminal_notify_osc777 "$title" "$command" +} + +add-zsh-hook preexec _terminal_notify_preexec +add-zsh-hook precmd _terminal_notify_precmd From b417d5a518565313a9939ae7a4116bce2ce356eb Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 22:05:48 +0800 Subject: [PATCH 30/41] refactor: remove bgnotify dependency --- home/.config/sheldon/plugins.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/home/.config/sheldon/plugins.toml b/home/.config/sheldon/plugins.toml index a1d64950..d16f3f84 100644 --- a/home/.config/sheldon/plugins.toml +++ b/home/.config/sheldon/plugins.toml @@ -82,10 +82,6 @@ use = [ "plugins/copypath/copypath.plugin.zsh", ] -[plugins.bgnotify] -github = "ohmyzsh/ohmyzsh" -use = ["plugins/bgnotify/bgnotify.plugin.zsh"] - [plugins.terminal-notify] local = "~/.config/sheldon/plugins" From 4fc4ad85bd5e017a04d7a191bdf172726c8592af Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 22:06:03 +0800 Subject: [PATCH 31/41] test: cover self-contained terminal notifications --- config/tests/terminal-notify.zsh | 80 ++++++++++++++++---------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index c3fc579a..35189b53 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -6,64 +6,62 @@ plugin="$repo_root/home/.config/sheldon/plugins/terminal-notify.plugin.zsh" test_root=$(mktemp -d) trap 'rm -rf "$test_root"' EXIT -# Windows Terminal uses OSC 777 and delegates focused-pane suppression to the -# terminal itself rather than probing Windows from WSL. +# Windows Terminal installs its own command lifecycle hooks. ( + typeset -ga preexec_functions precmd_functions unset TERM_PROGRAM export TERM=xterm-256color export WT_SESSION=ci - bgnotify_appid() { print -r -- fallback; } - bgnotify_termid=fallback - bgnotify_bell=true source "$plugin" - [[ $_terminal_notify_protocol == osc777 ]] - [[ $bgnotify_bell == false ]] - [[ "$(bgnotify_appid)" == __terminal_notify_dispatch__ ]] - [[ $bgnotify_termid == __terminal_notify_host_focus__ ]] - output=$(bgnotify "build; finished" $'command\ncompleted' '') - [[ $output == $'\e]777;notify;build, finished;command completed\e\\' ]] + [[ ${preexec_functions[(r)_terminal_notify_preexec]-} == _terminal_notify_preexec ]] + [[ ${precmd_functions[(r)_terminal_notify_precmd]-} == _terminal_notify_precmd ]] + [[ $_terminal_notify_threshold == 5 ]] + + # Successful commands use a clean title and preserve the command in the body. + _terminal_notify_started=$(( EPOCHSECONDS - 12 )) + _terminal_notify_command=$'mise; run\nupdate' + true + _terminal_notify_precmd >"$test_root/success.out" + output=$(<"$test_root/success.out") + [[ $output == $'\e]777;notify;Command finished · 12s;mise, run update\e\\' ]] + + # Failed commands use the failure title. + _terminal_notify_started=$(( EPOCHSECONDS - 65 )) + _terminal_notify_command='cargo build --release' + set +e + false + _terminal_notify_precmd >"$test_root/failure.out" + set -e + output=$(<"$test_root/failure.out") + [[ $output == $'\e]777;notify;Command failed · 1m 5s;cargo build --release\e\\' ]] + + # Short commands stay silent. + _terminal_notify_started=$(( EPOCHSECONDS - 4 )) + _terminal_notify_command='true' + true + _terminal_notify_precmd >"$test_root/short.out" + [[ ! -s "$test_root/short.out" ]] ) -# Unsupported terminals retain upstream bgnotify completely unchanged. +# Ghostty handles command completion itself, so this plugin installs no hooks. ( + typeset -ga preexec_functions precmd_functions unset WT_SESSION - export TERM=xterm-256color - export TERM_PROGRAM=unknown-terminal - bgnotify() { print -r -- fallback; } - bgnotify_bell=true + export TERM_PROGRAM=ghostty source "$plugin" - [[ -z $_terminal_notify_protocol ]] - [[ $bgnotify_bell == true ]] - [[ "$(bgnotify one two three)" == fallback ]] + [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] + [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] ) -# Direct Ghostty owns command completion via OSC 133, so bgnotify hooks are -# removed instead of producing duplicate notifications. -mkdir -p "$test_root/fpath" -cat >"$test_root/fpath/add-zsh-hook" <<'EOF' -local mode=$1 hook=$2 callback=$3 -[[ $mode == -d ]] || return 2 - -case $hook in - preexec) preexec_functions=(${preexec_functions:#$callback}) ;; - precmd) precmd_functions=(${precmd_functions:#$callback}) ;; - *) return 2 ;; -esac -EOF - +# Unsupported terminals are left untouched. ( typeset -ga preexec_functions precmd_functions - preexec_functions=(bgnotify_begin) - precmd_functions=(bgnotify_end) - bgnotify_begin() {} - bgnotify_end() {} - fpath=("$test_root/fpath" $fpath) unset WT_SESSION - export TERM_PROGRAM=ghostty + export TERM_PROGRAM=unknown-terminal source "$plugin" - [[ -z ${preexec_functions[(r)bgnotify_begin]-} ]] - [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] + [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] + [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] ) From ebe3ea4ea46753fab084b3b2a7f50e5bbb065d47 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 22:06:23 +0800 Subject: [PATCH 32/41] docs: describe native terminal notification hooks --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 12e13b7e..7a8a493f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Omarchy 環境則直接使用 Omarchy 的主題系統,不需要另外使用 Ti mise run bootstrap:windows ``` -長時間 command 的通知只針對 Ghostty 與 Windows Terminal 使用 terminal-native 路徑:Ghostty 直接使用 OSC 133 command-finished notifications;Windows Terminal 使用 OSC 777。其他 terminal 保留 bgnotify 原本的 OS notification fallback。Herdr 則使用 `delivery = "terminal"`,由 Herdr 自己在 agent 完成或等待輸入時請外層 terminal 顯示通知,不經 shell command-completion hook。 +長時間 command 的通知只針對 Ghostty 與 Windows Terminal 使用 terminal-native 路徑。Ghostty 直接使用 OSC 133 command-finished notifications;Windows Terminal 由一個輕量 Zsh `preexec` / `precmd` hook 偵測執行超過 5 秒的 command,再以 OSC 777 顯示 `Command finished · ` 或 `Command failed · `,通知內容則是原始 command。其他 terminal 不安裝額外的 command-completion notification hook。Herdr 使用 `delivery = "terminal"`,由 Herdr 自己在 agent 完成或等待輸入時請外層 terminal 顯示通知,不經 shell command-completion hook。 ## 常用指令 From ce914cf3e8d17e7523a0695a82cbb3b1c57919fa Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 22:07:10 +0800 Subject: [PATCH 33/41] test: avoid timing-sensitive notification assertions --- config/tests/terminal-notify.zsh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index 35189b53..6ad21145 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -18,17 +18,23 @@ trap 'rm -rf "$test_root"' EXIT [[ ${precmd_functions[(r)_terminal_notify_precmd]-} == _terminal_notify_precmd ]] [[ $_terminal_notify_threshold == 5 ]] + _terminal_notify_elapsed 65 + [[ $REPLY == '1m 5s' ]] + # Successful commands use a clean title and preserve the command in the body. - _terminal_notify_started=$(( EPOCHSECONDS - 12 )) + _terminal_notify_threshold=0 + _terminal_notify_started=$EPOCHSECONDS _terminal_notify_command=$'mise; run\nupdate' + _terminal_notify_elapsed() { REPLY='12s'; } true _terminal_notify_precmd >"$test_root/success.out" output=$(<"$test_root/success.out") [[ $output == $'\e]777;notify;Command finished · 12s;mise, run update\e\\' ]] # Failed commands use the failure title. - _terminal_notify_started=$(( EPOCHSECONDS - 65 )) + _terminal_notify_started=$EPOCHSECONDS _terminal_notify_command='cargo build --release' + _terminal_notify_elapsed() { REPLY='1m 5s'; } set +e false _terminal_notify_precmd >"$test_root/failure.out" @@ -36,8 +42,9 @@ trap 'rm -rf "$test_root"' EXIT output=$(<"$test_root/failure.out") [[ $output == $'\e]777;notify;Command failed · 1m 5s;cargo build --release\e\\' ]] - # Short commands stay silent. - _terminal_notify_started=$(( EPOCHSECONDS - 4 )) + # Commands below the threshold stay silent. + _terminal_notify_threshold=999999 + _terminal_notify_started=$EPOCHSECONDS _terminal_notify_command='true' true _terminal_notify_precmd >"$test_root/short.out" From c4d0d4b56297833100146b7fc70a21a2f8f6a4df Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 23:36:54 +0800 Subject: [PATCH 34/41] test: exercise terminal notify in interactive zsh --- config/tests/terminal-notify.zsh | 60 ++++++++++++++------------------ 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index 6ad21145..f876f159 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -6,12 +6,12 @@ plugin="$repo_root/home/.config/sheldon/plugins/terminal-notify.plugin.zsh" test_root=$(mktemp -d) trap 'rm -rf "$test_root"' EXIT -# Windows Terminal installs its own command lifecycle hooks. -( +# Windows Terminal installs its own command lifecycle hooks in an interactive shell. +WT_SESSION=ci TERM=xterm-256color zsh -f -i -c ' + set -eu + plugin=$1 + test_root=$2 typeset -ga preexec_functions precmd_functions - unset TERM_PROGRAM - export TERM=xterm-256color - export WT_SESSION=ci source "$plugin" [[ ${preexec_functions[(r)_terminal_notify_preexec]-} == _terminal_notify_preexec ]] @@ -19,56 +19,48 @@ trap 'rm -rf "$test_root"' EXIT [[ $_terminal_notify_threshold == 5 ]] _terminal_notify_elapsed 65 - [[ $REPLY == '1m 5s' ]] + [[ $REPLY == "1m 5s" ]] # Successful commands use a clean title and preserve the command in the body. _terminal_notify_threshold=0 _terminal_notify_started=$EPOCHSECONDS - _terminal_notify_command=$'mise; run\nupdate' - _terminal_notify_elapsed() { REPLY='12s'; } + _terminal_notify_command=$'"'"'mise; run\nupdate'"'"' + _terminal_notify_elapsed() { REPLY="12s"; } true _terminal_notify_precmd >"$test_root/success.out" output=$(<"$test_root/success.out") - [[ $output == $'\e]777;notify;Command finished · 12s;mise, run update\e\\' ]] + [[ $output == $'"'"'\e]777;notify;Command finished · 12s;mise, run update\e\\'"'"' ]] # Failed commands use the failure title. _terminal_notify_started=$EPOCHSECONDS - _terminal_notify_command='cargo build --release' - _terminal_notify_elapsed() { REPLY='1m 5s'; } + _terminal_notify_command="cargo build --release" + _terminal_notify_elapsed() { REPLY="1m 5s"; } set +e false _terminal_notify_precmd >"$test_root/failure.out" set -e output=$(<"$test_root/failure.out") - [[ $output == $'\e]777;notify;Command failed · 1m 5s;cargo build --release\e\\' ]] + [[ $output == $'"'"'\e]777;notify;Command failed · 1m 5s;cargo build --release\e\\'"'"' ]] # Commands below the threshold stay silent. _terminal_notify_threshold=999999 _terminal_notify_started=$EPOCHSECONDS - _terminal_notify_command='true' + _terminal_notify_command="true" true _terminal_notify_precmd >"$test_root/short.out" [[ ! -s "$test_root/short.out" ]] -) +' terminal-notify-test "$plugin" "$test_root" -# Ghostty handles command completion itself, so this plugin installs no hooks. -( - typeset -ga preexec_functions precmd_functions - unset WT_SESSION - export TERM_PROGRAM=ghostty - source "$plugin" - - [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] - [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] -) - -# Unsupported terminals are left untouched. -( - typeset -ga preexec_functions precmd_functions - unset WT_SESSION - export TERM_PROGRAM=unknown-terminal - source "$plugin" +# Ghostty and unsupported terminals install no hooks because the WT marker is absent. +for term_program in ghostty unknown-terminal; do + TERM_PROGRAM=$term_program TERM=xterm-256color zsh -f -i -c ' + set -eu + plugin=$1 + typeset -ga preexec_functions precmd_functions + unset WT_SESSION + source "$plugin" - [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] - [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] -) + [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] + [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] + ' terminal-notify-test "$plugin" +done From cc52db2e3fe9a1a1e0f2044e1c239b5e8047d783 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 23:44:26 +0800 Subject: [PATCH 35/41] docs: keep notification internals out of readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a8a493f..6f51849f 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ Omarchy 環境則直接使用 Omarchy 的主題系統,不需要另外使用 Ti ## Windows / WSL -在 WSL bootstrap 時會一併安裝 Windows 端的 JetBrainsMono Nerd Font,並套用 Windows Terminal 整合:`Ctrl+Shift+Z` 會送出 Zsh redo 所需的 escape sequence,並啟用 OSC 777 terminal-native desktop notifications。需要再次執行 Windows 整合 bootstrap 時可使用: +在 WSL bootstrap 時會一併安裝 Windows 端的 JetBrainsMono Nerd Font,並套用 Windows Terminal 整合:`Ctrl+Shift+Z` 會送出 Zsh redo 所需的 escape sequence,也會啟用長時間 command 的原生桌面通知。需要再次執行 Windows 整合 bootstrap 時可使用: ```bash mise run bootstrap:windows ``` -長時間 command 的通知只針對 Ghostty 與 Windows Terminal 使用 terminal-native 路徑。Ghostty 直接使用 OSC 133 command-finished notifications;Windows Terminal 由一個輕量 Zsh `preexec` / `precmd` hook 偵測執行超過 5 秒的 command,再以 OSC 777 顯示 `Command finished · ` 或 `Command failed · `,通知內容則是原始 command。其他 terminal 不安裝額外的 command-completion notification hook。Herdr 使用 `delivery = "terminal"`,由 Herdr 自己在 agent 完成或等待輸入時請外層 terminal 顯示通知,不經 shell command-completion hook。 +Ghostty 與 Windows Terminal 會在長時間 command 完成後顯示通知,包含成功或失敗狀態、執行時間與原始 command;其他 terminal 不額外啟用 command-completion 通知。Herdr 會在 agent 完成或等待輸入時使用 terminal notification。 ## 常用指令 From 9ded697f3a16191e7df214dfb840a00ef1fcfdc2 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 23:50:09 +0800 Subject: [PATCH 36/41] fix: ignore inherited WT marker in Ghostty --- home/.config/sheldon/plugins/terminal-notify.plugin.zsh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index 5e4d9c84..0f24a651 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -3,6 +3,11 @@ # while Herdr emits its own terminal notifications. [[ -o interactive ]] || return 0 + +# Ghostty can inherit WT_SESSION when launched from a Windows Terminal WSL +# shell. Prefer the actual terminal identity so its native command-finish +# notifications are not duplicated by the Windows Terminal hook. +[[ ${TERM_PROGRAM:-} == ghostty ]] && return 0 [[ -n ${WT_SESSION:-} ]] || return 0 zmodload zsh/datetime From 911840762b59893accc7e785a1d6ee0372b5f082 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 23:50:23 +0800 Subject: [PATCH 37/41] test: cover Ghostty inheriting WT_SESSION --- config/tests/terminal-notify.zsh | 34 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index f876f159..b1d1a373 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -51,16 +51,26 @@ WT_SESSION=ci TERM=xterm-256color zsh -f -i -c ' [[ ! -s "$test_root/short.out" ]] ' terminal-notify-test "$plugin" "$test_root" -# Ghostty and unsupported terminals install no hooks because the WT marker is absent. -for term_program in ghostty unknown-terminal; do - TERM_PROGRAM=$term_program TERM=xterm-256color zsh -f -i -c ' - set -eu - plugin=$1 - typeset -ga preexec_functions precmd_functions - unset WT_SESSION - source "$plugin" +# Ghostty can inherit WT_SESSION from its parent Windows Terminal shell. The +# actual terminal identity must win so Ghostty keeps only its native notifier. +WT_SESSION=ci TERM_PROGRAM=ghostty TERM=xterm-ghostty zsh -f -i -c ' + set -eu + plugin=$1 + typeset -ga preexec_functions precmd_functions + source "$plugin" + + [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] + [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] +' terminal-notify-test "$plugin" + +# Unsupported terminals are left untouched when the Windows Terminal marker is absent. +TERM_PROGRAM=unknown-terminal TERM=xterm-256color zsh -f -i -c ' + set -eu + plugin=$1 + typeset -ga preexec_functions precmd_functions + unset WT_SESSION + source "$plugin" - [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] - [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] - ' terminal-notify-test "$plugin" -done + [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] + [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] +' terminal-notify-test "$plugin" From 7bea34eb12e2a9a160c0e9302c13232014995bff Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 23:56:55 +0800 Subject: [PATCH 38/41] fix: skip Windows Terminal notifier in tmux --- home/.config/sheldon/plugins/terminal-notify.plugin.zsh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh index 0f24a651..9acefa58 100644 --- a/home/.config/sheldon/plugins/terminal-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -8,6 +8,9 @@ # shell. Prefer the actual terminal identity so its native command-finish # notifications are not duplicated by the Windows Terminal hook. [[ ${TERM_PROGRAM:-} == ghostty ]] && return 0 + +# tmux notification passthrough is intentionally outside this notifier's scope. +[[ -n ${TMUX:-} ]] && return 0 [[ -n ${WT_SESSION:-} ]] || return 0 zmodload zsh/datetime From 38db4a34cc06d5fcd94497bd0c587bf6e5fa9ce9 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 15 Sep 2026 23:57:09 +0800 Subject: [PATCH 39/41] test: cover intentional tmux exclusion --- config/tests/terminal-notify.zsh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh index b1d1a373..5174b1f4 100644 --- a/config/tests/terminal-notify.zsh +++ b/config/tests/terminal-notify.zsh @@ -63,6 +63,18 @@ WT_SESSION=ci TERM_PROGRAM=ghostty TERM=xterm-ghostty zsh -f -i -c ' [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] ' terminal-notify-test "$plugin" +# tmux passthrough is intentionally unsupported, so an inherited WT_SESSION +# must not install hooks that would emit a sequence tmux consumes. +WT_SESSION=ci TMUX=/tmp/tmux-test TERM=xterm-256color zsh -f -i -c ' + set -eu + plugin=$1 + typeset -ga preexec_functions precmd_functions + source "$plugin" + + [[ -z ${preexec_functions[(r)_terminal_notify_preexec]-} ]] + [[ -z ${precmd_functions[(r)_terminal_notify_precmd]-} ]] +' terminal-notify-test "$plugin" + # Unsupported terminals are left untouched when the Windows Terminal marker is absent. TERM_PROGRAM=unknown-terminal TERM=xterm-256color zsh -f -i -c ' set -eu From c017dca6cf2b15f0c5195b59f263dc934663bb7b Mon Sep 17 00:00:00 2001 From: Elliot Date: Wed, 16 Sep 2026 00:51:48 +0800 Subject: [PATCH 40/41] docs: note tmux notification exclusion --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f51849f..b431b6c1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Omarchy 環境則直接使用 Omarchy 的主題系統,不需要另外使用 Ti mise run bootstrap:windows ``` -Ghostty 與 Windows Terminal 會在長時間 command 完成後顯示通知,包含成功或失敗狀態、執行時間與原始 command;其他 terminal 不額外啟用 command-completion 通知。Herdr 會在 agent 完成或等待輸入時使用 terminal notification。 +Ghostty 與 Windows Terminal 會在長時間 command 完成後顯示通知,包含成功或失敗狀態、執行時間與原始 command;Windows Terminal 的 command-completion 通知不支援 tmux session,其他 terminal 也不額外啟用此通知。Herdr 會在 agent 完成或等待輸入時使用 terminal notification。 ## 常用指令 From 028e1c050cfa5ae15a9f05120e50ad39e323fe7c Mon Sep 17 00:00:00 2001 From: Elliot Date: Wed, 16 Sep 2026 00:56:46 +0800 Subject: [PATCH 41/41] test: fail terminal notification checks fast --- config/hk.pkl | 1 + 1 file changed, 1 insertion(+) diff --git a/config/hk.pkl b/config/hk.pkl index 662fe46d..16d32f95 100644 --- a/config/hk.pkl +++ b/config/hk.pkl @@ -78,6 +78,7 @@ local checks = new Mapping { "windows-terminal/notification.json" ) check = """ + set -e zsh tests/terminal-notify.zsh jq -e '.profiles.defaults["compatibility.allowOSC777"] == true' windows-terminal/notification.json >/dev/null grep -Fxq 'delivery = "terminal"' ../home/.config/herdr/config.toml