From 9baeecde2748a540ca7268d60743340b159a3c72 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 20:34:02 +0800 Subject: [PATCH 01/12] feat: built-in Windows toast notification with click-to-focus terminal Add a bundled PowerShell script (templates/tools/deepcode-notify.ps1) that: - Captures the console window HWND via GetConsoleWindow - Shows a BalloonTip notification when a task completes/fails - On click, activates the originating terminal via SetForegroundWindow Extend notify.ts with launchBuiltinNotify() and resolveBuiltinNotifyPath() so the CLI can resolve and invoke the bundled script without user configuration. Update SessionManager.maybeNotifyTaskCompletion() to fall back to the built-in notification on Windows when no external notify script is configured. --- src/common/notify.ts | 59 ++++++++++++++ src/session.ts | 20 +++-- templates/tools/deepcode-notify.ps1 | 122 ++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 templates/tools/deepcode-notify.ps1 diff --git a/src/common/notify.ts b/src/common/notify.ts index d1b541b5..05637b3a 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -1,4 +1,5 @@ import { spawn, type SpawnOptions } from "child_process"; +import * as path from "path"; type NotifyChildProcess = { once(event: "error", listener: (error: NodeJS.ErrnoException) => void): NotifyChildProcess; @@ -96,3 +97,61 @@ export function launchNotifyScript( // Ignore notification failures. } } + +/** + * Resolve the bundled built-in notification script shipped with the CLI. + * In the esbuild bundle this lives next to `dist/cli.js`, so we resolve + * relative to `__dirname` (which is `dist/`) and walk up to the project root. + */ +export function resolveBuiltinNotifyPath(): string | null { + if (process.platform !== "win32") { + return null; + } + try { + // __dirname is dist/ in the bundled output; the scripts live at + // /templates/tools/deepcode-notify.ps1 + return path.resolve(__dirname, "..", "templates", "tools", "deepcode-notify.ps1"); + } catch { + return null; + } +} + +/** + * Launch the built-in Windows notification (PowerShell BalloonTip with + * click-to-focus behaviour). Has no effect on non-Windows platforms. + * + * This is intentionally separate from `launchNotifyScript` so that callers + * can decide whether to prefer a user-configured external script or the + * built-in one. + */ +export function launchBuiltinNotify( + durationMs: number, + workingDirectory?: string, + spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, + configuredEnv: Record = {}, + context: NotifyContext = {} +): void { + const scriptPath = resolveBuiltinNotifyPath(); + if (!scriptPath) { + return; + } + + const options = { + cwd: workingDirectory, + detached: false, + env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context), + stdio: "ignore" as const, + }; + + try { + const child = spawnProcess( + "powershell.exe", + ["-ExecutionPolicy", "Bypass", "-NoProfile", "-File", scriptPath], + options + ); + child.once("error", () => undefined); + child.unref(); + } catch { + // Ignore notification failures. + } +} diff --git a/src/session.ts b/src/session.ts index f12b91f2..8240e9bf 100644 --- a/src/session.ts +++ b/src/session.ts @@ -5,7 +5,7 @@ import * as crypto from "crypto"; import matter from "gray-matter"; import ejs from "ejs"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; -import { launchNotifyScript } from "./common/notify"; +import { launchBuiltinNotify, launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities"; import { readTextFileWithMetadata } from "./common/file-utils"; @@ -2423,10 +2423,6 @@ ${skillMd} startedAt: number, configuredEnv: Record = {} ): void { - if (!notifyCommand) { - return; - } - const session = this.getSession(sessionId); if (!session || (session.status !== "completed" && session.status !== "failed")) { return; @@ -2443,12 +2439,22 @@ ${skillMd} } } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, { + const context = { status: session.status, failReason: session.failReason ?? undefined, body, title: session.summary ?? undefined, - }); + }; + + if (notifyCommand) { + launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, context); + return; + } + + // Windows: fall back to the built-in toast notification with click-to-focus. + if (process.platform === "win32") { + launchBuiltinNotify(Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, context); + } } private addSessionProcess(sessionId: string, processId: string | number, command: string): void { diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 new file mode 100644 index 00000000..d3cc91bb --- /dev/null +++ b/templates/tools/deepcode-notify.ps1 @@ -0,0 +1,122 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + DeepCode CLI built-in Windows notification script. + Shows a BalloonTip when a task completes or fails. + Click the notification to jump to the originating terminal window. + +.DESCRIPTION + Invoked automatically by DeepCode CLI on Windows when the `notify` + setting is either unset or set to "builtin". + + Environment variables passed by the CLI: + STATUS - "completed" | "failed" | "interrupted" + TITLE - Session summary / task title + BODY - Last assistant message body + DURATION - Task wall-clock duration in seconds +#> + +param() + +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# Read context from environment variables +# --------------------------------------------------------------------------- +$Status = $env:STATUS +$Title = $env:TITLE +$Body = $env:BODY +$Duration = $env:DURATION + +# --------------------------------------------------------------------------- +# Build notification text +# --------------------------------------------------------------------------- +$statusLabel = switch ($Status) { + "failed" { "Failed" } + "interrupted" { "Interrupted" } + default { "Completed" } +} + +$iconType = if ($Status -eq "failed") { "Error" } else { "Info" } + +$titleText = if ($Title) { "$Title" } else { "DeepCode Task" } + +$shortBody = if ($Body) { + if ($Body.Length -gt 120) { $Body.Substring(0, 117) + "..." } else { $Body } +} else { "" } + +$parts = @() +if ($shortBody) { $parts += $shortBody } +$parts += "[$statusLabel] Duration: ${Duration}s" +$parts += "Click here to jump to the terminal window" +$bodyText = $parts -join "`n" + +# --------------------------------------------------------------------------- +# Capture the console window handle +# (runs in the same console as the parent deepcode process) +# --------------------------------------------------------------------------- +Add-Type -MemberDefinition @' +[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +'@ -Name Win32 -Namespace DeepCodeNotify + +$consoleHwnd = [DeepCodeNotify.Win32]::GetConsoleWindow() + +if ($consoleHwnd -eq [IntPtr]::Zero) { + # No console attached — show a non-clickable notification and exit + Write-Warning "DeepCode: Could not capture console window handle." + exit 0 +} + +# --------------------------------------------------------------------------- +# Show the notification +# --------------------------------------------------------------------------- +Add-Type -AssemblyName System.Windows.Forms + +$notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Icon = [System.Drawing.SystemIcons]::Information +$notify.BalloonTipTitle = $titleText +$notify.BalloonTipText = $bodyText +$notify.BalloonTipIcon = $iconType +$notify.Visible = $true + +# Register click handler. +# Register-ObjectEvent runs the -Action in a background job, so we must +# redeclare the P/Invoke types inside the action. +Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked ` + -MessageData $consoleHwnd ` + -Action { + param($eventSourceIdentifier, $eventSender) + $hwnd = $Event.MessageData + try { + Add-Type -MemberDefinition @' +[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +'@ -Name Win32 -Namespace DeepCodeNotifyClick -IgnoreWarnings + } catch { } + + [DeepCodeNotifyClick.Win32]::ShowWindow($hwnd, 9) # SW_RESTORE + [DeepCodeNotifyClick.Win32]::SetForegroundWindow($hwnd) + + if ($Event.Sender) { + $Event.Sender.Dispose() + } + } | Out-Null + +# Register dismiss handler so we clean up even if timed out +Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed -Action { + if ($Event.Sender) { $Event.Sender.Dispose() } +} | Out-Null + +$notify.ShowBalloonTip(30000) # Show for up to 30 seconds + +# --------------------------------------------------------------------------- +# Keep the process alive to receive click / dismiss events +# --------------------------------------------------------------------------- +try { + Wait-Event -Timeout 35 +} finally { + Get-EventSubscriber | Unregister-Event -Force -ErrorAction SilentlyContinue + try { $notify.Dispose() } catch { } +} From f047b1df3555ccfdb33889b4b0f3a849ca098b3d Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 20:47:32 +0800 Subject: [PATCH 02/12] fix: handle missing __dirname in esbuild ESM bundle for resolveBuiltinNotifyPath --- src/common/notify.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/common/notify.ts b/src/common/notify.ts index 05637b3a..e090286a 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -1,5 +1,6 @@ import { spawn, type SpawnOptions } from "child_process"; import * as path from "path"; +import { fileURLToPath } from "url"; type NotifyChildProcess = { once(event: "error", listener: (error: NodeJS.ErrnoException) => void): NotifyChildProcess; @@ -100,17 +101,19 @@ export function launchNotifyScript( /** * Resolve the bundled built-in notification script shipped with the CLI. - * In the esbuild bundle this lives next to `dist/cli.js`, so we resolve - * relative to `__dirname` (which is `dist/`) and walk up to the project root. + * The esbuild ESM bundle does not inject `__dirname`, so we replicate the + * same fallback pattern used by `getExtensionRoot`. */ export function resolveBuiltinNotifyPath(): string | null { if (process.platform !== "win32") { return null; } try { - // __dirname is dist/ in the bundled output; the scripts live at - // /templates/tools/deepcode-notify.ps1 - return path.resolve(__dirname, "..", "templates", "tools", "deepcode-notify.ps1"); + const root = + typeof __dirname !== "undefined" + ? path.resolve(__dirname, "..") + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + return path.join(root, "templates", "tools", "deepcode-notify.ps1"); } catch { return null; } From 385e773956b3343dcf88d249a820befa82cb8b35 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 21:05:40 +0800 Subject: [PATCH 03/12] fix: use AttachThreadInput helper process for SetForegroundWindow permission --- templates/tools/deepcode-notify.ps1 | 110 ++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index d3cc91bb..762d2526 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -38,7 +38,6 @@ $statusLabel = switch ($Status) { } $iconType = if ($Status -eq "failed") { "Error" } else { "Info" } - $titleText = if ($Title) { "$Title" } else { "DeepCode Task" } $shortBody = if ($Body) { @@ -53,70 +52,121 @@ $bodyText = $parts -join "`n" # --------------------------------------------------------------------------- # Capture the console window handle -# (runs in the same console as the parent deepcode process) +# Running in the same console as the parent deepcode process, so +# GetConsoleWindow() returns the correct HWND. # --------------------------------------------------------------------------- Add-Type -MemberDefinition @' -[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); -[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); -[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +[DllImport("kernel32.dll")] +public static extern IntPtr GetConsoleWindow(); +[DllImport("user32.dll")] +public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] +public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +[DllImport("user32.dll")] +public static extern IntPtr GetForegroundWindow(); +[DllImport("user32.dll")] +public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); +[DllImport("user32.dll")] +public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); '@ -Name Win32 -Namespace DeepCodeNotify $consoleHwnd = [DeepCodeNotify.Win32]::GetConsoleWindow() if ($consoleHwnd -eq [IntPtr]::Zero) { - # No console attached — show a non-clickable notification and exit Write-Warning "DeepCode: Could not capture console window handle." exit 0 } +# Persist the HWND so the click handler can reach it even from a +# background job that has no access to the main-script scope. +$hwndFileBase = Join-Path ([System.IO.Path]::GetTempPath()) "deepcode\notify-hwnd" +New-Item -ItemType Directory -Path (Split-Path $hwndFileBase) -Force | Out-Null +$hwndFile = "$hwndFileBase-$PID.txt" +$consoleHwnd.ToInt64().ToString() | Out-File -FilePath $hwndFile -NoNewline + +# --------------------------------------------------------------------------- +# Window activation helper (written to disk so the click handler can +# invoke it as a fresh process that respects foreground rules). +# --------------------------------------------------------------------------- +$activatePs1 = "$hwndFileBase-activate-$PID.ps1" +@' +param([uint64]$WindowHwnd) + +Add-Type -MemberDefinition @" +[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); +[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); +[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); +"@ -Name W32 -Namespace DA + +$hwnd = [IntPtr]::new([int64]$WindowHwnd) +[DA.W32]::ShowWindow($hwnd, 9) + +# AttachThreadInput trick: briefly attach the target window's thread to +# the foreground thread so SetForegroundWindow is permitted. +$fgHwnd = [DA.W32]::GetForegroundWindow() +$tidTarget = 0; $null = [DA.W32]::GetWindowThreadProcessId($hwnd, [ref]$tidTarget) +$tidFg = 0; $null = [DA.W32]::GetWindowThreadProcessId($fgHwnd, [ref]$tidFg) +if ($tidTarget -and $tidFg) { + [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $true) + [DA.W32]::SetForegroundWindow($hwnd) + [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $false) +} else { + [DA.W32]::SetForegroundWindow($hwnd) +} +'@ | Out-File -FilePath $activatePs1 -Encoding UTF8 + # --------------------------------------------------------------------------- # Show the notification # --------------------------------------------------------------------------- Add-Type -AssemblyName System.Windows.Forms $notify = New-Object System.Windows.Forms.NotifyIcon -$notify.Icon = [System.Drawing.SystemIcons]::Information -$notify.BalloonTipTitle = $titleText -$notify.BalloonTipText = $bodyText -$notify.BalloonTipIcon = $iconType -$notify.Visible = $true +$notify.Icon = [System.Drawing.SystemIcons]::Information +$notify.BalloonTipTitle = $titleText +$notify.BalloonTipText = $bodyText +$notify.BalloonTipIcon = $iconType +$notify.Visible = $true # Register click handler. -# Register-ObjectEvent runs the -Action in a background job, so we must -# redeclare the P/Invoke types inside the action. +# Register-ObjectEvent runs the -Action in a background job. The job +# cannot reliably call SetForegroundWindow itself (foreground-lock), +# so we make the action spawn a short-lived helper PowerShell that +# uses AttachThreadInput to gain permission and activate the window. Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked ` - -MessageData $consoleHwnd ` + -MessageData @{ + HwndFile = $hwndFile + ActivatePs1 = $activatePs1 + } ` -Action { - param($eventSourceIdentifier, $eventSender) - $hwnd = $Event.MessageData try { - Add-Type -MemberDefinition @' -[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); -[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -'@ -Name Win32 -Namespace DeepCodeNotifyClick -IgnoreWarnings + $data = $Event.MessageData + $hwndVal = Get-Content $data.HwndFile -Raw -ErrorAction Stop + Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $data.ActivatePs1, + "-WindowHwnd", $hwndVal.Trim() + ) -WindowStyle Hidden -Wait } catch { } - [DeepCodeNotifyClick.Win32]::ShowWindow($hwnd, 9) # SW_RESTORE - [DeepCodeNotifyClick.Win32]::SetForegroundWindow($hwnd) - - if ($Event.Sender) { - $Event.Sender.Dispose() - } + try { $Event.Sender.Dispose() } catch { } } | Out-Null -# Register dismiss handler so we clean up even if timed out +# Dismiss handler Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed -Action { - if ($Event.Sender) { $Event.Sender.Dispose() } + try { $Event.Sender.Dispose() } catch { } } | Out-Null -$notify.ShowBalloonTip(30000) # Show for up to 30 seconds +$notify.ShowBalloonTip(30000) # --------------------------------------------------------------------------- -# Keep the process alive to receive click / dismiss events +# Keep the process alive to receive events # --------------------------------------------------------------------------- try { Wait-Event -Timeout 35 } finally { Get-EventSubscriber | Unregister-Event -Force -ErrorAction SilentlyContinue try { $notify.Dispose() } catch { } + Remove-Item $hwndFile, $activatePs1 -Force -ErrorAction SilentlyContinue } From dba8c2a33b83a91a2b8e872df9d75b73d20d94ab Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 21:12:31 +0800 Subject: [PATCH 04/12] fix: multi-strategy window activation (topmost + keybd_event + SwitchToThisWindow) for Win11 --- templates/tools/deepcode-notify.ps1 | 33 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index 762d2526..69c4a915 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -98,23 +98,44 @@ Add-Type -MemberDefinition @" [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); +[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); +[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); +[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); "@ -Name W32 -Namespace DA $hwnd = [IntPtr]::new([int64]$WindowHwnd) -[DA.W32]::ShowWindow($hwnd, 9) -# AttachThreadInput trick: briefly attach the target window's thread to -# the foreground thread so SetForegroundWindow is permitted. +# 1. Restore the window if minimized +[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE +Start-Sleep -Milliseconds 50 + +# 2. Bring to top briefly to give it Z-order priority +$SWP_NOMOVE = 0x0002; $SWP_NOSIZE = 0x0001 +$HWND_TOPMOST = [IntPtr](-1); $HWND_NOTOPMOST = [IntPtr](-2) +[DA.W32]::SetWindowPos($hwnd, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_NOMOVE -bor $SWP_NOSIZE) +[DA.W32]::SetWindowPos($hwnd, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_NOMOVE -bor $SWP_NOSIZE) + +# 3. Simulate Alt key press to gain foreground activation permission +[DA.W32]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) # Alt down +Start-Sleep -Milliseconds 50 +[DA.W32]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) # Alt up +Start-Sleep -Milliseconds 50 + +# 4. AttachThreadInput + SetForegroundWindow $fgHwnd = [DA.W32]::GetForegroundWindow() $tidTarget = 0; $null = [DA.W32]::GetWindowThreadProcessId($hwnd, [ref]$tidTarget) $tidFg = 0; $null = [DA.W32]::GetWindowThreadProcessId($fgHwnd, [ref]$tidFg) if ($tidTarget -and $tidFg) { [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $true) - [DA.W32]::SetForegroundWindow($hwnd) +} +[DA.W32]::SetForegroundWindow($hwnd) +if ($tidTarget -and $tidFg) { [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $false) -} else { - [DA.W32]::SetForegroundWindow($hwnd) } + +# 5. Fallback: SwitchToThisWindow (undocumented, often works on Win11) +Start-Sleep -Milliseconds 50 +[DA.W32]::SwitchToThisWindow($hwnd, $true) '@ | Out-File -FilePath $activatePs1 -Encoding UTF8 # --------------------------------------------------------------------------- From dbeb18f2a552bcb44885ce66a6f40a08ffd76676 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 21:19:38 +0800 Subject: [PATCH 05/12] fix: show user question in notification body; handle minimized window restore with IsIconic check --- src/session.ts | 2 +- templates/tools/deepcode-notify.ps1 | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/session.ts b/src/session.ts index 8240e9bf..e52ab167 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2433,7 +2433,7 @@ ${skillMd} const messages = this.listSessionMessages(sessionId); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg && msg.role === "assistant" && msg.content) { + if (msg && msg.role === "user" && msg.content) { body = msg.content; break; } diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index 69c4a915..597807bf 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -101,25 +101,31 @@ Add-Type -MemberDefinition @" [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); +[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); "@ -Name W32 -Namespace DA $hwnd = [IntPtr]::new([int64]$WindowHwnd) -# 1. Restore the window if minimized -[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE -Start-Sleep -Milliseconds 50 +# 1. Restore if minimized — call ShowWindow twice to be safe +[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE +Start-Sleep -Milliseconds 150 +if ([DA.W32]::IsIconic($hwnd)) { + [DA.W32]::ShowWindow($hwnd, 1) # SW_SHOWNORMAL + Start-Sleep -Milliseconds 150 +} # 2. Bring to top briefly to give it Z-order priority $SWP_NOMOVE = 0x0002; $SWP_NOSIZE = 0x0001 $HWND_TOPMOST = [IntPtr](-1); $HWND_NOTOPMOST = [IntPtr](-2) [DA.W32]::SetWindowPos($hwnd, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_NOMOVE -bor $SWP_NOSIZE) [DA.W32]::SetWindowPos($hwnd, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_NOMOVE -bor $SWP_NOSIZE) +Start-Sleep -Milliseconds 50 # 3. Simulate Alt key press to gain foreground activation permission [DA.W32]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) # Alt down Start-Sleep -Milliseconds 50 [DA.W32]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) # Alt up -Start-Sleep -Milliseconds 50 +Start-Sleep -Milliseconds 100 # 4. AttachThreadInput + SetForegroundWindow $fgHwnd = [DA.W32]::GetForegroundWindow() From d66c2d395b3ebcf033fcd120f38382cd293f0ded Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 21:33:32 +0800 Subject: [PATCH 06/12] fix: use WM_SYSCOMMAND/SC_RESTORE + BringWindowToTop for reliable minimized window restore --- templates/tools/deepcode-notify.ps1 | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index 597807bf..2f381f6f 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -99,32 +99,35 @@ Add-Type -MemberDefinition @" [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); -[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); -[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); +[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); +[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); +[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); "@ -Name W32 -Namespace DA $hwnd = [IntPtr]::new([int64]$WindowHwnd) -# 1. Restore if minimized — call ShowWindow twice to be safe -[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE +# 1. Restore — use WM_SYSCOMMAND / SC_RESTORE (how Windows taskbar restores windows) +$WM_SYSCOMMAND = 0x0112; $SC_RESTORE = [IntPtr]0xF120 +[DA.W32]::SendMessage($hwnd, $WM_SYSCOMMAND, $SC_RESTORE, [IntPtr]::Zero) | Out-Null +Start-Sleep -Milliseconds 100 +[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE (belt-and-suspenders) Start-Sleep -Milliseconds 150 + +# Still minimized? Try ShowWindow with SW_SHOWNORMAL if ([DA.W32]::IsIconic($hwnd)) { [DA.W32]::ShowWindow($hwnd, 1) # SW_SHOWNORMAL - Start-Sleep -Milliseconds 150 + Start-Sleep -Milliseconds 200 } -# 2. Bring to top briefly to give it Z-order priority -$SWP_NOMOVE = 0x0002; $SWP_NOSIZE = 0x0001 -$HWND_TOPMOST = [IntPtr](-1); $HWND_NOTOPMOST = [IntPtr](-2) -[DA.W32]::SetWindowPos($hwnd, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_NOMOVE -bor $SWP_NOSIZE) -[DA.W32]::SetWindowPos($hwnd, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_NOMOVE -bor $SWP_NOSIZE) +# 2. Bring to front +[DA.W32]::BringWindowToTop($hwnd) | Out-Null Start-Sleep -Milliseconds 50 -# 3. Simulate Alt key press to gain foreground activation permission -[DA.W32]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) # Alt down +# 3. Simulate Alt key press to gain foreground permission +[DA.W32]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) Start-Sleep -Milliseconds 50 -[DA.W32]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) # Alt up +[DA.W32]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) Start-Sleep -Milliseconds 100 # 4. AttachThreadInput + SetForegroundWindow @@ -139,7 +142,7 @@ if ($tidTarget -and $tidFg) { [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $false) } -# 5. Fallback: SwitchToThisWindow (undocumented, often works on Win11) +# 5. Fallback: SwitchToThisWindow Start-Sleep -Milliseconds 50 [DA.W32]::SwitchToThisWindow($hwnd, $true) '@ | Out-File -FilePath $activatePs1 -Encoding UTF8 From c6f6068f391e49eed55f20758acec02c1abef56e Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 21:51:03 +0800 Subject: [PATCH 07/12] fix: call AllowSetForegroundWindow(-1) before spawning activate helper to grant foreground permission --- templates/tools/deepcode-notify.ps1 | 54 +++++++---------------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index 2f381f6f..3a7b6641 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -95,54 +95,24 @@ param([uint64]$WindowHwnd) Add-Type -MemberDefinition @" [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); -[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); -[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); -[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); -[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); -[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); -[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); +[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); "@ -Name W32 -Namespace DA $hwnd = [IntPtr]::new([int64]$WindowHwnd) -# 1. Restore — use WM_SYSCOMMAND / SC_RESTORE (how Windows taskbar restores windows) -$WM_SYSCOMMAND = 0x0112; $SC_RESTORE = [IntPtr]0xF120 -[DA.W32]::SendMessage($hwnd, $WM_SYSCOMMAND, $SC_RESTORE, [IntPtr]::Zero) | Out-Null -Start-Sleep -Milliseconds 100 -[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE (belt-and-suspenders) +# 1. Restore from minimized +[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE Start-Sleep -Milliseconds 150 - -# Still minimized? Try ShowWindow with SW_SHOWNORMAL if ([DA.W32]::IsIconic($hwnd)) { [DA.W32]::ShowWindow($hwnd, 1) # SW_SHOWNORMAL - Start-Sleep -Milliseconds 200 + Start-Sleep -Milliseconds 150 } -# 2. Bring to front -[DA.W32]::BringWindowToTop($hwnd) | Out-Null -Start-Sleep -Milliseconds 50 +# 2. Set foreground (AllowSetForegroundWindow already called by parent) +[DA.W32]::SetForegroundWindow($hwnd) | Out-Null -# 3. Simulate Alt key press to gain foreground permission -[DA.W32]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) -Start-Sleep -Milliseconds 50 -[DA.W32]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) -Start-Sleep -Milliseconds 100 - -# 4. AttachThreadInput + SetForegroundWindow -$fgHwnd = [DA.W32]::GetForegroundWindow() -$tidTarget = 0; $null = [DA.W32]::GetWindowThreadProcessId($hwnd, [ref]$tidTarget) -$tidFg = 0; $null = [DA.W32]::GetWindowThreadProcessId($fgHwnd, [ref]$tidFg) -if ($tidTarget -and $tidFg) { - [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $true) -} -[DA.W32]::SetForegroundWindow($hwnd) -if ($tidTarget -and $tidFg) { - [DA.W32]::AttachThreadInput($tidTarget, $tidFg, $false) -} - -# 5. Fallback: SwitchToThisWindow +# 3. Fallback Start-Sleep -Milliseconds 50 [DA.W32]::SwitchToThisWindow($hwnd, $true) '@ | Out-File -FilePath $activatePs1 -Encoding UTF8 @@ -160,10 +130,9 @@ $notify.BalloonTipIcon = $iconType $notify.Visible = $true # Register click handler. -# Register-ObjectEvent runs the -Action in a background job. The job -# cannot reliably call SetForegroundWindow itself (foreground-lock), -# so we make the action spawn a short-lived helper PowerShell that -# uses AttachThreadInput to gain permission and activate the window. +# The BalloonTip click gives THIS process temporary foreground rights. +# We call AllowSetForegroundWindow(-1) so the child helper process we +# spawn is permitted to call SetForegroundWindow on the target window. Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked ` -MessageData @{ HwndFile = $hwndFile @@ -171,6 +140,9 @@ Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked ` } ` -Action { try { + Add-Type -MemberDefinition '[DllImport("user32.dll")]public static extern bool AllowSetForegroundWindow(int dwProcessId);' -Name ASFW -Namespace DC + [DC.ASFW]::AllowSetForegroundWindow(-1) | Out-Null + $data = $Event.MessageData $hwndVal = Get-Content $data.HwndFile -Raw -ErrorAction Stop Start-Process powershell.exe -ArgumentList @( From aced970b4ace384daef3cd85b121b66295b728a0 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 21:58:57 +0800 Subject: [PATCH 08/12] refactor: use main-thread Wait-Event instead of background job -Action for foreground permission --- templates/tools/deepcode-notify.ps1 | 137 ++++++++++++---------------- 1 file changed, 57 insertions(+), 80 deletions(-) diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index 3a7b6641..b1c02451 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -12,7 +12,7 @@ Environment variables passed by the CLI: STATUS - "completed" | "failed" | "interrupted" TITLE - Session summary / task title - BODY - Last assistant message body + BODY - Last user message body DURATION - Task wall-clock duration in seconds #> @@ -51,9 +51,7 @@ $parts += "Click here to jump to the terminal window" $bodyText = $parts -join "`n" # --------------------------------------------------------------------------- -# Capture the console window handle -# Running in the same console as the parent deepcode process, so -# GetConsoleWindow() returns the correct HWND. +# Win32 API declarations (used to capture & activate the console window) # --------------------------------------------------------------------------- Add-Type -MemberDefinition @' [DllImport("kernel32.dll")] @@ -63,62 +61,45 @@ public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] -public static extern IntPtr GetForegroundWindow(); +public static extern bool IsIconic(IntPtr hWnd); [DllImport("user32.dll")] -public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); -[DllImport("user32.dll")] -public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); -'@ -Name Win32 -Namespace DeepCodeNotify +public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); +'@ -Name Win32 -Namespace DC -$consoleHwnd = [DeepCodeNotify.Win32]::GetConsoleWindow() +$consoleHwnd = [DC.Win32]::GetConsoleWindow() if ($consoleHwnd -eq [IntPtr]::Zero) { Write-Warning "DeepCode: Could not capture console window handle." exit 0 } -# Persist the HWND so the click handler can reach it even from a -# background job that has no access to the main-script scope. -$hwndFileBase = Join-Path ([System.IO.Path]::GetTempPath()) "deepcode\notify-hwnd" -New-Item -ItemType Directory -Path (Split-Path $hwndFileBase) -Force | Out-Null -$hwndFile = "$hwndFileBase-$PID.txt" -$consoleHwnd.ToInt64().ToString() | Out-File -FilePath $hwndFile -NoNewline - # --------------------------------------------------------------------------- -# Window activation helper (written to disk so the click handler can -# invoke it as a fresh process that respects foreground rules). +# Helper: activate & focus the target console window # --------------------------------------------------------------------------- -$activatePs1 = "$hwndFileBase-activate-$PID.ps1" -@' -param([uint64]$WindowHwnd) - -Add-Type -MemberDefinition @" -[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); -[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); -[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); -"@ -Name W32 -Namespace DA - -$hwnd = [IntPtr]::new([int64]$WindowHwnd) - -# 1. Restore from minimized -[DA.W32]::ShowWindow($hwnd, 9) # SW_RESTORE -Start-Sleep -Milliseconds 150 -if ([DA.W32]::IsIconic($hwnd)) { - [DA.W32]::ShowWindow($hwnd, 1) # SW_SHOWNORMAL - Start-Sleep -Milliseconds 150 +function Activate-ConsoleWindow { + param([IntPtr]$hwnd) + + # 1. Restore from minimized + [DC.Win32]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE + Start-Sleep -Milliseconds 200 + + if ([DC.Win32]::IsIconic($hwnd)) { + [DC.Win32]::ShowWindow($hwnd, 1) | Out-Null # SW_SHOWNORMAL + Start-Sleep -Milliseconds 200 + } + + # 2. Set as foreground window + # (called from the main thread while we still have foreground rights + # from the BalloonTip click) + [DC.Win32]::SetForegroundWindow($hwnd) | Out-Null + + # 3. Undocumented fallback that often works on modern Windows + Start-Sleep -Milliseconds 50 + [DC.Win32]::SwitchToThisWindow($hwnd, $true) } -# 2. Set foreground (AllowSetForegroundWindow already called by parent) -[DA.W32]::SetForegroundWindow($hwnd) | Out-Null - -# 3. Fallback -Start-Sleep -Milliseconds 50 -[DA.W32]::SwitchToThisWindow($hwnd, $true) -'@ | Out-File -FilePath $activatePs1 -Encoding UTF8 - # --------------------------------------------------------------------------- -# Show the notification +# Build the notification # --------------------------------------------------------------------------- Add-Type -AssemblyName System.Windows.Forms @@ -129,46 +110,42 @@ $notify.BalloonTipText = $bodyText $notify.BalloonTipIcon = $iconType $notify.Visible = $true -# Register click handler. -# The BalloonTip click gives THIS process temporary foreground rights. -# We call AllowSetForegroundWindow(-1) so the child helper process we -# spawn is permitted to call SetForegroundWindow on the target window. -Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked ` - -MessageData @{ - HwndFile = $hwndFile - ActivatePs1 = $activatePs1 - } ` - -Action { - try { - Add-Type -MemberDefinition '[DllImport("user32.dll")]public static extern bool AllowSetForegroundWindow(int dwProcessId);' -Name ASFW -Namespace DC - [DC.ASFW]::AllowSetForegroundWindow(-1) | Out-Null - - $data = $Event.MessageData - $hwndVal = Get-Content $data.HwndFile -Raw -ErrorAction Stop - Start-Process powershell.exe -ArgumentList @( - "-ExecutionPolicy", "Bypass", "-NoProfile", - "-File", $data.ActivatePs1, - "-WindowHwnd", $hwndVal.Trim() - ) -WindowStyle Hidden -Wait - } catch { } - - try { $Event.Sender.Dispose() } catch { } - } | Out-Null - -# Dismiss handler -Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed -Action { - try { $Event.Sender.Dispose() } catch { } -} | Out-Null +# Register BalloonTipClicked WITHOUT -Action. +# We will capture the event in the main thread via Wait-Event so that +# Activate-ConsoleWindow runs with the foreground rights granted by the click. +$clickSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked + +# Dismiss handler — no action needed, just clean up +$dismissSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed $notify.ShowBalloonTip(30000) # --------------------------------------------------------------------------- -# Keep the process alive to receive events +# Event loop: wait for click, dismiss, or timeout # --------------------------------------------------------------------------- try { - Wait-Event -Timeout 35 + $remaining = 35 # seconds + while ($remaining -gt 0) { + $event = Wait-Event -Timeout $remaining + if (-not $event) { + break # timeout + } + + if ($event.SourceIdentifier -eq $clickSub.Name) { + # User clicked the notification → activate the console window. + # This runs in the MAIN thread, so SetForegroundWindow is allowed. + try { Activate-ConsoleWindow $consoleHwnd } catch { } + break + } + + if ($event.SourceIdentifier -eq $dismissSub.Name) { + break # dismissed / timed out + } + + Remove-Event -EventIdentifier $event.EventIdentifier + $remaining -= 1 + } } finally { Get-EventSubscriber | Unregister-Event -Force -ErrorAction SilentlyContinue try { $notify.Dispose() } catch { } - Remove-Item $hwndFile, $activatePs1 -Force -ErrorAction SilentlyContinue } From 8272d57a25d1f7285415b0a199b8aff0e35d2b24 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Thu, 4 Jun 2026 22:24:54 +0800 Subject: [PATCH 09/12] fix: replace SetForegroundWindow with FlashWindowEx taskbar flash (Win11 blocks programmatic foreground steal) --- templates/tools/deepcode-notify-diag.ps1 | 91 ++++++++++++++++ templates/tools/deepcode-notify.ps1 | 133 ++++++++++++----------- templates/tools/test-notify.ps1 | 107 ++++++++++++++++++ 3 files changed, 270 insertions(+), 61 deletions(-) create mode 100644 templates/tools/deepcode-notify-diag.ps1 create mode 100644 templates/tools/test-notify.ps1 diff --git a/templates/tools/deepcode-notify-diag.ps1 b/templates/tools/deepcode-notify-diag.ps1 new file mode 100644 index 00000000..6a6e9306 --- /dev/null +++ b/templates/tools/deepcode-notify-diag.ps1 @@ -0,0 +1,91 @@ +#Requires -Version 5.1 +$ErrorActionPreference = "Continue" + +Add-Type -MemberDefinition @' +[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); +[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); +[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); +[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); +'@ -Name W32 -Namespace T + +$WM_SYSCOMMAND = 0x0112 +$SC_MINIMIZE = [IntPtr]0xF020 + +Write-Host "=== DeepCode Window Activation Diagnostic ===" -ForegroundColor Cyan + +# Open notepad +Start-Process notepad.exe | Out-Null +Start-Sleep -Seconds 3 + +# Find actual notepad UI process +$proc = Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 +if (-not $proc) { Write-Host "ERROR: No notepad window found"; exit 1 } +$hwnd = $proc.MainWindowHandle +Write-Host "HWND=$hwnd Title='$($proc.MainWindowTitle)'" + +function Test-Restore($label, [ScriptBlock]$activate) { + Write-Host "--- $label ---" + [T.W32]::SendMessage($hwnd, $WM_SYSCOMMAND, $SC_MINIMIZE, [IntPtr]::Zero) | Out-Null + Start-Sleep -Milliseconds 600 + Write-Host " Minimized: $([T.W32]::IsIconic($hwnd))" + & $activate + Start-Sleep -Milliseconds 400 + $ok = -not [T.W32]::IsIconic($hwnd) + $fg = ([T.W32]::GetForegroundWindow() -eq $hwnd) + Write-Host " restored=$ok foreground=$fg" + return $ok +} + +$r1 = Test-Restore "M1: ShowWindow+SetFg (main thread)" { + [T.W32]::ShowWindow($hwnd, 9) | Out-Null; Start-Sleep 0.2 + [T.W32]::SetForegroundWindow($hwnd) | Out-Null +} +$r2 = Test-Restore "M2: SwitchToThisWindow" { + [T.W32]::SwitchToThisWindow($hwnd, $true) +} +$r3 = Test-Restore "M3: ShowWindow+Switch" { + [T.W32]::ShowWindow($hwnd, 9) | Out-Null; Start-Sleep 0.2 + [T.W32]::SwitchToThisWindow($hwnd, $true) +} + +# M4: Spawned helper (simulates real BalloonTip scenario) +Write-Host "--- M4: Spawned helper ---" +[T.W32]::SendMessage($hwnd, $WM_SYSCOMMAND, $SC_MINIMIZE, [IntPtr]::Zero) | Out-Null +Start-Sleep -Milliseconds 600 +Write-Host " Minimized: $([T.W32]::IsIconic($hwnd))" + +$h = $hwnd.ToInt64() +$tmp = Join-Path $env:TEMP "dc-diag-helper.ps1" +$helperLines = @( + 'param([uint64]$w)', + 'Add-Type -MemberDefinition "[DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);[DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h, int n);[DllImport(\"user32.dll\")]public static extern void SwitchToThisWindow(IntPtr h, bool f);" -Name X -Namespace Y', + '$h = [IntPtr]::new([int64]$w)', + '[Y.X]::ShowWindow($h, 9) | Out-Null', + 'Start-Sleep -Milliseconds 300', + '[Y.X]::SetForegroundWindow($h) | Out-Null', + 'Start-Sleep -Milliseconds 100', + '[Y.X]::SwitchToThisWindow($h, $true)' +) +$helperLines -join "`n" | Out-File -FilePath $tmp -Encoding UTF8 + +Start-Process powershell -ArgumentList "-ExecutionPolicy","Bypass","-NoProfile","-File",$tmp,"-w",$h -WindowStyle Hidden -Wait +Start-Sleep -Milliseconds 500 +$r4 = -not [T.W32]::IsIconic($hwnd) +$fg4 = ([T.W32]::GetForegroundWindow() -eq $hwnd) +Write-Host " restored=$r4 foreground=$fg4" +Remove-Item $tmp -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "=== Results ===" -ForegroundColor Cyan +Write-Host "M1 (main ShowWindow+SetFg): $(if($r1){'PASS'}else{'FAIL'})" +Write-Host "M2 (main SwitchToThis): $(if($r2){'PASS'}else{'FAIL'})" +Write-Host "M3 (main ShowWindow+Switch): $(if($r3){'PASS'}else{'FAIL'})" +Write-Host "M4 (spawned helper): $(if($r4){'PASS'}else{'FAIL'})" +if (-not $r4) { + Write-Host "ROOT CAUSE: Spawned child process cannot SetForegroundWindow on Win11" -ForegroundColor Yellow +} + +Get-Process notepad -ErrorAction SilentlyContinue | Stop-Process -Force +Write-Host "Done." diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index b1c02451..54b94349 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -3,12 +3,18 @@ .SYNOPSIS DeepCode CLI built-in Windows notification script. Shows a BalloonTip when a task completes or fails. - Click the notification to jump to the originating terminal window. + Click the notification to locate the originating terminal window. .DESCRIPTION Invoked automatically by DeepCode CLI on Windows when the `notify` setting is either unset or set to "builtin". + On BalloonTip click: + 1. Restores the console window from minimized state. + 2. Flashes the taskbar button until the user clicks it. + (Windows 11 security prevents programmatic foreground stealing, + so flashing is the most reliable way to direct the user.) + Environment variables passed by the CLI: STATUS - "completed" | "failed" | "interrupted" TITLE - Session summary / task title @@ -21,16 +27,13 @@ param() $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- -# Read context from environment variables +# Read context # --------------------------------------------------------------------------- $Status = $env:STATUS $Title = $env:TITLE $Body = $env:BODY $Duration = $env:DURATION -# --------------------------------------------------------------------------- -# Build notification text -# --------------------------------------------------------------------------- $statusLabel = switch ($Status) { "failed" { "Failed" } "interrupted" { "Interrupted" } @@ -41,65 +44,81 @@ $iconType = if ($Status -eq "failed") { "Error" } else { "Info" } $titleText = if ($Title) { "$Title" } else { "DeepCode Task" } $shortBody = if ($Body) { - if ($Body.Length -gt 120) { $Body.Substring(0, 117) + "..." } else { $Body } + if ($Body.Length -gt 100) { $Body.Substring(0, 97) + "..." } else { $Body } } else { "" } $parts = @() if ($shortBody) { $parts += $shortBody } $parts += "[$statusLabel] Duration: ${Duration}s" -$parts += "Click here to jump to the terminal window" +$parts += "Click to locate the terminal (look for flashing taskbar icon)" $bodyText = $parts -join "`n" # --------------------------------------------------------------------------- -# Win32 API declarations (used to capture & activate the console window) +# P/Invoke: console capture + window restore + taskbar flash # --------------------------------------------------------------------------- Add-Type -MemberDefinition @' -[DllImport("kernel32.dll")] -public static extern IntPtr GetConsoleWindow(); -[DllImport("user32.dll")] -public static extern bool SetForegroundWindow(IntPtr hWnd); -[DllImport("user32.dll")] -public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -[DllImport("user32.dll")] -public static extern bool IsIconic(IntPtr hWnd); -[DllImport("user32.dll")] -public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); -'@ -Name Win32 -Namespace DC +using System; +using System.Runtime.InteropServices; + +[StructLayout(LayoutKind.Sequential)] +public struct FLASHWINFO { + public uint cbSize; + public IntPtr hwnd; + public uint dwFlags; + public uint uCount; + public uint dwTimeout; +} -$consoleHwnd = [DC.Win32]::GetConsoleWindow() +public static class DeepCodeNotify { + [DllImport("kernel32.dll")] + public static extern IntPtr GetConsoleWindow(); -if ($consoleHwnd -eq [IntPtr]::Zero) { - Write-Warning "DeepCode: Could not capture console window handle." - exit 0 -} + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -# --------------------------------------------------------------------------- -# Helper: activate & focus the target console window -# --------------------------------------------------------------------------- -function Activate-ConsoleWindow { - param([IntPtr]$hwnd) + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); - # 1. Restore from minimized - [DC.Win32]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE - Start-Sleep -Milliseconds 200 + [DllImport("user32.dll")] + public static extern bool FlashWindowEx(ref FLASHWINFO pwfi); - if ([DC.Win32]::IsIconic($hwnd)) { - [DC.Win32]::ShowWindow($hwnd, 1) | Out-Null # SW_SHOWNORMAL - Start-Sleep -Milliseconds 200 - } + // dwFlags: 0x03 = FLASHW_ALL (caption + taskbar) + // 0x0C = FLASHW_TIMERNOFG (keep flashing until foreground) + private const uint FLASH_UNTIL_FG = 0x03 | 0x0C; - # 2. Set as foreground window - # (called from the main thread while we still have foreground rights - # from the BalloonTip click) - [DC.Win32]::SetForegroundWindow($hwnd) | Out-Null + public static void RestoreWindow(IntPtr hwnd) { + ShowWindow(hwnd, 9); // SW_RESTORE + System.Threading.Thread.Sleep(200); + if (IsIconic(hwnd)) { + ShowWindow(hwnd, 1); // SW_SHOWNORMAL + System.Threading.Thread.Sleep(200); + } + } + + public static void FlashTaskbar(IntPtr hwnd) { + FLASHWINFO info = new FLASHWINFO(); + info.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO)); + info.hwnd = hwnd; + info.dwFlags = FLASH_UNTIL_FG; + info.uCount = 0; + info.dwTimeout = 0; + FlashWindowEx(ref info); + } +} +'@ -Name Win32 -Namespace DC + +$consoleHwnd = [DC.DeepCodeNotify]::GetConsoleWindow() - # 3. Undocumented fallback that often works on modern Windows - Start-Sleep -Milliseconds 50 - [DC.Win32]::SwitchToThisWindow($hwnd, $true) +if ($consoleHwnd -eq [IntPtr]::Zero) { + # Running in a non-console terminal (mintty, Windows Terminal, etc.). + # We cannot flash a specific taskbar button, but the BalloonTip alone + # is still useful as a notification. + Write-Warning "DeepCode: Could not capture console window handle." + exit 0 } # --------------------------------------------------------------------------- -# Build the notification +# BalloonTip notification # --------------------------------------------------------------------------- Add-Type -AssemblyName System.Windows.Forms @@ -110,37 +129,29 @@ $notify.BalloonTipText = $bodyText $notify.BalloonTipIcon = $iconType $notify.Visible = $true -# Register BalloonTipClicked WITHOUT -Action. -# We will capture the event in the main thread via Wait-Event so that -# Activate-ConsoleWindow runs with the foreground rights granted by the click. -$clickSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked - -# Dismiss handler — no action needed, just clean up +$clickSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked $dismissSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed $notify.ShowBalloonTip(30000) # --------------------------------------------------------------------------- -# Event loop: wait for click, dismiss, or timeout +# Event loop # --------------------------------------------------------------------------- try { - $remaining = 35 # seconds + $remaining = 35 while ($remaining -gt 0) { $event = Wait-Event -Timeout $remaining - if (-not $event) { - break # timeout - } + if (-not $event) { break } if ($event.SourceIdentifier -eq $clickSub.Name) { - # User clicked the notification → activate the console window. - # This runs in the MAIN thread, so SetForegroundWindow is allowed. - try { Activate-ConsoleWindow $consoleHwnd } catch { } + try { + [DC.DeepCodeNotify]::RestoreWindow($consoleHwnd) + [DC.DeepCodeNotify]::FlashTaskbar($consoleHwnd) + } catch { } break } - if ($event.SourceIdentifier -eq $dismissSub.Name) { - break # dismissed / timed out - } + if ($event.SourceIdentifier -eq $dismissSub.Name) { break } Remove-Event -EventIdentifier $event.EventIdentifier $remaining -= 1 diff --git a/templates/tools/test-notify.ps1 b/templates/tools/test-notify.ps1 new file mode 100644 index 00000000..f565e602 --- /dev/null +++ b/templates/tools/test-notify.ps1 @@ -0,0 +1,107 @@ +#Requires -Version 5.1 +$ErrorActionPreference = "Continue" + +Add-Type -MemberDefinition @' +using System; +using System.Runtime.InteropServices; + +[StructLayout(LayoutKind.Sequential)] +public struct FLASHWINFO { + public uint cbSize; + public IntPtr hwnd; + public uint dwFlags; + public uint uCount; + public uint dwTimeout; +} + +public static class DeepCodeTest { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool FlashWindowEx(ref FLASHWINFO pwfi); + + public static void RestoreWindow(IntPtr hwnd) { + ShowWindow(hwnd, 9); + System.Threading.Thread.Sleep(200); + if (IsIconic(hwnd)) { + ShowWindow(hwnd, 1); + System.Threading.Thread.Sleep(200); + } + } + + public static void FlashTaskbar(IntPtr hwnd) { + FLASHWINFO info = new FLASHWINFO(); + info.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO)); + info.hwnd = hwnd; + info.dwFlags = 0x03 | 0x0C; // FLASHW_ALL | FLASHW_TIMERNOFG + FlashWindowEx(ref info); + } +} +'@ + +Write-Host "=== DeepCode Notify Test ===" -ForegroundColor Cyan + +$hwnd = [DeepCodeTest]::GetConsoleWindow() +Write-Host "1. Console HWND: $hwnd" + +if ($hwnd -eq [IntPtr]::Zero) { + Write-Host " ERROR: Must run from cmd.exe (not Git Bash)" -ForegroundColor Red + exit 1 +} + +Write-Host "2. Current: iconic=$([DeepCodeTest]::IsIconic($hwnd)) foreground=$(([DeepCodeTest]::GetForegroundWindow() -eq $hwnd))" + +# Minimize & auto-restore test +Write-Host "3. Minimizing in 2s..." +Start-Sleep 2 +[DeepCodeTest]::ShowWindow($hwnd, 6) | Out-Null +Start-Sleep 1 +Write-Host " Minimized: iconic=$([DeepCodeTest]::IsIconic($hwnd))" + +Write-Host "4. Restoring in 1s..." +Start-Sleep 1 +[DeepCodeTest]::RestoreWindow($hwnd) +Write-Host " After restore: iconic=$([DeepCodeTest]::IsIconic($hwnd))" +Write-Host " RESULT: $(if (-not [DeepCodeTest]::IsIconic($hwnd)){'RESTORED'}else{'STILL MINIMIZED'})" + +# BalloonTip click → taskbar flash test +Write-Host "" +Write-Host "5. BalloonTip test: click notification, then look for FLASHING taskbar icon for THIS window" +Add-Type -AssemblyName System.Windows.Forms + +$notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Icon = [System.Drawing.SystemIcons]::Information +$notify.BalloonTipTitle = "Test - Click Me" +$notify.BalloonTipText = "Click to flash this window's taskbar icon.`nLook for the orange flashing button!" +$notify.Visible = $true + +$clickSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked +$dismissSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed +$notify.ShowBalloonTip(15000) + +Write-Host " BalloonTip shown (15s timeout)..." +try { + $remaining = 20 + while ($remaining -gt 0) { + $event = Wait-Event -Timeout $remaining + if (-not $event) { Write-Host " Timeout."; break } + if ($event.SourceIdentifier -eq $clickSub.Name) { + Write-Host " CLICKED! Restoring + flashing taskbar..." + [DeepCodeTest]::RestoreWindow($hwnd) + [DeepCodeTest]::FlashTaskbar($hwnd) + Write-Host " Window should now be visible. Look for the flashing taskbar icon!" -ForegroundColor Green + Write-Host " (Click the taskbar icon to stop flashing)" + break + } + if ($event.SourceIdentifier -eq $dismissSub.Name) { Write-Host " Dismissed."; break } + Remove-Event -EventIdentifier $event.EventIdentifier + $remaining -= 1 + } +} finally { + Get-EventSubscriber | Unregister-Event -Force -ErrorAction SilentlyContinue + try { $notify.Dispose() } catch { } +} + +Write-Host "" +Write-Host "Test complete." From 1004064c75f9e2c2aa2634645db04ea5031f16c6 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Sat, 6 Jun 2026 01:28:55 +0800 Subject: [PATCH 10/12] feat: add Windows completion notification tip --- docs/configuration.md | 3 + docs/configuration_en.md | 5 +- docs/notify.md | 3 + docs/notify_en.md | 3 + src/common/notify.ts | 38 +- src/session.ts | 14 +- src/tests/session.test.ts | 4 +- src/tests/settings-and-notify.test.ts | 92 ++- templates/tools/deepcode-icon.png | Bin 0 -> 6492 bytes templates/tools/deepcode-notify.ps1 | 1041 ++++++++++++++++++++++--- templates/tools/test-notify.ps1 | 493 ++++++++++-- 11 files changed, 1493 insertions(+), 203 deletions(-) create mode 100644 templates/tools/deepcode-icon.png diff --git a/docs/configuration.md b/docs/configuration.md index 922f39e3..2c137bd9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,6 +69,8 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 +Windows 未配置 `notify` 时会使用内置桌面提示;点击提示会尝试聚焦启动 CLI 的终端窗口。配置自定义 `notify` 后,会优先执行你的脚本。 + 通知脚本执行时,会通过环境变量注入以下上下文信息: | 环境变量 | 说明 | @@ -76,6 +78,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `DURATION` | 会话耗时,单位秒(整数) | | `STATUS` | 会话状态:`"completed"` 或 `"failed"` | | `FAIL_REASON` | 失败原因(仅失败时设置) | +| `QUESTION` | 最后一条用户问题的文本内容 | | `BODY` | 最后一条 AI 助手回复的文本内容 | | `TITLE` | 会话标题(对应 resume 列表中的标题) | diff --git a/docs/configuration_en.md b/docs/configuration_en.md index f53fb114..16451f4c 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -69,6 +69,8 @@ When thinking mode is enabled, controls the depth of the model’s reasoning: Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). +On Windows, when `notify` is not configured, Deep Code uses the built-in desktop tip. Clicking the tip attempts to focus the terminal window that launched the CLI. A custom `notify` script takes precedence over the built-in tip. + The following context is injected as environment variables when the notify script runs: | Variable | Description | @@ -76,6 +78,7 @@ The following context is injected as environment variables when the notify scrip | `DURATION` | Session duration in seconds (integer) | | `STATUS` | Session status: `"completed"` or `"failed"` | | `FAIL_REASON` | Failure reason (only set on failure) | +| `QUESTION` | The text content of the last user question | | `BODY` | The text content of the last AI assistant reply | | `TITLE` | Session title (matches the resume list title) | @@ -193,4 +196,4 @@ Applied in the following priority order (lower-numbered overridden by higher-num 2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` 3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` 4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/docs/notify.md b/docs/notify.md index d73eef45..7a588231 100644 --- a/docs/notify.md +++ b/docs/notify.md @@ -6,6 +6,8 @@ 在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。 +在 Windows 上,如果未配置 `notify`,Deep Code 会使用内置桌面提示;点击提示会尝试聚焦启动 CLI 的终端窗口。配置自定义 `notify` 后,会优先执行你的脚本。 + ## 注入的环境变量 | 环境变量 | 说明 | @@ -13,6 +15,7 @@ | `DURATION` | 会话耗时,单位秒(整数) | | `STATUS` | 会话状态:`"completed"` 或 `"failed"` | | `FAIL_REASON` | 失败原因(仅失败时设置) | +| `QUESTION` | 最后一条用户问题的文本内容 | | `BODY` | 最后一条 AI 助手回复的文本内容 | | `TITLE` | 会话标题(对应 resume 列表中的标题) | diff --git a/docs/notify_en.md b/docs/notify_en.md index b949161c..9ba7e3d1 100644 --- a/docs/notify_en.md +++ b/docs/notify_en.md @@ -6,6 +6,8 @@ When the AI assistant finishes a round of tasks, Deep Code can automatically exe Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables. +On Windows, when `notify` is not configured, Deep Code uses the built-in desktop tip. Clicking the tip attempts to focus the terminal window that launched the CLI. A custom `notify` script takes precedence over the built-in tip. + ## Injected Environment Variables | Variable | Description | @@ -13,6 +15,7 @@ Configure the `notify` field in `settings.json` with the full path to an executa | `DURATION` | Session duration in seconds (integer) | | `STATUS` | Session status: `"completed"` or `"failed"` | | `FAIL_REASON` | Failure reason (only set on failure) | +| `QUESTION` | The text content of the last user question | | `BODY` | The text content of the last AI assistant reply | | `TITLE` | Session title (matches the resume list title) | diff --git a/src/common/notify.ts b/src/common/notify.ts index e090286a..027358d2 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -1,4 +1,5 @@ import { spawn, type SpawnOptions } from "child_process"; +import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; @@ -21,6 +22,7 @@ export function formatDurationSeconds(durationMs: number): string { export type NotifyContext = { status?: string; failReason?: string; + question?: string; body?: string; title?: string; }; @@ -36,6 +38,7 @@ export function buildNotifyEnv( }; delete env.STATUS; delete env.FAIL_REASON; + delete env.QUESTION; delete env.BODY; delete env.TITLE; @@ -45,6 +48,9 @@ export function buildNotifyEnv( if (context.failReason) { env.FAIL_REASON = context.failReason; } + if (context.question) { + env.QUESTION = context.question; + } if (context.body) { env.BODY = context.body; } @@ -109,16 +115,38 @@ export function resolveBuiltinNotifyPath(): string | null { return null; } try { - const root = + const moduleDir = typeof __dirname !== "undefined" - ? path.resolve(__dirname, "..") - : path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - return path.join(root, "templates", "tools", "deepcode-notify.ps1"); + ? path.resolve(__dirname) + : path.resolve(path.dirname(fileURLToPath(import.meta.url))); + const candidates = [ + path.resolve(moduleDir, "..", "templates", "tools", "deepcode-notify.ps1"), + path.resolve(moduleDir, "..", "..", "templates", "tools", "deepcode-notify.ps1"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; } catch { return null; } } +function getBuiltinNotifyEnv( + durationMs: number, + configuredEnv: Record, + context: NotifyContext, + workingDirectory?: string +): NodeJS.ProcessEnv { + const env = buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context); + env.DEEPCODE_NOTIFY_PROCESS_PID ??= String(process.pid); + env.DEEPCODE_NOTIFY_PARENT_PID ??= String(process.ppid); + + const debugEnabled = env.DEEPCODE_NOTIFY_DEBUG === "1" || env.DEEPCODE_NOTIFY_DEBUG === "true"; + if (debugEnabled && !env.DEEPCODE_NOTIFY_DEBUG_LOG) { + env.DEEPCODE_NOTIFY_DEBUG_LOG = path.join(workingDirectory ?? process.cwd(), ".deepcode", "notify.log"); + } + + return env; +} + /** * Launch the built-in Windows notification (PowerShell BalloonTip with * click-to-focus behaviour). Has no effect on non-Windows platforms. @@ -142,7 +170,7 @@ export function launchBuiltinNotify( const options = { cwd: workingDirectory, detached: false, - env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context), + env: getBuiltinNotifyEnv(durationMs, configuredEnv, context, workingDirectory), stdio: "ignore" as const, }; diff --git a/src/session.ts b/src/session.ts index e52ab167..41a9e28f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2428,13 +2428,22 @@ ${skillMd} return; } - // Find the last assistant message body for the BODY env variable. + // Find the latest user question and assistant answer for the desktop tip. + let question: string | undefined; let body: string | undefined; const messages = this.listSessionMessages(sessionId); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg && msg.role === "user" && msg.content) { + if (!msg) { + continue; + } + if (!question && msg.role === "user" && msg.content?.trim()) { + question = msg.content; + } + if (!body && msg.role === "assistant" && msg.content?.trim()) { body = msg.content; + } + if (question && body) { break; } } @@ -2442,6 +2451,7 @@ ${skillMd} const context = { status: session.status, failReason: session.failReason ?? undefined, + question, body, title: session.summary ?? undefined, }; diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index b02642bb..95cf3b26 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -981,6 +981,7 @@ test( const records = await waitForNotifyRecords(notifyOutput, 1); assert.equal(records[0]?.STATUS, "completed"); assert.equal(records[0]?.FAIL_REASON, null); + assert.equal(records[0]?.QUESTION, "notify success"); assert.equal(records[0]?.BODY, "final answer"); assert.equal(records[0]?.TITLE, "notify success"); assert.match(String(records[0]?.DURATION), /^\d+$/); @@ -1015,6 +1016,7 @@ test( const failedRecord = records[1]; assert.equal(failedRecord?.STATUS, "failed"); assert.equal(failedRecord?.FAIL_REASON, "second request failed"); + assert.equal(failedRecord?.QUESTION, "second prompt"); assert.equal(failedRecord?.BODY, "first answer"); assert.notEqual(failedRecord?.BODY, "stale-body"); assert.equal(failedRecord?.TITLE, "notify failure"); @@ -3306,7 +3308,7 @@ function createNotifyRecorderScript(dir: string): string { scriptPath, `#!/usr/bin/env node const fs = require("fs"); -const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"]; +const keys = ["DURATION", "STATUS", "FAIL_REASON", "QUESTION", "BODY", "TITLE"]; const record = {}; for (const key of keys) { record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null; diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 9e18dc1c..ea4ae3e6 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { buildNotifyEnv, formatDurationSeconds, + launchBuiltinNotify, launchNotifyScript, + resolveBuiltinNotifyPath, type NotifyContext, type NotifySpawn, } from "../common/notify"; @@ -433,14 +435,16 @@ test("buildNotifyEnv injects DURATION without context", () => { assert.equal(env.DURATION, "2"); assert.equal(env.STATUS, undefined); assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.QUESTION, undefined); assert.equal(env.BODY, undefined); assert.equal(env.TITLE, undefined); }); -test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => { +test("buildNotifyEnv injects STATUS, FAIL_REASON, QUESTION, BODY, and TITLE from context", () => { const context: NotifyContext = { status: "failed", failReason: "API key not found", + question: "Can you fix the login bug?", body: "Hello, this is the last assistant message.", title: "Fix login bug", }; @@ -449,6 +453,7 @@ test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", assert.equal(env.DURATION, "5"); assert.equal(env.STATUS, "failed"); assert.equal(env.FAIL_REASON, "API key not found"); + assert.equal(env.QUESTION, "Can you fix the login bug?"); assert.equal(env.BODY, "Hello, this is the last assistant message."); assert.equal(env.TITLE, "Fix login bug"); }); @@ -460,6 +465,7 @@ test("buildNotifyEnv omits optional context fields when not provided", () => { HOME: "/tmp/home", STATUS: "stale-status", FAIL_REASON: "stale-failure", + QUESTION: "stale-question", BODY: "stale-body", TITLE: "stale-title", }, @@ -467,6 +473,7 @@ test("buildNotifyEnv omits optional context fields when not provided", () => { ); assert.equal(env.STATUS, "completed"); assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.QUESTION, undefined); assert.equal(env.BODY, undefined); assert.equal(env.TITLE, undefined); }); @@ -478,22 +485,26 @@ test("buildNotifyEnv ignores empty strings in context", () => { { status: "", failReason: "", + question: "", body: "", title: "", } ); assert.equal(env.STATUS, undefined); assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.QUESTION, undefined); assert.equal(env.BODY, undefined); assert.equal(env.TITLE, undefined); }); -test("buildNotifyEnv preserves special characters in body and title", () => { +test("buildNotifyEnv preserves special characters in question, body, and title", () => { const context: NotifyContext = { + question: "Question?\nWith tabs\tand quotes", body: 'Line 1\nLine 2\tindented "quoted"', title: "Fix: login & signup (urgent)", }; const env = buildNotifyEnv(1000, {}, context); + assert.equal(env.QUESTION, "Question?\nWith tabs\tand quotes"); assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"'); assert.equal(env.TITLE, "Fix: login & signup (urgent)"); }); @@ -526,6 +537,7 @@ test( const context: NotifyContext = { status: "completed", + question: "Can you finish the task?", body: "Task finished successfully.", title: "Fix login bug", }; @@ -540,6 +552,7 @@ test( assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); assert.equal(calls[0]?.options.env?.STATUS, "completed"); assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined); + assert.equal(calls[0]?.options.env?.QUESTION, "Can you finish the task?"); assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); assert.equal(calls[1]?.command, "/bin/sh"); @@ -547,7 +560,82 @@ test( assert.equal(calls[1]?.options.cwd, "/tmp/project"); assert.equal(calls[1]?.options.env?.DURATION, "2"); assert.equal(calls[1]?.options.env?.STATUS, "completed"); + assert.equal(calls[1]?.options.env?.QUESTION, "Can you finish the task?"); assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully."); assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug"); } ); + +test( + "resolveBuiltinNotifyPath returns the bundled Windows notification script", + { skip: process.platform !== "win32" }, + () => { + const notifyPath = resolveBuiltinNotifyPath(); + assert.ok(notifyPath); + assert.match(notifyPath.replace(/\\/g, "/"), /templates\/tools\/deepcode-notify\.ps1$/); + } +); + +test( + "launchBuiltinNotify starts PowerShell with context and DeepCode process ids", + { skip: process.platform !== "win32" }, + () => { + const calls: Array<{ + command: string; + args: string[]; + options: { cwd?: string | URL; env?: NodeJS.ProcessEnv; detached?: boolean; stdio?: unknown }; + }> = []; + + const spawnProcess: NotifySpawn = (command, args, options) => { + calls.push({ + command, + args, + options: { + cwd: options.cwd, + detached: options.detached, + env: options.env, + stdio: options.stdio, + }, + }); + + return { + once() { + return this; + }, + unref() { + return undefined; + }, + }; + }; + + launchBuiltinNotify( + 2750, + "C:/tmp/project", + spawnProcess, + { WEBHOOK: "configured", DEEPCODE_NOTIFY_WINDOW_HWND: "1234" }, + { + status: "completed", + question: "Can you finish the task?", + body: "Task finished successfully.", + title: "Fix login bug", + } + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.command, "powershell.exe"); + assert.deepEqual(calls[0]?.args.slice(0, 4), ["-ExecutionPolicy", "Bypass", "-NoProfile", "-File"]); + assert.match(calls[0]?.args[4]?.replace(/\\/g, "/") ?? "", /templates\/tools\/deepcode-notify\.ps1$/); + assert.equal(calls[0]?.options.cwd, "C:/tmp/project"); + assert.equal(calls[0]?.options.detached, false); + assert.equal(calls[0]?.options.stdio, "ignore"); + assert.equal(calls[0]?.options.env?.DURATION, "2"); + assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[0]?.options.env?.STATUS, "completed"); + assert.equal(calls[0]?.options.env?.QUESTION, "Can you finish the task?"); + assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); + assert.equal(calls[0]?.options.env?.DEEPCODE_NOTIFY_PROCESS_PID, String(process.pid)); + assert.equal(calls[0]?.options.env?.DEEPCODE_NOTIFY_PARENT_PID, String(process.ppid)); + assert.equal(calls[0]?.options.env?.DEEPCODE_NOTIFY_WINDOW_HWND, "1234"); + } +); diff --git a/templates/tools/deepcode-icon.png b/templates/tools/deepcode-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5092a8d3cc2a2cef6610a968d13081a7f8f8ca91 GIT binary patch literal 6492 zcmaKRXEYm*7q(S<)7mXnHEL7^wRhF5S=4Imps1}zlnABPuXc;7QKM=Wu|lFotQ0kB zL~8Gd5g~Z{|N5Tuz8{|Z-23I8bI-Zw-0{3KGu3CJ=cOkjBV#gns%!C&WB&(Q>VFog z4jleRC88HWZStl6Orc-b)i|xsE2{KqRK5C3yY=VRm}o}9&mW&QsyJFNHZ?Iw zf2N~1cjeT&E11OnzOpxoTlj7q3oE&nmf32ms_s&UG((3<&^0`$yLvyrsGz%UZq9wf z54=J0B#hKn@4vY`(WnWVD}R#8EAKs3X}V20|bi#mRnF34yd}MRIwnS zuo3{)-JE7a;Umk|!jb@y=C<&DRb-=VOv}@Vs>a~zM^UMb$6i5z;hb+{jpaU#zFR;d zy!=|m?t}ZNwcUNDSN}o3TSA}t-><#oN>{~q)?f0fh}~7WdY+BjlE7Xh4>@&83|sx@ zKH(Awle2Ta6|(Tfw$L3lp<|A6&JsW1P_OG}rpSCRlVowF5c)7QILoCmG7tBCbv}>* z#F!EfmSmJ+Dt2CZt?eoT*DNR9P(1Xhx8HD?m^aUn;yH2NMYmSm~x7SZRuy$3^e4$LP!WOqULib0VQ^c-i7bj^c9_{HBaZ17o z`1n#v@~wOwgut=XF&+Eop;_+Bn&v1)qfszlw|}ZqU!yyT)19boe6>Iu9;6R*!1a7# zi+OdnB)?9EM9y)PmmgiOj(>_3c)M`->6ud%Q3QK2GiyjY`gMQsgEK2c2eg%oRK6&! zXOZ`ylbtx%-#k_6&&L$joADKNv!9msOA4M;0tP_(RR2u=jjqlSD^*RA@0p4qR*Y3v;Ld5ylXTXCx&VgHE&&^q zy=M2A)LO(MjuU_w@8DiD)x*%o&g(n-GJ_!Lf@}F*=TvD{=vAb8(WOwYl@wqRCWaMF zi2nAAn)Opz1tubQe?+U$d3AB{?s@`H!>9S}qt4ml%lLY-*(wOF8pDNMDN|X+hlgHR z%`qG8;LNGuQRxo14rp|(IeFC{QU#Lm?G*Ny_D7^Qnf1PT?|z~@A{8x`_BKnKL?P~xy)n@17hYd_Uy$h~+xa}+ z^{=!2gkumAG#br(^et;$G5Pmg5HfI{$sBcHMOR!diHR3rK&F;j?|<{Dg4Cs3s7pU0 zq3Zoa`9I+%%PTHuhL~f2zP|!U60!}N!y(07BDKgb5Se0QKRu5L+oE$1qEI$2!W>1j z_bBR=19?7A#gOSyh@X+2Q&*{YIb#V~HPr2EdtaDkZ?65d>u9k{zn@komnlo+VMupu z2m_1b^=^IL-e<{lJ#kE;?6K0j1c$8j*WHy46uZDNbg;Fn8>tqk5|rF;xKyfS2;UOD z$sogA91cLWGJ;}UXFYC0);VZ~xMBq!FgdcHHqpE2ojPRWS|56Jd8(}ulS>!aWv_K+ z*FSx32t4vL*Jc@4(58t;^RMYgzE1i3p6{N&pu&5=91SuCnjwDu&hYHd-2HA&!kwh> zFFY+>A%l&M%R^22T>s5r`I4o$KcgodtD(so({@LxM?j?%Hrl1X zKgCiqI&@kXF#j-w63ooO``WGm3Tr@4ZpZqyg%pKMe$;z3jgtP=J!=Z<9Pz{Ig zd=x2T3D3jwbay3XAoxQGY%U9nCpZ>e16{;>yA$1}4&49y!$!Sm5`%DlO9g)!ak_d# z-xDq>bzR;_BG0WumlS`FT6hk*e4x|U+4HJWAv#1crwy}ZrP|@ZB@yOE{1kZRFs7$& zDcx$#>M^|2Lylud9t6n*Itq^!-OPC)sDCE#OYD{3076zAgC)P^m62_dF#q$$ zo1Ae;$vdpm4+KMY9ECQv6IHpeERelhtk7#|!w8*@m~|L=*g+7jhWs~`1Yetgq|ip| z*CX_c+ij)UdGcehFG^M}vQVM?LWC?@6`C9ta7hw(M}y)>!o&!|I)Q zZg5o=Xd0WA#)E$3d-%UK69EWdw$%P)+URU`cG;+s-v1jRtx{eeb`&{}g!cXrR2qIq z5cr(7xgtXNV06T?dMDa|?!gHjqiNip7>nu6oH_5cZrqfYzNxJCX4cxklEb7V~k2nJXvRgCXUB^K?l1=;8Cli`OPuKZ2RFX&aMzb zVKek*0i+@b5g7Y^>%-*Z{<0I_^`nkSD>V)C-~Ifc&DqBOzv->dXO>w&o1%N6@Ph}h z@(H=m4RwmQ%f?{Vw~scEZ-n}u?oj@e4!WWqoq@WORYhFS@meTnZjk+6ps!l~*|Apr z#|HUGNKiOL`6B4#aH4*xGakT!sKalkDO>xDlv~I!>_9W1@U{1M(z@iTZm!g^4Dar{ z$NIY<_!EE070b9v6@>oMk(uTVEPB!}7RHBSU z5DTW;8B>w$%dWj|k1@5TAoj-Rhev7I!WluLOXj)^CUY~M{eM=Jd9rbl0 z+uM|*5F7pB88x{mWeQnEjJncH% z>eQyJRYXUNNgfn?h)YA)f0I;E?~$;H<{TrWw(ac?Idz`^Y+UYz(^`ZCS=O;3*CydF zS>Tyh!m%XVkeSO3CTQ?!6ln98Q>ZKOs2`3%TO)8=7Dt{5CG+zjjy$g^@$augQd}Mw z_sEGU(#6wn*mU$*w{A*m++~b5@Ir(fW3r&PpAL=`SvNA%! zp;W4GfdfAHER6W^&wvGL`UzY_$t(E%@$>BRkELsNt7}y4V6i1qV?bv$Sv==ovd_;Eatt)KwjLja3~j5kx&@^c#hA(* z{e&1DcwdN0Io7bN|HyCLTutkfun`B-3~0e8l--hQ>F~49Ax9kn{0Zg*Phb26s`1X8}5W5q%2#Mp{w6;W8 zPUEMCvZmfL_b?FfFXP8hkb>{9aHMt?PQNsZHbhy5>RNTfO*WWvp-yGuZot9!E$Nai zl&QpDH{yRUctnCjzh)2zo0%=d9j)Kjrq_%N2;4thaJBO?GDpG948oe;IA=iBX)kB^ zWdDTuCX-UuBgk!pv2wmmnsTF||LW1Ri}|)mbxiyl@iWdM=fI-$6XM+je|e;eOx~VR zJ(YrfMCsJAknN*VNX-834PwX2b;4zjOz65;NlK3i@=%OSC(rOqyY=&%ZfXB*r3t)v zNANqnO**F0fwv~3em!56>m;LFBw|a+ruxLzbjq=lWhDm&$Xo^^E?qisBP^NgF1OyC zZ(Dxja7I4y+d!_vLQx8E73|cUR6NzZ5;+J-x6Xl!%U|2_w%fiq|6&*c;N9W8S*WHZ zLb?)LlUj0H06X{cuyVYuIYmrg#Gz}m{qT`5qW9=djw%c0%L{GU)xYx92jGBW+%}mTAn(=?U8x`EK469T85Lk6 z0mZ~n3?(q3e^20FBXC-Sm=D4$k;!@GfT*(OV>Qs+NmH?F%R@yK9%04&#MkcxH*6j4|mB zn=5)h^4Upruwl??SXo+EQE1?qh;G;PM^k&uGmZY^AJqr!Li2_qbKvvQ$0x>;ozS99 zM|mr#J}UK$QJ}pcD2{UK=X7S8(TI0|$1_;&>sml()DU;j*#+ao2Q9920~5X)ThE(7 zW8T+0PaO#B{a@<}J3;_T#2j_@v`AXe)_;NZpxY@83X(IoESjWf0+xHVk2;Wn))^H) zrnlM)sYN|Y1%{8G616XH6-h~6R&=42^8YofMAd9GrU0P(=K*;eH zr0J-EHou!Ll-5tcR)qN(^ev%xMITt9)Rr9HZ*GI)7sv;JoCUW-?W`jd8qh0_KQciX zvS9iLG;9qB92f!*qx_EpskdyB7wjA}K3Z%r?4KqH{>_kUxt8oorF3IL@5V1`MYI;g zf8Q*y%0_$AWbZB%wc2K9jG@JF@jRUzAM-}Jzdd>QTJec3tk6ytR#b`>p1_$*peapn zNOxgYA@=Gvv){yBV{V?`m3k$1iyZZs09%to5ca5}$|ypDxQ>Rf2WsTzZKKQ{3w}M- zAx}~rQiC`Y9(}ke9J1ulIcP!8CfP!BLL%SuPyUD~^GG_kr`(=L>(LjsF z4B~*;NPx>Z=N}qEz&F18i*^)Tp$2jd&&~*Y^gw&pfHTf-ro88vdPYIO;>x&0`(qMQ zz&lQuZ>Fc1bBpiD$DB;EKSpi&Dr11ff6Ez|yCt1pJ>XN7-gLKGD;a*|-s+yX5m#H7 z#44{*Z8Zb$66j1xq6DLD%~9~31x!=6b|-FU{Xo{WX~bxm^v>d(=l;;e%}t6-T%1ed z%C))S$0J8I;cMo6*aYt9(^Fg{U9m;?OKslvc7cllOv4#Jap%Q&;nF!L1Q^E1`Q&?O z98elbYC$LCRUyxed55CRJl)^4OdUUH77#g(<;CXS0%OK~BSiYwdI%j?iw^^Uz{hgd z6EA8l+8GLiL%mH}g)R-a0~_LI)llxoEr0)AS4_DUT0i;-;h9IbMS_(UCe}jPScT+Y z6^W~w+|P8nAH1`OK!a>G>$|gea)smv)LV#i738Mg9z(-pI5nFh?@-FyI79HZd)C{C5zK?`}3lN{Yw3t zPadZvH6vN581;T^X~oC<(kystIH_Jr)7xoP6m+f|!6Aupx`NgLf<;FfChhYOW~#r; z=rpWM2YWKDe$ak;IOmb8e?OA%lZ_eAYhSD*x|lK@<%m!!sdHj2+sRr(vZ>!`kw*u# zIwQ(_BN(~Fjf^}6t0+C&n@QbbYeEh=+|v(qc$^$Xx+3`=M^`#bzvf$9QYwAY(y72W zJOxseycZGhkDJBn!OTf1)?E-*&H9TaNQRII9tJV7QcKR z>L5b@b@BvGpwMr($G>foW(La%=Ew=LzA)Vkz7tn1`(Q-x-cFHgn7ZDk_fehAkXI?X zR<@kjm11+KtjnA53X%bpaZv3~clpc4TIzceod7e0eYecAtP^W4DWpL6mY0u9-CnUT zUz4$MKRulLFVZ!CI^JFZh5=@UhTM{KqJ0D8-yfK<-R{m zrDd*d6ky~>V=2>)-WJ!x%i94E#q*cOkcuYcBgz{mPB8@ll4fsXp+xx>91++>q3CN}<#jbu zA;)Hy@=@PPo8@j>x&5x1;EGh8_nY5GVYt}#O#x(aUHk9=PZVI$EW|)hrpH4}GH(o9 z4m#4Ti5qki2eUJOm_Y~I*?#8&09ar{bva{jzAUvH8zEoavMH48mtuX_@faYYNStxK zsHN@FJ-cxEd*{%+^6p97v{k7tqR^<1CP~2Z?4TO6?vfCayb!rRDTx z;Z+W|mCm&%bBF((aJCfX6(EV*Z3bB*r2pQ9!SIEb^zrKU1q#_}VAJ{q`h1r}{p>jf z40$&5XYXb2Tjy`G_tVLJ`SLI>Uy9!SSU3Xus8QdVSzTWAUC}BPL z!rt!Hi=Qu6?J7U*`65Z^m69}p2xiys_K;NWAQ5?INycbn?skq>@zrf)vq z!RyDZ+?vb7Hr5Ef%78`fmmCE#>l0OsnB>B}P}`q9D~hdRfJGT5M`sAIq|>JMOdqRq zOu_A0w(hemplkoLZ{zkzh&mv0{bHFIBYV-u-58209pUHYU@CWDtbHX1dy6CX5+rdQcz8=I+{OF^T1!ts(PHjtp)!*h!|6s5`(3RfgFxQbCUw)}_GYd=L+KBFuYZM?Z z=(XE>pG$UIZ9`<{c^QgjZIXO<#KwP|-RGI~#F1BBg@I$pp^7d)n4Zm7#gYwrpl0F( z&RMILsBf+?;q^z2F|O< zFDUw3OiN&E8fqSznf<7@q`&OQsBV~yPI}xJu}Shkn9nY?JaKEPFg%OOMz=*4s}_X+ z=~(xE#m1E+Q>&LF@5f3vUt>6^g+F<+>o ztKr_-V%eI`&3%A^G=k_+vK0#2d=4UhrH@H~r28uKUGH{6Rn%T;L$4N76b}xmm)I7* nUG91P|0RU~OI-*`zor@CRN9%CpF#gqM92*EOm)9&zl{4o26xpr literal 0 HcmV?d00001 diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index 54b94349..c8c8b25d 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -2,161 +2,974 @@ <# .SYNOPSIS DeepCode CLI built-in Windows notification script. - Shows a BalloonTip when a task completes or fails. - Click the notification to locate the originating terminal window. + Shows a clickable desktop tip when a task completes or fails. + Click the notification to focus the originating terminal window. .DESCRIPTION Invoked automatically by DeepCode CLI on Windows when the `notify` - setting is either unset or set to "builtin". - - On BalloonTip click: - 1. Restores the console window from minimized state. - 2. Flashes the taskbar button until the user clicks it. - (Windows 11 security prevents programmatic foreground stealing, - so flashing is the most reliable way to direct the user.) + setting is unset. Test-only switches are available for the smoke test + script in this directory; the CLI calls this script without arguments. Environment variables passed by the CLI: - STATUS - "completed" | "failed" | "interrupted" - TITLE - Session summary / task title - BODY - Last user message body - DURATION - Task wall-clock duration in seconds + STATUS - "completed" | "failed" | "interrupted" + TITLE - Session summary / task title + QUESTION - Last user prompt text + BODY - Last assistant message body + DURATION - Task wall-clock duration in seconds + DEEPCODE_NOTIFY_PARENT_PID - Parent process id used to locate terminal + DEEPCODE_NOTIFY_PROCESS_PID - DeepCode process id + DEEPCODE_NOTIFY_DEBUG_LOG - Optional path for script error logging #> -param() +param( + [switch]$ValidateOnly, + [switch]$SelfTest, + [int64]$TestWindowHwnd = 0, + [int]$TimeoutSeconds = 35, + [int]$AutoClickAfterMilliseconds = 0, + [string]$ReadyPath, + [string]$ResultPath +) $ErrorActionPreference = "Stop" -# --------------------------------------------------------------------------- -# Read context -# --------------------------------------------------------------------------- -$Status = $env:STATUS -$Title = $env:TITLE -$Body = $env:BODY -$Duration = $env:DURATION - -$statusLabel = switch ($Status) { - "failed" { "Failed" } - "interrupted" { "Interrupted" } - default { "Completed" } +function Write-NotifyDebug { + param([string]$Message) + + $logPath = $env:DEEPCODE_NOTIFY_DEBUG_LOG + if (-not $logPath) { return } + + try { + $dir = Split-Path -Parent $logPath + if ($dir) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + "[$timestamp] $Message" | Out-File -FilePath $logPath -Append -Encoding UTF8 + } catch { } +} + +function Write-TestResult { + param([hashtable]$Result) + + $json = $Result | ConvertTo-Json -Compress -Depth 6 + if ($ResultPath) { + $dir = Split-Path -Parent $ResultPath + if ($dir) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $json | Out-File -FilePath $ResultPath -Encoding UTF8 + } else { + Write-Output $json + } } -$iconType = if ($Status -eq "failed") { "Error" } else { "Info" } -$titleText = if ($Title) { "$Title" } else { "DeepCode Task" } +function Convert-ToWindowHandle { + param([object]$Value) -$shortBody = if ($Body) { - if ($Body.Length -gt 100) { $Body.Substring(0, 97) + "..." } else { $Body } -} else { "" } + if ($null -eq $Value) { return [IntPtr]::Zero } + $text = "$Value".Trim() + if (-not $text) { return [IntPtr]::Zero } -$parts = @() -if ($shortBody) { $parts += $shortBody } -$parts += "[$statusLabel] Duration: ${Duration}s" -$parts += "Click to locate the terminal (look for flashing taskbar icon)" -$bodyText = $parts -join "`n" + $raw = [int64]0 + if ([int64]::TryParse($text, [ref]$raw) -and $raw -ne 0) { + return [IntPtr]::new($raw) + } + return [IntPtr]::Zero +} -# --------------------------------------------------------------------------- -# P/Invoke: console capture + window restore + taskbar flash -# --------------------------------------------------------------------------- -Add-Type -MemberDefinition @' +try { + # --------------------------------------------------------------------------- + # P/Invoke: console capture + window restore + activation + taskbar flash. + # --------------------------------------------------------------------------- + Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; +using System.Threading; + +namespace DC { + [StructLayout(LayoutKind.Sequential)] + public struct FLASHWINFO { + public uint cbSize; + public IntPtr hwnd; + public uint dwFlags; + public uint uCount; + public uint dwTimeout; + } + + public static class DeepCodeNotify { + private const int SW_SHOWNORMAL = 1; + private const int SW_MINIMIZE = 6; + private const int SW_RESTORE = 9; + private const uint WM_SYSCOMMAND = 0x0112; + private static readonly IntPtr SC_RESTORE = new IntPtr(0xF120); + private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + private static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2); + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint KEYEVENTF_KEYUP = 0x0002; + private const byte VK_MENU = 0x12; + private const uint FLASH_UNTIL_FOREGROUND = 0x03 | 0x0C; + private static string lastActivationSummary = ""; + + [DllImport("kernel32.dll")] + public static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AttachConsole(uint dwProcessId); + + [DllImport("kernel32.dll")] + public static extern bool FreeConsole(); + + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool BringWindowToTop(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); + + [DllImport("user32.dll")] + public static extern bool FlashWindowEx(ref FLASHWINFO pwfi); + + [DllImport("user32.dll")] + public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + + [DllImport("user32.dll")] + public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("kernel32.dll")] + public static extern uint GetCurrentThreadId(); + + public static string GetLastActivationSummary() { + return lastActivationSummary ?? ""; + } + + public static int GetWindowProcessId(IntPtr hwnd) { + uint processId; + GetWindowThreadProcessId(hwnd, out processId); + return (int)processId; + } + + public static IntPtr GetConsoleWindowForProcess(uint processId) { + FreeConsole(); + if (!AttachConsole(processId)) { + return IntPtr.Zero; + } + + IntPtr hwnd = GetConsoleWindow(); + FreeConsole(); + return hwnd; + } + + public static bool MinimizeWindow(IntPtr hwnd) { + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) { + return false; + } + ShowWindow(hwnd, SW_MINIMIZE); + Thread.Sleep(250); + return IsIconic(hwnd); + } + + public static bool RestoreWindow(IntPtr hwnd) { + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) { + return false; + } + + SendMessage(hwnd, WM_SYSCOMMAND, SC_RESTORE, IntPtr.Zero); + ShowWindowAsync(hwnd, SW_RESTORE); + ShowWindow(hwnd, SW_RESTORE); + Thread.Sleep(200); + + if (IsIconic(hwnd)) { + ShowWindow(hwnd, SW_SHOWNORMAL); + Thread.Sleep(200); + } + + return !IsIconic(hwnd); + } -[StructLayout(LayoutKind.Sequential)] -public struct FLASHWINFO { - public uint cbSize; - public IntPtr hwnd; - public uint dwFlags; - public uint uCount; - public uint dwTimeout; + public static bool ActivateWindow(IntPtr hwnd) { + if (!RestoreWindow(hwnd)) { + lastActivationSummary = "restore=false"; + return false; + } + + bool top1 = SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + bool top2 = SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + bool bringTop = BringWindowToTop(hwnd); + + // The classic Alt-key nudge relaxes Windows foreground-lock rules. + keybd_event(VK_MENU, 0, 0, UIntPtr.Zero); + Thread.Sleep(30); + keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + Thread.Sleep(60); + + uint targetPid; + uint targetThread = GetWindowThreadProcessId(hwnd, out targetPid); + IntPtr foregroundBefore = GetForegroundWindow(); + uint foregroundPid; + uint foregroundThread = GetWindowThreadProcessId(foregroundBefore, out foregroundPid); + uint currentThread = GetCurrentThreadId(); + + bool attachCurrentTarget = false; + bool attachCurrentForeground = false; + bool attachTargetForeground = false; + if (targetThread != 0) { + attachCurrentTarget = AttachThreadInput(currentThread, targetThread, true); + } + if (foregroundThread != 0) { + attachCurrentForeground = AttachThreadInput(currentThread, foregroundThread, true); + } + if (targetThread != 0 && foregroundThread != 0 && targetThread != foregroundThread) { + attachTargetForeground = AttachThreadInput(targetThread, foregroundThread, true); + } + + bool setForeground = SetForegroundWindow(hwnd); + int setForegroundError = Marshal.GetLastWin32Error(); + BringWindowToTop(hwnd); + + if (targetThread != 0 && foregroundThread != 0 && targetThread != foregroundThread) { + AttachThreadInput(targetThread, foregroundThread, false); + } + if (foregroundThread != 0) { + AttachThreadInput(currentThread, foregroundThread, false); + } + if (targetThread != 0) { + AttachThreadInput(currentThread, targetThread, false); + } + + Thread.Sleep(100); + + if (GetForegroundWindow() == hwnd) { + lastActivationSummary = + "restore=true topmost=" + top1 + + " notopmost=" + top2 + + " bringTop=" + bringTop + + " attachCurrentTarget=" + attachCurrentTarget + + " attachCurrentForeground=" + attachCurrentForeground + + " attachTargetForeground=" + attachTargetForeground + + " setForeground=" + setForeground + + " setForegroundError=" + setForegroundError + + " switch=false foreground=true"; + return true; + } + + SwitchToThisWindow(hwnd, true); + Thread.Sleep(100); + bool foregroundAfterSwitch = GetForegroundWindow() == hwnd; + lastActivationSummary = + "restore=true topmost=" + top1 + + " notopmost=" + top2 + + " bringTop=" + bringTop + + " attachCurrentTarget=" + attachCurrentTarget + + " attachCurrentForeground=" + attachCurrentForeground + + " attachTargetForeground=" + attachTargetForeground + + " setForeground=" + setForeground + + " setForegroundError=" + setForegroundError + + " switch=true foreground=" + foregroundAfterSwitch; + return foregroundAfterSwitch; + } + + public static bool FlashTaskbar(IntPtr hwnd) { + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) { + return false; + } + + FLASHWINFO info = new FLASHWINFO(); + info.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO)); + info.hwnd = hwnd; + info.dwFlags = FLASH_UNTIL_FOREGROUND; + info.uCount = 0; + info.dwTimeout = 0; + return FlashWindowEx(ref info); + } + } } +'@ + + function Test-TerminalProcessName { + param([string]$ProcessName) + + $name = "$ProcessName".ToLowerInvariant() + return $name -in @( + "windowsterminal", + "wt", + "conhost", + "openconsole", + "cmd", + "powershell", + "pwsh" + ) + } + + function Test-UsableWindowProcessName { + param([string]$ProcessName) -public static class DeepCodeNotify { - [DllImport("kernel32.dll")] - public static extern IntPtr GetConsoleWindow(); + $name = "$ProcessName".ToLowerInvariant() + return $name -notin @( + "explorer", + "shellexperiencehost", + "searchhost", + "startmenuexperiencehost", + "systemsettings" + ) + } + + function Test-UsableWindowHandle { + param([IntPtr]$WindowHwnd) - [DllImport("user32.dll")] - public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + if ($WindowHwnd -eq [IntPtr]::Zero -or -not [DC.DeepCodeNotify]::IsWindow($WindowHwnd)) { + return $false + } - [DllImport("user32.dll")] - public static extern bool IsIconic(IntPtr hWnd); + try { + $windowPid = [DC.DeepCodeNotify]::GetWindowProcessId($WindowHwnd) + if ($windowPid -le 0) { return $false } + $proc = Get-Process -Id $windowPid -ErrorAction Stop + Write-NotifyDebug "window candidate hwnd=$($WindowHwnd.ToInt64()) pid=$windowPid name=$($proc.ProcessName) title='$($proc.MainWindowTitle)'" + return Test-UsableWindowProcessName $proc.ProcessName + } catch { + Write-NotifyDebug "window candidate hwnd=$($WindowHwnd.ToInt64()) lookup failed: $($_.Exception.Message)" + return $false + } + } - [DllImport("user32.dll")] - public static extern bool FlashWindowEx(ref FLASHWINFO pwfi); + function Find-ConsoleWindowHandle { + param([int]$StartProcessId) - // dwFlags: 0x03 = FLASHW_ALL (caption + taskbar) - // 0x0C = FLASHW_TIMERNOFG (keep flashing until foreground) - private const uint FLASH_UNTIL_FG = 0x03 | 0x0C; + $current = $StartProcessId + $seen = @{} + Write-NotifyDebug "Find-ConsoleWindowHandle startPid=$StartProcessId" + while ($current -gt 0 -and -not $seen.ContainsKey($current)) { + $seen[$current] = $true - public static void RestoreWindow(IntPtr hwnd) { - ShowWindow(hwnd, 9); // SW_RESTORE - System.Threading.Thread.Sleep(200); - if (IsIconic(hwnd)) { - ShowWindow(hwnd, 1); // SW_SHOWNORMAL - System.Threading.Thread.Sleep(200); + try { + $consoleHwnd = [DC.DeepCodeNotify]::GetConsoleWindowForProcess([uint32]$current) + Write-NotifyDebug "AttachConsole pid=$current hwnd=$($consoleHwnd.ToInt64())" + if ($consoleHwnd -ne [IntPtr]::Zero -and [DC.DeepCodeNotify]::IsWindow($consoleHwnd)) { + return $consoleHwnd } + } catch { + Write-NotifyDebug "AttachConsole pid=$current failed: $($_.Exception.Message)" + } + + try { + $cim = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $current" -ErrorAction Stop + if (-not $cim -or -not $cim.ParentProcessId) { break } + $current = [int]$cim.ParentProcessId + } catch { + break + } } - public static void FlashTaskbar(IntPtr hwnd) { - FLASHWINFO info = new FLASHWINFO(); - info.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO)); - info.hwnd = hwnd; - info.dwFlags = FLASH_UNTIL_FG; - info.uCount = 0; - info.dwTimeout = 0; - FlashWindowEx(ref info); + return [IntPtr]::Zero + } + + function Find-AncestorWindowHandle { + param([int]$StartProcessId) + + $current = $StartProcessId + $seen = @{} + $fallbackHwnd = [IntPtr]::Zero + Write-NotifyDebug "Find-AncestorWindowHandle startPid=$StartProcessId" + while ($current -gt 0 -and -not $seen.ContainsKey($current)) { + $seen[$current] = $true + + try { + $proc = Get-Process -Id $current -ErrorAction Stop + Write-NotifyDebug "process pid=$current name=$($proc.ProcessName) hwnd=$($proc.MainWindowHandle) title='$($proc.MainWindowTitle)'" + if ((Test-TerminalProcessName $proc.ProcessName) -and $proc.MainWindowHandle -and $proc.MainWindowHandle -ne 0) { + return [IntPtr]::new([int64]$proc.MainWindowHandle) + } + if ( + $fallbackHwnd -eq [IntPtr]::Zero -and + (Test-UsableWindowProcessName $proc.ProcessName) -and + $proc.MainWindowHandle -and + $proc.MainWindowHandle -ne 0 + ) { + $fallbackHwnd = [IntPtr]::new([int64]$proc.MainWindowHandle) + Write-NotifyDebug "ancestor fallback candidate pid=$current hwnd=$($fallbackHwnd.ToInt64()) name=$($proc.ProcessName)" + } + } catch { } + + try { + $cim = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $current" -ErrorAction Stop + if (-not $cim -or -not $cim.ParentProcessId) { break } + Write-NotifyDebug "process pid=$current parentPid=$($cim.ParentProcessId)" + $current = [int]$cim.ParentProcessId + } catch { + Write-NotifyDebug "process pid=$current parent lookup failed: $($_.Exception.Message)" + break + } + } + + return $fallbackHwnd + } + + function Resolve-TargetWindowHandle { + $explicitHwnd = Convert-ToWindowHandle $TestWindowHwnd + if ($explicitHwnd -ne [IntPtr]::Zero) { return $explicitHwnd } + + $envHwnd = Convert-ToWindowHandle $env:DEEPCODE_NOTIFY_WINDOW_HWND + if ($envHwnd -ne [IntPtr]::Zero) { return $envHwnd } + + foreach ($pidValue in @($env:DEEPCODE_NOTIFY_PARENT_PID, $env:DEEPCODE_NOTIFY_PROCESS_PID)) { + $pidText = "$pidValue".Trim() + if (-not $pidText) { continue } + + $pidNumber = 0 + if ([int]::TryParse($pidText, [ref]$pidNumber)) { + $consoleHwnd = Find-ConsoleWindowHandle $pidNumber + if ($consoleHwnd -ne [IntPtr]::Zero) { + Write-NotifyDebug "resolved via console attach pid=$pidNumber hwnd=$($consoleHwnd.ToInt64())" + return $consoleHwnd + } + + $hwnd = Find-AncestorWindowHandle $pidNumber + if ($hwnd -ne [IntPtr]::Zero) { + Write-NotifyDebug "resolved via ancestor terminal pid=$pidNumber hwnd=$($hwnd.ToInt64())" + return $hwnd + } + } } -} -'@ -Name Win32 -Namespace DC -$consoleHwnd = [DC.DeepCodeNotify]::GetConsoleWindow() + $foregroundHwnd = [DC.DeepCodeNotify]::GetForegroundWindow() + if (Test-UsableWindowHandle $foregroundHwnd) { + Write-NotifyDebug "resolved via startup foreground hwnd=$($foregroundHwnd.ToInt64())" + return $foregroundHwnd + } + + $ownConsoleHwnd = [DC.DeepCodeNotify]::GetConsoleWindow() + Write-NotifyDebug "fallback own GetConsoleWindow hwnd=$($ownConsoleHwnd.ToInt64())" + return $ownConsoleHwnd + } + + function Invoke-ActivateTargetWindow { + param( + [IntPtr]$WindowHwnd, + [int]$Attempts = 1 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt += 1) { + $activated = [DC.DeepCodeNotify]::ActivateWindow($WindowHwnd) + Write-NotifyDebug "ActivateWindow attempt=$attempt activated=$activated summary='$([DC.DeepCodeNotify]::GetLastActivationSummary())'" + if ($activated) { return $true } + Start-Sleep -Milliseconds 150 + } + + try { + Add-Type -AssemblyName Microsoft.VisualBasic + $targetPid = [DC.DeepCodeNotify]::GetWindowProcessId($WindowHwnd) + if ($targetPid -gt 0) { + $appActivated = [Microsoft.VisualBasic.Interaction]::AppActivate([int]$targetPid) + Write-NotifyDebug "AppActivate pid=$targetPid result=$appActivated" + Start-Sleep -Milliseconds 250 + if ([DC.DeepCodeNotify]::GetForegroundWindow() -eq $WindowHwnd) { + return $true + } + } + } catch { + Write-NotifyDebug "AppActivate failed: $($_.Exception.Message)" + } + + return $false + } + + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -WarningAction SilentlyContinue -TypeDefinition @' +namespace DC { + public class DeepCodeToastForm : System.Windows.Forms.Form { + private const int WM_MOUSEACTIVATE = 0x0021; + private const int CS_DROPSHADOW = 0x00020000; + private static readonly System.IntPtr MA_ACTIVATE = new System.IntPtr(1); -if ($consoleHwnd -eq [IntPtr]::Zero) { - # Running in a non-console terminal (mintty, Windows Terminal, etc.). - # We cannot flash a specific taskbar button, but the BalloonTip alone - # is still useful as a notification. - Write-Warning "DeepCode: Could not capture console window handle." - exit 0 + public event System.EventHandler ToastClickRequested; + + protected override System.Windows.Forms.CreateParams CreateParams { + get { + System.Windows.Forms.CreateParams cp = base.CreateParams; + cp.ClassStyle |= CS_DROPSHADOW; + return cp; + } + } + + protected override void WndProc(ref System.Windows.Forms.Message m) { + if (m.Msg == WM_MOUSEACTIVATE) { + m.Result = MA_ACTIVATE; + if (!IsCloseButtonUnderCursor()) { + System.EventHandler handler = ToastClickRequested; + if (handler != null) { + handler(this, System.EventArgs.Empty); + } + } + return; + } + base.WndProc(ref m); + } + + private bool IsCloseButtonUnderCursor() { + System.Drawing.Point clientPoint = PointToClient(System.Windows.Forms.Cursor.Position); + System.Windows.Forms.Control child = GetChildAtPoint(clientPoint); + return child != null && child.Name == "DeepCodeToastClose"; + } + } } +'@ -# --------------------------------------------------------------------------- -# BalloonTip notification -# --------------------------------------------------------------------------- -Add-Type -AssemblyName System.Windows.Forms + $targetHwnd = Resolve-TargetWindowHandle + $hasTargetWindow = $targetHwnd -ne [IntPtr]::Zero -and [DC.DeepCodeNotify]::IsWindow($targetHwnd) + Write-NotifyDebug "resolved targetHwnd=$($targetHwnd.ToInt64()) hasTargetWindow=$hasTargetWindow parentPid=$env:DEEPCODE_NOTIFY_PARENT_PID processPid=$env:DEEPCODE_NOTIFY_PROCESS_PID" -$notify = New-Object System.Windows.Forms.NotifyIcon -$notify.Icon = [System.Drawing.SystemIcons]::Information -$notify.BalloonTipTitle = $titleText -$notify.BalloonTipText = $bodyText -$notify.BalloonTipIcon = $iconType -$notify.Visible = $true + if ($ValidateOnly) { + Write-TestResult @{ + ok = $true + mode = "validate" + targetHwnd = if ($hasTargetWindow) { $targetHwnd.ToInt64() } else { 0 } + hasTargetWindow = $hasTargetWindow + formsLoaded = $true + } + exit 0 + } -$clickSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked -$dismissSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed + if ($SelfTest) { + if (-not $hasTargetWindow) { + throw "SelfTest requires a valid target window handle." + } -$notify.ShowBalloonTip(30000) + $minimized = [DC.DeepCodeNotify]::MinimizeWindow($targetHwnd) + $restored = [DC.DeepCodeNotify]::RestoreWindow($targetHwnd) + $foreground = Invoke-ActivateTargetWindow $targetHwnd + $flashed = [DC.DeepCodeNotify]::FlashTaskbar($targetHwnd) + $ok = $minimized -and $restored -and -not [DC.DeepCodeNotify]::IsIconic($targetHwnd) -# --------------------------------------------------------------------------- -# Event loop -# --------------------------------------------------------------------------- -try { - $remaining = 35 - while ($remaining -gt 0) { - $event = Wait-Event -Timeout $remaining - if (-not $event) { break } + Write-TestResult @{ + ok = $ok + mode = "selftest" + targetHwnd = $targetHwnd.ToInt64() + minimized = $minimized + restored = $restored + foreground = $foreground + flashed = $flashed + activationSummary = [DC.DeepCodeNotify]::GetLastActivationSummary() + iconicAfterRestore = [DC.DeepCodeNotify]::IsIconic($targetHwnd) + } + + if (-not $ok) { exit 1 } + exit 0 + } + + # --------------------------------------------------------------------------- + # Read context and build notification text. + # --------------------------------------------------------------------------- + function Normalize-NotifySnippet { + param( + [string]$Text, + [int]$MaxChars, + [int]$MaxLines + ) + + if ([string]::IsNullOrWhiteSpace($Text)) { + return "" + } + + $lines = @() + foreach ($line in ("$Text" -split "\r?\n")) { + $normalized = (($line -replace "\s+", " ").Trim()) + if ($normalized) { + $lines += $normalized + } + if ($lines.Count -ge $MaxLines) { + break + } + } + + if ($lines.Count -eq 0) { + return "" + } + + $snippet = $lines -join " " + if ($snippet.Length -gt $MaxChars) { + $take = [Math]::Max(0, $MaxChars - 3) + return $snippet.Substring(0, $take).TrimEnd() + "..." + } + + return $snippet + } + + function New-RoundedRectanglePath { + param( + [System.Drawing.Rectangle]$Rectangle, + [int]$Radius + ) + + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + if ($Radius -le 0) { + $path.AddRectangle($Rectangle) + return $path + } + + $diameter = [Math]::Max(1, $Radius * 2) + $path.AddArc($Rectangle.Left, $Rectangle.Top, $diameter, $diameter, 180, 90) + $path.AddArc($Rectangle.Right - $diameter, $Rectangle.Top, $diameter, $diameter, 270, 90) + $path.AddArc($Rectangle.Right - $diameter, $Rectangle.Bottom - $diameter, $diameter, $diameter, 0, 90) + $path.AddArc($Rectangle.Left, $Rectangle.Bottom - $diameter, $diameter, $diameter, 90, 90) + $path.CloseFigure() + return $path + } + + $Status = $env:STATUS + $Title = $env:TITLE + $Question = $env:QUESTION + $Body = $env:BODY + $Duration = $env:DURATION + + $statusLabel = switch ($Status) { + "failed" { "Failed" } + "interrupted" { "Interrupted" } + default { "Completed" } + } + + $titleText = "deepcode" + $questionText = Normalize-NotifySnippet $Question 128 2 + if (-not $questionText) { + $questionText = Normalize-NotifySnippet $Title 128 2 + } + if (-not $questionText) { + $questionText = "Task finished" + } + + $answerText = Normalize-NotifySnippet $Body 210 3 + if (-not $answerText -and $env:FAIL_REASON) { + $answerText = Normalize-NotifySnippet $env:FAIL_REASON 210 2 + } + if (-not $answerText) { + $answerText = if ($Duration) { "$statusLabel in ${Duration}s" } else { $statusLabel } + } + + # --------------------------------------------------------------------------- + # Clickable desktop tip window. + # --------------------------------------------------------------------------- + $script:notifyClicked = $false + $script:notifyActivated = $false + $script:notifyFlashed = $false + $script:finalForeground = $false + $script:handlingClick = $false + + $backgroundColor = [System.Drawing.Color]::FromArgb(248, 248, 250) + $brandColor = [System.Drawing.Color]::FromArgb(38, 39, 43) + $questionColor = [System.Drawing.Color]::FromArgb(31, 31, 35) + $answerColor = [System.Drawing.Color]::FromArgb(101, 104, 111) + $chromeColor = [System.Drawing.Color]::FromArgb(77, 80, 86) + $chromeHoverColor = [System.Drawing.Color]::FromArgb(230, 231, 235) + + $form = New-Object DC.DeepCodeToastForm + $form.Text = $titleText + $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None + $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual + $form.ShowInTaskbar = $false + $form.TopMost = $true + $form.ClientSize = New-Object System.Drawing.Size(382, 144) + $form.BackColor = $backgroundColor + $form.Cursor = [System.Windows.Forms.Cursors]::Hand + + $roundedRegionPath = New-RoundedRectanglePath (New-Object System.Drawing.Rectangle(0, 0, $form.Width, $form.Height)) 10 + $form.Region = New-Object System.Drawing.Region($roundedRegionPath) + $roundedRegionPath.Dispose() + + $form.Add_Paint({ + param($sender, $eventArgs) + + try { + $graphics = $eventArgs.Graphics + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $rect = New-Object System.Drawing.Rectangle(0, 0, ($sender.Width - 1), ($sender.Height - 1)) + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + $diameter = 20 + $path.AddArc($rect.Left, $rect.Top, $diameter, $diameter, 180, 90) + $path.AddArc($rect.Right - $diameter, $rect.Top, $diameter, $diameter, 270, 90) + $path.AddArc($rect.Right - $diameter, $rect.Bottom - $diameter, $diameter, $diameter, 0, 90) + $path.AddArc($rect.Left, $rect.Bottom - $diameter, $diameter, $diameter, 90, 90) + $path.CloseFigure() + $pen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(197, 199, 204), 1) + $graphics.DrawPath($pen, $path) + $pen.Dispose() + $path.Dispose() + } catch { + Write-NotifyDebug "toast paint failed: $($_.Exception.Message)" + } + }) + + $workArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea + $form.Left = [Math]::Max($workArea.Left, $workArea.Right - $form.Width - 18) + $form.Top = [Math]::Max($workArea.Top, $workArea.Bottom - $form.Height - 18) + + $iconImage = $null + $iconPath = Join-Path $PSScriptRoot "deepcode-icon.png" + if (Test-Path -LiteralPath $iconPath) { + try { + $iconImage = [System.Drawing.Image]::FromFile($iconPath) + } catch { + Write-NotifyDebug "icon load failed: $($_.Exception.Message)" + } + } + + $iconBox = New-Object System.Windows.Forms.PictureBox + $iconBox.Left = 18 + $iconBox.Top = 15 + $iconBox.Width = 20 + $iconBox.Height = 20 + $iconBox.BackColor = $backgroundColor + $iconBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom + $iconBox.Cursor = [System.Windows.Forms.Cursors]::Hand + if ($iconImage) { + $iconBox.Image = $iconImage + } + + $titleLabel = New-Object System.Windows.Forms.Label + $titleLabel.AutoSize = $false + $titleLabel.Left = 46 + $titleLabel.Top = 12 + $titleLabel.Width = 205 + $titleLabel.Height = 26 + $titleLabel.BackColor = $backgroundColor + $titleLabel.Font = New-Object System.Drawing.Font("Segoe UI", 9.5, [System.Drawing.FontStyle]::Regular) + $titleLabel.ForeColor = $brandColor + $titleLabel.Text = $titleText + $titleLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $menuLabel = New-Object System.Windows.Forms.Label + $menuLabel.AutoSize = $false + $menuLabel.Left = $form.ClientSize.Width - 76 + $menuLabel.Top = 9 + $menuLabel.Width = 34 + $menuLabel.Height = 28 + $menuLabel.BackColor = $backgroundColor + $menuLabel.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Regular) + $menuLabel.ForeColor = $chromeColor + $menuLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $menuLabel.Text = "..." + $menuLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $closeLabel = New-Object System.Windows.Forms.Label + $closeLabel.Name = "DeepCodeToastClose" + $closeLabel.AutoSize = $false + $closeLabel.Left = $form.ClientSize.Width - 40 + $closeLabel.Top = 9 + $closeLabel.Width = 28 + $closeLabel.Height = 28 + $closeLabel.BackColor = $backgroundColor + $closeLabel.Font = New-Object System.Drawing.Font("Segoe UI", 13, [System.Drawing.FontStyle]::Regular) + $closeLabel.ForeColor = $chromeColor + $closeLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $closeLabel.Text = [string][char]0x00D7 + $closeLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $closeOnly = { + if ($script:handlingClick) { return } + $script:handlingClick = $true + try { $timeoutTimer.Stop() } catch { } + if ($autoClickTimer) { + try { $autoClickTimer.Stop() } catch { } + } + $form.Close() + } + + $questionLabel = New-Object System.Windows.Forms.Label + $questionLabel.AutoSize = $false + $questionLabel.Left = 24 + $questionLabel.Top = 50 + $questionLabel.Width = $form.ClientSize.Width - 48 + $questionLabel.Height = 25 + $questionLabel.AutoEllipsis = $true + $questionLabel.BackColor = $backgroundColor + $questionLabel.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Regular) + $questionLabel.ForeColor = $questionColor + $questionLabel.Text = $questionText + $questionLabel.Cursor = [System.Windows.Forms.Cursors]::Hand - if ($event.SourceIdentifier -eq $clickSub.Name) { + $answerLabel = New-Object System.Windows.Forms.Label + $answerLabel.AutoSize = $false + $answerLabel.Left = 24 + $answerLabel.Top = 77 + $answerLabel.Width = $form.ClientSize.Width - 48 + $answerLabel.Height = 52 + $answerLabel.AutoEllipsis = $true + $answerLabel.BackColor = $backgroundColor + $answerLabel.Font = New-Object System.Drawing.Font("Segoe UI", 10.5, [System.Drawing.FontStyle]::Regular) + $answerLabel.ForeColor = $answerColor + $answerLabel.Text = $answerText + $answerLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $form.Controls.Add($iconBox) + $form.Controls.Add($titleLabel) + $form.Controls.Add($menuLabel) + $form.Controls.Add($closeLabel) + $form.Controls.Add($questionLabel) + $form.Controls.Add($answerLabel) + + $timeoutTimer = New-Object System.Windows.Forms.Timer + $timeoutTimer.Interval = [Math]::Max(1, $TimeoutSeconds) * 1000 + $timeoutTimer.Add_Tick({ + Write-NotifyDebug "toast timeout" + $timeoutTimer.Stop() + $form.Close() + }) + + $autoClickTimer = $null + if ($AutoClickAfterMilliseconds -gt 0) { + $autoClickTimer = New-Object System.Windows.Forms.Timer + $autoClickTimer.Interval = [Math]::Max(1, $AutoClickAfterMilliseconds) + } + + $activateAndClose = { + if ($script:handlingClick) { return } + $script:handlingClick = $true + $script:notifyClicked = $true + Write-NotifyDebug "toast click received targetHwnd=$($targetHwnd.ToInt64()) hasTargetWindow=$hasTargetWindow" + try { $timeoutTimer.Stop() } catch { } + if ($autoClickTimer) { + try { $autoClickTimer.Stop() } catch { } + } + + if ($hasTargetWindow) { + # Keep the clicked tip window alive while requesting foreground access. + # Hiding first can discard the user-click foreground permission on Windows. + $script:notifyActivated = Invoke-ActivateTargetWindow $targetHwnd 1 + Write-NotifyDebug "Invoke-ActivateTargetWindow activated=$script:notifyActivated" + if (-not $script:notifyActivated) { + $script:notifyFlashed = [DC.DeepCodeNotify]::FlashTaskbar($targetHwnd) + Write-NotifyDebug "FlashTaskbar flashed=$script:notifyFlashed" + } + $script:finalForeground = [DC.DeepCodeNotify]::GetForegroundWindow() -eq $targetHwnd + Write-NotifyDebug "final foreground after click=$script:finalForeground" + } else { + Write-NotifyDebug "click ignored because no target window was resolved" + } + + try { + $form.Hide() + [System.Windows.Forms.Application]::DoEvents() + } catch { } + $form.Close() + } + + $form.Add_Click($activateAndClose) + $form.Add_MouseUp($activateAndClose) + $form.Add_ToastClickRequested($activateAndClose) + $iconBox.Add_Click($activateAndClose) + $iconBox.Add_MouseUp($activateAndClose) + $titleLabel.Add_Click($activateAndClose) + $titleLabel.Add_MouseUp($activateAndClose) + $menuLabel.Add_Click($activateAndClose) + $menuLabel.Add_MouseUp($activateAndClose) + $questionLabel.Add_Click($activateAndClose) + $questionLabel.Add_MouseUp($activateAndClose) + $answerLabel.Add_Click($activateAndClose) + $answerLabel.Add_MouseUp($activateAndClose) + $closeLabel.Add_MouseEnter({ $closeLabel.BackColor = $chromeHoverColor }) + $closeLabel.Add_MouseLeave({ $closeLabel.BackColor = $backgroundColor }) + $closeLabel.Add_Click($closeOnly) + $closeLabel.Add_MouseUp($closeOnly) + + if ($autoClickTimer) { + $autoClickTimer.Add_Tick({ + Write-NotifyDebug "auto click timer fired" + $autoClickTimer.Stop() + & $activateAndClose + }) + } + + $form.Add_Shown({ + Write-NotifyDebug "toast shown timeoutSeconds=$TimeoutSeconds title='$titleText'" + if ($ReadyPath) { try { - [DC.DeepCodeNotify]::RestoreWindow($consoleHwnd) - [DC.DeepCodeNotify]::FlashTaskbar($consoleHwnd) - } catch { } - break + $readyDir = Split-Path -Parent $ReadyPath + if ($readyDir) { + New-Item -ItemType Directory -Path $readyDir -Force | Out-Null + } + @{ + ok = $true + mode = "ready" + hwnd = $form.Handle.ToInt64() + left = $form.Left + top = $form.Top + width = $form.Width + height = $form.Height + centerX = $form.Left + [int]($form.Width / 2) + centerY = $form.Top + [int]($form.Height / 2) + } | ConvertTo-Json -Compress | Out-File -FilePath $ReadyPath -Encoding UTF8 + } catch { + Write-NotifyDebug "ready write failed: $($_.Exception.Message)" + } } + $timeoutTimer.Start() + if ($autoClickTimer) { $autoClickTimer.Start() } + }) - if ($event.SourceIdentifier -eq $dismissSub.Name) { break } + try { + [System.Windows.Forms.Application]::Run($form) + } finally { + try { $timeoutTimer.Stop(); $timeoutTimer.Dispose() } catch { } + if ($autoClickTimer) { + try { $autoClickTimer.Stop(); $autoClickTimer.Dispose() } catch { } + } + try { $form.Dispose() } catch { } + if ($iconImage) { + try { $iconImage.Dispose() } catch { } + } + if ($hasTargetWindow) { + $script:finalForeground = [DC.DeepCodeNotify]::GetForegroundWindow() -eq $targetHwnd + } + Write-NotifyDebug "toast disposed clicked=$script:notifyClicked activated=$script:notifyActivated flashed=$script:notifyFlashed finalForeground=$script:finalForeground" + } - Remove-Event -EventIdentifier $event.EventIdentifier - $remaining -= 1 + if ($ResultPath) { + Write-TestResult @{ + ok = $script:notifyClicked -and $script:finalForeground + mode = "toast" + clicked = $script:notifyClicked + activated = $script:notifyActivated + flashed = $script:notifyFlashed + finalForeground = $script:finalForeground + targetHwnd = if ($hasTargetWindow) { $targetHwnd.ToInt64() } else { 0 } + hasTargetWindow = $hasTargetWindow + } + } +} catch { + Write-NotifyDebug ($_ | Out-String) + if ($ValidateOnly -or $SelfTest -or $ResultPath) { + Write-TestResult @{ + ok = $false + mode = if ($SelfTest) { "selftest" } elseif ($ValidateOnly) { "validate" } else { "notify" } + error = $_.Exception.Message + } } -} finally { - Get-EventSubscriber | Unregister-Event -Force -ErrorAction SilentlyContinue - try { $notify.Dispose() } catch { } + exit 1 } diff --git a/templates/tools/test-notify.ps1 b/templates/tools/test-notify.ps1 index f565e602..d048a12e 100644 --- a/templates/tools/test-notify.ps1 +++ b/templates/tools/test-notify.ps1 @@ -1,107 +1,444 @@ #Requires -Version 5.1 -$ErrorActionPreference = "Continue" +<# +.SYNOPSIS + Automated smoke test for the built-in DeepCode Windows notification script. -Add-Type -MemberDefinition @' +.DESCRIPTION + This verifies that deepcode-notify.ps1 can compile its Win32 declarations, + create Windows Forms notification dependencies, and restore a minimized + target window. It opens a disposable WinForms window and closes it after + the test. +#> + +param( + [string]$NotifyScript = (Join-Path $PSScriptRoot "deepcode-notify.ps1"), + [switch]$ShowBalloonSmoke, + [switch]$ManualClickTest, + [switch]$CurrentTerminalClickTest +) + +$ErrorActionPreference = "Stop" + +function Invoke-NotifyScriptJson { + param([string[]]$Arguments) + + $output = & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript @Arguments + if ($LASTEXITCODE -ne 0) { + throw "deepcode-notify.ps1 failed with exit code $LASTEXITCODE. Output: $output" + } + + try { + return ($output | Out-String | ConvertFrom-Json) + } catch { + throw "deepcode-notify.ps1 did not return valid JSON. Output: $output" + } +} + +function Start-TestWindow { + $testDir = Join-Path ([System.IO.Path]::GetTempPath()) "deepcode-notify-test-$PID" + New-Item -ItemType Directory -Path $testDir -Force | Out-Null + + $helperPath = Join-Path $testDir "window-helper.ps1" + $hwndPath = Join-Path $testDir "hwnd.txt" + + @' +param([string]$HwndPath) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$form = New-Object System.Windows.Forms.Form +$form.Text = "DeepCode Notify Test Window" +$form.Size = New-Object System.Drawing.Size(420, 180) +$form.StartPosition = "CenterScreen" +$form.ShowInTaskbar = $true +$form.TopMost = $false + +$label = New-Object System.Windows.Forms.Label +$label.Text = "DeepCode notification smoke test target" +$label.Dock = "Fill" +$label.TextAlign = "MiddleCenter" +$form.Controls.Add($label) + +$form.Add_Shown({ + $form.Handle.ToInt64().ToString() | Out-File -FilePath $HwndPath -NoNewline -Encoding ASCII +}) + +[System.Windows.Forms.Application]::Run($form) +'@ | Out-File -FilePath $helperPath -Encoding UTF8 + + $proc = Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $helperPath, + "-HwndPath", $hwndPath + ) -PassThru + + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $hwndPath) { + $raw = Get-Content -LiteralPath $hwndPath -Raw + $hwnd = [int64]0 + if ([int64]::TryParse($raw.Trim(), [ref]$hwnd) -and $hwnd -ne 0) { + return @{ + Process = $proc + Hwnd = $hwnd + Directory = $testDir + } + } + } + Start-Sleep -Milliseconds 200 + } + + try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch { } + Remove-Item -LiteralPath $testDir -Recurse -Force -ErrorAction SilentlyContinue + throw "Timed out waiting for the disposable WinForms test window." +} + +function Invoke-TestMouseClick { + param( + [int]$X, + [int]$Y + ) + + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace DeepCodeNotifyTest { + [StructLayout(LayoutKind.Sequential)] + public struct POINT { + public int X; + public int Y; + } + + public static class Mouse { + [DllImport("user32.dll")] + public static extern bool SetCursorPos(int X, int Y); + + [DllImport("user32.dll")] + public static extern bool GetCursorPos(out POINT lpPoint); + + [DllImport("user32.dll")] + public static extern IntPtr WindowFromPoint(POINT point); + + [DllImport("user32.dll")] + public static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + + [DllImport("user32.dll")] + public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); + } +} +'@ -ErrorAction SilentlyContinue + + $moved = [DeepCodeNotifyTest.Mouse]::SetCursorPos($X, $Y) + Start-Sleep -Milliseconds 80 + $point = New-Object DeepCodeNotifyTest.POINT + [DeepCodeNotifyTest.Mouse]::GetCursorPos([ref]$point) | Out-Null + $hit = [DeepCodeNotifyTest.Mouse]::WindowFromPoint($point) + $root = [DeepCodeNotifyTest.Mouse]::GetAncestor($hit, 2) + Write-Host " Mouse moved=$moved cursor=($($point.X),$($point.Y)) hitHwnd=$($hit.ToInt64()) rootHwnd=$($root.ToInt64())" + [DeepCodeNotifyTest.Mouse]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 40 + [DeepCodeNotifyTest.Mouse]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) + Write-Host " mouse_event click sent" + Start-Sleep -Milliseconds 350 +} + +function Get-TestWindowCenter { + param([int64]$Hwnd) + + Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; -[StructLayout(LayoutKind.Sequential)] -public struct FLASHWINFO { - public uint cbSize; - public IntPtr hwnd; - public uint dwFlags; - public uint uCount; - public uint dwTimeout; +namespace DeepCodeNotifyTest { + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + public static class WindowRect { + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + } +} +'@ -ErrorAction SilentlyContinue + + $rect = New-Object DeepCodeNotifyTest.RECT + $ok = [DeepCodeNotifyTest.WindowRect]::GetWindowRect([IntPtr]::new($Hwnd), [ref]$rect) + if (-not $ok) { + throw "GetWindowRect failed for HWND $Hwnd" + } + + return @{ + X = [int](($rect.Left + $rect.Right) / 2) + Y = [int](($rect.Top + $rect.Bottom) / 2) + } +} + +function Get-ForegroundWindowHwnd { + Add-Type -MemberDefinition @' +[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); +'@ -Name Foreground -Namespace DeepCodeNotifyTest -ErrorAction SilentlyContinue + + return [DeepCodeNotifyTest.Foreground]::GetForegroundWindow().ToInt64() } -public static class DeepCodeTest { - [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); - [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); - [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); - [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] public static extern bool FlashWindowEx(ref FLASHWINFO pwfi); - - public static void RestoreWindow(IntPtr hwnd) { - ShowWindow(hwnd, 9); - System.Threading.Thread.Sleep(200); - if (IsIconic(hwnd)) { - ShowWindow(hwnd, 1); - System.Threading.Thread.Sleep(200); +function Wait-JsonFile { + param( + [string]$Path, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $Path) { + try { + $raw = Get-Content -LiteralPath $Path -Raw + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $json = $raw | ConvertFrom-Json + if ($json) { + return $json + } } + } catch { } } + Start-Sleep -Milliseconds 100 + } + + throw "Timed out waiting for JSON file: $Path" +} - public static void FlashTaskbar(IntPtr hwnd) { - FLASHWINFO info = new FLASHWINFO(); - info.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO)); - info.hwnd = hwnd; - info.dwFlags = 0x03 | 0x0C; // FLASHW_ALL | FLASHW_TIMERNOFG - FlashWindowEx(ref info); +function Wait-ProcessExit { + param( + [System.Diagnostics.Process]$Process, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if ($Process.HasExited) { + return } + Start-Sleep -Milliseconds 100 + } + + try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch { } + throw "Timed out waiting for process $($Process.Id) to exit." } -'@ -Write-Host "=== DeepCode Notify Test ===" -ForegroundColor Cyan +Write-Host "=== DeepCode Notify Automated Smoke Test ===" -ForegroundColor Cyan +Write-Host "Script: $NotifyScript" -$hwnd = [DeepCodeTest]::GetConsoleWindow() -Write-Host "1. Console HWND: $hwnd" +if (-not (Test-Path -LiteralPath $NotifyScript)) { + throw "Notify script not found: $NotifyScript" +} -if ($hwnd -eq [IntPtr]::Zero) { - Write-Host " ERROR: Must run from cmd.exe (not Git Bash)" -ForegroundColor Red - exit 1 +Write-Host "1. Validating script dependencies..." +$validate = Invoke-NotifyScriptJson @("-ValidateOnly") +if (-not $validate.ok) { + throw "ValidateOnly failed: $($validate | ConvertTo-Json -Compress)" } +Write-Host " PASS: Add-Type and Windows Forms dependencies loaded" -Write-Host "2. Current: iconic=$([DeepCodeTest]::IsIconic($hwnd)) foreground=$(([DeepCodeTest]::GetForegroundWindow() -eq $hwnd))" +if ($CurrentTerminalClickTest) { + Write-Host "2. Current terminal click test: click the DeepCode desktop tip within 45 seconds..." + $testDir = Join-Path ([System.IO.Path]::GetTempPath()) "deepcode-notify-current-terminal-$PID" + New-Item -ItemType Directory -Path $testDir -Force | Out-Null + $debugLog = Join-Path $testDir "notify-current-terminal.log" + $resultPath = Join-Path $testDir "notify-current-terminal.json" -# Minimize & auto-restore test -Write-Host "3. Minimizing in 2s..." -Start-Sleep 2 -[DeepCodeTest]::ShowWindow($hwnd, 6) | Out-Null -Start-Sleep 1 -Write-Host " Minimized: iconic=$([DeepCodeTest]::IsIconic($hwnd))" + try { + $foregroundHwnd = Get-ForegroundWindowHwnd + $env:STATUS = "completed" + $env:TITLE = "DeepCode Current Terminal Test" + $env:QUESTION = "Current terminal click test" + $env:BODY = "Click this tip to focus the terminal that launched the test" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$foregroundHwnd" + $env:DEEPCODE_NOTIFY_PARENT_PID = "$PID" + $env:DEEPCODE_NOTIFY_PROCESS_PID = "$PID" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $debugLog -Write-Host "4. Restoring in 1s..." -Start-Sleep 1 -[DeepCodeTest]::RestoreWindow($hwnd) -Write-Host " After restore: iconic=$([DeepCodeTest]::IsIconic($hwnd))" -Write-Host " RESULT: $(if (-not [DeepCodeTest]::IsIconic($hwnd)){'RESTORED'}else{'STILL MINIMIZED'})" + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript ` + -TimeoutSeconds 45 ` + -ResultPath $resultPath -# BalloonTip click → taskbar flash test -Write-Host "" -Write-Host "5. BalloonTip test: click notification, then look for FLASHING taskbar icon for THIS window" -Add-Type -AssemblyName System.Windows.Forms + if ($LASTEXITCODE -ne 0) { + throw "Current terminal click test failed with exit code $LASTEXITCODE" + } + + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + Write-Host " Captured foreground HWND: $foregroundHwnd" + Write-Host " Result: clicked=$($result.clicked) activated=$($result.activated) finalForeground=$($result.finalForeground) targetHwnd=$($result.targetHwnd)" + Write-Host " Debug log:" + if (Test-Path -LiteralPath $debugLog) { + Get-Content -LiteralPath $debugLog | ForEach-Object { Write-Host " $_" } + } else { + Write-Host " " + } + + if (-not $result.ok) { + throw "Current terminal click test did not focus the resolved terminal window." + } -$notify = New-Object System.Windows.Forms.NotifyIcon -$notify.Icon = [System.Drawing.SystemIcons]::Information -$notify.BalloonTipTitle = "Test - Click Me" -$notify.BalloonTipText = "Click to flash this window's taskbar icon.`nLook for the orange flashing button!" -$notify.Visible = $true + Write-Host "" + Write-Host "RESULT: PASS" -ForegroundColor Green + exit 0 + } finally { + Remove-Item -LiteralPath $testDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "2. Validating current terminal PID resolution..." +$previousNotifyWindowHwnd = $env:DEEPCODE_NOTIFY_WINDOW_HWND +$previousNotifyParentPid = $env:DEEPCODE_NOTIFY_PARENT_PID +$previousNotifyProcessPid = $env:DEEPCODE_NOTIFY_PROCESS_PID +try { + Remove-Item Env:\DEEPCODE_NOTIFY_WINDOW_HWND -ErrorAction SilentlyContinue + $env:DEEPCODE_NOTIFY_PARENT_PID = "$PID" + $env:DEEPCODE_NOTIFY_PROCESS_PID = "$PID" + $pidResolve = Invoke-NotifyScriptJson @("-ValidateOnly") + if (-not $pidResolve.ok -or -not $pidResolve.hasTargetWindow -or [int64]$pidResolve.targetHwnd -eq 0) { + throw "Current terminal PID resolution failed: $($pidResolve | ConvertTo-Json -Compress)" + } + Write-Host " PASS: targetHwnd=$($pidResolve.targetHwnd)" +} finally { + if ($null -eq $previousNotifyWindowHwnd) { + Remove-Item Env:\DEEPCODE_NOTIFY_WINDOW_HWND -ErrorAction SilentlyContinue + } else { + $env:DEEPCODE_NOTIFY_WINDOW_HWND = $previousNotifyWindowHwnd + } + if ($null -eq $previousNotifyParentPid) { + Remove-Item Env:\DEEPCODE_NOTIFY_PARENT_PID -ErrorAction SilentlyContinue + } else { + $env:DEEPCODE_NOTIFY_PARENT_PID = $previousNotifyParentPid + } + if ($null -eq $previousNotifyProcessPid) { + Remove-Item Env:\DEEPCODE_NOTIFY_PROCESS_PID -ErrorAction SilentlyContinue + } else { + $env:DEEPCODE_NOTIFY_PROCESS_PID = $previousNotifyProcessPid + } +} -$clickSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClicked -$dismissSub = Register-ObjectEvent -InputObject $notify -EventName BalloonTipClosed -$notify.ShowBalloonTip(15000) +Write-Host "3. Opening disposable WinForms target..." +$target = Start-TestWindow +$targetHwnd = $target.Hwnd +Write-Host " Target HWND: $targetHwnd" -Write-Host " BalloonTip shown (15s timeout)..." try { - $remaining = 20 - while ($remaining -gt 0) { - $event = Wait-Event -Timeout $remaining - if (-not $event) { Write-Host " Timeout."; break } - if ($event.SourceIdentifier -eq $clickSub.Name) { - Write-Host " CLICKED! Restoring + flashing taskbar..." - [DeepCodeTest]::RestoreWindow($hwnd) - [DeepCodeTest]::FlashTaskbar($hwnd) - Write-Host " Window should now be visible. Look for the flashing taskbar icon!" -ForegroundColor Green - Write-Host " (Click the taskbar icon to stop flashing)" - break - } - if ($event.SourceIdentifier -eq $dismissSub.Name) { Write-Host " Dismissed."; break } - Remove-Event -EventIdentifier $event.EventIdentifier - $remaining -= 1 + Write-Host "4. Testing minimized-window restore and taskbar flash..." + $selfTest = Invoke-NotifyScriptJson @("-SelfTest", "-TestWindowHwnd", "$targetHwnd") + if (-not $selfTest.ok) { + throw "SelfTest failed: $($selfTest | ConvertTo-Json -Compress)" + } + + Write-Host " PASS: minimized=$($selfTest.minimized) restored=$($selfTest.restored) flashed=$($selfTest.flashed) foreground=$($selfTest.foreground)" + + Write-Host "5. Testing desktop tip click path with automatic click..." + $autoClickResultPath = Join-Path $target.Directory "toast-auto-click.json" + $autoClickReadyPath = Join-Path $target.Directory "toast-auto-click-ready.json" + $autoClickDebugLog = Join-Path $target.Directory "toast-auto-click.log" + $env:STATUS = "completed" + $env:TITLE = "DeepCode Notify Auto Click" + $env:QUESTION = "Automated click path test question" + $env:BODY = "Automated click path test" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $autoClickDebugLog + + $notifyProc = Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $NotifyScript, + "-TimeoutSeconds", "8", + "-ReadyPath", $autoClickReadyPath, + "-ResultPath", $autoClickResultPath + ) -PassThru + + $ready = Wait-JsonFile -Path $autoClickReadyPath -TimeoutSeconds 5 + Write-Host " Ready: hwnd=$($ready.hwnd) left=$($ready.left) top=$($ready.top) width=$($ready.width) height=$($ready.height) centerX=$($ready.centerX) centerY=$($ready.centerY)" + if ([int64]$ready.hwnd -ne 0) { + $center = Get-TestWindowCenter -Hwnd ([int64]$ready.hwnd) + Write-Host " HWND center: x=$($center.X) y=$($center.Y)" + } elseif ([int]$ready.centerX -ne 0 -and [int]$ready.centerY -ne 0) { + $center = @{ + X = [int]$ready.centerX + Y = [int]$ready.centerY + } + } else { + throw "Desktop tip ready data did not include a usable HWND or center point: $($ready | ConvertTo-Json -Compress)" + } + Write-Host " Clicking tip center: x=$($center.X) y=$($center.Y)" + Invoke-TestMouseClick -X $center.X -Y $center.Y + Wait-ProcessExit -Process $notifyProc -TimeoutSeconds 10 + + if ($notifyProc.ExitCode -ne 0) { + throw "Desktop tip click failed with exit code $($notifyProc.ExitCode)" + } + + $autoClick = Get-Content -LiteralPath $autoClickResultPath -Raw | ConvertFrom-Json + if (-not $autoClick.ok) { + if (Test-Path -LiteralPath $autoClickDebugLog) { + Write-Host " Debug log:" + Get-Content -LiteralPath $autoClickDebugLog | ForEach-Object { Write-Host " $_" } + } + throw "Desktop tip auto-click did not pass: $($autoClick | ConvertTo-Json -Compress)" + } + Write-Host " PASS: clicked=$($autoClick.clicked) activated=$($autoClick.activated) finalForeground=$($autoClick.finalForeground) flashed=$($autoClick.flashed)" + + if ($ShowBalloonSmoke) { + Write-Host "6. Showing a one-second desktop tip smoke test..." + $env:STATUS = "completed" + $env:TITLE = "DeepCode Notify Smoke" + $env:QUESTION = "Smoke test question" + $env:BODY = "Automated smoke test" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript -TimeoutSeconds 1 + if ($LASTEXITCODE -ne 0) { + throw "BalloonTip smoke failed with exit code $LASTEXITCODE" + } + Write-Host " PASS: desktop tip path exited successfully" + } + + if ($ManualClickTest) { + Write-Host "7. Manual click test: click the DeepCode desktop tip within 45 seconds..." + $debugLog = Join-Path $target.Directory "notify-click.log" + $env:STATUS = "completed" + $env:TITLE = "DeepCode Manual Click Test" + $env:QUESTION = "Manual click test question" + $env:BODY = "Click this notification to focus the test window" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $debugLog + + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript -TimeoutSeconds 45 + if ($LASTEXITCODE -ne 0) { + throw "Manual click test failed with exit code $LASTEXITCODE" + } + + Write-Host " Debug log:" + if (Test-Path -LiteralPath $debugLog) { + Get-Content -LiteralPath $debugLog | ForEach-Object { Write-Host " $_" } + } else { + Write-Host " " } + } } finally { - Get-EventSubscriber | Unregister-Event -Force -ErrorAction SilentlyContinue - try { $notify.Dispose() } catch { } + if ($target) { + try { + Get-Process -Id $target.Process.Id -ErrorAction SilentlyContinue | Stop-Process -Force + } catch { } + Remove-Item -LiteralPath $target.Directory -Recurse -Force -ErrorAction SilentlyContinue + } } Write-Host "" -Write-Host "Test complete." +Write-Host "RESULT: PASS" -ForegroundColor Green From f2ac078ac589870fa3d3cb455501924263ab98c0 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Sat, 6 Jun 2026 01:38:36 +0800 Subject: [PATCH 11/12] fix: restore minimized Windows notification focus --- src/common/notify.ts | 37 +++++++++++++- src/session.ts | 10 ++-- templates/tools/deepcode-notify.ps1 | 64 ++++++++++++++++++++++++ templates/tools/test-notify.ps1 | 76 ++++++++++++++++++++++++++++- 4 files changed, 180 insertions(+), 7 deletions(-) diff --git a/src/common/notify.ts b/src/common/notify.ts index 027358d2..edca6000 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -1,4 +1,4 @@ -import { spawn, type SpawnOptions } from "child_process"; +import { spawn, spawnSync, type SpawnOptions } from "child_process"; import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; @@ -129,6 +129,41 @@ export function resolveBuiltinNotifyPath(): string | null { } } +export function captureForegroundWindowHwnd(): string | undefined { + if (process.platform !== "win32") { + return undefined; + } + + const script = [ + "$code = @'", + "using System;", + "using System.Runtime.InteropServices;", + "public static class DCForeground {", + ' [DllImport("user32.dll")]', + " public static extern IntPtr GetForegroundWindow();", + "}", + "'@", + "Add-Type -TypeDefinition $code", + "[DCForeground]::GetForegroundWindow().ToInt64()", + ].join("\n"); + + try { + const result = spawnSync("powershell.exe", ["-ExecutionPolicy", "Bypass", "-NoProfile", "-Command", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 700, + windowsHide: true, + }); + if (result.status !== 0) { + return undefined; + } + const hwnd = result.stdout.trim(); + return /^[1-9]\d*$/.test(hwnd) ? hwnd : undefined; + } catch { + return undefined; + } +} + function getBuiltinNotifyEnv( durationMs: number, configuredEnv: Record, diff --git a/src/session.ts b/src/session.ts index 41a9e28f..a3dd74cb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -5,7 +5,7 @@ import * as crypto from "crypto"; import matter from "gray-matter"; import ejs from "ejs"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; -import { launchBuiltinNotify, launchNotifyScript } from "./common/notify"; +import { captureForegroundWindowHwnd, launchBuiltinNotify, launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities"; import { readTextFileWithMetadata } from "./common/file-utils"; @@ -1199,6 +1199,8 @@ ${skillMd} const startedAt = Date.now(); const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); + const builtinNotifyWindowHwnd = process.platform === "win32" && !notify ? captureForegroundWindowHwnd() : undefined; + const notifyEnv = builtinNotifyWindowHwnd ? { ...env, DEEPCODE_NOTIFY_WINDOW_HWND: builtinNotifyWindowHwnd } : env; const now = new Date().toISOString(); rebuildSessionStateFromHistory(sessionId, this.listSessionMessages(sessionId)); @@ -1217,7 +1219,7 @@ ${skillMd} ), false ); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv); return; } @@ -1229,7 +1231,7 @@ ${skillMd} failReason: "interrupted", updateTime: now, })); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv); return; } @@ -1434,7 +1436,7 @@ ${skillMd} if (this.sessionControllers.get(sessionId) === sessionController) { this.sessionControllers.delete(sessionId); } - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv); } } diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 index c8c8b25d..d34f1e81 100644 --- a/templates/tools/deepcode-notify.ps1 +++ b/templates/tools/deepcode-notify.ps1 @@ -644,6 +644,68 @@ namespace DC { return $path } + function Invoke-ToastShownSound { + if ($ResultPath) { return } + + try { + [System.Media.SystemSounds]::Asterisk.Play() + } catch { + Write-NotifyDebug "toast sound failed: $($_.Exception.Message)" + } + } + + function Set-ToastVisualScale { + param( + [System.Windows.Forms.Form]$ToastForm, + [int]$BaseLeft, + [int]$BaseTop, + [int]$BaseWidth, + [int]$BaseHeight, + [double]$Scale, + [double]$Opacity + ) + + try { + $width = [Math]::Max(1, [int][Math]::Round($BaseWidth * $Scale)) + $height = [Math]::Max(1, [int][Math]::Round($BaseHeight * $Scale)) + $left = $BaseLeft + [int][Math]::Round(($BaseWidth - $width) / 2) + $top = $BaseTop + [int][Math]::Round(($BaseHeight - $height) / 2) + + $ToastForm.SetBounds($left, $top, $width, $height) + $ToastForm.Opacity = [Math]::Min(1, [Math]::Max(0, $Opacity)) + + $path = New-RoundedRectanglePath (New-Object System.Drawing.Rectangle(0, 0, $ToastForm.Width, $ToastForm.Height)) 10 + $oldRegion = $ToastForm.Region + $ToastForm.Region = New-Object System.Drawing.Region($path) + if ($oldRegion) { $oldRegion.Dispose() } + $path.Dispose() + + $ToastForm.Refresh() + [System.Windows.Forms.Application]::DoEvents() + } catch { + Write-NotifyDebug "toast animation frame failed: $($_.Exception.Message)" + } + } + + function Invoke-ToastClickAnimation { + param([System.Windows.Forms.Form]$ToastForm) + + $baseLeft = $ToastForm.Left + $baseTop = $ToastForm.Top + $baseWidth = $ToastForm.Width + $baseHeight = $ToastForm.Height + + foreach ($frame in @( + @{ Scale = 0.96; Opacity = 0.96; Delay = 35 }, + @{ Scale = 1.02; Opacity = 1.0; Delay = 45 }, + @{ Scale = 0.94; Opacity = 0.65; Delay = 35 }, + @{ Scale = 0.90; Opacity = 0.15; Delay = 25 } + )) { + Set-ToastVisualScale $ToastForm $baseLeft $baseTop $baseWidth $baseHeight $frame.Scale $frame.Opacity + Start-Sleep -Milliseconds $frame.Delay + } + } + $Status = $env:STATUS $Title = $env:TITLE $Question = $env:QUESTION @@ -874,6 +936,7 @@ namespace DC { } try { + Invoke-ToastClickAnimation $form $form.Hide() [System.Windows.Forms.Application]::DoEvents() } catch { } @@ -908,6 +971,7 @@ namespace DC { $form.Add_Shown({ Write-NotifyDebug "toast shown timeoutSeconds=$TimeoutSeconds title='$titleText'" + Invoke-ToastShownSound if ($ReadyPath) { try { $readyDir = Split-Path -Parent $ReadyPath diff --git a/templates/tools/test-notify.ps1 b/templates/tools/test-notify.ps1 index d048a12e..428953cb 100644 --- a/templates/tools/test-notify.ps1 +++ b/templates/tools/test-notify.ps1 @@ -188,6 +188,29 @@ function Get-ForegroundWindowHwnd { return [DeepCodeNotifyTest.Foreground]::GetForegroundWindow().ToInt64() } +function Set-TestWindowMinimized { + param([int64]$Hwnd) + + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace DeepCodeNotifyTest { + public static class WindowState { + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); + } +} +'@ -ErrorAction SilentlyContinue + + [DeepCodeNotifyTest.WindowState]::ShowWindow([IntPtr]::new($Hwnd), 6) | Out-Null + Start-Sleep -Milliseconds 300 + return [DeepCodeNotifyTest.WindowState]::IsIconic([IntPtr]::new($Hwnd)) +} + function Wait-JsonFile { param( [string]$Path, @@ -392,8 +415,57 @@ try { } Write-Host " PASS: clicked=$($autoClick.clicked) activated=$($autoClick.activated) finalForeground=$($autoClick.finalForeground) flashed=$($autoClick.flashed)" + Write-Host "6. Testing desktop tip click path while target is minimized..." + $minimizedClickResultPath = Join-Path $target.Directory "toast-minimized-click.json" + $minimizedClickReadyPath = Join-Path $target.Directory "toast-minimized-click-ready.json" + $minimizedClickDebugLog = Join-Path $target.Directory "toast-minimized-click.log" + $targetMinimized = Set-TestWindowMinimized -Hwnd $targetHwnd + if (-not $targetMinimized) { + throw "Failed to minimize target window before click test." + } + + $env:STATUS = "completed" + $env:TITLE = "DeepCode Notify Minimized Click" + $env:QUESTION = "Minimized click path test question" + $env:BODY = "Click should restore and focus the minimized target" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $minimizedClickDebugLog + + $minimizedNotifyProc = Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $NotifyScript, + "-TimeoutSeconds", "8", + "-ReadyPath", $minimizedClickReadyPath, + "-ResultPath", $minimizedClickResultPath + ) -PassThru + + $minimizedReady = Wait-JsonFile -Path $minimizedClickReadyPath -TimeoutSeconds 5 + if ([int64]$minimizedReady.hwnd -eq 0) { + throw "Minimized-target tip ready data did not include a usable HWND: $($minimizedReady | ConvertTo-Json -Compress)" + } + $minimizedCenter = Get-TestWindowCenter -Hwnd ([int64]$minimizedReady.hwnd) + Write-Host " Clicking minimized-target tip center: x=$($minimizedCenter.X) y=$($minimizedCenter.Y)" + Invoke-TestMouseClick -X $minimizedCenter.X -Y $minimizedCenter.Y + Wait-ProcessExit -Process $minimizedNotifyProc -TimeoutSeconds 10 + + if ($minimizedNotifyProc.ExitCode -ne 0) { + throw "Minimized target click failed with exit code $($minimizedNotifyProc.ExitCode)" + } + + $minimizedClick = Get-Content -LiteralPath $minimizedClickResultPath -Raw | ConvertFrom-Json + if (-not $minimizedClick.ok) { + if (Test-Path -LiteralPath $minimizedClickDebugLog) { + Write-Host " Debug log:" + Get-Content -LiteralPath $minimizedClickDebugLog | ForEach-Object { Write-Host " $_" } + } + throw "Desktop tip minimized-target click did not pass: $($minimizedClick | ConvertTo-Json -Compress)" + } + Write-Host " PASS: clicked=$($minimizedClick.clicked) activated=$($minimizedClick.activated) finalForeground=$($minimizedClick.finalForeground) flashed=$($minimizedClick.flashed)" + if ($ShowBalloonSmoke) { - Write-Host "6. Showing a one-second desktop tip smoke test..." + Write-Host "7. Showing a one-second desktop tip smoke test..." $env:STATUS = "completed" $env:TITLE = "DeepCode Notify Smoke" $env:QUESTION = "Smoke test question" @@ -408,7 +480,7 @@ try { } if ($ManualClickTest) { - Write-Host "7. Manual click test: click the DeepCode desktop tip within 45 seconds..." + Write-Host "8. Manual click test: click the DeepCode desktop tip within 45 seconds..." $debugLog = Join-Path $target.Directory "notify-click.log" $env:STATUS = "completed" $env:TITLE = "DeepCode Manual Click Test" From 4752e58590372591538b249c10f61fdb7fbc8a01 Mon Sep 17 00:00:00 2001 From: sixsix <529424117@qq.com> Date: Sat, 6 Jun 2026 02:04:17 +0800 Subject: [PATCH 12/12] chore: remove notification diagnostic script --- templates/tools/deepcode-notify-diag.ps1 | 91 ------------------------ 1 file changed, 91 deletions(-) delete mode 100644 templates/tools/deepcode-notify-diag.ps1 diff --git a/templates/tools/deepcode-notify-diag.ps1 b/templates/tools/deepcode-notify-diag.ps1 deleted file mode 100644 index 6a6e9306..00000000 --- a/templates/tools/deepcode-notify-diag.ps1 +++ /dev/null @@ -1,91 +0,0 @@ -#Requires -Version 5.1 -$ErrorActionPreference = "Continue" - -Add-Type -MemberDefinition @' -[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); -[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); -[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); -[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); -[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); -'@ -Name W32 -Namespace T - -$WM_SYSCOMMAND = 0x0112 -$SC_MINIMIZE = [IntPtr]0xF020 - -Write-Host "=== DeepCode Window Activation Diagnostic ===" -ForegroundColor Cyan - -# Open notepad -Start-Process notepad.exe | Out-Null -Start-Sleep -Seconds 3 - -# Find actual notepad UI process -$proc = Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 -if (-not $proc) { Write-Host "ERROR: No notepad window found"; exit 1 } -$hwnd = $proc.MainWindowHandle -Write-Host "HWND=$hwnd Title='$($proc.MainWindowTitle)'" - -function Test-Restore($label, [ScriptBlock]$activate) { - Write-Host "--- $label ---" - [T.W32]::SendMessage($hwnd, $WM_SYSCOMMAND, $SC_MINIMIZE, [IntPtr]::Zero) | Out-Null - Start-Sleep -Milliseconds 600 - Write-Host " Minimized: $([T.W32]::IsIconic($hwnd))" - & $activate - Start-Sleep -Milliseconds 400 - $ok = -not [T.W32]::IsIconic($hwnd) - $fg = ([T.W32]::GetForegroundWindow() -eq $hwnd) - Write-Host " restored=$ok foreground=$fg" - return $ok -} - -$r1 = Test-Restore "M1: ShowWindow+SetFg (main thread)" { - [T.W32]::ShowWindow($hwnd, 9) | Out-Null; Start-Sleep 0.2 - [T.W32]::SetForegroundWindow($hwnd) | Out-Null -} -$r2 = Test-Restore "M2: SwitchToThisWindow" { - [T.W32]::SwitchToThisWindow($hwnd, $true) -} -$r3 = Test-Restore "M3: ShowWindow+Switch" { - [T.W32]::ShowWindow($hwnd, 9) | Out-Null; Start-Sleep 0.2 - [T.W32]::SwitchToThisWindow($hwnd, $true) -} - -# M4: Spawned helper (simulates real BalloonTip scenario) -Write-Host "--- M4: Spawned helper ---" -[T.W32]::SendMessage($hwnd, $WM_SYSCOMMAND, $SC_MINIMIZE, [IntPtr]::Zero) | Out-Null -Start-Sleep -Milliseconds 600 -Write-Host " Minimized: $([T.W32]::IsIconic($hwnd))" - -$h = $hwnd.ToInt64() -$tmp = Join-Path $env:TEMP "dc-diag-helper.ps1" -$helperLines = @( - 'param([uint64]$w)', - 'Add-Type -MemberDefinition "[DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);[DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h, int n);[DllImport(\"user32.dll\")]public static extern void SwitchToThisWindow(IntPtr h, bool f);" -Name X -Namespace Y', - '$h = [IntPtr]::new([int64]$w)', - '[Y.X]::ShowWindow($h, 9) | Out-Null', - 'Start-Sleep -Milliseconds 300', - '[Y.X]::SetForegroundWindow($h) | Out-Null', - 'Start-Sleep -Milliseconds 100', - '[Y.X]::SwitchToThisWindow($h, $true)' -) -$helperLines -join "`n" | Out-File -FilePath $tmp -Encoding UTF8 - -Start-Process powershell -ArgumentList "-ExecutionPolicy","Bypass","-NoProfile","-File",$tmp,"-w",$h -WindowStyle Hidden -Wait -Start-Sleep -Milliseconds 500 -$r4 = -not [T.W32]::IsIconic($hwnd) -$fg4 = ([T.W32]::GetForegroundWindow() -eq $hwnd) -Write-Host " restored=$r4 foreground=$fg4" -Remove-Item $tmp -Force -ErrorAction SilentlyContinue - -Write-Host "" -Write-Host "=== Results ===" -ForegroundColor Cyan -Write-Host "M1 (main ShowWindow+SetFg): $(if($r1){'PASS'}else{'FAIL'})" -Write-Host "M2 (main SwitchToThis): $(if($r2){'PASS'}else{'FAIL'})" -Write-Host "M3 (main ShowWindow+Switch): $(if($r3){'PASS'}else{'FAIL'})" -Write-Host "M4 (spawned helper): $(if($r4){'PASS'}else{'FAIL'})" -if (-not $r4) { - Write-Host "ROOT CAUSE: Spawned child process cannot SetForegroundWindow on Win11" -ForegroundColor Yellow -} - -Get-Process notepad -ErrorAction SilentlyContinue | Stop-Process -Force -Write-Host "Done."