SLOPSHOPPER

finish-notify

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

newprocess
v0.1.3no licenseupdated 2026-10-15minhnhattonthat/claude-finish-notify
A shopper browsing a rack in a slop shop
README

finish-notify

Claude Code mod (Windows only): a Claude-styled popup when Claude finishes a response.

  • Title is the project folder; click the popup to dismiss it.
  • Focus terminal raises the terminal window running that session (selects the tab in Windows Terminal; finds the window in VS Code).
  • Stacks up to 3 popups; older ones wait hidden and return as others close.

Install

/plugin marketplace add minhnhattonthat/claude-finish-notify
/plugin install finish-notify@finish-notify

Needs Windows PowerShell 5.1 (built in).

Source 1 files
hooks/register.ts 197 lines
1import 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