Popup with a Focus-terminal button whenever Claude finishes a response (Windows).

Claude Code mod (Windows only): a Claude-styled popup when Claude finishes a response.
/plugin marketplace add minhnhattonthat/claude-finish-notify
/plugin install finish-notify@finish-notify
Needs Windows PowerShell 5.1 (built in).
hooks/register.ts 197 lines1import type { Register } from 'claude-code'
2
3// Windows only: a topmost no-focus-steal popup with a button that raises the terminal window hosting this session.
4const SCRIPT = String.raw`
5Add-Type -AssemblyName System.Windows.Forms,System.Drawing,UIAutomationClient,UIAutomationTypes
6Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition '
7using System; using System.Text; using System.Runtime.InteropServices; using System.Windows.Forms;
8public class Toast : Form {
9 protected override bool ShowWithoutActivation { get { return true; } }
10 protected override CreateParams CreateParams { get { var p = base.CreateParams; p.ExStyle |= 0x08000000 | 0x80; return p; } }
11}
12public static class Win {
13 [DllImport("user32")] public static extern bool SetProcessDPIAware();
14 delegate bool EP(IntPtr h, IntPtr l);
15 [DllImport("kernel32")] public static extern bool FreeConsole();
16 [DllImport("kernel32")] public static extern bool AttachConsole(uint pid);
17 [DllImport("kernel32")] public static extern IntPtr GetConsoleWindow();
18 [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] static extern IntPtr CreateFile(string f, uint a, uint s, IntPtr sec, uint d, uint fl, IntPtr t);
19 [DllImport("kernel32")] static extern bool WriteFile(IntPtr h, byte[] b, uint n, out uint w, IntPtr o);
20 [DllImport("kernel32")] static extern bool CloseHandle(IntPtr h);
21 [DllImport("user32")] public static extern IntPtr GetAncestor(IntPtr h, uint f);
22 [DllImport("user32")] public static extern bool SetForegroundWindow(IntPtr h);
23 [DllImport("user32")] public static extern bool ShowWindow(IntPtr h, int c);
24 [DllImport("user32")] public static extern bool IsIconic(IntPtr h);
25 [DllImport("user32")] public static extern bool IsWindowVisible(IntPtr h);
26 [DllImport("user32")] static extern bool EnumWindows(EP cb, IntPtr l);
27 [DllImport("user32")] static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
28 [DllImport("user32")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
29 // Titles of top-level windows starting with prefix (the other popups, hidden ones too).
30 public static string[] Titles(string prefix) {
31 var found = new System.Collections.Generic.List<string>();
32 EnumWindows(delegate(IntPtr h, IntPtr l) {
33 var sb = new StringBuilder(256); GetWindowText(h, sb, 256);
34 if (sb.ToString().StartsWith(prefix)) found.Add(sb.ToString());
35 return true; }, IntPtr.Zero);
36 return found.ToArray();
37 }
38 public static uint Pid(IntPtr h) { uint p; GetWindowThreadProcessId(h, out p); return p; }
39 // Visible titled window of any listed process, matching names in order (deepest folder first):
40 // a title segment ("file - folder - profile - app") equal to a name wins, then a substring, else the topmost window.
41 public static IntPtr FindWindow(uint[] pids, string[] names) {
42 var hs = new System.Collections.Generic.List<IntPtr>(); var ts = new System.Collections.Generic.List<string>();
43 EnumWindows(delegate(IntPtr h, IntPtr l) {
44 if (!IsWindowVisible(h) || Array.IndexOf(pids, Pid(h)) < 0) return true;
45 var sb = new StringBuilder(256); GetWindowText(h, sb, 256);
46 if (sb.Length > 0) { hs.Add(h); ts.Add(sb.ToString()); }
47 return true; }, IntPtr.Zero);
48 foreach (var n in names) for (int i = 0; i < hs.Count; i++)
49 foreach (var seg in ts[i].Split(new string[] { " - " }, StringSplitOptions.None))
50 if (string.Equals(seg.Replace(" (Workspace)", ""), n, StringComparison.OrdinalIgnoreCase)) return hs[i];
51 foreach (var n in names) for (int i = 0; i < hs.Count; i++)
52 if (ts[i].IndexOf(n, StringComparison.OrdinalIgnoreCase) >= 0) return hs[i];
53 return hs.Count > 0 ? hs[0] : IntPtr.Zero;
54 }
55 // Sets the title of the console owned by pid (the tab or pane running it) via OSC 0.
56 public static void SetTitle(uint pid, string title) {
57 FreeConsole(); if (!AttachConsole(pid)) return;
58 IntPtr h = CreateFile("CONOUT$", 0x40000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
59 byte[] b = Encoding.UTF8.GetBytes("\u001b]0;" + title + "\u0007"); uint n;
60 WriteFile(h, b, (uint)b.Length, out n, IntPtr.Zero); CloseHandle(h); FreeConsole();
61 }
62}'
63[Win]::SetProcessDPIAware() | Out-Null
64$project = $env:CC_TITLE
65$term = [IntPtr]::Zero; $conPid = 0; $isWT = $false
66# Ancestors, nearest first.
67$pids = @(); $p = $PID
68for ($i = 0; $i -lt 12; $i++) {
69 $p = (Get-CimInstance Win32_Process -Filter "ProcessId=$p").ParentProcessId
70 if (-not $p) { break }; $pids += [uint32]$p
71}
72# A visible root-owner of an ancestor console = the terminal window (conhost, Windows Terminal).
73[Win]::FreeConsole() | Out-Null
74foreach ($p in $pids) {
75 if ([Win]::AttachConsole($p)) {
76 $c = [Win]::GetConsoleWindow(); [Win]::FreeConsole() | Out-Null
77 if ($c -ne [IntPtr]::Zero) {
78 $r = [Win]::GetAncestor($c, 3); if ($r -eq [IntPtr]::Zero) { $r = $c }
79 if ([Win]::IsWindowVisible($r)) { $term = $r; $conPid = $p; break }
80 if ($conPid -eq 0) { $conPid = $p }
81 }
82 }
83}
84# Hidden pseudo-console (VS Code etc.): fall back to a window of an ancestor process, by cwd folder (the workspace may be a parent of cwd).
85$names = [string[]]@(($env:CC_CWD -split '[\\/]' | Where-Object { $_ })[-1..-2] | Where-Object { $_ })
86if ($term -eq [IntPtr]::Zero) { $term = [Win]::FindWindow($pids, $names) }
87if ($term -ne [IntPtr]::Zero) { $isWT = (Get-Process -Id ([Win]::Pid($term))).ProcessName -eq 'WindowsTerminal' }
88if ($env:CC_DEBUG) { "term=$term wt=$isWT conPid=$conPid" }
89
90function Select-Tab {
91 # Marks this console's tab with a unique title, selects the tab carrying it, then names it after the project.
92 $mark = 'cc-focus-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
93 [Win]::SetTitle($conPid, $mark); Start-Sleep -Milliseconds 400
94 $A = [Windows.Automation.AutomationElement]
95 $cond = New-Object Windows.Automation.PropertyCondition($A::ControlTypeProperty, [Windows.Automation.ControlType]::TabItem)
96 foreach ($t in $A::FromHandle($term).FindAll('Descendants', $cond)) {
97 if ($t.Current.Name -like "*$mark*") { $t.GetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern).Select(); break }
98 }
99 [Win]::SetTitle($conPid, $project)
100}
101
102# Layout in 96-dpi px, scaled to the screen's DPI; sized like a Windows toast (364 wide, 12 px from the corner).
103$s = [Drawing.Graphics]::FromHwnd([IntPtr]::Zero).DpiX / 96
104function S($n) { [int][Math]::Round($n * $s) }
105function Pt($x, $y) { New-Object Drawing.Point((S $x), (S $y)) }
106function Sz($x, $y) { New-Object Drawing.Size((S $x), (S $y)) }
107function Rgb($hex) { [Drawing.ColorTranslator]::FromHtml($hex) }
108$bg = Rgb '#262624'; $ink = Rgb '#F5F4EE'; $dim = Rgb '#A3A199'; $accent = Rgb '#D97757'; $accentHot = Rgb '#E58B6D'; $btn = Rgb '#3A3935'; $btnHot = Rgb '#4A4944'
109
110# Claude-style starburst: 12 rounded rays, alternating long and short.
111$px = S 36
112$bmp = New-Object Drawing.Bitmap($px, $px)
113$g = [Drawing.Graphics]::FromImage($bmp); $g.SmoothingMode = 'AntiAlias'
114$pen = New-Object Drawing.Pen($accent, [single]($px * 0.085)); $pen.StartCap = 'Round'; $pen.EndCap = 'Round'
115$c = $px / 2
116for ($i = 0; $i -lt 12; $i++) {
117 $a = $i * [Math]::PI / 6; $r = if ($i % 2) { $px * 0.36 } else { $px * 0.47 }
118 $g.DrawLine($pen, [single]($c + [Math]::Cos($a) * $px * 0.1), [single]($c + [Math]::Sin($a) * $px * 0.1), [single]($c + [Math]::Cos($a) * $r), [single]($c + [Math]::Sin($a) * $r))
119}
120$g.Dispose()
121
122function Lbl($text, $font, $color, $x, $y, $w, $h) {
123 New-Object Windows.Forms.Label -Property @{ Text = $text; Font = $font; ForeColor = $color; BackColor = [Drawing.Color]::Transparent; Location = (Pt $x $y); Size = (Sz $w $h); Cursor = 'Hand' }
124}
125function Btn($text, $x, $w, $back, $hot) {
126 $b = New-Object Windows.Forms.Button -Property @{ Text = $text; Font = New-Object Drawing.Font('Segoe UI', 9); ForeColor = $ink; BackColor = $back; FlatStyle = 'Flat'; UseVisualStyleBackColor = $false; Location = (Pt $x 96); Size = (Sz $w 28); Cursor = 'Hand' }
127 $b.FlatAppearance.BorderSize = 0; $b.FlatAppearance.MouseOverBackColor = $hot; $b.FlatAppearance.MouseDownBackColor = $hot
128 $b
129}
130
131$f = New-Object Toast
132$f.AutoScaleMode = 'None'; $f.FormBorderStyle = 'None'; $f.TopMost = $true; $f.ShowInTaskbar = $false; $f.StartPosition = 'Manual'
133$f.Size = Sz 364 136; $f.BackColor = $bg
134$wa = [Windows.Forms.Screen]::PrimaryScreen.WorkingArea
135# Stacking: each popup's title carries its start time; newest sits at the bottom, older ones above it, like native toasts.
136$born = [DateTime]::UtcNow.Ticks
137$f.Text = "cc-finish-notify|$born"
138# At most 3 show; older ones wait hidden and return when a newer one closes.
139function Place {
140 $slot = @([Win]::Titles('cc-finish-notify|') | Where-Object { [long]($_.Split('|')[1]) -gt $born }).Count
141 if ($slot -ge 3) { if ($f.Visible) { $f.Visible = $false }; return }
142 $f.Location = New-Object Drawing.Point(($wa.Right - $f.Width - (S 12)), ($wa.Bottom - ($f.Height + (S 12)) * ($slot + 1)))
143 if (-not $f.Visible) { $f.Visible = $true }
144}
145Place
146$f.Add_Shown({ Place })
147$f.Add_Paint({ $edge = New-Object Drawing.Pen($btn); $_.Graphics.DrawRectangle($edge, 0, 0, $f.Width - 1, $f.Height - 1) })
148
149$focus = {
150 if ($term -ne [IntPtr]::Zero) {
151 if ($isWT -and $conPid) { try { Select-Tab } catch {} }
152 if ([Win]::IsIconic($term)) { [Win]::ShowWindow($term, 9) | Out-Null }
153 [Win]::SetForegroundWindow($term) | Out-Null
154 }
155 $f.Close()
156}
157$icon = New-Object Windows.Forms.PictureBox -Property @{ Image = $bmp; Location = (Pt 16 16); Size = New-Object Drawing.Size($px, $px); BackColor = [Drawing.Color]::Transparent; Cursor = 'Hand' }
158$app = Lbl 'Claude Code' (New-Object Drawing.Font('Segoe UI', 8.5)) $dim 64 12 280 16
159$title = Lbl $env:CC_TITLE (New-Object Drawing.Font('Segoe UI Semibold', 11)) $ink 64 28 284 22
160$body = Lbl $env:CC_BODY (New-Object Drawing.Font('Segoe UI', 9)) $dim 64 52 284 36
161$go = Btn 'Focus terminal' 16 332 $accent $accentHot
162$go.Add_Click($focus)
163# Clicking anywhere else on the toast dismisses it.
164foreach ($el in $f, $icon, $app, $title, $body) { $el.Add_Click({ $f.Close() }) }
165$f.Controls.AddRange(@($icon, $app, $title, $body, $go))
166
167$restack = New-Object Windows.Forms.Timer -Property @{ Interval = 250 }
168$restack.Add_Tick({ Place }); $restack.Start()
169[Windows.Forms.Application]::Run($f)
170`
171
172export const register: Register = on => {
173 on('turn.complete', async ($, e, next) => {
174 const done = await next(e)
175
176 if (e.agentId === undefined && !e.isAborted) {
177 const cwd = await $.session.cwd()
178 const project = cwd.split(/[\\/]/).filter(Boolean).pop() ?? 'Claude'
179 const prefix = e.reason === 'answer' ? '' : `Stopped (${e.reason}): `
180 const flat = prefix + e.answer.replace(/\s+/g, ' ').trim()
181 const body = flat.length > 100 ? flat.slice(0, 99) + '…' : flat // two lines in the popup
182
183 // spawn, not run: run kills the child after its 30 s timeout. Not awaited: the popup lives until clicked
184 // (or until the session ends or the mod reloads, which kill the child).
185 void (async () => {
186 const popup = $.process.spawn({
187 argv: ['powershell', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', 'iex $env:CC_PS'],
188 env: { CC_PS: SCRIPT, CC_TITLE: project, CC_CWD: cwd, CC_BODY: body },
189 })
190 for await (const _ of popup);
191 })().catch(() => {})
192 }
193
194 return done
195 })
196}
197