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. 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, diff --git a/README.md b/README.md index a9a3f62d..b431b6c1 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,也會啟用長時間 command 的原生桌面通知。需要再次執行 Windows 整合 bootstrap 時可使用: ```bash mise run bootstrap:windows ``` +Ghostty 與 Windows Terminal 會在長時間 command 完成後顯示通知,包含成功或失敗狀態、執行時間與原始 command;Windows Terminal 的 command-completion 通知不支援 tmux session,其他 terminal 也不額外啟用此通知。Herdr 會在 agent 完成或等待輸入時使用 terminal notification。 + ## 常用指令 | 用途 | 指令 | diff --git a/config/conf.d/platform.wsl.toml b/config/conf.d/platform.wsl.toml index 56748fb2..4bb7ac61 100644 --- a/config/conf.d/platform.wsl.toml +++ b/config/conf.d/platform.wsl.toml @@ -1,11 +1,8 @@ # 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 keybinds" +description = "Configure Windows font and Terminal integration" hide = false run = [ "sh \"${MISE_CONFIG_DIR:-$HOME/.config/mise}/scripts/bootstrap-windows.sh\"", 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" } diff --git a/config/hk.pkl b/config/hk.pkl index 5612e9a3..16d32f95 100644 --- a/config/hk.pkl +++ b/config/hk.pkl @@ -70,13 +70,19 @@ 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/herdr/config.toml", + "../home/.config/sheldon/plugins/terminal-notify.plugin.zsh", + "tests/terminal-notify.zsh", + "windows-terminal/notification.json" ) - check = "zsh tests/wsl-notify.zsh" + 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 + """ } } diff --git a/config/scripts/bootstrap-windows-terminal.sh b/config/scripts/bootstrap-windows-terminal.sh index fb788928..628b0013 100644 --- a/config/scripts/bootstrap-windows-terminal.sh +++ b/config/scripts/bootstrap-windows-terminal.sh @@ -3,14 +3,15 @@ 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 -if [ ! -f "$fragment" ]; then - printf 'windows terminal bootstrap: keybind fragment was not found: %s\n' "$fragment" >&2 - exit 1 -fi +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 + 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 +86,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 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}" \ + node "$editor_script" "$settings_path" "$import_name" + done done 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 -} diff --git a/config/tests/terminal-notify.zsh b/config/tests/terminal-notify.zsh new file mode 100644 index 00000000..5174b1f4 --- /dev/null +++ b/config/tests/terminal-notify.zsh @@ -0,0 +1,88 @@ +#!/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 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 + source "$plugin" + + [[ ${preexec_functions[(r)_terminal_notify_preexec]-} == _terminal_notify_preexec ]] + [[ ${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_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 + _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\\'"'"' ]] + + # 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" + [[ ! -s "$test_root/short.out" ]] +' terminal-notify-test "$plugin" "$test_root" + +# 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" + +# 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 + 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" 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" 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 + } + } +} 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" diff --git a/home/.config/sheldon/plugins.toml b/home/.config/sheldon/plugins.toml index 6259a45a..d16f3f84 100644 --- a/home/.config/sheldon/plugins.toml +++ b/home/.config/sheldon/plugins.toml @@ -82,14 +82,8 @@ use = [ "plugins/copypath/copypath.plugin.zsh", ] -[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] 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..9acefa58 --- /dev/null +++ b/home/.config/sheldon/plugins/terminal-notify.plugin.zsh @@ -0,0 +1,90 @@ +# 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. + +[[ -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 + +# tmux notification passthrough is intentionally outside this notifier's scope. +[[ -n ${TMUX:-} ]] && return 0 +[[ -n ${WT_SESSION:-} ]] || return 0 + +zmodload zsh/datetime +autoload -Uz add-zsh-hook + +typeset -g _terminal_notify_threshold=5 +typeset -g _terminal_notify_started=0 +typeset -g _terminal_notify_command='' + +_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_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 + + _terminal_notify_clean "$title" + title=$REPLY + _terminal_notify_clean "$message" + message=$REPLY + + print -rn -- $'\e]777;notify;'"$title;$message"$'\e\\' +} + +_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 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 -}