From d3275451fe4e9fbdae03e7bd2b070f1b071f0f2a Mon Sep 17 00:00:00 2001 From: Rui Batista Date: Mon, 24 Aug 2026 15:48:53 +0100 Subject: [PATCH 1/2] Fix Virtual Review in Windows Terminal Modern command-line windows (cmd, PowerShell, WSL under Windows Terminal) expose their text through a UIA TermControl element rather than the legacy console buffer. Detect that element from the focused object and capture from it directly, so every tab and split pane works regardless of the host window's class name. Also guard currentClassName access, which can raise a COM error and previously aborted the whole review command after the initial beep. Ported from ruifontes/virtualReview#16. Co-Authored-By: serrebidev Co-Authored-By: Claude Opus 5 (1M context) --- addon/globalPlugins/virtualRevision.py | 55 ++++++++++++++++++++------ 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/addon/globalPlugins/virtualRevision.py b/addon/globalPlugins/virtualRevision.py index 90d8eb0..29d935b 100644 --- a/addon/globalPlugins/virtualRevision.py +++ b/addon/globalPlugins/virtualRevision.py @@ -20,6 +20,36 @@ SCRCAT_TEXTREVIEW = None +def _isTermControl(obj): + # True when obj is a Windows Terminal "TermControl" (the UIA element that hosts the text + # of cmd/PowerShell/WSL tabs and panes). Reading UIAElement.currentClassName can raise a + # COM error on some objects, and this runs against every object we inspect, so it must never + # let an exception escape and abort the review command. + try: + return bool(getattr(obj, "UIAElement", None)) and obj.UIAElement.currentClassName == "TermControl" + except Exception: + return False + + +def _findTermControl(obj): + # Return the focused object, or its nearest ancestor, that is a TermControl, else None. + # NVDA normally puts focus directly on the TermControl, so this usually returns obj itself, + # but walking up a few ancestors covers hosts that focus a child of the control. Capturing + # straight from the focused control (rather than searching down from the foreground window) + # is what makes every tab and split pane work, regardless of the host window's class name. + node = obj + depth = 0 + while node is not None and depth < 20: + if _isTermControl(node): + return node + try: + node = node.parent + except Exception: + break + depth += 1 + return None + + def obtainUWPWindowText(): foreground = api.getForegroundObject() desktop = api.getDesktopObject() @@ -28,11 +58,7 @@ def obtainUWPWindowText(): while curObject: if curObject.name is not None: uwpTextList.append(curObject.name) - if ( - hasattr(curObject, "UIAElement") - and curObject.UIAElement - and curObject.UIAElement.currentClassName == "TermControl" - ): + if _isTermControl(curObject): info = curObject.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) text = info.clipboardText.rstrip() @@ -56,11 +82,7 @@ def obtainUWPWindowText(): curObject = parent.simpleNext except AttributeError: continue - if ( - hasattr(foreground, "UIAElement") - and foreground.UIAElement - and foreground.UIAElement.currentClassName == "TermControl" - ): + if _isTermControl(foreground): info = foreground.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) text = info.clipboardText.rstrip() @@ -82,8 +104,19 @@ def script_virtualWindowReview(self, gesture): # In case of universal apps, traverse child elements. text = None obj = api.getFocusObject() + # Modern command-line windows (Windows Terminal, and any XAML/UWP terminal host) expose + # their text through a "TermControl" UIA element instead of the legacy console buffer. + # Detect it from the focused control and capture straight from there, so every tab and + # split pane works no matter what the host window's class name is. Checked first because + # the terminal control's HWND class often matches the UWP prefixes below, and that branch's + # generic child walk is slower and can miss the active pane. + term = _findTermControl(obj) + if term is not None: + info = term.makeTextInfo(textInfos.POSITION_FIRST) + info.expand(textInfos.UNIT_STORY) + text = info.clipboardText.rstrip() # Because it may take a while to iterate through elements, play abeep to alert users of this fact and the fact it's a UWP screen. - if obj.windowClassName.startswith(("Windows.UI.Core", "Windows.UI.Input.InputSite")): + elif obj.windowClassName.startswith(("Windows.UI.Core", "Windows.UI.Input.InputSite")): import tones tones.beep(400, 300) From 3c32dacf079b2b2b13525962522a68d00ed7696b Mon Sep 17 00:00:00 2001 From: Rui Batista Date: Mon, 24 Aug 2026 15:50:01 +0100 Subject: [PATCH 2/2] Strip padding and border-only lines from terminal review text Terminal cells are space-padded to the column width and TUI apps reserve whole rows for borders and status chrome, so a UNIT_STORY capture fills the review window with blank padding and box-drawing-only lines. Strip trailing whitespace per line and drop lines with no real content. Applied to the focused-TermControl capture as well, which the upstream PR predates. Ported from ruifontes/virtualReview#18. Co-Authored-By: serrebidev Co-Authored-By: Claude Opus 5 (1M context) --- addon/globalPlugins/virtualRevision.py | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/addon/globalPlugins/virtualRevision.py b/addon/globalPlugins/virtualRevision.py index 29d935b..994bbe8 100644 --- a/addon/globalPlugins/virtualRevision.py +++ b/addon/globalPlugins/virtualRevision.py @@ -50,6 +50,32 @@ def _findTermControl(obj): return None +def _isDecorativeTerminalLine(line): + # True for lines whose only non-whitespace characters are in the Unicode Box Drawing + # block (U+2500-U+257F): the borders, separators, and corners that TUI apps paint. + # Content lines that happen to contain a "│" border still survive because the + # letters/digits inside fall outside the range. + stripped = line.strip() + if not stripped: + return True + return all("─" <= ch <= "╿" for ch in stripped) + + +def _cleanTerminalText(text): + # Terminal cells are space-padded to the column width, and TUI apps reserve fixed + # rows/columns for borders, status bars, input, etc. Captured as a UNIT_STORY this + # turns into long runs of blank padding and box-border-only lines that bury the actual + # content when the review window is navigated line by line. Strip per-line trailing + # whitespace and drop lines that carry no real content. + lines = [] + for line in text.split("\n"): + stripped = line.rstrip() + if _isDecorativeTerminalLine(stripped): + continue + lines.append(stripped) + return "\n".join(lines) + + def obtainUWPWindowText(): foreground = api.getForegroundObject() desktop = api.getDesktopObject() @@ -61,7 +87,7 @@ def obtainUWPWindowText(): if _isTermControl(curObject): info = curObject.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) - text = info.clipboardText.rstrip() + text = _cleanTerminalText(info.clipboardText) uwpTextList.append(text) if curObject.simpleFirstChild: curObject = curObject.simpleFirstChild @@ -85,7 +111,7 @@ def obtainUWPWindowText(): if _isTermControl(foreground): info = foreground.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) - text = info.clipboardText.rstrip() + text = _cleanTerminalText(info.clipboardText) uwpTextList.append(text) return uwpTextList @@ -114,7 +140,7 @@ def script_virtualWindowReview(self, gesture): if term is not None: info = term.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) - text = info.clipboardText.rstrip() + text = _cleanTerminalText(info.clipboardText) # Because it may take a while to iterate through elements, play abeep to alert users of this fact and the fact it's a UWP screen. elif obj.windowClassName.startswith(("Windows.UI.Core", "Windows.UI.Input.InputSite")): import tones