Skip to content

Update Open in Admin Terminal to v1.17.1 - #4993

Open
aimagist wants to merge 6 commits into
ramensoftware:mainfrom
aimagist:open-in-admin-terminal-v1.17.1
Open

Update Open in Admin Terminal to v1.17.1#4993
aimagist wants to merge 6 commits into
ramensoftware:mainfrom
aimagist:open-in-admin-terminal-v1.17.1

Conversation

@aimagist

@aimagist aimagist commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Changelog

If this pull request updates an existing mod, describe the changes below:

  • Fixed the context-menu entry for filesystem folders and drives in Explorer's navigation pane and Quick access.
  • Added optional non-elevated terminal entries and configurable normal/elevated script actions for .ps1, .bat, .cmd, .vbs, and .js files.
  • Added configurable script-label terminal-name behavior.

Mod authorship

If this pull request introduces a new mod, please complete the section below.

This mod was created by:

    • The submitter, without AI assistance
    • The submitter, with AI assistance
    • Claude
    • ChatGPT
    • Gemini
    • Another AI (please specify):
    • Other (please specify):

Please select the options that best apply. Your selection does not affect the acceptance criteria, but it helps reviewers understand the context of the code and provide relevant feedback.

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 2, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

@aimagist

aimagist commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 2, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The script-execution feature launches its interpreters by bare name from a user-controlled directory (elevated), and the menu-hook gate that was removed to fix the navigation pane makes every popup menu in Explorer do COM work. Details below.

1. Script interpreters are launched by bare name, with the script's own folder as the working directory — an elevated hijack vector

BuildScriptInterpreterSpec sets spec.executable to bare L"cmd.exe", L"cscript.exe" and (fallback) L"powershell.exe", and spec.workingDirectory to the folder containing the script. That pair is then handed to ShellExecuteExW with lpVerb = L"runas".

The clearest failure case is the .vbs/.js "keep open" path:

std::wstring scriptCommand =
    L"cscript.exe //nologo " + QuoteCommandLineArgument(scriptPath);
if (s.keepOpenAfterScript) {
    spec.executable = L"cmd.exe";
    spec.parameters = L"/k " + scriptCommand;

cmd.exe resolves a bare command name against its current directory first, and the current directory here is the script's folder. So a folder that contains a.vbs and a file named cscript.exe (e.g. an extracted download or a shared/writable folder) will run that cscript.exe elevated when the user picks "Run script as administrator". The bare lpFile values have the same problem — ShellExecuteExW resolves an unqualified name through a search path the mod doesn't control, rather than the one it intended.

The mod already resolves every terminal preset to a full path (ResolveTerminalChoiceExecutableSearchExecutablePath); the script interpreters should be treated the same way. Suggested fix:

static std::wstring SystemPath(PCWSTR exe) {
    WCHAR dir[MAX_PATH];
    UINT len = GetSystemDirectoryW(dir, ARRAYSIZE(dir));
    if (!len || len >= ARRAYSIZE(dir)) {
        return exe;
    }
    std::wstring path(dir, len);
    path += L'\\';
    return path + exe;
}

then spec.executable = SystemPath(L"cmd.exe"), SystemPath(L"cscript.exe"), and for the /k line L"/k " + QuoteCommandLineArgument(SystemPath(L"cscript.exe")) + L" //nologo " + QuoteCommandLineArgument(scriptPath). For the powershell.exe fallback, use the existing ResolveExecutableCommand(L"powershell.exe", {}, spec.executable) helper (it already returns a full path) instead of the bare literal.

2. The window-class gate was removed from TrackPopupMenuEx_Hook, so every popup menu in Explorer now runs the resolution path

-    if (menu && hwnd &&
-        (IsShellViewWindow(hwnd) || IsNavigationPaneWindow(hwnd))) {
+    if (menu && hwnd) {

With showOnNavigationPane enabled, ResolveMenuTarget calls ResolveNavigationPaneMenuTarget for any window that tracks a popup menu — taskbar and tray menus, jump lists, desktop menus, toolbar dropdowns. Each of those now does CoCreateInstance(CLSID_ShellWindows) plus a full enumeration of every shell window (QueryInterface + get_HWND per item, marshalled to another apartment) before failing. On an ordinary right-click in the file list it's worse: the nav-pane resolution runs first and enumerates, fails, and then GetActiveShellViewForHwnd enumerates a second time — so turning the option on doubles the COM work on every Explorer right-click, on the UI thread, while the menu is being opened.

A cheap pre-filter restores the old behavior without breaking the fix. ResolveNavigationPaneMenuTarget only succeeds when GetAncestor(hwnd, GA_ROOT) is the browser window matched by GetExplorerServiceProviderForHwnd, so gating on the root window's class can't lose the nav-pane case:

HWND root = GetAncestor(hwnd, GA_ROOT);
WCHAR rootClass[64] = {};
if (root) {
    GetClassNameW(root, rootClass, ARRAYSIZE(rootClass));
}
bool eligibleWindow = IsShellViewWindow(hwnd) ||
                      _wcsicmp(rootClass, L"CabinetWClass") == 0 ||
                      _wcsicmp(rootClass, L"ExploreWClass") == 0;
if (menu && hwnd && eligibleWindow) { ... }

For reference, mods/explorer-context-menu-custom-items.wh.cpp#L266 bails out of the same hook on the first line when the window isn't a shell view. It would also be worth resolving IServiceProvider once per menu and passing it to both the nav-pane and shell-view paths instead of enumerating twice.

3. The navigation-pane target comes from the live cursor position, not from where the menu was actually invoked

IsNavigationPaneContextWindow consults GetCursorPos() / WindowFromPoint, and ResolveNavigationPaneMenuTarget hit-tests the tree at GetCursorPos(). TrackPopupMenuEx already receives the menu's screen coordinates as x/y — for a mouse right-click that's the click point, and for a keyboard-invoked menu (Shift+F10 / the context key) it's the position of the focused item. Using the live cursor instead means:

  • Press Shift+F10 on a file-list item while the mouse happens to rest over the navigation pane → the mod resolves the tree item under the cursor and injects an entry pointing at the wrong folder.
  • Right-clicking the empty area below the tree falls through to GetSelectedItems(), so an unrelated menu gets an entry for whatever folder happens to be selected in the tree.

Passing x/y from the hook down into ResolveMenuTarget / IsNavigationPaneContextWindow / ResolveNavigationPaneMenuTarget and using that point for WindowFromPoint and INameSpaceTreeControl::HitTest fixes both cases and removes the GetSelectedItems fallback's guesswork.

4. scriptExtensions can only remove extensions, never add them

IsScriptExtension first rejects anything outside a hard-coded list:

if (_wcsicmp(ext, L".ps1") != 0 && _wcsicmp(ext, L".bat") != 0 &&
    _wcsicmp(ext, L".cmd") != 0 && _wcsicmp(ext, L".vbs") != 0 &&
    _wcsicmp(ext, L".js") != 0) {
    return false;
}

and only then checks the user's list. But the setting says "Semicolon-separated extensions treated as scripts", so a user who adds .py, .wsf or .ps1xml gets no entry and no explanation — the setting silently does nothing for anything the mod doesn't already know how to run. Either reword the setting to what it does (a filter over the supported set), or drop it entirely (showOnScriptFiles + scriptMenuEntries already cover the use case), or make the extension→interpreter mapping data-driven so the setting can genuinely add types.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • LaunchTerminalNonElevated is a verbatim copy of LaunchAdminTerminal except for lpVerb. Collapse into one function: LaunchTerminal(const MenuTarget& target, bool elevated) with executeInfo.lpVerb = elevated ? L"runas" : L"open";. Same for GetCachedMenuBitmapNoShield vs GetCachedMenuBitmapForTerminal — they differ only in the shield overlay and the cache-key suffix, so a bool withShield parameter would remove ~45 duplicated lines.
  • ShellExecuteExW is called with SEE_MASK_NOASYNC directly on Explorer's UI thread, so that thread is blocked until the launch (and, for runas, the UAC consent) completes. SEE_MASK_NOASYNC is meant for callers whose thread may exit; the shell itself invokes context-menu verbs on a worker thread for exactly this reason. Consider doing the launch on a short-lived thread (SHCreateThread with CTF_COINIT, or a detached thread that calls CoInitializeEx) and keeping the flag there.
  • scriptExecutionPolicyBypass defaults to true. Execution policy isn't a security boundary, but it does stop accidentally running an unsigned downloaded .ps1 — and this mod adds a one-click "run it as administrator" entry for exactly those files. Defaulting it to false seems like the safer default; users who want it can flip it.
  • ClearMenuBitmapCache() runs from Wh_ModSettingsChanged / Wh_ModUninit on an arbitrary thread and DeleteObjects bitmaps that may still be attached to a menu being displayed on a UI thread. Only reachable on a settings change or unload, so low impact, but the menu can be left with a dangling hbmpItem.
  • GetSettingString checks Wh_GetStringSetting for NULL — it never returns NULL (it returns L"" on error/unset), so the check is dead. WindhawkUtils::StringSetting would also remove the manual Wh_FreeStringSetting.
  • Wh_SetFunctionHook with reinterpret_cast<void*> in Wh_ModInit can be WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, TrackPopupMenuEx_Hook, &TrackPopupMenuEx_Orig) for type-checked hooking.
  • Of the five new forward declarations at the top, only GetSettingsSnapshot and IsShellViewWindow are actually needed — LaunchTerminalNonElevated, BuildScriptLaunchSpec and IsScriptExtension are all defined before their first use.
  • g_currentMenuEligible / g_currentMenuHwnd stay set after a menu is dismissed without a selection (they're only cleared on the next TrackPopupMenuEx or on a handled command), so a later PostMessageW(WM_COMMAND, 0xBF31) to the same window would be swallowed. Clearing the state once the menu is gone would tighten this.
  • ResolveMenuTarget calls GetSettingsSnapshot() again for the script check even though the caller already holds a snapshot — passing the Settings in avoids a second read (and a second copy of the whole struct) that could disagree with the one used for the rest of the menu.
  • The Default menu position scans item text for "Open" / "Terminal", which only matches on English Windows; on other UI languages it silently falls back to position 0.
  • -loleaut32 looks unused now (no SysAlloc*/Variant* calls) — worth dropping from @compilerOptions if nothing needs it.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The script label flips depending on an unrelated setting: GetScriptTerminalDisplayName inspects interpreter.executable, but for .vbs/.js with keepOpenAfterScript enabled (the default) that executable is cmd.exe, so the menu reads "Run script in Command Prompt as administrator" instead of the "Windows Script Host" the README promises. Deriving the name from the extension (and the chosen interpreter) rather than from the wrapper would be more predictable. The StrStrIW(interpreter.executable.c_str(), L"cmd") substring test also matches any path containing cmd.
  • Windows Terminal's parser may swallow the interpreter's own switches: BuildScriptLaunchSpec emits wt new-tab -d <dir> pwsh.exe -NoExit -ExecutionPolicy Bypass -File "..." with no -- separator (the wezterm branch does use --). Leading-dash arguments after the command, and ; anywhere in the path (wt treats it as a sub-command separator), are worth testing explicitly with a script in a path containing a space.
  • Menu icon size uses GetSystemMetrics(SM_CXMENUCHECK) and the bitmap cache key doesn't include DPI, so on a mixed-DPI multi-monitor setup the icon is sized for the system DPI and the first-created bitmap is reused everywhere. GetSystemMetricsForDpi(SM_CXMENUCHECK, GetDpiForWindow(hwnd)) plus the DPI in the cache key would fix it.
  • ResolveNavigationPaneMenuTarget converts the point with ScreenToClient(GetControlWindow(FCW_TREE)) but hit-tests through INameSpaceTreeControl::HitTest, which expects coordinates in its client space. Those are the same window today, but FCW_TREE isn't guaranteed to be the control's own HWND — QueryInterface(IID_IOleWindow) on the tree control and using IOleWindow::GetWindow would be a more direct source for the conversion.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 2, 2026
@aimagist

aimagist commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 3221a1f:

  1. Script interpreters now use trusted absolute paths: cmd.exe and cscript.exe are resolved from the Windows system directory, the nested cscript command is quoted, and the PowerShell fallback resolves to a full path. Launching fails closed if no interpreter is resolved.
  2. TrackPopupMenuEx now prefilters to shell views/Explorer browser roots, and navigation-pane COM resolution only runs when the menu invocation point is actually in the navigation pane.
  3. Navigation targeting now passes the hook's x/y coordinates through to WindowFromPoint and INameSpaceTreeControl::HitTest. Live-cursor and selected-item fallback guessing were removed.
  4. scriptExtensions is now documented accurately as a filter over the five supported types.

Also fixed the related stale menu-command state, reused the caller's settings snapshot, made VBS/JS labels consistently identify Windows Script Host, and changed execution-policy bypass to default off.

Validation:

  • 9 regression tests passed
  • Windhawk compliance scan passed (the only warning is the pre-existing read-only CreateFileW used to inspect an app execution alias)
  • Syntax compilation passed for x86, x64, and ARM64
  • Canonical and catalog files match byte-for-byte at v1.17.1

@aimagist

aimagist commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 2, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Three things worth fixing before this goes in:

1. cmd metacharacters in paths aren't quoted — a script/folder named a&b.bat runs the wrong command, elevated

QuoteCommandLineArgument only adds quotes when the argument contains a space, tab or quote:

if (arg.find_first_of(L" \t\r\n\"") == std::wstring::npos) {
    return arg;
}

That's correct for CommandLineToArgvW-style parsing (PowerShell -File, wt, etc.), but it is not enough for anything passed to cmd.exe, which treats &, ^, |, <, >, (, ) as syntax. All three cmd call sites use it:

// BuildScriptInterpreterSpec, .bat/.cmd
spec.parameters = std::wstring(s.keepOpenAfterScript ? L"/k " : L"/c ") +
                  QuoteCommandLineArgument(scriptPath);

// BuildScriptInterpreterSpec, .vbs/.js with keepOpenAfterScript
spec.parameters = L"/k " + QuoteCommandLineArgument(cscriptPath) +
                  L" //nologo " + QuoteCommandLineArgument(scriptPath);

// BuildLaunchSpec, cmd terminal (pre-existing)
spec.parameters = L"/k cd /d " + QuoteCommandLineArgument(target);

C:\tools\build&deploy.bat has no space, so it goes through unquoted and cmd sees /k C:\tools\build & deploy.bat — it runs C:\tools\build and then, as a separate command in the script's own directory, deploy.bat. Since this is the "Run script as administrator" path, that second command executes elevated. R&D as a folder name breaks the plain cd /d case the same way (cmd /k cd /d C:\R&D).

Windows paths can't contain ", so unconditional quoting is safe here. Add a helper and use it for the cmd arguments:

static std::wstring QuoteAlways(const std::wstring& arg) {
    return L'"' + arg + L'"';
}

(cmd keeps the outer quotes when the string contains special characters and strips them otherwise, so both cases work.)

2. Navigation pane: HitTest is the only resolution path, so keyboard-invoked menus lose the entry

ResolveNavigationPaneMenuTarget now depends entirely on WindowFromPoint(invocationPoint) plus INameSpaceTreeControl::HitTest at that point. That works for a right-click, but a context menu opened with the Menu key / Shift+F10 in the tree is invoked with a point Explorer derives from the focused item (and WM_CONTEXTMENU uses (-1, -1) for keyboard invocation) — if that point doesn't land on the item, IsNavigationPaneContextWindow returns false, IsShellViewWindow(hwnd) is also false for the CabinetWClass owner, and the entry silently doesn't appear. The same happens on any build where GetControlWindow(FCW_TREE) doesn't hand back a window.

Keep the hit test as the primary path (it is the right call for right-click, since the tree's context target is the item under the cursor), but restore the v1.16 GetSelectedItems path as a fallback when the hit test yields nothing:

if (!ok) {
    IShellItemArray* selectedItems = nullptr;
    if (SUCCEEDED(navigationPane->GetSelectedItems(&selectedItems)) && selectedItems) {
        DWORD count = 0;
        IShellItem* item = nullptr;
        if (SUCCEEDED(selectedItems->GetCount(&count)) && count == 1 &&
            SUCCEEDED(selectedItems->GetItemAt(0, &item)) && item) {
            std::wstring path;
            if (GetFilesystemPathFromShellItem(item, path) && IsDirectoryPath(path)) {
                targetOut.path = std::move(path);
                targetOut.kind = IsDriveRootPath(targetOut.path) ? TargetKind::DriveItem
                                                                 : TargetKind::FolderItem;
                ok = true;
            }
            item->Release();
        }
        selectedItems->Release();
    }
}

Also worth guarding the IsNavigationPaneContextWindow call against the keyboard sentinel (x == -1 && y == -1) so it doesn't hit-test a meaningless point.

3. The new unconditional ClearCurrentMenuState() may have killed the PostMessageW fallback

    ClearCurrentMenuState();
    return result;
}

PostMessageW_Hook is the fallback for hosts that don't use TPM_RETURNCMD and instead post WM_COMMAND for the chosen item. With this clear at the end of the hook, that fallback only still works if the post happens inside TrackPopupMenuEx_Orig; anything posted after the menu loop returns now finds g_currentMenuEligible == false and falls through to the original, so clicking the entry does nothing.

Two clean ways out — please pick one rather than leaving it ambiguous:

  • If the PostMessageW fallback is still needed, drop this clear (v1.16 relied on the next TrackPopupMenuEx call clearing the state, which is stale-but-harmless because hwnd is also compared), or clear only when result is not one of the mod's IDs.
  • If it isn't needed — i.e. Explorer's classic menu always goes through TPM_RETURNCMD — then remove the PostMessageW hook entirely. It's a process-wide inline hook on a very hot user32 API in explorer.exe, and carrying it for a dead path isn't worth it.
Optional improvements

Minor polish — none of this affects users, so it's your call.

  • LaunchTerminalNonElevated is a verbatim copy of LaunchAdminTerminal with lpVerb changed from L"runas" to L"open". ~40 duplicated lines that will drift. Make it one function: static void LaunchTerminal(const MenuTarget& target, PCWSTR verb).
  • GetCachedMenuBitmapNoShield duplicates GetCachedMenuBitmapForTerminal + TryCreateMenuBitmapForTerminal. Same fix — a single GetCachedMenuBitmap(const Settings&, bool withShield) that appends L"\nno-shield" to the cache key and passes nullptr for the overlay icon.
  • GetSettingString's fallback parameter is dead code. Wh_GetStringSetting never returns NULL — it returns L"" when unset or on error — so the if (PCWSTR s = ...) branch is always taken and value = fallback is always overwritten. Either drop the parameter or check for an empty string instead. While you're there, WindhawkUtils::StringSetting (RAII) removes the manual Wh_FreeStringSetting; see mods/taskbar-clock-customization.wh.cpp for the usage pattern.
  • Wh_SetFunctionHook with reinterpret_cast<void*>WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, TrackPopupMenuEx_Hook, &TrackPopupMenuEx_Orig) is type-safe and drops the casts.
  • BOOL Wh_ModSettingsChanged(BOOL* reload) always sets *reload = FALSE — the plain void Wh_ModSettingsChanged() overload is equivalent and clearer. The bReload form only earns its place when the mod conditionally asks for a reload.
  • Built-in Windows terminals are resolved with SearchPathW, which includes the current directory in its search order, while the new ResolveSystemExecutablePath correctly pins cmd.exe/cscript.exe to System32. Since the mod is @include explorer.exe only this is a hardening nit, not a real hijack surface, but using ResolveSystemExecutablePath for cmd.exe/powershell.exe/wsl.exe too would make the code consistent.
  • ClearMenuBitmapCache() in Wh_ModSettingsChanged DeleteObjects bitmaps that a currently-open context menu may still reference. Only reachable if settings change while a menu is up, so it's a cosmetic-at-worst race, but leaving the old bitmaps to leak until unload would also be defensible.
  • @description is now out of date — it only mentions opening an elevated terminal, not the non-elevated entry or the script actions.
  • README has no visual for the new entries. The existing screenshots/GIFs cover the folder case well; a small shot of the script entries (and of the two-entry non-elevated layout) would help users understand what they're enabling.
  • Default menu position matches on English text (StrStrIW(text, L"Open") / L"Terminal"), so on a localized Windows it silently degrades to position 0. Pre-existing; worth a note in the README if you don't want to change it.
  • Menu bitmap size comes from GetSystemMetrics(SM_CXMENUCHECK) (system DPI) and the cache key doesn't include DPI, so on a mixed-DPI setup the icon is sized for the primary monitor. GetSystemMetricsForDpi(SM_CXMENUCHECK, GetDpiForWindow(hwnd)) plus a DPI component in the cache key would fix it.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • .js in the default scriptExtensions is likely to surprise people. For anyone with web/Node projects, every .js source file in Explorer gets a "Run script in Windows Script Host as administrator" entry, and running a Node module under cscript elevated is at best a no-op and at worst destructive. Consider dropping .js from the default value (users who want WSH .js can add it back), or at least calling this out in the setting description.
  • scriptExtensions is purely subtractive. IsScriptExtension first gates on the hard-coded .ps1/.bat/.cmd/.vbs/.js list, so adding e.g. .py to the setting does nothing, and clearing the setting silently disables all script entries even with showOnScriptFiles enabled. That matches the $description ("filter"), but a user who reads it as "list of extensions to support" will be confused. A checkbox per extension (nested group) would express this more honestly.
  • The script entry is inserted before the interpreter is resolved. If BuildScriptInterpreterSpec can't find an interpreter (e.g. cscript.exe missing because WSH is disabled by policy), the menu item still appears and clicking it does nothing but write a log line. Consider resolving the launch spec in ResolveMenuTarget/InsertAdminTerminalMenuItem and skipping the entry when it comes back empty.
  • ShellExecuteExW runs on Explorer's UI thread from inside the TrackPopupMenuEx hook, so the whole thread is blocked while the UAC consent dialog is up — all Explorer windows on that thread stop repainting until the user answers. Pre-existing, but now it happens twice as often with the script entries. Handing the launch to a short-lived worker thread (SHCreateThread / a detached thread with CoInitializeEx) would keep the shell responsive.
  • BuildScriptInterpreterSpec's working-directory computation falls apart if find_last_of(L"\\/") returns npos: scriptPath.substr(0, npos) yields the full script path, which then gets passed as lpDirectory. Shell paths are always absolute so this shouldn't trigger in practice, but an explicit npos check would be cheap.
  • -ExecutionPolicy Bypass combined with "Run as administrator" is a meaningful capability to hand a user. Defaulting it off and naming the setting explicitly is the right call; it might be worth one line in the README saying what it disables.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 2, 2026
@aimagist

aimagist commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the three mandatory findings in 1149629:

  1. Paths embedded in cmd.exe commands are now always quoted, including cmd terminal targets, batch/CMD scripts, and the nested cscript/script paths. Regression coverage includes paths containing &.
  2. Keyboard navigation-pane invocation now recognizes the (-1, -1) sentinel, skips meaningless hit testing, and resolves the single selected tree item. The selected-item fallback is deliberately limited to that keyboard sentinel, so mouse invocation and empty navigation-pane space remain hit-test-only and cannot guess the selected folder.
  3. The PostMessageW fallback remains supported: state clears immediately for TPM_RETURNCMD menus and cancellations, but is retained after a successful non-TPM_RETURNCMD menu until the delayed WM_COMMAND hook consumes it.

Validation: 12 regression tests passed; syntax compilation passed for x86, x64, and ARM64; Windhawk compliance passed; and the canonical/catalog files match byte-for-byte at v1.17.1. Optional refactors remain deferred.

@aimagist

aimagist commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 2, 2026
@windhawk-reviewer

Copy link
Copy Markdown

This pull request has already had 2 AI reviews in the last 24 hours, which is the limit, so no review was posted this time.

Comment /ai-review again after 2026-08-03 18:12 UTC (in 22 hours) to get another one.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 2, 2026
@aimagist

aimagist commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 3, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Findings 2 and 3 from the previous round look correctly addressed. The cmd quoting fix, however, went the wrong way: switching to unconditional quoting doesn't stop cmd from re-parsing the path, and it broke a case that worked before.

1. cmd strips the outer quotes off /k and /c command lines — the new QuoteCmdPath doesn't fix &, and it breaks .vbs/.js

cmd's documented rule (cmd /?) is that the text after /c or /k keeps its quotes only if all of these hold: exactly two quote characters, no character from &<>()@^| between them, whitespace between them, and the quoted string names an executable file. Otherwise, if the first character is a quote, cmd strips the first quote and the last quote on the line and then parses what's left as an ordinary command line. QuoteCmdPath makes the first character a quote in every case, so the stripping branch is now the one that runs.

What that means for the three call sites:

// BuildLaunchSpec, cmd terminal — OK, first char after /k is 'c', no stripping
spec.parameters = L"/k cd /d " + QuoteCmdPath(target);

// BuildScriptInterpreterSpec, .bat/.cmd — quotes stripped, path re-parsed by cmd
spec.parameters = std::wstring(s.keepOpenAfterScript ? L"/k " : L"/c ") +
                  QuoteCmdPath(scriptPath);

// BuildScriptInterpreterSpec, .vbs/.js with keepOpenAfterScript — four quotes,
// outer pair stripped, the rest is mangled
spec.parameters = L"/k " + QuoteCmdPath(cscriptPath) +
                  L" //nologo " + QuoteCmdPath(scriptPath);
  • .bat / .cmd, path with a cmd metacharacter — cmd /k "C:\tools\build&deploy.bat" becomes C:\tools\build&deploy.bat, i.e. run C:\tools\build, then run deploy.bat as a separate elevated command in the script's own directory. Same class of problem the last round flagged; unconditional quoting didn't remove it.
  • .bat / .cmd, path with a space and parentheses — cmd /k "C:\Program Files (x86)\App\install.bat" fails rule 1 on (/), so the quotes come off and cmd reports 'C:\Program' is not recognized. Program Files (x86) is a very common place to right-click an installer's .bat.
  • .vbs / .js with keepOpenAfterScript (the default) — this is the classic cmd /k "prog" args gotcha. cmd /k "C:\Windows\System32\cscript.exe" //nologo "C:\t\a.vbs" has four quotes, so the outer pair is stripped and what remains (C:\Windows\System32\cscript.exe" //nologo "C:\t\a.vbs) no longer parses as interpreter + arguments. Note this regressed in 1149629: the previous commit left cscript.exe's System32 path unquoted (no spaces in it), so the line didn't start with a quote and cmd left it alone.

The fix for all of them is the standard idiom — build the command with each argument quoted, then wrap the whole command in one more pair of quotes, so the pair cmd strips is the outer one:

static std::wstring BuildCmdCommandLine(bool keepOpen,
                                        const std::wstring& command) {
    // cmd removes the first and last quote of the /k|/c line, so wrap the
    // whole command in an extra pair and let the inner quotes survive.
    return std::wstring(keepOpen ? L"/k \"" : L"/c \"") + command + L'"';
}
// .bat / .cmd
spec.parameters = BuildCmdCommandLine(s.keepOpenAfterScript,
                                      QuoteCmdPath(scriptPath));

// .vbs / .js, keep open
spec.parameters = BuildCmdCommandLine(true,
                                      QuoteCmdPath(cscriptPath) + L" //nologo " +
                                          QuoteCmdPath(scriptPath));

cmd /k ""C:\tools\build&deploy.bat"" → the outer pair is stripped → "C:\tools\build&deploy.bat" → the & is inside quotes and the path runs as one command. Adding /S (cmd /s /k "...") makes the strip-first-and-last behavior explicit rather than a consequence of failing rule 1, if you prefer that to be obvious in the code. The cd /d line is fine as it stands (it doesn't begin with a quote) but wrapping it the same way costs nothing and keeps the three sites consistent.

Worth adding to your regression set: a .bat under C:\Program Files (x86)\..., a .bat whose folder contains &, and a .vbs with keepOpenAfterScript both on and off — the last one should currently fail even with a plain path like C:\t\a.vbs.

Optional improvements

Minor polish — none of this affects users, so it's your call. Most of these are carried over from the previous rounds and were deliberately deferred; re-listing them only so nothing is lost.

  • @description is out of date. It still describes only the elevated-terminal entry: "Adds an Explorer classic context menu entry to open an elevated terminal in the current or selected folder." The mod now also adds a non-elevated entry and script actions. This is the text users see in the mod list, so it's the one piece of stale metadata worth fixing.
  • LaunchTerminalNonElevated is a verbatim copy of LaunchAdminTerminal apart from lpVerb. One LaunchTerminal(const MenuTarget&, PCWSTR verb) removes ~40 lines that will otherwise drift apart.
  • GetCachedMenuBitmapNoShield duplicates GetCachedMenuBitmapForTerminal + TryCreateMenuBitmapForTerminal. A single GetCachedMenuBitmap(const Settings&, bool withShield) that appends L"\nno-shield" to the key and passes nullptr as the overlay would collapse both.
  • GetSettingString's fallback is dead code. Wh_GetStringSetting never returns NULL — it returns L"" when unset or on error — so if (PCWSTR s = ...) is always taken. Check for an empty string instead, or use WindhawkUtils::StringSetting (RAII), which also drops the manual Wh_FreeStringSetting.
  • Wh_SetFunctionHook with reinterpret_cast<void*>WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, TrackPopupMenuEx_Hook, &TrackPopupMenuEx_Orig) is type-checked and drops the casts. See mods/win32-ui-modernizer.wh.cpp#L39611 for the same hook.
  • BOOL Wh_ModSettingsChanged(BOOL* reload) always sets *reload = FALSE — the void Wh_ModSettingsChanged() overload is equivalent and clearer.
  • SearchExecutablePath uses SearchPathW, whose search order includes Explorer's current directory, while ResolveSystemExecutablePath correctly pins cmd.exe/cscript.exe to System32. Since the mod is @include explorer.exe this is a hardening nit rather than a real hijack surface, but routing cmd.exe/powershell.exe/wsl.exe through ResolveSystemExecutablePath too would make it consistent.
  • ClearMenuBitmapCache() in Wh_ModSettingsChanged DeleteObjects bitmaps a currently-open menu may still reference via hbmpItem. Only reachable if settings change while a menu is up, so cosmetic at worst.
  • Stale menu state after a non-TPM_RETURNCMD menu. ShouldClearMenuStateAfterTracking deliberately keeps the state so PostMessageW_Hook can consume a delayed WM_COMMAND, which is the right call — but TrackPopupMenuEx also returns nonzero when the user simply dismisses such a menu, so the state then survives until the next TrackPopupMenuEx. Narrow and guarded by the hwnd comparison; just noting the remaining window.
  • BuildScriptInterpreterSpec is called up to three times per menu open for a .ps1 (GetScriptIconSettings, GetScriptTerminalDisplayName, then again in the launch path), and each call re-runs SearchPathW plus candidate GetFileAttributesW probes on the UI thread. Resolving once in ResolveMenuTarget and passing the spec around would cut that.
  • Default menu position matches English text (StrStrIW(text, L"Open") / L"Terminal"), so on a localized Windows it silently degrades to position 0. Pre-existing; worth a README line if you don't want to change it.
  • Menu bitmap size comes from GetSystemMetrics(SM_CXMENUCHECK) and the cache key has no DPI component, so on a mixed-DPI setup the icon is sized for the primary monitor. GetSystemMetricsForDpi(SM_CXMENUCHECK, GetDpiForWindow(hwnd)) plus DPI in the key fixes it.
  • -loleaut32 looks unused — the code uses VARIANT as a plain struct with VT_I4 and calls no oleaut32 function.
  • No README visual for the new entries. The existing shots cover the folder case well; a small capture of the script entries and of the two-entry non-elevated layout would help users understand what they're turning on.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The (-1, -1) keyboard sentinel may never reach the hook. Explorer's WM_CONTEXTMENU handling normally converts the keyboard sentinel into the focused item's screen position before calling TrackPopupMenuEx, so the hook usually sees real coordinates. If that holds, the new IsKeyboardContextMenuPoint branch and the GetSelectedItems fallback are dead code, and Shift+F10 in the tree is already handled by the hit test. Worth confirming with logging before relying on the fallback.
  • The nav-pane option is gated by the file-list options. ResolveNavigationPaneMenuTarget produces FolderItem/DriveItem, which IsTargetEnabled then checks against showOnFolderItem/showOnDriveItem. So enabling showOnNavigationPane while showOnFolderItem is off gives no entry in the tree. Defensible, but not obvious from the setting descriptions.
  • The script entry is inserted before the interpreter is resolved. If BuildScriptInterpreterSpec comes back empty (WSH disabled by policy, no PowerShell), the item still appears and clicking it only writes a log line. Related: when the terminal is a script host, BuildScriptLaunchSpec puts an empty interpreter.executable into the argument list, so wt new-tab -d "dir" "" just opens a default tab. Resolving the spec during ResolveMenuTarget and skipping the entry when it's empty would avoid both.
  • Windows Terminal's parser may swallow the interpreter's own switches. wt new-tab -d <dir> pwsh.exe -NoExit -ExecutionPolicy Bypass -File "..." has no -- separator (the wezterm branch does use one), and wt treats ; anywhere in the line as a sub-command separator. Worth testing with a script in a path containing a space and one containing ;.
  • ShellExecuteExW runs on the invoking UI thread with SEE_MASK_NOASYNC, so that thread is blocked while the UAC dialog is up — for a desktop or taskbar invocation that's the main shell thread, which stops repainting until the user answers. Pre-existing, but the script entries make it more frequent. SHCreateThread with CTF_COINIT, or a detached thread that calls CoInitializeEx, keeps the shell responsive; the flag then belongs on that thread.
  • .js in the default scriptExtensions means every .js file in a web or Node project gets a "Run script in Windows Script Host as administrator" entry. Running a Node module under cscript elevated is at best a no-op. Consider dropping .js from the default and letting users add it back.
  • scriptExtensions is purely subtractive. The $description now says "filter", which is accurate, but clearing the setting silently disables all script entries even with showOnScriptFiles on. A nested group with a checkbox per extension would express this more directly.
  • BuildScriptInterpreterSpec's working directory falls apart if find_last_of(L"\\/") returns npos: substr(0, npos) yields the full script path, which then becomes lpDirectory. Shell paths are always absolute so this shouldn't trigger, but an explicit check is cheap.
  • -ExecutionPolicy Bypass plus "Run as administrator" is a meaningful capability. Defaulting it off was the right call; one README line saying what it disables would round it out.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 3, 2026
@windhawk-reviewer windhawk-reviewer Bot removed the waiting-for-ai-review An AI review was requested and is being prepared. label Aug 3, 2026
@aimagist

aimagist commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the remaining mandatory cmd.exe quoting finding in c6cc016.

  • Added a single BuildCmdCommandLine path that uses /s /c|/k and wraps the complete command in an outer quote pair, preserving the inner quotes around each path after cmd strips the outer pair.
  • Applied it consistently to Command Prompt folder launches, .bat/.cmd execution, and keep-open .vbs/.js execution. Non-keep-open WSH scripts still launch the trusted absolute cscript.exe directly.
  • Added regression coverage for R&D, build&deploy.bat, Program Files (x86), both /k and /c, and .vbs with keep-open enabled and disabled.

Validation: 13 tests passed; syntax compilation passed for x86, x64, and ARM64; Windhawk compliance passed; and the canonical/catalog files match byte-for-byte at v1.17.1. Findings 2 and 3 remain unchanged. Optional refactors remain deferred.

@aimagist

aimagist commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The cmd quoting fix is correct now — /s + an outer quote pair makes cmd strip exactly the pair you added, and ""C:\Program Files (x86)\App\install.bat"", ""C:\tools\build&deploy.bat"" and the nested cscript line all reduce to the right command. Two things remain:

1. The cmd /s /k "…" wrapper is also fed to Windows Terminal / WezTerm / Alacritty / ConEmu, where it doesn't survive — .bat/.cmd scripts break on any path with a space, in the default configuration

BuildScriptLaunchSpec appends the interpreter's parameter string verbatim to the host terminal's argument list:

args = {L"new-tab", L"-d", interpreter.workingDirectory, interpreter.executable};
spec.parameters = JoinCommandLineArguments(args);
if (!interpreter.parameters.empty()) {
    spec.parameters += L" " + interpreter.parameters;   // raw /s /k ""…"" string
}

But interpreter.parameters was built by BuildCmdCommandLine for direct invocation of cmd.exe. When it goes through a host terminal it is parsed as ordinary arguments first and re-quoted by that host before cmd ever sees it, and the doubled quotes don't survive the round trip. For C:\Users\me\My Scripts\build.bat with the defaults (terminalChoice: auto → Windows Terminal on Win11, keepOpenAfterScript: true) the mod emits:

new-tab -d "C:\Users\me\My Scripts" C:\Windows\System32\cmd.exe /s /k ""C:\Users\me\My Scripts\build.bat""

""C:\Users\me\My Scripts\build.bat"" under CommandLineToArgvW rules opens a quoted section and immediately closes it (the second " is followed by C, not another "), so the rest is unquoted and the space splits the token — the host ends up passing /s /k C:\Users\me\My Scripts\build.bat and cmd tries to run C:\Users\me\My. Affected combinations:

  • .bat / .cmd — always (the wrapper is unconditional).
  • .vbs / .js with keepOpenAfterScript (the default) — same, plus the inner cscript quotes.
  • .ps1 is fine (its parameters are plain argv-style, so the host re-quotes them correctly), and so is .vbs/.js with keep-open off.

The direct (non-hosted) paths you already validated are unaffected — this is only the nesting case. The simplest fix that keeps the verified behavior is to not nest when a cmd wrapper is involved, the same way .ps1 already ignores the terminal choice under WSL/Git Bash:

static LaunchSpec BuildScriptLaunchSpec(const Settings& s,
                                        const std::wstring& scriptPath) {
    LaunchSpec interpreter = BuildScriptInterpreterSpec(s, scriptPath);
    // A cmd /s /k "…" command line is meant for direct invocation; a host
    // terminal re-parses and re-quotes it, which mangles the inner quotes.
    if (!IsScriptHostChoice(s.terminalEffectiveChoice) ||
        UsesCmdWrapper(s, scriptPath)) {
        return interpreter;
    }
    ...

If you'd rather keep the host terminal for .bat/.cmd, build that case from argument elements instead of a pre-wrapped string and drop the /s wrapper, so the host does the quoting once:

args = {L"new-tab", L"-d", dir, cmdExe,
        s.keepOpenAfterScript ? L"/k" : L"/c", scriptPath};
spec.parameters = JoinCommandLineArguments(args);

which reaches cmd as /k "C:\Users\me\My Scripts\build.bat" — two quotes, no &<>()@^| between them, names an executable, so cmd's rule 1 preserves them. (Paths with cmd metacharacters would still break in the nested case; the direct path handles those correctly, which is another argument for the first option.) Worth adding a .bat under a spaced path with terminalChoice: wt to the regression set — it currently fails.

2. Navigation-pane detection now hangs entirely on the tracking coordinates, with no fallback for the mouse path

IsNavigationPaneContextWindow only consults hwnd-independent state:

if (IsKeyboardContextMenuPoint(invocationPoint)) {
    HWND focusedWindow = GetFocus();
    return focusedWindow && IsNavigationPaneWindow(focusedWindow);
}
HWND invocationWindow = WindowFromPoint(invocationPoint);
return invocationWindow && IsNavigationPaneWindow(invocationWindow);

and ResolveMenuTarget bails to if (!IsShellViewWindow(hwnd)) return false; when that comes back false. For nav-pane menus hwnd is the CabinetWClass frame (that's exactly why v1.16 missed them), so if the point isn't over the tree there is nothing left to fall back on and the entry silently disappears again — the bug this release fixes.

That's fine for a plain right-click, but the classic menu isn't always tracked at the click point. Your own README notes that on Windows 11 the entry shows up under Show more options, i.e. the classic menu is opened from the XAML flyout — please verify with showOnNavigationPane enabled on a Win11 machine with the modern context menu that x/y still land on the tree item. A cheap fallback makes it robust either way, since GetFocus() is the nav pane after a right-click there and hwnd not being a shell view already rules out the file-list case:

static bool IsNavigationPaneContextWindow(HWND hwnd, const POINT& pt) {
    if (!IsKeyboardContextMenuPoint(pt)) {
        HWND w = WindowFromPoint(pt);
        if (w && IsNavigationPaneWindow(w)) {
            return true;
        }
    }
    // The tracking point isn't always the click point.
    HWND focus = GetFocus();
    return focus && IsNavigationPaneWindow(focus) && !IsShellViewWindow(hwnd);
}

and correspondingly letting the GetSelectedItems fallback in ResolveNavigationPaneMenuTarget run whenever the hit test found nothing, not only for the (-1, -1) sentinel. Mouse invocation that does hit an item keeps using the hit test, so the "don't guess" property you wanted is preserved for the case it matters.

Optional improvements

Minor polish — none of this affects users, so it's your call. Most are carried over from earlier rounds and were deliberately deferred; re-listing so nothing is lost.

  • @description is out of date. It still reads "Adds an Explorer classic context menu entry to open an elevated terminal in the current or selected folder." — no mention of the non-elevated entry or the script actions. This is the text users see in the mod list.
  • Four of the five forward declarations at the top are dead. GetSettingsSnapshot, LaunchTerminalNonElevated, BuildScriptLaunchSpec and IsScriptExtension are all defined before their first use; only IsShellViewWindow is needed (for the call at ResolveMenuTarget).
  • LaunchTerminalNonElevated is a verbatim copy of LaunchAdminTerminal apart from lpVerb. One LaunchTerminal(const MenuTarget&, PCWSTR verb) removes ~40 lines that will otherwise drift.
  • GetCachedMenuBitmapNoShield duplicates GetCachedMenuBitmapForTerminal + TryCreateMenuBitmapForTerminal. A single GetCachedMenuBitmap(const Settings&, bool withShield) that appends L"\nno-shield" to the key and passes nullptr as the overlay collapses both.
  • GetSettingString's fallback is dead code. Wh_GetStringSetting never returns NULL — it returns L"" when unset or on error — so if (PCWSTR s = ...) is always taken. Check for an empty string instead, or use WindhawkUtils::StringSetting (RAII), which also drops the manual Wh_FreeStringSetting.
  • Wh_SetFunctionHook with reinterpret_cast<void*>WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, TrackPopupMenuEx_Hook, &TrackPopupMenuEx_Orig) is type-checked and drops the casts. See mods/win32-ui-modernizer.wh.cpp#L39611 for the same hook.
  • BOOL Wh_ModSettingsChanged(BOOL* reload) always sets *reload = FALSE — the void Wh_ModSettingsChanged() overload is equivalent and clearer.
  • SearchExecutablePath uses SearchPathW, whose search order includes Explorer's current directory, while ResolveSystemExecutablePath correctly pins cmd.exe/cscript.exe to System32. Since the mod is @include explorer.exe this is a hardening nit rather than a real hijack surface, but routing cmd.exe/powershell.exe/wsl.exe through ResolveSystemExecutablePath too would make it consistent.
  • ClearMenuBitmapCache() in Wh_ModSettingsChanged DeleteObjects bitmaps a currently-open menu may still reference via hbmpItem. Only reachable if settings change while a menu is up, so cosmetic at worst.
  • Stale menu state after a non-TPM_RETURNCMD menu. ShouldClearMenuStateAfterTracking keeps the state so PostMessageW_Hook can consume a delayed WM_COMMAND, which is the right call — but TrackPopupMenuEx also returns nonzero when such a menu is simply dismissed, so the state then survives until the next TrackPopupMenuEx. Narrow and guarded by the hwnd comparison; just noting the remaining window.
  • BuildScriptInterpreterSpec runs up to three times per .ps1 menu open (GetScriptIconSettings, GetScriptTerminalDisplayName, then again at launch), each re-running SearchPathW plus candidate GetFileAttributesW probes on the UI thread. Resolving once in ResolveMenuTarget and passing the spec around would cut that.
  • Default menu position matches English text (StrStrIW(text, L"Open") / L"Terminal"), so on a localized Windows it silently degrades to position 0. Pre-existing; worth a README line if you don't want to change it.
  • Menu bitmap size comes from GetSystemMetrics(SM_CXMENUCHECK) and the cache key has no DPI component, so on a mixed-DPI setup the icon is sized for the primary monitor. GetSystemMetricsForDpi(SM_CXMENUCHECK, GetDpiForWindow(hwnd)) plus DPI in the key fixes it.
  • -loleaut32 looks unusedVARIANT is only used as a plain struct with VT_I4, and no oleaut32 function is called.
  • No README visual for the new entries. The existing shots cover the folder case well; a small capture of the script entries and of the two-entry non-elevated layout would help users understand what they're turning on.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • cmd expands %…% even inside quotes. A script or folder whose name contains a variable-looking pair (build %TEMP% .bat, 100%done%.bat) is expanded before the command runs, so it resolves to a path that doesn't exist. Rare, and there's no clean escape for a /k command line, so this is probably just a known limitation — but the argv-style paths (.ps1, non-keep-open .vbs) don't have it.
  • Windows Terminal's parser may swallow the interpreter's own switches. wt new-tab -d <dir> pwsh.exe -NoExit -ExecutionPolicy Bypass -File "…" has no -- separator (the wezterm branch does use one), and wt treats ; anywhere in the line as a sub-command separator. Worth testing with a script in a path containing a space and one containing ;.
  • The script entry is inserted before the interpreter is resolved. If BuildScriptInterpreterSpec comes back empty (WSH disabled by policy, no PowerShell), the item still appears and clicking it only writes a log line. Related: under a script-host terminal an empty interpreter.executable lands in the argument list, so wt new-tab -d "dir" "" just opens a default tab. Resolving the spec during ResolveMenuTarget and skipping the entry when it's empty would avoid both.
  • ShellExecuteExW runs on the invoking UI thread with SEE_MASK_NOASYNC, so that thread is blocked while the UAC dialog is up. Pre-existing, but the script entries make it more frequent. SHCreateThread with CTF_COINIT, or a detached thread that calls CoInitializeEx, keeps the shell responsive; the flag then belongs on that thread.
  • .js in the default scriptExtensions means every .js file in a web or Node project gets a "Run script in Windows Script Host as administrator" entry. Running a Node module under cscript elevated is at best a no-op. Consider dropping .js from the default and letting users add it back.
  • scriptExtensions is purely subtractive. The $description now says "filter", which is accurate, but clearing the setting silently disables all script entries even with showOnScriptFiles on. A nested group with a checkbox per extension would express this more directly.
  • BuildScriptInterpreterSpec's working directory falls apart if find_last_of(L"\\/") returns npos: substr(0, npos) yields the full script path, which then becomes lpDirectory. Shell paths are always absolute so this shouldn't trigger, but an explicit check is cheap.
  • ResolveNavigationPaneMenuTarget converts the point with ScreenToClient(GetControlWindow(FCW_TREE)) but hit-tests through INameSpaceTreeControl::HitTest, which expects coordinates in its client space. Same window today, but FCW_TREE isn't guaranteed to be the control's own HWND — QueryInterface(IID_IOleWindow) on the tree control would be a more direct source for the conversion.
  • -ExecutionPolicy Bypass plus "Run as administrator" is a meaningful capability. Defaulting it off was the right call; one README line saying what it disables would round it out.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
@aimagist

aimagist commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the two required findings in 6b442f7:

  1. Script launches that require the direct cmd /s /k|c command grammar now bypass Windows Terminal, WezTerm, Alacritty, and ConEmu. This covers .bat/.cmd and keep-open .vbs/.js, preserving the already-verified metacharacter and nested-interpreter quoting. Plain argv-style PowerShell and non-keep-open WSH launches still use the selected terminal host.
  2. Navigation-pane detection now falls back to the focused tree when the tracking point no longer identifies it and the menu owner is not a shell view, covering the Windows 11 Show more options transition. A point that is actually in the navigation pane remains hit-test-only, so right-clicking empty tree space still does not guess the selected folder; file-list menus are also excluded.

Validation:

  • 15 regression tests passed
  • syntax compilation passed for x86, x64, and ARM64
  • Windhawk compliance passed (the only warning remains the pre-existing read-only CreateFileW app-execution-alias probe)
  • canonical and catalog files match byte-for-byte at v1.17.1

The collapsed optional refactors remain deferred as out-of-scope polish.

@aimagist

aimagist commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The cmd quoting fix in 6b442f7 is correct now — /s plus the wrapping quote pair makes the strip-first-and-last branch explicit, and all three call sites (cd /d, .bat/.cmd, nested cscript) survive &, (x86) and the four-quote case. The navigation-pane focus fallback also reads correctly. One thing the new terminal-bypass introduced:

The script entry names (and shows the icon of) the selected terminal, but cmd-wrapped scripts no longer launch there

The new early return in BuildScriptLaunchSpec bypasses the selected terminal host whenever the launch goes through cmd:

bool usesCmdWrapper = _wcsicmp(ext, L".bat") == 0 ||
                      _wcsicmp(ext, L".cmd") == 0 ||
                      (s.keepOpenAfterScript &&
                       (_wcsicmp(ext, L".vbs") == 0 || _wcsicmp(ext, L".js") == 0));
if (!IsScriptHostChoice(s.terminalEffectiveChoice) || usesCmdWrapper) {
    return interpreter;   // launches cmd.exe directly
}

but the label and the icon still branch on IsScriptHostChoice alone:

// GetScriptTerminalDisplayName
if (IsScriptHostChoice(settings.terminalEffectiveChoice)) {
    return GetTerminalDisplayName(settings);   // "Windows Terminal"
}

// GetScriptIconSettings
if (IsScriptHostChoice(settings.terminalEffectiveChoice)) {
    return settings;                            // wt's icon
}

So with Windows Terminal (or WezTerm / Alacritty / ConEmu) selected and appendScriptTerminalName on — the default — right-clicking build.bat gives "Run script in Windows Terminal as administrator" with the Windows Terminal icon, and clicking it opens a classic console window. Same for .vbs/.js with keepOpenAfterScript, which is also the default, so this is the out-of-the-box path for three of the five supported extensions. It also contradicts the README ("Script actions use the selected terminal when it can host the required Windows interpreter... and use that actual program name in the menu"), which is the property appendScriptTerminalName exists to expose.

Hoisting the predicate into a helper and using it in all three places fixes label, icon and launch together:

static bool UsesCmdWrapper(const Settings& s, PCWSTR ext) {
    return _wcsicmp(ext, L".bat") == 0 || _wcsicmp(ext, L".cmd") == 0 ||
           (s.keepOpenAfterScript &&
            (_wcsicmp(ext, L".vbs") == 0 || _wcsicmp(ext, L".js") == 0));
}

static bool UsesSelectedTerminalHost(const Settings& s,
                                     const std::wstring& scriptPath) {
    return IsScriptHostChoice(s.terminalEffectiveChoice) &&
           !UsesCmdWrapper(s, PathFindExtensionW(scriptPath.c_str()));
}

Then BuildScriptLaunchSpec, GetScriptTerminalDisplayName and GetScriptIconSettings all test UsesSelectedTerminalHost(...), and a .bat under Windows Terminal correctly reads "Run script in Command Prompt as administrator" with the cmd.exe icon. The .vbs/.js keep-open case will then also fall into the existing extension branch and report "Windows Script Host", which matches what the user gets. Worth a README line too, since "the selected terminal hosts the interpreter" is now only true for .ps1 and non-keep-open .vbs/.js.

Optional improvements

Minor polish — none of this affects users, so it's your call. Most are carried over from earlier rounds and were deliberately deferred; re-listed compactly so nothing is lost.

  • @description is still out of date"...open an elevated terminal in the current or selected folder", with no mention of the non-elevated entry or the script actions. This is the text users see in the mod list.
  • PostMessageW is hooked for a path that may never run. The classic menu's WM_COMMAND is posted by win32k when the menu loop ends, not through user32's PostMessageW export, so PostMessageW_Hook likely never fires — and Explorer's classic menu goes through TPM_RETURNCMD anyway. If you can confirm it never triggers, dropping it removes an inline hook on a very hot user32 API in explorer.exe; if it does trigger somewhere, ignore this.
  • Four of the five forward declarations are deadGetSettingsSnapshot, LaunchTerminalNonElevated, BuildScriptLaunchSpec and IsScriptExtension are all defined before first use; only IsShellViewWindow is needed (for the call in IsNavigationPaneContextWindow).
  • LaunchTerminalNonElevated is a verbatim copy of LaunchAdminTerminal apart from lpVerb. One LaunchTerminal(const MenuTarget&, PCWSTR verb) removes ~40 lines that will otherwise drift.
  • GetCachedMenuBitmapNoShield duplicates GetCachedMenuBitmapForTerminal + TryCreateMenuBitmapForTerminal. A single GetCachedMenuBitmap(const Settings&, bool withShield) that appends L"\nno-shield" to the key and passes nullptr as the overlay collapses both.
  • GetSettingString's fallback is dead code. Wh_GetStringSetting never returns NULL — it returns L"" when unset or on error. Check for an empty string, or use WindhawkUtils::StringSetting (RAII), which also drops the manual Wh_FreeStringSetting.
  • Wh_SetFunctionHook with reinterpret_cast<void*>WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, TrackPopupMenuEx_Hook, &TrackPopupMenuEx_Orig) is type-checked and drops the casts.
  • BOOL Wh_ModSettingsChanged(BOOL* reload) always sets *reload = FALSE — the void Wh_ModSettingsChanged() overload is equivalent and clearer.
  • SearchExecutablePath uses SearchPathW, whose search order includes Explorer's current directory, while ResolveSystemExecutablePath correctly pins cmd.exe/cscript.exe to System32. @include explorer.exe makes this a hardening nit, not a real hijack surface, but routing cmd.exe/powershell.exe/wsl.exe through ResolveSystemExecutablePath too would be consistent.
  • ClearMenuBitmapCache() in Wh_ModSettingsChanged DeleteObjects bitmaps a currently-open menu may still reference via hbmpItem. Only reachable if settings change while a menu is up, so cosmetic at worst.
  • Stale menu state after a non-TPM_RETURNCMD menu. ShouldClearMenuStateAfterTracking keeps the state so PostMessageW_Hook can consume a delayed WM_COMMAND — but TrackPopupMenuEx also returns nonzero when such a menu is simply dismissed, so the state survives until the next TrackPopupMenuEx. Narrow, and guarded by the hwnd comparison.
  • BuildScriptInterpreterSpec runs up to three times per .ps1 menu open (GetScriptIconSettings, GetScriptTerminalDisplayName, then again at launch), each re-running SearchPathW plus candidate GetFileAttributesW probes on the UI thread. Resolving once in ResolveMenuTarget and passing the spec around would cut that — and would compose well with the UsesSelectedTerminalHost helper above.
  • Default menu position matches English text (StrStrIW(text, L"Open") / L"Terminal"), so on a localized Windows it silently degrades to position 0. Pre-existing; worth a README line if you don't want to change it.
  • Menu bitmap size comes from GetSystemMetrics(SM_CXMENUCHECK) and the cache key has no DPI component, so on a mixed-DPI setup the icon is sized for the primary monitor. GetSystemMetricsForDpi(SM_CXMENUCHECK, GetDpiForWindow(hwnd)) plus DPI in the key fixes it.
  • -loleaut32 looks unusedVARIANT is only used as a plain struct with VT_I4, and no oleaut32 function is called.
  • No README visual for the new entries. A small capture of the script entries and of the two-entry non-elevated layout would help users understand what they're turning on.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The focused-tree fallback can attach the entry to unrelated Explorer frame menus. With showOnNavigationPane on, click a folder in the tree (it takes focus and selection), then right-click the toolbar, the address bar, or a Windows 11 tab: hwnd isn't a shell view and WindowFromPoint isn't the tree, so ShouldUseFocusedNavigationPaneFallback fires and the entry lands in that menu pointing at the tree selection. Harmless if clicked, but out of place. A cheap tightening is to also require the invocation point to be inside the focused tree's window rect (GetWindowRect(focusedWindow, &r) && PtInRect(&r, invocationPoint)) — though that may exclude the Windows 11 Show more options case if the flyout is placed outside the tree, so it's worth checking against that path before adopting.
  • cmd expands %…% even inside quotes. A script or folder whose name contains a variable-looking pair (build %TEMP% .bat, 100%done%.bat) is expanded before the command runs. There's no clean escape for a /k command line, so this is probably just a known limitation — the argv-style paths (.ps1, non-keep-open .vbs) don't have it.
  • Windows Terminal's parser may swallow the interpreter's own switches. wt new-tab -d <dir> pwsh.exe -NoExit -ExecutionPolicy Bypass -File "…" has no -- separator (the wezterm branch does use one), and wt treats ; anywhere in the line as a sub-command separator. Worth testing with a script in a path containing a space and one containing ;.
  • The script entry is inserted before the interpreter is resolved. If BuildScriptInterpreterSpec comes back empty (WSH disabled by policy, no PowerShell), the item still appears and clicking it only writes a log line. Related: under a script-host terminal an empty interpreter.executable lands in the argument list, so wt new-tab -d "dir" "" just opens a default tab. Resolving the spec during ResolveMenuTarget and skipping the entry when it's empty would avoid both.
  • ShellExecuteExW runs on the invoking UI thread with SEE_MASK_NOASYNC, so that thread is blocked while the UAC dialog is up. Pre-existing, but the script entries make it more frequent. SHCreateThread with CTF_COINIT, or a detached thread that calls CoInitializeEx, keeps the shell responsive; the flag then belongs on that thread.
  • .js in the default scriptExtensions means every .js file in a web or Node project gets a "Run script in Windows Script Host as administrator" entry. Running a Node module under cscript elevated is at best a no-op. Consider dropping .js from the default and letting users add it back.
  • scriptExtensions is purely subtractive. The $description says "filter", which is accurate, but clearing the setting silently disables all script entries even with showOnScriptFiles on. A nested group with a checkbox per extension would express this more directly.
  • BuildScriptInterpreterSpec's working directory falls apart if find_last_of(L"\\/") returns npos: substr(0, npos) yields the full script path, which then becomes lpDirectory. Shell paths are always absolute so this shouldn't trigger, but an explicit check is cheap.
  • ResolveNavigationPaneMenuTarget converts the point with ScreenToClient(GetControlWindow(FCW_TREE)) but hit-tests through INameSpaceTreeControl::HitTest, which expects coordinates in its client space. Same window today, but FCW_TREE isn't guaranteed to be the control's own HWND — QueryInterface(IID_IOleWindow) on the tree control would be a more direct source for the conversion.
  • -ExecutionPolicy Bypass plus "Run as administrator" is a meaningful capability. Defaulting it off was the right call; one README line saying what it disables would round it out.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
@aimagist

aimagist commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the current AI-review finding in e8b8cb28eebeb192f165c1bc8ed1b140e3bfef19.

  1. Added one shared predicate for whether a script can use the selected terminal host, and use it for launch, label, and icon decisions.
  2. .bat/.cmd now show Command Prompt with the direct cmd.exe icon when they bypass a selected terminal host.
  3. Keep-open .vbs/.js actions show Windows Script Host while using the direct Command Prompt wrapper required to keep the window open.
  4. Added a regression test covering both label and effective icon selection; the local suite now has 16 tests.
  5. Updated the embedded README and standalone README to describe this behavior.

Verification:

  • tests/run.ps1: PASS (16 tests)
  • Whole-mod syntax checks: i686, x86_64, and aarch64 targets passed.
  • Windhawk compliance passed; the only review warning remains the pre-existing read-only CreateFileW app-execution-alias probe.
  • Canonical source and the catalog mirror match byte-for-byte; the PR changes exactly mods/open-in-admin-terminal.wh.cpp.

Deferred intentionally: the optional focused-navigation fallback tightening needs manual Explorer testing first, because restricting it by the tree rectangle could regress the Windows 11 “Show more options” path. Other optional cleanup suggestions are outside this review correction.

Remaining manual verification: Explorer menu behavior for supported script types and navigation-pane/menu surfaces.

@aimagist

aimagist commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The UsesSelectedTerminalHost predicate is now used consistently for launch, label and icon — that finding is resolved. One behavioral issue is left; it predates this PR but the script feature makes it easier to run into.

On the desktop, every right-click that isn't a supported folder still gets the entry — retargeted at the Desktop folder

The last block of ResolveMenuTarget fires on !ok, and !ok is exactly the state that means "the selection is not something this mod supports":

    if (hasSelectedCount && selectedCount > 1) {
        ok = false;                       // multi-selection -> deliberately no entry
    } else if (...) {
        ...
    } else if (selectedPaths.size() == 1 && IsFilePath(selectedPaths[0])) {
        if (settings.showOnScriptFiles && IsScriptExtension(...)) { ... }
        // non-script file -> ok stays false
    }
    ...
    if (!ok && isDesktopShellView) {      // <- catches all of the above
        std::wstring folderPath;
        if (GetDesktopFolderPath(folderPath) && IsDirectoryPath(folderPath)) {
            targetOut.kind = TargetKind::FolderBackground;
            targetOut.path = folderPath;
            ok = true;
        }
    }

FolderBackground then passes IsTargetEnabled via showOnFolderBackground, which defaults to true, so on the desktop the entry appears for:

  • a selected file — right-click notes.txt or a .lnk shortcut → "Open Windows Terminal as Administrator", opening the Desktop folder. The same right-click inside an Explorer window correctly produces nothing.
  • a multi-selection — the selectedCount > 1 rule the file-list path enforces is undone by the fallback.
  • a virtual item — Recycle Bin, This PC, Network: SHGDN_FORPARSING yields ::{GUID}, which is neither a file nor a directory, so it lands in the fallback too.

It also contradicts the README ("The mod intentionally targets filesystem folders and drive roots only"), and it interacts with the new feature: with showOnScriptFiles off, right-clicking deploy.bat on the desktop shows the folder entry rather than nothing.

The fallback is only meant to cover "the shell view / current folder couldn't be resolved for a desktop background click", so gate it on there being no selection:

    bool ok = false;
    bool sawSelection = false;
    IFolderView* folderView = nullptr;
    if (SUCCEEDED(shellView->QueryInterface(IID_IFolderView, ...)) && folderView) {
        ...
        if (hasSelectedCount && selectedCount > 1) {
            sawSelection = true;
        } else if (hasSelectedCount && selectedCount == 0) {
            ...
        } else {
            std::vector<std::wstring> selectedPaths;
            UINT shellSelectedCount = GetSelectedPaths(shellView, selectedPaths, 2);
            sawSelection = shellSelectedCount > 0 || !selectedPaths.empty();
            ...
        }
        folderView->Release();
    }

    // Only fall back to the desktop folder when nothing is selected — otherwise a
    // right-click on a file, a virtual item or a multi-selection would silently be
    // retargeted at the Desktop folder.
    if (!ok && !sawSelection && isDesktopShellView) {
Optional improvements

Minor polish — none of this affects users, so it's your call. Most are carried over from earlier rounds and were deliberately deferred; re-listed compactly so nothing is lost.

  • Keep-open .vbs/.js shows the Command Prompt icon under a "Windows Script Host" label. GetScriptTerminalDisplayName now correctly special-cases the extension, but GetScriptIconSettings still takes the icon from interpreter.executable, which for that path is the cmd.exe wrapper. Deriving the icon from cscript.exe (the thing the label names) would make the two agree — the same consistency the previous round was about, just for the icon.
  • @description is still out of date"...open an elevated terminal in the current or selected folder", with no mention of the non-elevated entry or the script actions. This is the text users see in the mod list.
  • LaunchTerminalNonElevated is a verbatim copy of LaunchAdminTerminal apart from lpVerb. One LaunchTerminal(const MenuTarget&, PCWSTR verb) removes ~40 lines that will otherwise drift.
  • GetCachedMenuBitmapNoShield duplicates GetCachedMenuBitmapForTerminal + TryCreateMenuBitmapForTerminal. A single GetCachedMenuBitmap(const Settings&, bool withShield) that appends L"\nno-shield" to the key and passes nullptr as the overlay collapses both.
  • GetSettingString's fallback is dead code. Wh_GetStringSetting never returns NULL — it returns L"" when unset or on error — so if (PCWSTR s = ...) is always taken. Check for an empty string, or use WindhawkUtils::StringSetting (RAII), which also drops the manual Wh_FreeStringSetting.
  • Wh_SetFunctionHook with reinterpret_cast<void*>WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, TrackPopupMenuEx_Hook, &TrackPopupMenuEx_Orig) is type-checked and drops the casts. See mods/win32-ui-modernizer.wh.cpp#L39611 for the same hook.
  • BOOL Wh_ModSettingsChanged(BOOL* reload) always sets *reload = FALSE — the void Wh_ModSettingsChanged() overload is equivalent and clearer.
  • Four of the five forward declarations are deadGetSettingsSnapshot, LaunchTerminalNonElevated, BuildScriptLaunchSpec and IsScriptExtension are all defined before first use; only IsShellViewWindow is needed (for the call in IsNavigationPaneContextWindow).
  • PostMessageW is hooked for a path that may never run. The classic menu's WM_COMMAND is posted by win32k when the menu loop ends, not through user32's PostMessageW export, and Explorer's classic menu goes through TPM_RETURNCMD anyway. If logging confirms it never fires, dropping it removes an inline hook on a very hot user32 API in explorer.exe.
  • SearchExecutablePath uses SearchPathW, whose search order includes Explorer's current directory, while ResolveSystemExecutablePath correctly pins cmd.exe/cscript.exe to System32. @include explorer.exe makes this a hardening nit rather than a real hijack surface, but routing cmd.exe/powershell.exe/wsl.exe through ResolveSystemExecutablePath too would be consistent.
  • ClearMenuBitmapCache() in Wh_ModSettingsChanged DeleteObjects bitmaps a currently-open menu may still reference via hbmpItem. Only reachable if settings change while a menu is up, so cosmetic at worst.
  • Stale menu state after a non-TPM_RETURNCMD menu. ShouldClearMenuStateAfterTracking keeps the state so PostMessageW_Hook can consume a delayed WM_COMMAND — but TrackPopupMenuEx also returns nonzero when such a menu is simply dismissed, so the state survives until the next TrackPopupMenuEx. Narrow, and guarded by the hwnd comparison.
  • BuildScriptInterpreterSpec runs up to three times per .ps1 menu open (GetScriptIconSettings, GetScriptTerminalDisplayName, then again at launch), each re-running SearchPathW plus candidate GetFileAttributesW probes on the UI thread. Resolving once in ResolveMenuTarget and passing the spec around would cut that.
  • Default menu position matches English text (StrStrIW(text, L"Open") / L"Terminal"), so on a localized Windows it silently degrades to position 0. Pre-existing; worth a README line if you don't want to change it.
  • Menu bitmap size comes from GetSystemMetrics(SM_CXMENUCHECK) and the cache key has no DPI component, so on a mixed-DPI setup the icon is sized for the primary monitor. GetSystemMetricsForDpi(SM_CXMENUCHECK, GetDpiForWindow(hwnd)) plus DPI in the key fixes it.
  • -loleaut32 looks unusedVARIANT is only used as a plain struct with VT_I4, and no oleaut32 function is called.
  • No README visual for the new entries. The existing shots cover the folder case well; a small capture of the script entries and of the two-entry non-elevated layout would help users understand what they're turning on.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The focused-tree fallback can attach the entry to unrelated Explorer frame menus. With showOnNavigationPane on, click a folder in the tree (it takes focus and selection), then right-click the toolbar, the address bar or a Windows 11 tab: hwnd isn't a shell view and WindowFromPoint isn't the tree, so ShouldUseFocusedNavigationPaneFallback fires and the entry lands in that menu pointing at the tree selection. You deferred tightening this pending manual testing of the Windows 11 "Show more options" path — noting it so it isn't lost. Requiring the invocation point to be inside the focused tree's rect (GetWindowRect + PtInRect) is the cheap version, if that path turns out not to need the looser rule.
  • The nav-pane option is gated by the file-list options. ResolveNavigationPaneMenuTarget produces FolderItem/DriveItem, which IsTargetEnabled checks against showOnFolderItem/showOnDriveItem, so enabling showOnNavigationPane while showOnFolderItem is off gives no entry in the tree. Defensible, but not obvious from the setting descriptions.
  • The script entry is inserted before the interpreter is resolved. If BuildScriptInterpreterSpec comes back empty (WSH disabled by policy, no PowerShell), the item still appears and clicking it only writes a log line. Related: under a script-host terminal an empty interpreter.executable lands in the argument list, so wt new-tab -d "dir" "" just opens a default tab. Resolving the spec during ResolveMenuTarget and skipping the entry when it's empty would avoid both — and would compose with the "called three times" item above.
  • Paths are read into MAX_PATH buffers. GetSelectedPaths uses StrRetToBufW(..., path, MAX_PATH) and GetCurrentFolderPath uses SHGetPathFromIDListW with a WCHAR[MAX_PATH], so on a long-path-enabled system a folder deeper than 260 characters is truncated, IsDirectoryPath fails, and the entry silently doesn't appear. SIGDN_FILESYSPATH via IShellItem (as the nav-pane path already does) has no such limit.
  • Windows Terminal's parser may swallow the interpreter's own switches. wt new-tab -d <dir> pwsh.exe -NoExit -ExecutionPolicy Bypass -File "…" has no -- separator (the wezterm branch does use one), and wt treats ; anywhere in the line as a sub-command separator. Worth testing with a script in a path containing a space and one containing ;.
  • cmd expands %…% even inside quotes. A script or folder whose name contains a variable-looking pair (build %TEMP% .bat, 100%done%.bat) is expanded before the command runs. There's no clean escape for a /k command line, so this is probably just a known limitation — the argv-style paths (.ps1, non-keep-open .vbs) don't have it.
  • ShellExecuteExW runs on the invoking UI thread with SEE_MASK_NOASYNC, so that thread is blocked while the UAC dialog is up — for a desktop or taskbar invocation that's the main shell thread. Pre-existing, but the script entries make it more frequent. SHCreateThread with CTF_COINIT, or a detached thread that calls CoInitializeEx, keeps the shell responsive; the flag then belongs on that thread.
  • .js in the default scriptExtensions means every .js file in a web or Node project gets a "Run script in Windows Script Host as administrator" entry. Running a Node module under cscript elevated is at best a no-op. Consider dropping .js from the default and letting users add it back.
  • scriptExtensions is purely subtractive. The $description says "filter", which is accurate, but clearing the setting silently disables all script entries even with showOnScriptFiles on. A nested group with a checkbox per extension would express this more directly.
  • BuildScriptInterpreterSpec's working directory falls apart if find_last_of(L"\\/") returns npos: substr(0, npos) yields the full script path, which then becomes lpDirectory. Shell paths are always absolute so this shouldn't trigger, but an explicit check is cheap.
  • ResolveNavigationPaneMenuTarget converts the point with ScreenToClient(GetControlWindow(FCW_TREE)) but hit-tests through INameSpaceTreeControl::HitTest, which expects coordinates in its client space. Same window today, but FCW_TREE isn't guaranteed to be the control's own HWND — QueryInterface(IID_IOleWindow) on the tree control would be a more direct source for the conversion.
  • -ExecutionPolicy Bypass plus "Run as administrator" is a meaningful capability. Defaulting it off was the right call; one README line saying what it disables would round it out.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-author The author's turn: request an AI review, or respond to one that was posted.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant