Skip to content

Add lvt Viewer: a graphical live visual tree GUI - #49

Merged
asklar merged 60 commits into
mainfrom
lvt-visual-viewer
Aug 27, 2026
Merged

Add lvt Viewer: a graphical live visual tree GUI#49
asklar merged 60 commits into
mainfrom
lvt-visual-viewer

Conversation

@asklar

@asklar asklar commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What this adds

lvt Viewer: a WPF (.NET) desktop GUI under src/viewer/LvtViewer/ that
gives lvt a Visual-Studio-Live-Visual-Tree / Inspect.exe-style graphical
front end:

  1. Crosshair-drag target picking, exactly like Inspect.exe: press-drag a
    toolbar crosshair; a highlight overlay tracks the top-level window under
    the cursor; on release, WindowFromPoint + GetAncestor(GA_ROOT) +
    GetWindowThreadProcessId resolve the exact window and it becomes the
    target (by HWND, not by process name/PID — precise even for multi-window
    processes).
  2. Two-pane view: a live TreeView (color-coded by framework) and a
    property panel showing bounds/text/className/framework, plus for UIA
    targets AutomationId/ControlType/SupportedPatterns/pattern state
    (e.g. Toggle.ToggleState).
  3. Real-time updates driven by lvt watch's JSON diff stream — no manual
    re-dump needed when the target's UI changes.
  4. Property editing for Toggle.ToggleState and
    Value.Value/RangeValue.Value, via lvt's existing toggle/set-value
    action verbs (never an arbitrary property), addressed by lvt's durable
    element key rather than its positional eN id.

Architecture decision: data source

Three integration points were on the table: polling lvt dump, driving
lvt mcp over stdio, or reading lvt watch's live diff stream. I used
lvt watch
, and it's the sole data source (not dump + watch layered
together). Full reasoning is in src/viewer/README.md, short version:

  • MCP has no push/subscribe tool. docs/mcp-server.md and
    mcp/src/server.rs are explicit: get_uia_tree/get_visual_tree/
    find_elements are pull, and wait_for blocks on one condition and
    returns once. Live updates over MCP would mean polling and diffing
    client-side — strictly worse than watch, which already does that
    diffing inside lvt and streams the result.
  • dump once + watch on top would race two independent walks of the
    target (watch's own internal starting snapshot vs. the separate dump
    call), and a node that changed in that gap could go uncorrected forever.
    A freshly started watch process's first burst of added events already
    is a complete, self-consistent snapshot (see run_watch_loop /
    snapshot_added_events), and everything after is a true diff against that
    same walk — so watch alone has no race to have.

The viewer never links lvt_core; it only shells out to lvt.exe
(Services/LvtCli.cs for one-shot verbs, Services/WatchSession.cs for the
long-running watch process) and parses its JSON.

lvt.exe is located via Services/LvtLocator.cs: LVT_EXE env var → next
to LvtViewer.exe → this repo's own build/lvt.exe (found by walking up
from the viewer's build output — makes dotnet build+run work immediately
in a normal dev checkout) → %USERPROFILE%\.lvt\lvt.exe → PATH.

Build wiring

-DLVT_BUILD_VIEWER=ON (default OFF, mirroring LVT_BUILD_MANAGED) adds
an opt-in CMake custom target that runs dotnet build on the viewer and
copies lvt.exe alongside its output, so cmake --build build produces a
viewer that works out of the box. Building it standalone
(dotnet build src/viewer/LvtViewer -c Release) also works and is documented
in src/viewer/README.md.

What I verified manually

Built lvt.exe from the main CMake preset and the viewer from both dotnet build and the LVT_BUILD_VIEWER CMake target; both run. Against a real,
live Notepad instance I verified, end-to-end:

  • Connecting populates the target label and starts a live watch session.
  • The tree populates with real elements (root Window, Document/text-editor
    child, etc.), and selecting one populates the property panel with real
    data (Id, Key, Framework, Class, Text, Bounds, and the full UIA
    property set including SupportedPatterns).
  • Live update 1: called lvt set-value on Notepad's text element to
    change its content — the viewer's property panel picked up the new
    Value.Value with no manual refresh, purely from the live watch stream.
  • Live update 2: resized Notepad's window via SetWindowPos — the
    viewer's Bounds reflected the new size live, same mechanism.
  • dotnet build succeeds in both Debug and Release.

Update: the crosshair-drag gesture has now been verified live, with a real
mouse, on an unlocked desktop
(the sandbox this PR was originally built in
had a locked interactive desktop, which blocked all real input — see below
for what that looked like). With a genuine mouse-down on the crosshair,
drag, and mouse-up:

  • The gesture correctly starts a drag on mouse-down over the crosshair,
    tracks the window under the cursor while held, and resolves + connects to
    the released-over window on mouse-up — done twice, against two different
    real Notepad windows.
  • Both times, the HWND/PID the viewer reported (from its own TargetText,
    read back via lvt dump --uia) were cross-checked against an independent,
    ground-truth WindowFromPoint/GetAncestor(GA_ROOT)/
    GetWindowThreadProcessId call made directly against the same screen
    point — an exact match both times (same HWND, same PID).
  • One drag attempt initially "did nothing": a freshly opened Notepad window
    happened to be positioned so it covered the viewer's own crosshair, so the
    mouse-down landed on Notepad instead of the crosshair — confirmed by
    checking what WindowFromPoint returned for the crosshair's own screen
    coordinates at that moment. That's correct, expected window z-order
    behavior (you can't click through an opaque window on top), not a bug;
    bringing the viewer to the front before dragging fixed it. Worth being
    aware of as a usability note — Inspect.exe has the identical constraint —
    but not something to change in the implementation.

src/viewer/README.md's "sandbox caveat" section has been removed/updated
to reflect this.

Stretch / incomplete

  • Property editing covers exactly toggle and set-value; the rest of
    uia_actions.cpp (click, select, expand, scroll, ...) isn't wired
    into the property panel yet. The plumbing generalizes easily.
  • No legend in the UI for the tree's framework color-coding.
  • Visual-tree mode (unchecking "UI Automation tree") reads and live-updates
    identically, but property editing is UIA-only, matching lvt's own
    mode restrictions (see "Modes" in docs/mcp-server.md).

How to build and run

# from a VS x64 dev prompt, VCPKG_ROOT set
cmake --preset default -DLVT_BUILD_VIEWER=ON
cmake --build build
.\build\viewer\LvtViewer.exe

or standalone once lvt.exe is built:

cd src\viewer\LvtViewer
dotnet build -c Release
.\bin\Release\net10.0-windows\LvtViewer.exe

Full details in src/viewer/README.md.

Release packaging

The existing per-architecture CLI archives remain lean. The x64 release leg also publishes a separate lvt-viewer-vX.Y.Z-x64.zip containing the framework-dependent viewer plus the complete matching x64 CLI, TAP DLLs, managed walkers, and plugins. It requires the .NET 10 Desktop Runtime; users do not need to combine it with a CLI archive.

asklar and others added 6 commits August 21, 2026 15:20
A new WPF (.NET) desktop app under src/viewer/LvtViewer/ that gives lvt a
Visual-Studio-Live-Visual-Tree / Inspect.exe style GUI:

- Crosshair-drag target picking (Interop/CrosshairPicker.cs): press-drag a
  toolbar crosshair, a highlight overlay tracks the top-level window under
  the cursor, and on release it's resolved via WindowFromPoint +
  GetAncestor(GA_ROOT) + GetWindowThreadProcessId, exactly like Inspect.exe.
- Two-pane view: a live TreeView of elements (color-coded by framework) and
  a property panel showing bounds/text/className/framework plus, for UIA
  targets, AutomationId/ControlType/SupportedPatterns/pattern state.
- Real-time updates driven by lvt watch's JSON diff stream — chosen over
  polling lvt dump or driving lvt mcp (which has no push/subscribe tool,
  only wait_for). See src/viewer/README.md for the full reasoning.
- Property editing for Toggle.ToggleState and Value.Value/RangeValue.Value,
  via lvt's existing 	oggle/set-value action verbs, addressed by lvt's
  durable element key rather than its positional eN id.

The viewer never links lvt_core; it drives lvt.exe as a subprocess
(Services/LvtCli.cs, Services/WatchSession.cs), located via
Services/LvtLocator.cs (LVT_EXE env var, alongside the viewer, this repo's
own build/ directory, or PATH).

Verified end-to-end against a live Notepad instance: connect, live tree/
property population, a live text-value change via set-value, and a live
bounds update from resizing the target window, all without manual refresh.
See src/viewer/README.md for the one piece that could not be physically
exercised in this sandbox (the mouse-drag gesture itself, blocked by a
locked interactive desktop) and how its logic was verified instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Adds an opt-in LVT_BUILD_VIEWER option (default OFF, mirroring
LVT_BUILD_MANAGED) that runs dotnet build on the viewer's csproj as a
custom target, and copies lvt.exe alongside the viewer's output so a fresh
cmake --build build produces a viewer that works with zero extra setup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
The physical crosshair-drag gesture was re-verified end-to-end on an
unlocked desktop: a real mouse-down/drag/mouse-up correctly resolves and
connects to the window under the cursor on release, cross-checked twice
against an independent WindowFromPoint/GetAncestor/GetWindowThreadProcessId
call at the same screen point (exact HWND/PID match both times). Replaces
the prior locked-desktop caveat, which no longer applies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
… lvt.exe

Manual testing against Notepad (with UI Automation off, using the visual
tree) surfaced "lvt: TAP DLL not found" and a tree showing only bare Win32
bridge/window structure - none of the actual WinUI 3 content inside
Notepad's XAML islands.

Root cause: the LVT_BUILD_VIEWER wiring only copied lvt.exe next to the
viewer's build output; get_tap_directory() resolves TAP DLLs relative to
lvt.exe's own module directory, so that specific copy of lvt.exe could
never find lvt_tap_x64.dll (or lvt_wpf_tap/lvt_winforms_tap, or any
plugin). A partial copy is worse than none: it resolves silently and
just stops enriching anything, indistinguishable from the target not
using that framework at all.

Fix mirrors the release packaging step in .github/workflows/release.yml
and the install() rules already in this file: copy every TAP DLL,
managed .NET assembly, and plugin (including the Avalonia TAP/managed
walker and the Chromium extension/host) alongside the viewer's lvt.exe,
guarded by if(TARGET ...) so it degrades gracefully when a framework is
disabled.

This logic had to move out of the LVT_BUILD_VIEWER block (right after
the `lvt` executable) to after every target it references is actually
defined further down the file - if(TARGET lvt_tap) and friends only see
targets CMake has already processed by that point in a linear read of
the file, so the first attempt silently no-op'd every check and only
ever copied lvt.exe itself, which is exactly what caused the bug above.

Verified with a clean build (build/viewer deleted, full rebuild): every
TAP DLL, managed assembly, and plugin (Avalonia's TAP + tree walker,
Chromium's plugin + native host + extension) now lands correctly
alongside the viewer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
lvt watch re-injects XAML diagnostics and walks the entire visual tree from
scratch on every poll tick (default 500ms), forever. For a rich production
tree (hundreds to thousands of elements, e.g. File Explorer's or Settings'
WinUI3 shell) that walk is dispatched via a blocking SendMessage onto the
target's UI thread. Doing this unbounded, every tick, indefinitely, caused a
real, reproduced hang: opening a second File Explorer window needed that
same UI thread for its own startup handshake and got stuck behind our walk.

Two independent bugs contributed:

1. Unbounded per-tick UI-thread occupancy (lvt_tap.cpp). Added a wall-clock
   time budget (kUiThreadBudgetMs) to CollectBounds/CollectPositionsAndText,
   tuned to 1500ms after measuring real per-node cost (~4ms on a richly
   styled WinUI3 app) — comfortably under the ~5s threshold Windows itself
   uses to mark a window as not responding, while covering far more of a
   real tree than an initial too-tight 200ms budget did (4% -> 32% node
   coverage on a 1104-node test case).

2. Cross-window content contamination (xaml_diag_common.cpp).
   InitializeXamlDiagnosticsEx targets a whole process, not a single HWND.
   When several top-level windows of the same app share one process
   (multiple Notepad or File Explorer windows are common, and were exactly
   how both bugs were found), every window's XAML content comes back in one
   combined stream, and the old bridge-matching heuristic had no rejection
   threshold: it always forced every root onto *some* bridge, including
   bridges belonging to a different window, silently misattaching one
   window's elements (and their Text/bounds) onto a sibling window's tree.
   Now a match is only accepted within a size-relative tolerance of its
   bridge, but only when more than one root is actually competing for
   attention — a single-root window (the overwhelmingly common case) keeps
   the exact legacy behavior, so no regression there.

Verified: 111 unit tests and 74 integration tests (1 pre-existing skip) all
pass, including the WinUI3-stitching suite that exercises this exact
grafting logic. Manually reproduced the original hang against a live File
Explorer instance, confirmed both processes stayed fully responsive after
the fix, and confirmed WinUI3 content that had been dropped by an
intermediate version of this fix (single-root, zero-measured-content case)
is correctly restored.
@asklar
asklar force-pushed the lvt-visual-viewer branch from 45875e4 to 80f45fa Compare August 21, 2026 22:54
asklar and others added 23 commits August 21, 2026 16:02
xaml_should_capture_property previously only captured a hand-picked list of
"text" property names (Text, Content, Header, ...) plus a hand-picked list
of "state" property names (Visibility, IsEnabled, Tag, ...) - every other
property, however ordinary (FontSize, Opacity, a custom DP), was always
rejected regardless of its value. The property panel should show a
control's properties generally, not just the ones lvt happened to
special-case for the earlier XAML/WPF property-interpretation bug fixes.

The former "text property" branch and the desired general behavior turn out
to be identical: accept a confirmed primitive ValueType (String, Boolean,
Int32, Double, Enum, or empty/unconfirmed) unless the value's shape looks
like an opaque XAML handle. So the property-name allowlist for that branch
is gone; every non-state property now goes through the same check. State
properties keep their existing, narrower exemption (bypass the handle
heuristic entirely) since that protects specific known-string properties
like AutomationId from being misjudged when ValueType doesn't come through
confirmed - it is not something safe to extend to arbitrary properties.

Verified via the existing xaml_should_capture_property unit tests (updated
NonTextNonStatePropertiesAreIgnored, now
ArbitraryPropertiesAreCapturedWithRecognizedValueTypes, plus a new test
confirming reference-typed properties like Foreground/Brush are still
excluded) and confirmed live against a running Settings window: property
count captured across the tree increased substantially (734 properties
across 1104 nodes, up from a much smaller curated-only baseline).

Known remaining limit, not addressed here: IVisualTreeService::
GetPropertyValuesChain only reports properties that have some explicit
value source in the chain (local/style/template/animation) - it does not
enumerate every property a control type supports the way walking XAML's
IXamlType metadata would. Truly exhaustive "every property, including ones
nobody ever touched" needs that bigger, separate change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…lter

Adds five requested viewer improvements on top of the existing crosshair
targeting, live tree, and property panel:

1. Highlight selected node in the target app (MainWindow.xaml.cs). Reuses
   the same borderless/click-through/topmost HighlightOverlay the
   crosshair-drag gesture already draws, now also drawn around
   SelectedElement's bounds whenever "Highlight selection" is checked, and
   kept in sync live as a watch tick moves/resizes the selected element.

2. Point-to-select: drag a second crosshair (Interop/ElementPicker.cs) onto
   the target's rendered UI to navigate the tree selection to that element,
   Inspect.exe-style but at element granularity instead of window
   granularity. Hit-testing is done entirely client-side against the
   already-loaded live tree (lvt's bounds are already absolute screen
   pixels), so it needs no round-trip to lvt.exe. Programmatically selecting
   a TreeViewItem requires expanding every ancestor and resolving containers
   level by level (WPF only realizes a container once its parent is actually
   expanded) — ElementNodeViewModel gained a Parent back-reference, kept in
   sync by LiveTree.RebuildHierarchy, to make that walk possible.

3. Editable-properties hint: property editing has always been UIA-mode-only
   (toggle/set-value have no equivalent in visual-tree mode), but the UI
   never said so — a user testing in visual-tree mode would just find
   nothing editable with no explanation. Added an inline hint under the
   Properties header, visible only in visual-tree mode.

4. Richer tree labels (ElementNodeViewModel.DisplayName): structural
   containers (Grid, Border, StackPanel, ...) that never carry visible text
   used to show as a bare, indistinguishable repeated type name. When Text
   is empty, DisplayName now falls back through a priority list of
   identifying properties (AutomationProperties.Name, Name,
   AutomationId, x:Name) before giving up and showing just the type.

5. Framework/content-type filter (FrameworkFilterOption.cs,
   MainViewModel.FrameworkFilters/ApplyFrameworkFilter): a multiselect
   checkbox dropdown, populated lazily as new framework values are
   discovered in the live tree, visible only in visual-tree mode (UIA's
   tree has no equivalent per-node distinction). A node stays visible if
   its own framework passes the filter *or* any descendant's does, so
   excluding one type never hides an unrelated matching descendant deeper
   in the same branch — ElementNodeViewModel gained an IsVisible property
   bound to each TreeViewItem's Visibility for this.

Verified: full CMake build (native + managed + viewer wiring) succeeds
cleanly, all 127 unit tests and the WinUI3-stitching integration suite
still pass, and the freshly built build/viewer/LvtViewer.exe launches and
was handed off for live manual testing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
The previous commit set _selectionHighlight.Owner = this in MainWindow's
constructor, before MainWindow itself had been shown. WPF requires a window
to have already been shown before it can be assigned as another window's
Owner, so this threw System.InvalidOperationException ("Cannot set Owner
property to a Window that has not been shown previously") wrapped in a
XamlParseException, crashing the app on every single launch with no window
ever appearing - this was the bug the user just hit trying to test the
other fixes in this PR.

Moved the assignment into the Loaded handler, alongside where the crosshair
and element pickers are already wired up - by the time Loaded fires,
MainWindow has been shown.

Verified: rebuilt via both `dotnet build` and the CMake `lvt_viewer` target,
launched the resulting build/viewer/LvtViewer.exe directly, and confirmed
the process stays running with its window visible (MainWindowTitle "lvt
Viewer", Responding: True) instead of exiting within seconds as it did
before this fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
If the target process closed or crashed while an element was selected and
highlighted, the highlight overlay (item 1) stayed on screen forever,
pointing at the last-known bounds of a window that no longer existed —
nothing told MainWindow the target was gone.

WatchSession already raises Exited for exactly this (process closed/
crashed), and MainViewModel already handled it by updating StatusText, but
did not clear SelectedElement or reset the live tree, so nothing propagated
to the view. Also fixed a related gap: MainWindow only refreshed the
highlight in reaction to TreeView.SelectedItemChanged (a UI event) or the
HighlightSelected toggle, not to SelectedElement changing programmatically
from the view model — so even after fixing MainViewModel to clear it, the
overlay would not have noticed. Now MainWindow's PropertyChanged handler
also watches for SelectedElement itself.

IsConnected (item 2's element-pick crosshair gate) is reset to false in the
same Exited handler, and back to true in Reconnect(), so the gesture that
needs a live tree to hit-test against is not left enabled with nothing to
pick from either.

Verified via full unit test suite (127 tests, unaffected — this is a
viewer-only change) and by rebuilding via both `dotnet build` and the
CMake `lvt_viewer` target; traced the fix logically end to end (watch exit
-> SelectedElement cleared -> MainWindow's PropertyChanged handler fires ->
UpdateSelectionHighlight sees a null SelectedElement -> overlay hidden).

This is unrelated to the explorer.exe crash reported alongside it: that
crash's faulting module is DUI70.dll, caused by lvt_dui_tap_x64.dll — a
separate, pre-existing plugin in the user's ~/.lvt/plugins directory dated
February 2026, not part of this repository or anything changed this
session.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Investigated a report that watching the Microsoft Store app's visual tree
"doesn't seem to work". It does — it is just very slow: a one-shot dump
took ~8s, and `lvt watch`'s first tick (dump + injection + property/bounds
collection, all before its first line of output) took ~20s, measured
directly against a live Store window. That is long enough with the UI
sitting on a plain "Connecting…"/"Watching…" status the whole time that it
looks stuck or broken rather than just working slowly.

(Root cause of the slowness itself — InitializeXamlDiagnosticsEx replaying
the Store app's large/deep tree synchronously, on top of the property/
bounds walk this session already added a time budget to — is real but not
addressed here: safely reducing it further would need more invasive changes
to the XAML diagnostics pipeline than is worth risking right along the
hang/contamination fixes already made today.)

Added MainViewModel.ArmSlowConnectHint(): a delayed (4s) status update that
only fires if no watch data has arrived yet, explaining that a rich UI tree
can take 15-20+ seconds on first connect. Canceled the moment real data
arrives (OnWatchEvent), so it never overwrites a status that has since
moved on, and also canceled on watch.Exited/Dispose so a stale hint can't
fire after the session has already ended.

Verified: full unit test suite (127 tests) unaffected (this is a
viewer-only status-text change), rebuilt via both `dotnet build` and the
CMake `lvt_viewer` target, and confirmed the rebuilt build/viewer/
LvtViewer.exe launches normally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Investigated a report that watching Microsoft Store's visual tree "cannot
connect within even 20s or longer" despite an earlier fix (a status hint)
suggesting this was just slow. It was not just slow: `lvt watch` was stuck
in a genuine runaway-output loop, verified directly against a live Store
window (250MB+ of stdout within a minute, still growing, before being
stopped). Root-caused to two compounding bugs, one a straight-up
correctness bug that predates this session, the other a regression I
introduced earlier today trying to fix a different problem (the UI-thread
hang from the File Explorer session):

1. (This session's regression, now reverted.) lvt_tap.cpp's
   CollectBounds/CollectPositionsAndText had a wall-clock time budget
   (kUiThreadBudgetMs) added to bound how long a single per-tick property/
   bounds walk could occupy the target's UI thread. Wall-clock cutoffs are
   inherently non-deterministic: measured against Store (~1100 nodes,
   ~4ms/node), ordinary timing jitter meant a *different* subset of nodes
   fit in the budget window each tick, so unchanged elements' bounds/
   properties flip-flopped between "known" and "absent" every tick, purely
   as a budget artifact — and watch's diffing correctly (if unhelpfully)
   reported that as constant "changed" events forever. Reverted entirely:
   the actual protection against pathologically slow collection was already
   one layer up (see xaml_diag_common.cpp: the TAP DLL only connects to
   lvt.exe's pipe *after* this walk finishes, so lvt.exe's own existing
   15-second "TAP DLL did not connect" timeout already bounds it — and,
   unlike a per-node budget, fails the whole tick cleanly with no partial
   data, which run_watch_loop already treats as a normal transient skip).

2. (Pre-existing correctness bug, unrelated to anything changed this
   session.) watch_diff.cpp's serialize_change_event called the same
   element_to_json used for `dump`'s full-tree output, which recursively
   serializes every element's *entire subtree*. For a per-node watch event
   describing one "added"/"removed"/"changed" node, that is enormous,
   redundant waste: the client (see LiveTree.ApplyAdded in the viewer) only
   ever reads an event's own scalar fields — it rebuilds tree structure from
   the flat stream of per-node events and their "path" field, never from an
   individual event's nested children. Serializing full subtrees per event
   meant a tree N nodes deep across D levels produced up to O(N*D) redundant
   data instead of O(N): measured on Store's ~1400-node, ~20-level-deep
   tree, a single node's "added" event line alone was 5.8MB. Added
   element_to_json_flat (no children) for watch events specifically, kept
   the recursive element_to_json for dump's own top-level serialization
   where nesting is correct, and replaced the equivalent deep-copy
   (`event.element = *indexed.element`, which via Element's value-type
   children also copied full subtrees) with element_without_children,
   built field-by-field so children are never touched.

Verified directly against a live, freshly-relaunched Microsoft Store window
(so no stale AppContainer-staged DLL copy could mask the fix): stdout grew
~530KB per 20s and CPU held around 27% average, versus the unfixed
behavior's ~9MB/s sustained runaway growth and near-100%+ (multi-thread)
CPU. Event composition changed from "changed" events dominated by
AutomationId/HelpText/Tag/Visibility repeating 14-16x each with no bounds
in between, to overwhelmingly "bounds" changes repeating only ~4x over the
same window — consistent with Microsoft Store's own actually-animated home
page content (promotional tiles), not tooling instability.

Also verified: full unit test suite (127 tests) and full integration test
suite (74 tests, 1 pre-existing skip) both pass, including the WinUI3-
stitching suite that exercises the property/bounds collection path
directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
1. Fix FindDeepestElementAt's tie-breaking bug (item 2, point-to-select).
   The previous logic let whichever sibling was checked *last* win a tie,
   on the theory that later-enumerated content is topmost/most specific.
   That is wrong when the last sibling is a bare utility window with no
   children of its own: UWP/ApplicationFrameHost windows carry a full-
   bounds "ApplicationFrameInputSinkWindow" HWND as a later sibling of the
   actual XAML-hosting bridge, so every point-to-select pick landed on that
   input sink instead of the real element underneath it, regardless of
   where in the UI you actually pointed.

   Fixed by tracking two tiers instead of one running "best": a match with
   real matching descendants (bestDeep) now always wins over a sibling that
   only matches itself with no deeper content (bestShallow), regardless of
   iteration order. Later-wins still applies as a tiebreaker, but only
   between siblings in the *same* tier.

2. Add a search bar (Find Next): a text box plus button in a new toolbar
   row, matching Text/ClassName/Type/any property name or value, case-
   insensitively. Cycles through matches in tree (depth-first) order on
   repeated presses (Enter in the box also works), wrapping around; a
   changed query resets the cursor to start over from the first match.
   Reuses the same SelectElementInTree path already built for point-to-
   select, so a found match gets the same expand-ancestors-and-select
   treatment (and, via the existing SelectedElement wiring, the same
   highlight-selection overlay).

Verified: full unit test suite (127 tests, unaffected — both changes are
viewer-only) and rebuilt via both `dotnet build` and the CMake `lvt_viewer`
target; confirmed the rebuilt build/viewer/LvtViewer.exe launches normally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…'s UI thread

Reported live: after the viewer attached to an app, that app's own UI
became laggy — moving its window took a while to respond. Root cause: even
after removing the earlier (buggy) per-node time budget, a full
CollectBounds/CollectPositionsAndText pass over a rich tree is several
seconds of work, dispatched as one single blocking SendMessage call from a
worker thread to the target's UI thread. That call does not return until
the entire pass finishes, so the target's UI thread has no opportunity to
service its own pending messages in between — including the modal loop
DefWindowProc runs while the user drags the window (WM_NCLBUTTONDOWN/
SC_MOVE), which needs that exact same thread.

Fixed by dispatching both passes in small chunks (20 nodes per SendMessage
call, with a short Sleep between calls) instead of one unbroken call
covering every node. AdviseThreadProcImpl now flattens m_nodes into a
stable m_orderedHandles vector once per pass (a std::map has no efficient
random-access range) and loops sending one BatchRequest{self, start, count}
per chunk; CollectBounds/CollectPositionsAndText/CollectBoundsOnUIThread/
CollectPositionsOnUIThread all now take a (start, count) range into that
vector instead of iterating m_nodes directly. Every node still gets
collected, in the same order, every tick — only the dispatch is chunked —
so this does not reopen the flapping the earlier time budget caused: no
non-determinism is introduced, just gaps for the UI thread's own message
pump to run between chunks.

Verified: full unit test suite (127 tests) and the WinUI3-stitching
integration suite (28/29, the one skip a pre-existing, unrelated foreground-
window-contention flake) both pass, confirming the chunked collection still
produces correct, complete data end to end.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…shair

1. Find Previous: a second search button (and Shift+Enter in the search
   box) cycling backward through matches instead of only forward. Shares
   Find's match-computation and cursor-wrapping logic with Find Next,
   parameterized by step direction; a fresh search (cursor reset by a
   changed query) now starts at the natural end for the requested
   direction — first match for Next, last for Previous.

2. Hide the highlight overlay when the target window minimizes. A
   minimized target's bounds are meaningless to draw a rectangle around.
   MainViewModel now exposes the connected target's HWND (CurrentHwnd), and
   both ShowHighlightForCurrentNode (item 1's selection highlight) and
   PreviewElementAt (item 2's drag preview) check NativeMethods.IsIconic
   before showing. No separate polling needed: Windows moves a minimized
   window to a fixed off-screen "iconic" position, which is itself a real
   bounds change that already flows through the existing live-tree wiring
   and re-triggers these checks.

3. Investigated a report that "the blue crosshair doesn't work — cursor
   doesn't change". Traced with lvt itself, dogfooded against the running
   viewer's own UI: the element-pick crosshair's IsEnabled correctly
   reflects IsConnected (via lvt dump --pid <viewer>, its Border showed
   "enabled": "false" while nothing was connected) — working as designed,
   but a plain Border has no built-in disabled visual the way a Button
   does, so a legitimately-disabled crosshair looked identical to a broken
   one. Added an Opacity trigger (1.0 enabled, 0.35 disabled) and a tooltip
   that says to connect to a target first.

Verified: full unit test suite (127 tests, unaffected — all three changes
are viewer-only) and rebuilt via both `dotnet build` and the CMake
`lvt_viewer` target; confirmed the rebuilt build/viewer/LvtViewer.exe
launches normally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Reported: the element highlight rectangle appeared nowhere near the actual
selected element (a Microsoft Store NavigationViewItem, bounds reported as
169,695,72x62, highlighted over by the Windows taskbar instead).

Root-caused precisely, not guessed at: lvt.exe is a plain console app with
no DPI-awareness declaration, so Windows silently virtualizes every Win32
coordinate query it makes (GetWindowRect and friends) down to a 96-DPI-
equivalent space. Verified live on this system's 150% scaling: an unaware
GetWindowRect call against the same Store window returned (161,319)-
(702,826), while a properly Per-Monitor-V2-aware call against the identical
HWND returned the true physical rect (242,479)-(1053,1240) - exactly a
1/1.5 scale-down, and 169 (the reported element's left edge) sits outside
the *virtualized* window rect's own space consistently with that, not
outside the *physical* one.

This WPF viewer, on the other hand, is Per-Monitor-V2 DPI aware itself (the
.NET default - no manifest overrides it), so positioning its own HWND via
SetWindowPos needs true physical pixels. An earlier attempt at this fix
assumed lvt's numbers already *were* physical pixels and used SetWindowPos
directly with no conversion (correct only by accident on a single-DPI
system, and still wrong in general) - actually testing that assumption
against a real multi-provider run surfaced it was backwards (a WPF
integration test broke: the injected WPF walker had its own matching
DPI-compensation logic already, tuned to lvt.exe's *unaware* status, that a
more invasive "make lvt.exe DPI aware everywhere" fix would have had to
un-do consistently across every provider - reverted that broader change as
too large a ripple to verify fully right now, in favor of this narrower,
self-contained one).

Fixed HighlightOverlay.MoveTo to scale lvt's rect up by the system DPI
factor (GetDpiForSystem, called from this already-aware process, correctly
returns the true system DPI - 144/96=1.5 here) before calling SetWindowPos.
WPF's own Left/Top/Width/Height need no such conversion: device-independent
units are the same 96-DPI-equivalent space lvt's numbers are already in.

Verified: full unit test suite (127 tests) and full integration test suite
(73 passed, 2 skipped on a known unrelated foreground-window-contention
flake, 0 failed) both pass — confirming this narrower fix has no ripple
effect on any provider, unlike the reverted broader attempt. Also
independently verified the arithmetic: GetDpiForSystem() from a properly
DPI-aware calling context on this system returns 144 (1.5x), and
161 (lvt's reported left edge) * 1.5 = 241.5 ≈ 242, matching the Store
window's true physical left edge exactly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
The previous commit's fix put lvt-specific DPI scaling unconditionally
inside HighlightOverlay.MoveTo. That broke a second, previously-working
caller: reported live immediately after pushing — the crosshair-drag
window-picker preview (CrosshairPicker.UpdateHighlight) started landing
nowhere near the actual window, double-scaled and clearly wrong.

Root cause: HighlightOverlay has two callers whose rects do not start out
in the same coordinate space, and the previous fix conflated them.
CrosshairPicker calls NativeMethods.GetVisibleFrame(hwnd) directly from
this (Per-Monitor-V2 DPI aware) process, so that rect is already true
physical pixels needing no conversion. MainWindow's selection/point-to-
select highlight, on the other hand, builds its rect from
ElementNodeViewModel's Bounds* fields, which came from lvt.exe - a plain
console app with no DPI-awareness declaration, so its coordinates are
virtualized to a 96-DPI-equivalent space by Windows. Scaling everything
unconditionally fixed the second caller and double-scaled the first.

Fixed by moving the DPI conversion out of HighlightOverlay entirely (back
to a simple, unconditional "true physical pixels in, SetWindowPos" — no
caller-awareness needed) and applying it only at the two call sites that
actually need it: MainWindow.ToPhysicalRect (used by both
ShowHighlightForCurrentNode and PreviewElementAt) scales an
ElementNodeViewModel's lvt-sourced bounds up to physical pixels before
constructing the rect passed to MoveTo.

Also fixed a related, previously-unnoticed bug in the same family:
point-to-select's hit-testing (FindDeepestElementAt) compared a screen
point from GetCursorPos (already physical, from this process) directly
against lvt-sourced element bounds (virtualized) — apples to oranges on
any scaled display, which would have made point-to-select miss or
mis-target elements even once the highlight rendering itself was correct.
FindDeepestElementAtPhysicalPoint now converts the incoming physical point
down to lvt's virtualized space first, so both sides of every comparison
this file makes are now in one single, consistent space at each specific
call site — never assumed globally.

NativeMethods.LvtToPhysicalDpiScale centralizes the scale factor
(GetDpiForSystem, called from this DPI-aware process) with one shared
doc comment explaining the whole two-coordinate-space situation, so future
callers have one obvious place to look rather than rediscovering this from
scratch.

Verified: full unit test suite (127 tests, unaffected) and rebuilt via both
`dotnet build` and the CMake full build; confirmed the rebuilt
build/viewer/LvtViewer.exe launches normally. Traced the arithmetic for
both paths by hand: CrosshairPicker's already-physical rect now passes
through MoveTo completely unchanged (matching its pre-regression
behavior); the selection highlight's lvt-sourced rect is scaled by exactly
the same verified factor (1.5x on this system) as the previous fix, which
was independently confirmed correct against a real window's true physical
position.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…ins focus

Reported: when the target window moves, the highlight rectangle only
catches up once the viewer app is refocused.

Root cause: HighlightOverlay.MoveTo positions the overlay by calling
SetWindowPos directly on the raw HWND (needed so the two callers'
different coordinate spaces could be handled correctly at their own call
sites rather than inside this class - see the last two commits). But WPF's
own composition/render pipeline tracks a window's position and size
through its Left/Top/Width/Height dependency properties, entirely
independent of the HWND's actual Win32 position. SetWindowPos moves the
real window fine, but leaves WPF's own understanding of where it is stale,
so WPF keeps rendering the old frame until something else (refocusing,
which forces a full redraw) resyncs it.

Fixed by also updating Left/Top/Width/Height, in logical units via
VisualTreeHelper.GetDpi(this), immediately after the SetWindowPos call —
querying the DPI *after* the move specifically so it reflects whichever
monitor the window is now actually on, rather than reintroducing the
cross-monitor mismatch the physical-pixel-first SetWindowPos design was
built to avoid in the first place.

Verified: full unit test suite (127 tests, unaffected — viewer-only
change) and rebuilt via both `dotnet build` and the CMake `lvt_viewer`
target; confirmed the rebuilt build/viewer/LvtViewer.exe launches normally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Investigated a user report that lvt's initial tree enumeration was much
slower than the UIA tree, with a hypothesis that the "key" field was a
major contributor.

Measured on a real ~1900-element WinUI3 tree (Microsoft Store): the "key"
field alone was 3.98MB of a 9.78MB dump (40.7%), averaging 2372 chars per
element. Root cause: base_identity_key() concatenated framework, type, AND
className into every key segment, but for every provider that sets both
(xaml_diag_common.cpp, wpf_inject.cpp), `type` is computed as the substring
of `className` after its last '.' — so it never carries information
className does not already have. Since a key is the full "/"-joined chain
of every ancestor's segment, that redundant chunk was duplicated once per
descendant, not just once per element.

Fix: base_identity_key() now uses className alone (falling back to type
only when className is empty, e.g. many UIA elements report no ClassName —
see uia_provider.cpp) instead of concatenating both. Uniqueness is
unaffected: assign_child_keys' existing sibling-collision counting already
falls back to an index/hwnd/name discriminator whenever two children share
a base identity, so this can only make that fallback trigger slightly more
often in a narrow edge case, never silently collide.

Measured after the fix on the same Store tree: key bytes dropped from
3.98MB to 3.11MB (-21.7%), total JSON from 9.78MB to 8.77MB (-10.3%).

This changes the key's format from "framework|type|className" to
"framework|className" (still an opaque, self-describing, "/"-joined durable
key — nothing round-trips it by parsing individual pipe fields; verified via
lvt_api.cpp's looks_like_visual_key/ref parsing, which only checks for the
presence of '|', and via unit_tests.cpp's ElementKeys/ElementLookup suite,
none of which assert on the literal key string). Updated the one README
example that showed the old 3-part shape.

Note: this key-bloat fix is a real, measured, but secondary contributor.
The dominant cost on a rich XAML/WinUI3 tree is ~4.5ms/element in
IVisualTreeService::GetPropertyValuesChain (property-chain collection) —
inherent to how XAML diagnostics returns properties, and a separate,
larger investigation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
… log

Used this instrumentation to root-cause a "dump is much slower than the
UIA tree" report: with only a thread-id prefix and no timestamps, the
existing log couldn't show where time within a single AdviseVisualTreeChange
cycle was actually going. Added GetTickCount64() to every log line, plus a
few extra checkpoints (before AdviseVisualTreeChange, after each of the
CollectBounds/CollectPositionsAndText batch-dispatch loops, and around
SerializeAndSend's JSON-build and UTF-8-conversion steps) so a single
capture can be split into per-phase durations after the fact.

This is how the real bottleneck was found and measured: on real Calculator
and Microsoft Store trees, CollectBounds (IVisualTreeService::
GetPropertyValuesChain per element) costs ~4.5ms/element and dominates
total time; JSON building/writing is a few tens of milliseconds even for a
multi-hundred-KB payload. Keeping the timestamps in permanently, since the
existing thread-id-only log had no way to answer "which phase is slow"
without re-instrumenting from scratch each time.

Also caught and corrected a testing-methodology trap worth recording here:
for AppContainer/UWP targets (Microsoft Store, Calculator, ...), this log
does not live under %TEMP% — GetTempPathW() inside the injected,
sandboxed target process resolves to that package's own virtualized temp
directory instead (%LOCALAPPDATA%\Packages\<PackageFamilyName>\AC\Temp\
lvt_tap.log). That file is held open by the long-lived target process for
as long as the TAP DLL stays loaded (it never unloads once injected), so it
accumulates entries across every run against that process, not just the
most recent one — reading it without filtering by these new timestamps
looks exactly like a runaway "hundreds of repeated tree walks per dump"
bug that briefly looked real during this investigation, until isolating
just one run's actual time window showed a single, clean cycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…er tree

Root-caused why lvt's tree enumeration was much slower than the UIA tree on
a rich XAML/WinUI3 app: IVisualTreeService::GetPropertyValuesChain (walking
an element's entire property inheritance chain, potentially 50-150
properties, to read out ActualWidth/ActualHeight) costs ~4.5ms/element,
measured live against Microsoft Store, Calculator, and a WinUI3 sample app.
For a tree of a thousand-plus elements that is several seconds spent on a
COM/cross-thread call almost entirely to answer "how big is this thing" —
this is the dominant cost of a dump/watch tick, well ahead of the JSON
size/key-bloat fix from the previous commit.

The full per-element property set is a real, wanted feature (added
recently to support showing every property a control has, not a curated
subset) — this is not a bug to remove, so default behavior is unchanged.
Added a second, cheaper collection path instead, opted into with --fast:

- lvt_tap.cpp: CollectBounds now skips GetPropertyValuesChain entirely when
  fast mode is on. CollectPositionsAndText — which already reads Text and
  position via direct WinRT property access on an IInspectable it obtains
  anyway (no COM property-chain walk) — is extended to also read
  ActualWidth/ActualHeight (FrameworkElement) the same cheap way when
  bounds are not already known, and to read Content (ContentControl,
  unboxed via IPropertyValue only when it is actually a plain string, not a
  nested element subtree) in both modes — GetPropertyValuesChain's own
  filter treats a reference-typed Content as an opaque handle and drops it
  today, so this is new data either way, not a duplicate.
- Repurposed the existing but entirely dead m_collectProps flag (parsed
  from a pipe-name suffix, never actually read by anything) into a real
  m_fastMode gate, renaming the suffix from the unused "|PROPS" to "|FAST".
- Threaded a new fastProperties parameter through inject_and_collect_xaml_tree
  -> XamlProvider/WinUI3Provider::enrich -> build_tree, all defaulting to
  false so every existing caller is unaffected.
- main.cpp: new --fast CLI flag, applies to both dump and watch (they share
  the same build_root_tree call).

Trade-off, made explicit in the design and in docs: --fast reports bounds,
Text, Content, and the existing curated state properties (IsEnabled,
Visibility, IsChecked, ...) but not arbitrary custom properties outside
that set — full mode remains the default specifically so nothing about
today's behavior changes for an existing caller.

Verified: 127 unit tests and 74 integration tests (2 pre-existing
foreground-contention skips) pass, including a new
WinUI3SampleFixture.FastModeStillFindsNamedControlsAndBounds that dumps a
live WinUI3 app with --fast and checks a named control is still found with
correct identity and that at least one element reports real (non-zero)
bounds — the two things --fast exists to still guarantee. Measured live: a
WinUI3 sample app dump went from 1943ms to 974ms (~2x) with identical
bounds coverage between modes.

Scope: XAML/WinUI3 only (the TAP DLL providers) — ComCtl/WPF/WinForms/
Avalonia/Chromium never had this cost, and WPF's separate injected walker
(WpfTreeWalker.cs) does not use GetPropertyValuesChain's pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
MCP (mcp/src/server.rs, src/lvt_api.cpp): added an optional fast:bool
parameter to get_visual_tree, forwarded to build_tree_for/build_tree.
get_element_properties intentionally does NOT gain this parameter — its
own per-call cost (a full walk just to answer about one already-known
element) is a separate, lower-priority optimization for later, and it
should keep returning everything regardless of how the tree was built.

Viewer: the live tree (LvtViewer's sole data source, always a `lvt watch`
subprocess) now passes --fast, since it only needs bounds/Text/Content/
basic state to browse, search by, and highlight/hit-test elements — not
every custom property of every element, eagerly, on every tick. Since the
live tree therefore no longer carries a selected element's exhaustive
property set, MainViewModel.SelectedElement's setter now fires a one-shot
"lvt query <key>" call (deliberately without --fast, so it always returns
everything) whenever the selection changes, and merges the result into
that node's PropertyRows. "query" without a property name dumps every
property as flat top-level JSON fields (main.cpp's query_element_to_json)
— a different shape from watch's nested {properties: {...}}, so this is
parsed directly rather than reusing the ElementDto watch-event model.
Guards against a stale response landing on a since-changed selection.

Net effect for the viewer: connects roughly as fast as the underlying
--fast tree walk (see the previous commit's measurements) instead of
paying the full property-chain cost for the whole tree up front, and the
property panel still ends up showing every property — just fetched only
for the one element actually being looked at, and only when it is
selected.

Docs: documented --fast in README.md (CLI option table, watch section's
diffing implication), docs/mcp-server.md (get_visual_tree's tool
description and a dedicated explainer paragraph), and both copies of
skills/lvt/SKILL.md (kept in sync; verified identical after editing both).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
The viewer had no logging anywhere — a live-only bug (a tree rebuild that
disrupted navigation, the crosshair picker going unexpectedly disabled) had
no trail to diagnose from after the fact, only "reproduce it live under a
debugger," which does not work for anything timing- or environment-
dependent. This mirrors the TAP DLL's %TEMP%\lvt_tap.log (lvt_tap.cpp's
LogMsg), which is what actually made several bugs in this same codebase
root-causeable earlier this session.

Services/Logger.cs: a minimal, thread-safe, append-only text logger,
written to %TEMP%\lvt_viewer_<pid>.log — pid-suffixed, unlike the TAP DLL's
single shared log, because several viewer instances (or the same instance
across relaunches during development) can be live at once, and interleaving
their output into one file would make any single run's story impossible to
follow. Each line carries an elapsed-ms timestamp and a short category
(watch/tree/viewmodel/app) so a log can be filtered by subsystem.

App.xaml.cs: wired DispatcherUnhandledException, AppDomain.
UnhandledException, and TaskScheduler.UnobservedTaskException to log
through it too, so a crash leaves a record instead of just vanishing —
relevant given this session's earlier "explorer crashed, the highlight
overlay stayed" report, which had nothing to point at what actually
happened.

Instrumented the areas most relevant to the two bugs reported live in this
session:
- WatchSession.cs: process start/pid, each argument list, every exit
  (with exit code), every stdout event (type/key/path), every stderr line,
  and explicit Stop() calls — this is the direct trail for "the crosshair
  sometimes goes disabled for no reason", since ElementPickHandle.IsEnabled
  is bound to IsConnected, which only ever clears when this process exits.
- LiveTree.cs: which key/event set the dirty flag that triggers a full
  RebuildHierarchy pass — the direct trail for "the tree refreshes as I
  navigate, resetting my place".
- MainViewModel.cs: ConnectTo/Reconnect (target hwnd/pid/title), and every
  IsConnected transition tagged explicitly as "crosshair enabled/disabled"
  so grepping the log for that phrase finds every occurrence directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Root-caused "the tree refreshes as I navigate through it, which resets
[expansion state]" by code review (git history shows this binding has been
OneWay since it was first introduced, with no comment explaining why —
an oversight, not a deliberate choice):

MainWindow.xaml bound TreeViewItem.IsExpanded to ElementNodeViewModel.
IsExpanded with Mode=OneWay. That means expanding a node by hand (clicking
its expander arrow) only ever changed WPF's own TreeViewItem state — it
never flowed back into the view model, since OneWay by definition never
writes back. ElementNodeViewModel.IsExpanded stayed false for every node
the user ever manually expanded.

LiveTree.RebuildHierarchy runs on *any* add/remove/reorder anywhere in the
tree, not just near whatever the user is currently looking at, and its
SyncCollection can regenerate a node's TreeViewItem container as part of
resyncing its parent's children. A regenerated container re-applies the
OneWay binding from scratch — reading a view-model value that never
recorded the user's manual expand — so it came back collapsed. On a target
with any frequently-changing content (a clock, a spinner, virtualized list
recycling), this could happen on nearly every tick, which is what looked
like the tree resetting during ordinary navigation.

Fix: Mode=TwoWay, so expanding/collapsing a node writes ElementNodeViewModel.
IsExpanded, and a later container regeneration reads back the same state
the user actually left it in.

Verified: 127 unit tests and 75/76 integration tests (1 pre-existing
foreground-contention skip) pass — this is a WPF-binding-only change with
no C++ surface, so the existing suite is the correctness bar here; the
live navigation behavior itself needs the user's own confirmation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…failure

Root-caused "the element crosshair sometimes gets disabled for no reason":
ElementPickHandle.IsEnabled is bound to MainViewModel.IsConnected, which
only ever clears when the `lvt watch` subprocess exits (WatchSession.
Exited). run_watch_loop's *first* tree build — the one done before entering
the tick loop — had no retry tolerance at all: one failed injection attempt
(this session already measured "InitializeXamlDiagnosticsEx failed" as a
real, transient, occasionally-reproducing condition against XAML/WinUI3
targets, even moments before a retry would have succeeded) made the whole
process return 1 and exit immediately. From the viewer's side, a process
that exits one second after starting is indistinguishable from a
deliberate disconnect, except that it was not one.

This was already an inconsistency: every *later* tick already tolerates
exactly this failure by design (see the existing comment on that code path)
and just skips the tick rather than ending the session — only the initial
connection lacked the same tolerance.

Fix: wrap the initial build_output_tree call in the same kind of bounded
retry (5 attempts, backing off 300ms per attempt) the tick loop already
gets, rather than failing outright on the first attempt.

Verified: 127 unit tests and 75/76 integration tests (1 pre-existing
foreground-contention skip) pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
… per event

Grepped the viewer's new log file (per the previous commit) from a live
session against Microsoft Store and found the actual mechanism directly:
LiveTree.Apply() called RebuildHierarchy() — a full walk of every node in
the tree, resyncing every parent's Children collection — after processing
*every single* watch event, not once per batch/tick. watch emits one JSON
line per *element*, not one per tick, so this was never "one rebuild per
tick": the initial connect alone (the whole tree arriving as "added"
events) did as many full-hierarchy rebuilds as the tree has elements.

Measured directly from the log: 5454 full-hierarchy rebuilds in one ~140s
session against Microsoft Store, still happening at the very end (its
animated home-page content keeps ticking, not just at initial connect).
MainViewModel.ApplyFrameworkFilter() — a second, separate full-tree
IsVisible walk — had the exact same per-event bug alongside it.

This was never a correctness bug (SyncCollection's diffing is fine, and
the previous commit's TreeViewItem.IsExpanded TwoWay fix is still correct
and needed) — it was WPF re-laying-out the entire TreeView many times a
second while the target's own live content kept changing anywhere in the
tree, which reads exactly like the view resetting during ordinary
navigation even though the underlying data model was fine throughout.

Fix: LiveTree.Apply() now only mutates state and marks itself dirty; a new
Flush() actually does the rebuild, and is meant to be called once per
drained batch, not once per event. MainViewModel coalesces both Flush()
and ApplyFrameworkFilter() behind one Dispatcher.BeginInvoke at Background
priority per OnWatchEvent burst — Background priority is what does the
batching: as long as more Normal-priority OnWatchEvent dispatches are
already queued (which is true throughout a burst, whether from the initial
connect or a live tick), this callback keeps getting pushed behind them and
only actually runs once the queue drains.

Verified: 127 unit tests unaffected (this is a viewer-only, watch-consumer
change with no C++ surface). Live behavior needs the user's own
confirmation, but this is root-caused directly from what the log showed
happening, not guessed at.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
The previous fix (batching RebuildHierarchy behind a debounce) treated the
symptom, not the disease. Per explicit direction: an event that only
concerns already-enumerated elements must only ever touch those elements —
add one node, remove one node, update one node's properties — never
discard and recompute the tree's structure, no matter how that recompute
is scheduled or how cheap any individual call to it is measured to be.

Rewrote LiveTree.cs around that principle instead of layering another
batching trick on top of the old design:

- AttachToParent(node) inserts exactly one node into exactly the place its
  own Path says it belongs: Roots if it is the root, or its parent's
  Children (sorted-inserted by child index) if the parent is already known.
  If the parent has not attached yet — `watch`'s later ticks do not
  guarantee parent-before-child arrival order, only the very first burst
  does — the node queues in _pendingChildren keyed by the parent path it is
  waiting for, and resolves (transitively, for however many generations
  were queued) the moment that parent itself attaches.
- DetachFromParent(node) removes exactly one node from wherever it
  currently sits — Roots, its parent's Children, or the pending queue it
  was waiting in if its own parent never attached — and nothing else.
- A "changed" event whose path moved (a reorder/reparent) is exactly
  DetachFromParent + update Path + AttachToParent for that one node.
- Property/bounds/scalar-field changes are unaffected — they already only
  ever touched the one node's own bound properties, never the hierarchy.

MainViewModel.OnWatchEvent now calls LiveTree.Apply synchronously, on every
event, with no deferral at all — there is no "whole tree" cost left to
batch. ApplyFrameworkFilter (a separate, still-genuinely-O(tree-size)
IsVisible walk that does not touch the hierarchy) keeps its own debounce
timer, renamed to reflect that it is the only thing left needing one.

Verified: 127 unit tests pass (viewer-only change, no C++ surface); built
clean via both `dotnet build` and the CMake `lvt_viewer` target. Live
navigation behavior needs the user's own confirmation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
… cancel

Reported: the window crosshair could select a minimized window, could
select a window that was actually occluded by another window on top of it
at that point, and the highlight rect it drew was sometimes wildly wrong
(a screenshot showed it spanning from partway up the desktop down through
an unrelated Store window, matching neither).

Root cause: ResolveWindowUnderCursor used WindowFromPoint + GetAncestor
(GA_ROOT) — Inspect.exe's classic approach, but incomplete here. It has two
real problems on modern Windows:
  - It does not filter by IsIconic/IsWindowVisible at all, so a minimized
    window (parked at Windows' fixed off-screen "iconic" position, which is
    itself not where the window was ever visibly at) or an invisible one
    was still a legal answer.
  - It has no notion of DWM cloaking (DWMWA_CLOAKED) — a UWP app on another
    virtual desktop, or one DWM is mid-transition on, is "cloaked" but
    still a perfectly real HWND that WindowFromPoint can return, and its
    rect can be stale garbage from whenever it last actually rendered. This
    is almost certainly the direct cause of the wildly-wrong highlight rect
    in the screenshot — a cloaked window's rect has no relationship to
    anything currently on screen.

Fixed by replacing the WindowFromPoint-based resolution with an explicit
EnumWindows walk (top-level windows in true top-to-bottom Z-order) that
skips our own toolbar/overlay windows, anything minimized, invisible, or
DWM-cloaked, and hit-tests each remaining candidate's DWM extended-frame-
bounds rect against the cursor point in that Z-order — the first match is
therefore exactly the topmost *actually visible* window at that point.
A window fully occluded there is correctly never reached, no matter how
large its own rect is; a minimized window is never a candidate at all
rather than incidentally excluded by geometry.

Also added Escape-to-cancel to both CrosshairPicker (window picker) and
ElementPicker (item 2's point-to-select): mouse capture does not affect
keyboard focus, so both now hook PreviewKeyDown on the owning Window
(rather than the drag handle itself) and release capture on Escape, which
routes through the existing OnLostCapture cleanup (clears the dragging
flag, hides any preview highlight, restores the cursor) with no separate
cancel path to keep in sync. Status-bar hints during a drag now say
"(Esc to cancel)" so this is discoverable.

Verified: 127 unit tests pass (this is a Win32-interop/WPF-only change with
no C++ surface). Live picking behavior needs the user's own confirmation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…t key algorithm

Root-caused directly from the viewer's log (grepped for the exact bounds/
timing of a reported "reboot" while navigating to a ContentPresenter node)
combined with reading watch_diff.cpp and element_key.cpp side by side.

There were two completely separate durable-key algorithms in the codebase:

  - assign_element_keys (element_key.cpp), used by dump/query/UIA output.
    Disambiguates a child among just its own siblings — by native handle
    first, then a stable name property, then a *local* sibling index only
    as a last resort. A change anywhere outside a node's own immediate
    parent can never affect that node's key.

  - collect_index/assign_keys/index_tree/index_tree_pair (also in
    element_key.cpp, but only ever called from watch_diff.cpp), used
    exclusively by `watch`'s diffing. Disambiguated using a *global*,
    whole-tree count of each identity (framework/className) plus the full
    root-to-node path as the fallback discriminator when a collision
    existed *anywhere* in the tree.

By the time watch_diff.cpp ran, every element already had a correct,
locally-stable `.key` from assign_element_keys — build_tree/build_uia_tree
both already call it. watch's own index_tree/index_tree_pair then silently
overwrote that with the inferior, globally-scoped one, discarding the good
key entirely. A real XAML tree is thick with duplicate-identity elements
(Grid, Border, TextBlock, ContentPresenter, Rectangle, ...), so almost any
live change anywhere in a large tree (an animation, a spinner, list
virtualization recycling an item several branches away) would flip the
"is there a collision anywhere in the whole tree" answer for one of these
identities, changing the full-path-derived key of a large, unrelated
portion of the tree on that tick. `diff_trees` matches purely by key, so
that showed up as those elements being reported as removed and different
ones added in their place — reproduced live as "the tree rebuilds while
navigating" against Microsoft Store's tree, and directly explaining why it
specifically happened once navigation reached a ContentPresenter (one of
the most common duplicate-identity element types in a WinUI3 tree).

Fix: deleted the divergent algorithm entirely (collect_index/assign_keys/
index_tree/index_tree_pair are gone from element_key.h/.cpp) and gave
watch_diff.cpp its own small, private indexing helper that calls
assign_element_keys — the one, single, already-correct algorithm — and
only computes structural "path" itself (which assign_element_keys was
never responsible for). diff_trees/snapshot_added_events now take a
mutable Element&, since assigning keys is a mutation; both call sites
(main.cpp's run_watch_loop, and the unit tests) already owned non-const
locals, so this needed no other changes.

Trade-off, made deliberately and documented in both code and tests:
assign_element_keys always threads an element's full parent-key chain
into its own key — that locality is exactly what stops an unrelated change
elsewhere in the tree from ever touching a node's key, but it also means
an actual reparent now changes the key regardless of whether the element
has a stable name/hwnd, so a genuine move is now reported as Removed+Added
rather than a single Changed/path event. Updated WatchDiff.MovedElement to
assert the new (still fully accurate, just less elegant) two-event shape,
and added WatchDiff.DuplicateIdentityElsewhereDoesNotDestabilizeUnrelatedSubtree
as a direct regression test for the actual reported bug: a same-identity
sibling inserted under one Pane must never touch an unrelated Pane's own
same-identity child.

Verified: 128 unit tests pass (127 existing + the 1 new regression test),
75/76 integration tests pass (1 pre-existing foreground-contention skip).
Also ran `lvt watch` live for ~30s against a real, animated Microsoft Store
window: 2066 elements arrived once as "added" (the initial snapshot), and
over the following ticks only 17 one-time "removed" events (each a
distinct key, no repeats — consistent with a few carousel items
legitimately scrolling out of a virtualized list) and 191 legitimate
"changed" events, with zero cascading remove/re-add churn.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
asklar and others added 23 commits August 25, 2026 14:03
Extends the same "connect once, reuse many times" mechanism from the
XAML/WinUI3 redesign to the runtime-loaded plugin surface, per plan: any
plugin can now optionally implement lvt_connection_open/get_tree/
poll_events/close (plugin.h) to let watch/MCP sessions reuse a connection
across many refreshes instead of calling the one-shot lvt_enrich_tree fresh
every time.

- plugin.h: bump LVT_PLUGIN_API_VERSION to 2, add the new optional
  functions/struct. Backward compatible: every v2 function is probed
  independently via GetProcAddress, and a plugin's reported api_version now
  only needs to fall within [1, LVT_PLUGIN_API_VERSION] rather than match
  exactly, so an existing v1-only plugin binary keeps loading and working
  unchanged.
- plugin_loader.cpp/h: relaxed the version check accordingly, probe the new
  optional exports, and add PluginConnection (an IFrameworkConnection
  adapter over the C ABI) plus open_plugin_connection() so the registry can
  treat a plugin connection identically to XamlDiagConnection. Extracted
  the JSON->Element grafting logic shared by the one-shot and connection
  paths into graft_plugin_tree_json (mirrors xaml_diag_common.cpp's
  graft_xaml_tree_json split, for the same reason: one implementation, not
  two that could drift).
- tree_builder.cpp: the Plugin framework case now checks the
  ConnectionLookup (keyed by the plugin's own detected framework name)
  before falling back to the existing one-shot enrich_with_plugin.
- docs: avalonia-plugin.md and chromium-plugin.md note the new optional
  capability; neither plugin implements it yet, so both continue to work
  exactly as before via the one-shot path.

129 unit + 76 integration tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Completes the last core-scope phase from the persistent-connection plan.
The TAP DLL's OnVisualTreeChange already tracks every Add/Remove for a
connection's lifetime (needed for correctness once the tree persists across
many refreshes - see the earlier commit); this makes it also push each
mutation to lvt.exe proactively, over the same pipe, instead of only ever
being visible via a GET_TREE poll.

- lvt_tap.cpp: OnVisualTreeChange now releases m_nodesMutex before calling
  the new PushChangeEvent (writes one {"type":"CHANGE",...} line via the
  existing WriteLine/m_pipeWriteMutex), keeping lock ordering simple and
  avoiding any risk of a lock-order inversion. Pushing is best-effort:
  WriteLine fails quietly (and is expected to) during the initial
  synchronous tree replay, before the pipe is even connected.
- xaml_diag_common.cpp: get_tree() now loops over response lines, since a
  pushed CHANGE event can legitimately arrive interleaved with the response
  to a specific GET_TREE request - lines starting with '{' are parsed and
  queued (queue_change_event), the tree response itself is the first line
  starting with '['. poll_events() now returns the queued events instead of
  always being empty. The queue is capped (drops oldest) so a caller that
  never calls poll_events() at all cannot turn this into an unbounded leak.
- main.cpp/lvt_api.cpp: watch's tick loop and MCP's per-session build now
  drain each connection's queue every refresh (logged under --debug for
  watch), which is what actually bounds the queue in the common case - the
  cap in xaml_diag_common.cpp is the defensive fallback for a caller that
  doesn't.

Deliberately conservative in scope, per the plan: this lands the wire
mechanism and makes poll_events() real, but does not rearchitect watch's
tick loop to consume these events instead of polling GET_TREE - that is a
larger, separable data-flow change flagged as a further, optional
optimization, not required now that the core leak/reinjection fix is
already proven live.

Verified: PushChangeEvent's pre-connect skip path exercises correctly and
harmlessly (confirmed live via the TAP log - hundreds of legitimate
"sent=0" attempts during the initial tree replay, before the pipe
connects, exactly as designed). 129 unit + 76 integration tests pass (one
transient foreground-contention failure on a full-suite run reproduced
clean in isolation and on a subsequent full-suite rerun, consistent with
this suite's already-documented flakiness class, not a regression).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Root-causes and fixes the "WinUI3 bounds/text/property capture bug": every
WinUI3 element beneath a DesktopWindowXamlSource root (File Explorer's
modern address bar/breadcrumb/tab UI, confirmed live) reported zero bounds
and empty text, while system-XAML apps (Calculator) were unaffected.

The actual cause was environmental, not a code defect: LVT_WASDK_WINMD_DIR
could only ever be found for the older, unified "Microsoft.WindowsAppSDK"
NuGet package layout (winmds under lib/uap10.0). This machine only had the
newer split packaging cached (Microsoft.WindowsAppSDK.WinUI, .Foundation,
.Base, ... - each capability its own package, winmds under metadata/), so
detection silently found nothing, cppwinrt was never run, and
LVT_HAS_WINUI3_PROJECTION compiled to 0 - meaning CollectPositionsAndText's
WinUI3-specific (Microsoft.UI.Xaml) casts were compiled out entirely, and
every genuine WinUI3 element's TransformToVisual/Text/Content extraction
could only ever fail against the System XAML (Windows.UI.Xaml) fallback
that remained. GetPropertyValuesChain-based bounds looked like they
"worked" (ActualWidth/ActualHeight were found), but their values were "0" -
elements never measured via the code path that would have populated them
correctly.

- CMakeLists.txt: detect both NuGet layouts when searching for
  LVT_WASDK_WINMD_DIR. For the split layout, also pull in the latest STABLE
  version of every sibling Microsoft.WindowsAppSDK.* package (Xaml.winmd
  references types they define) and of Microsoft.Web.WebView2 (a separate
  package entirely, needed for WinUI3's WebView2 control type) - both
  needed for cppwinrt to fully resolve the projection. Deliberately skips
  experimental/preview/prerelease versions and never mixes two versions of
  the same package: an earlier iteration of this fix picked a cached
  2.0-experimental .winui package and fed cppwinrt multiple conflicting
  versions of Microsoft.UI.Xaml.winmd simultaneously, which failed with
  "Type ... could not be found" for a WebView2 type the experimental
  version's Xaml.winmd referenced but the mismatched sibling set didn't
  define.

Verified live: File Explorer's WinUI3 UI (address bar, tab strip, command
bar) now reports real bounds and text (e.g. a real button's icon glyph)
where every single node previously reported bounds={0,0,0,0} and empty
text. 129 unit + 76 integration tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Brings UIA onto the same persistent-connection architecture as XAML/WinUI3.
Before this, every --uia watch tick and every MCP UIA tree read did a fresh
CoCreateInstance(CUIAutomation[8]) + timeout setup + cache request + full
walk. That was never the same severity as the old XAML reinjection leak
(UIA injects nothing into the target), but it was still unnecessary per-call
churn and it left UIA as the last major tree mode not participating in the
ConnectionRegistry design this branch just introduced.

- src/providers/uia_provider.h/.cpp: add UiaConnection as an
  IFrameworkConnection implementation for UIA; split the walk into
  create_automation + build_tree_with_automation so both the old one-shot
  UiaProvider path and the new persistent path share one tree-building
  implementation; re-apply per-call UIA timeouts on the reused automation
  object; and keep the process-wide MTA alive for the connection lifetime via
  CoIncrementMTAUsage/CoDecrementMTAUsage so a client created on one short-
  lived MTA worker stays valid for later short-lived MTA workers.
- src/main.cpp: watch now acquires a persistent "uia" connection, and its
  --uia build path downcasts the generic IFrameworkConnection back to
  UiaConnection when it needs per-call UIA view/property/timeout options,
  falling back to the exact old one-shot path if no live connection is
  available.
- src/lvt_api.cpp: MCP sessions now lazily acquire and hold a persistent UIA
  connection alongside the existing xaml/winui3 ones, use it for get_uia_tree
  / find_elements reads, and also retry missing visual-tree framework
  acquisitions even when the session already holds some other label (e.g.
  "uia") so mixed-mode sessions do not accidentally suppress later xaml/winui3
  connects.
- src/providers/uia_actions.h/.cpp: MCP action/wait paths can reuse the
  session's persistent UIA connection for the tree walks they already perform
  to resolve a reference, poll a wait, and refresh the post-action element,
  while leaving the live RuntimeId re-find / pattern invocation path itself as
  the existing one-shot automation object.

Verified: `cmake --build build --target lvt lvt_core lvt_unit_tests
lvt_integration_tests --config Release`; `build\lvt_unit_tests.exe` (129
passing); `build\lvt_integration_tests.exe` (76 passing, 1 expected skip:
NotepadFixture.XamlBoundsIfDetected); the targeted
WinUI3SampleFixture.UiaWatchEmitsAddedEvents test passed on a 3x repeat; and a
live `lvt.exe watch --uia --pid <WinUI3Sample>` run stayed alive and emitted
UIA added events continuously for 12 seconds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
…ck permanently

Fix: once a persistent XAML/WinUI3 connection died (e.g. a timeout on an
unusually large/slow tree), build_tree's ConnectionLookup correctly fell
back to one-shot re-injection for that single tick, but nothing ever
re-acquired a fresh persistent connection afterward. This permanently
regressed watch/MCP sessions back to the old per-tick reinjection bug
(repeated InitializeXamlDiagnosticsEx calls) for the remainder of the
session, as soon as one connection death occurred.

- main.cpp: add refresh_dead_watch_connections(), called every watch tick
  before build_output_tree. Detects dead connections via is_alive(),
  resets the dead handle *before* reacquiring (ConnectionRegistry::release
  is key-based only, so releasing after reacquiring would decrement the
  new entry's refcount instead of the dead one's), then re-derives fresh
  replacements via acquire_watch_connections.
- lvt_api.cpp: apply the same dead-entry pruning to
  connection_lookup_for_session (XAML/WinUI3 MCP path), which only checked
  label presence, not liveness. This mirrors the pattern already correctly
  implemented in uia_connection_for_session.

Verified live against Microsoft Store (the app that originally reproduced
the bug): a multi-minute watch session under heavy load produced steady
incremental added/changed/removed events with no full-tree-reset pattern,
and a killed process produced clean teardown (message window destroyed,
no leak). 129 unit + 76 integration tests pass.
Topmost placed the highlight rectangle in an independent always-on-top
band, disconnected from the target window's actual z-order. This made
the highlight remain visible even when the target was minimized or
covered by an unrelated window on top of it.

Fix: make HighlightOverlay a native Win32 owned window of the target
(SetWindowLongPtr(GWLP_HWNDPARENT, ...)). Windows enforces that an owned
window always stays directly above its owner in z-order, so when the
owner is covered/minimized, the overlay is automatically covered/hidden
right along with it - no separate occlusion-polling logic needed.

- HighlightOverlay.xaml.cs: add SetWindowLongPtr P/Invoke (pointer-width
  correct, unlike the existing 32-bit SetWindowLong used for GWL_EXSTYLE)
  and SetOwner(IntPtr), which sets the native owner and immediately calls
  SetWindowPos to force the z-order change to take effect right away
  (changing GWLP_HWNDPARENT alone doesn't trigger an immediate reorder).
- HighlightOverlay.xaml: remove Topmost="True".
- CrosshairPicker.cs: call SetOwner(hwnd) before showing the drag-preview
  highlight, retargeting it to whatever window is currently under the
  cursor.
- MainWindow.xaml.cs: replace the WPF-level Owner assignment with
  SetOwner(CurrentHwnd) in both the persistent selection highlight and
  the element-picker preview path; clarify in the doc comment that this
  is a separate HighlightOverlay instance from CrosshairPicker's, not the
  same one.
build_tree's XAML/WinUI3 branches shared a single else-branch between two
different callers: one-shot CLI commands (dump/query/screenshot), which
call build_tree with no ConnectionLookup at all and correctly want a single
inject-collect-disconnect since they never hold a persistent connection by
design, and watch/MCP, which always pass a real ConnectionLookup but could
still land in that same branch whenever it returned null (no entry yet) or
a dead connection.

Both cases looked identical to the branch (`connection == nullptr`), so
watch/MCP could silently do a full one-shot reinject - the exact per-tick
cost this whole persistent-connection mechanism exists to eliminate -
whenever their held connection died, without any way to distinguish that
from the intentional one-shot CLI behavior. Combined with the previous gap
(nothing ever refreshed a dead connection, fixed in 578748e), this is what
let a single connection death permanently regress a watch session back to
per-tick reinjection.

Now the branch explicitly checks `!connectionLookup` (no lookup at all)
before taking the one-shot path. When a lookup was supplied but has no
alive connection right now, enrichment for that framework is skipped for
this call instead - the existing refresh_dead_watch_connections (watch) /
session reconnect logic (MCP) is responsible for re-acquiring a fresh
persistent connection before the next tick/call, and does so on the very
next tick rather than falling back to reinjection in the meantime.

The Plugin branch is deliberately left unchanged: neither watch's
acquire_watch_connections nor MCP's connection_lookup_for_session actually
acquire a persistent connection for plugin frameworks yet (the plugin ABI
v2 PluginConnection machinery exists in plugin_loader.cpp but isn't wired
into either acquisition path), so a null lookup result there always means
"not wired up," not "died" - applying the same change would silently break
plugin enrichment in watch/MCP entirely rather than fix a bug. Tracked as a
separate follow-up.

129 unit + 76 integration tests pass (1 known environmental skip,
unrelated).
The viewer kept losing its tree/selection mid-session even after the
lvt.exe core-side connection-death fixes (578748e, a2063a6) were verified
solid — because this was never a core bug at all. WatchSession.Start()
calls Stop() (kills the previous watch process) before starting a new one;
.NET's Process.Exited event fires asynchronously on a threadpool thread,
and can arrive *after* Start() has already assigned _process to the new,
healthy process. The handler fired Exited unconditionally, so this stale
notification for the just-replaced old process still reached
MainViewModel, which wipes IsConnected and the entire tree in response —
even though the new watch session was alive and streaming events at that
exact moment.

Confirmed live via the viewer's log: every occurrence logged
"ExitCode=-1" immediately after a fresh ConnectTo, which is simply the
ordinary exit code from our own Process.Kill(entireProcessTree: true) in
Stop() - not a crash, and not related to the target app closing at all.

Fix: the Exited handler now checks whether the process it fired for is
still the one WatchSession is currently tracking (ReferenceEquals against
_process) before forwarding the notification, discarding it otherwise.
Stop() already nulls _process before killing the old process, and Start()
assigns the new one immediately after, so this correctly distinguishes "a
stale notification for an already-replaced session" from "the currently
tracked session genuinely died".
The first attempt at fixing highlight occlusion (39caa4c: owned window +
one-time SetWindowPos instead of Topmost) was insufficient - confirmed
live, the highlight still showed through a covering app. Win32's
owned-window z-order rule only guarantees "stays above its owner"; it
does not guarantee "stays below whatever unrelated window already
happens to be above the owner". Any subsequent z-order recalculation
re-snaps an owned window directly above its owner regardless of what was
on top a moment before, so ownership + a single SetWindowPos call cannot
reliably keep the overlay hidden behind a covering app.

Separately, the SetWindowPos call in SetOwner() was missing
SWP_NOOWNERZORDER: repositioning an *owned* window is otherwise free to
also reposition its *owner* in the z-order as a side effect (documented
Win32 behavior) - this is what caused other apps' windows to visibly
shuffle z-order every time the crosshair drag moved over a new candidate
window, since each move called SetOwner + SetWindowPos again.

Fixes:
- HighlightOverlay.xaml.cs: add SWP_NOOWNERZORDER. Add Track(hwnd, rect),
  the new single entry point both callers use instead of calling
  SetOwner/MoveTo/Show individually; it owns + positions the window and
  starts a 200ms poll timer that re-checks (via Reevaluate) whether the
  target is still actually visible there - not minimized
  (NativeMethods.IsIconic) and not covered by some other real window
  (NativeMethods.IsOccludedAt) - hiding the overlay otherwise. Hide() is
  shadowed to also stop the poll timer so a deliberate hide does not get
  silently reversed by the next tick.
- NativeMethods.cs: add IsOccludedAt(targetHwnd, point), an EnumWindows-
  based top-to-bottom occlusion check generalizing
  CrosshairPicker.ResolveWindowUnderCursor's existing technique - true if
  some other, real, visible top-level window not belonging to this
  process comes before targetHwnd in Z-order at that point.
- CrosshairPicker.cs / MainWindow.xaml.cs: replace the separate
  SetOwner+MoveTo+Show call sequences with a single Track() call.

Verified: 129 unit tests unaffected (viewer-only change); manual live
retest pending against a real covering-window scenario.
refresh_dead_watch_connections (578748e) fixed a connection that died mid-
session, but missed a related case: acquire_watch_connections previously
only added a (label, handle) entry to `connections` when its acquire()
call actually succeeded - so if the *very first* attempt to establish a
"xaml"/"winui3" connection at watch startup failed (InitializeXamlDiagnosticsEx
is known to fail transiently on a first try against a slow/busy target -
observed live and repeatedly against Microsoft Store), no entry for that
label ever existed at all.

refresh_dead_watch_connections can only repair an existing (label, handle)
pair that has gone dead; it had nothing to notice or retry for a label
that was simply never added. Combined with tree_builder.cpp no longer
silently falling back to one-shot reinjection when a supplied
ConnectionLookup returns nothing (a2063a6), this meant a target whose
first xaml/winui3 acquisition attempt failed would permanently show only
bare Win32 windows with no framework enrichment for the rest of the watch
session - confirmed live: Microsoft Store's tree showing three unlabeled
"Window" nodes with empty class/text/bounds, "watching the visual tree
live" but never recovering.

Fixes:
- acquire_watch_connections: always record a (label, handle) entry for
  each detected xaml/winui3 framework, even when this particular acquire()
  attempt failed (handle then empty/falsy) - the ordinary emplace_back is
  no longer conditional on success.
- refresh_dead_watch_connections: treat a missing/empty handle exactly
  like a dead one (`!handle || !handle->is_alive()`) so it gets retried on
  the next tick, not just an entry that existed and later died.

lvt_api.cpp's MCP path already re-evaluates from scratch on every call
(has_label checks a truthy handle, and needXaml/needWinUI3 are
recomputed every time), so it does not share this gap and needed no
change.

129 unit + 76 integration tests pass (1 known environmental skip
unrelated to this change: NotepadFixture.XamlBoundsIfDetected; a second
skip seen once during a full run, WinUI3SampleFixture.ActionResultReports
HowItWasPerformed, was confirmed environmental via isolated rerun -
foreground contention from a concurrently running viewer instance, self-
detected and gracefully skipped by the test itself).
ResolveWindowUnderCursor's Z-order walk unconditionally skipped past the
viewer's own windows (ownHwnd/overlayHwnd) without checking whether they
actually occupy the cursor's current point, then kept searching further
down the Z-order for the next window whose rect happened to also contain
that same screen point. Since the cursor is naturally still over the
viewer's own crosshair handle right at drag-start, this let the search
continue past our own (topmost, actually-visible-there) window and "find"
some entirely unrelated, far lower window instead - one that is not
actually visible at that point at all, being covered by our own window,
but whose rect (e.g. a maximized app spanning nearly the whole screen)
merely happens to also span those same coordinates.

Observed live: dragging the crosshair from directly over the viewer's own
button picked a large, unrelated, actually-hidden-behind-the-viewer window
(Teams) instead of correctly finding nothing (or whatever genuinely was
topmost) at that point - matching the user's own diagnosis ("preselected
... because it is large").

Fix: when the walk reaches one of our own windows, check whether it
actually contains the cursor point. If so, stop the search there (nothing
beneath is actually visible/selectable at this point); only continue
looking further down the Z-order if our own window does not occupy this
exact point.
The previous own-window occlusion fix treated the highlight overlay like the viewer window. Because the overlay covers the cursor once shown, every subsequent move and mouse-up resolved no target, making the rectangle flash and preventing selection. Always skip overlayHwnd while retaining the viewer-window occlusion check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Replace the verbose delayed still-connecting sentence with an explicit IsConnecting state and a compact indeterminate progress bar. Keep it active until the first tree event, restore the normal watching status then, and log measured process-start-to-first-event timing for diagnosing slow targets. Also wire the standard Ctrl+F Find command to focus and select the Find box from anywhere in the viewer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Bind F3 to Find Next and Shift+F3 to Find Previous, and expose the shortcuts in the button tooltips.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Build the CoreWindow connection probe from the Win32 skeleton only instead of running a complete one-shot framework enrichment immediately before opening the persistent connection. In fast mode, skip the no-op CollectBounds dispatch loop entirely. Tighten the persistent-watch integration test to require exactly one injection and exercise --fast.

On Microsoft Store, the redundant probe measured about 25 seconds and the no-op fast bounds dispatch about 1.9 seconds. After removal, manual connection dropped to about five seconds and first tree output measured about three seconds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Preserve IXamlDiagnostics InstanceHandle on grafted XAML/WinUI3 elements and use xaml:0x... / winui3:0x... as their globally compact keys, retaining structural paths as fallback for providers without native identity. Store key payload drops from 4,210,498 to 37,757 characters (average 2,018 to 18 per node), and total JSON drops from 9.91 MB to 5.74 MB. Keys remain stable across independent dumps and resolve in a later query process.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Explicitly set StandardOutputEncoding and StandardErrorEncoding to UTF-8 for both the long-running watch process and one-shot CLI calls. Native lvt output already contains the correct Unicode private-use glyph; mojibake was introduced only by the viewer's implicit process-stream decoder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Use the new multi-frame ICO for the executable resource, main window, and taskbar representation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Clear per-request properties and geometry before collecting a persistent tree snapshot. This prevents Text/Content entries from accumulating on every watch tick and forces fast mode to refresh ActualWidth/ActualHeight after resizes. Upsert directly-read Text/Content values within each snapshot instead of producing duplicate JSON properties.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Private-use characters depend on app-specific fonts that may not exist in the viewer. Render them deterministically as U+XXXX in element labels instead of displaying a missing-glyph rectangle, while leaving printable Unicode unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Install the public framework_connection header under include/lvt/providers so tree_builder.h works for package consumers. Classify compact xaml:0x... and winui3:0x... durable keys as visual references so UIA MCP sessions reject them with the correct mode guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Distinguish WinUI 3 from WinUI 2 by requiring Microsoft.Internal.FrameworkUdk.dll instead of treating Microsoft.UI.Xaml.dll alone as WinUI 3. Add Windows.UI.Composition.DesktopWindowContentBridge hosting to XamlProvider and graft system DesktopWindowXamlSource roots beneath it. Scan up to 10,000 monotonically allocated diagnostics connection identifiers, matching UWPSpy behavior for long-lived processes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

There are correctness issues in the new persistent-connection plumbing (teardown/concurrency and plugin option handling) that can cause unsafe behavior or silent behavior changes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Lite
Findings: 1 High severity · 2 Medium severity

New issues introduced by this change (3)
Severity Finding
High severity src/​lvt_api.cpp — method_disconnect erases g_sessionConnections (and can drop the per-target lock entry) without…
Medium severity src/​tree_builder.cpp — When a plugin has a persistent connection (IFrameworkConnection) the code calls…
Medium severity src/​plugin_loader.cpp — open_plugin_connection can create a persistent connection even if the plugin doesn't export…
What changed in this PR

This PR adds a new lvt Viewer WPF desktop GUI (under src/viewer/LvtViewer/) that drives lvt.exe as a subprocess (primarily via lvt watch) to provide a Live Visual Tree / Inspect.exe-style interactive front-end, while also extending lvt_core to support more robust/efficient live updates (persistent framework connections, improved durable keys, and a --fast mode) needed to make long-running watch/viewer scenarios performant and stable.

Changes:

  • Add the WPF Viewer app (UI, interop crosshair pickers/overlays, view models, and subprocess-driven services).
  • Introduce persistent per-framework connections (IFrameworkConnection + ConnectionRegistry) and wire them into watch and MCP visual-tree reads.
  • Update durable key logic (compact XAML/WinUI3 instance-handle keys), expand XAML property capture rules, add --fast plumbing, and add/extend unit + integration tests + docs.
File Description
tests/​unit_tests.cpp Adds regression/unit tests for compact keys, diff semantics, reconciliation, and XAML property capture.
tests/​integration_tests.cpp Adds integration coverage for --fast and persistent connection reuse across watch ticks.
src/​watch_diff.h Updates diff APIs to take mutable trees (internal key assignment).
src/​viewer/​README.md Documents viewer architecture, watch-based data source decision, and build/run steps.
src/​viewer/​LvtViewer/​ViewModels/​RelayCommand.cs Adds minimal ICommand helper for MVVM commands.
src/​viewer/​LvtViewer/​ViewModels/​PropertyRowViewModel.cs Models property rows and editability classification (toggle/value).
src/​viewer/​LvtViewer/​ViewModels/​ObservableObject.cs Adds minimal INotifyPropertyChanged base for view models.
src/​viewer/​LvtViewer/​ViewModels/​FrameworkFilterOption.cs Adds framework filter option model for visual-tree filtering UI.
src/​viewer/​LvtViewer/​ViewModels/​ElementNodeViewModel.cs Adds long-lived element node VM with live updates + property rows + display formatting.
src/​viewer/​LvtViewer/​Services/​WatchSession.cs Implements long-running lvt watch subprocess session + stdout/stderr pumps.
src/​viewer/​LvtViewer/​Services/​LvtLocator.cs Implements lvt.exe discovery logic (env/sibling/build/userprofile/PATH).
src/​viewer/​LvtViewer/​Services/​LvtCli.cs Implements one-shot lvt.exe verb invocation for edits (toggle/set-value).
src/​viewer/​LvtViewer/​Services/​Logger.cs Adds viewer diagnostic log to %TEMP% for post-mortem debugging.
src/​viewer/​LvtViewer/​Services/​LiveTree.cs Maintains incremental in-memory tree keyed by durable key, applying watch events.
src/​viewer/​LvtViewer/​Services/​JsonDefaults.cs Centralizes System.Text.Json defaults for parsing lvt JSON.
src/​viewer/​LvtViewer/​Models/​WatchEventDto.cs Defines DTOs for watch event stream and field changes.
src/​viewer/​LvtViewer/​Models/​ElementDto.cs Defines DTOs matching lvt JSON dump/watch element payloads.
src/​viewer/​LvtViewer/​MainWindow.xaml.cs Wires pickers, TreeView selection, and highlight overlay behavior.
src/​viewer/​LvtViewer/​MainWindow.xaml Defines the viewer UI (toolbar, tree, property panel, search, filter popup).
src/​viewer/​LvtViewer/​LvtViewer.csproj Adds WPF project targeting net10.0-windows with icon resource.
src/​viewer/​LvtViewer/​Interop/​NativeMethods.cs Adds P/Invoke helpers and occlusion/DPI utilities for pickers/overlays.
src/​viewer/​LvtViewer/​Interop/​HighlightOverlay.xaml.cs Implements click-through owned overlay window with occlusion polling.
src/​viewer/​LvtViewer/​Interop/​HighlightOverlay.xaml Defines overlay window chrome and rectangle visuals.
src/​viewer/​LvtViewer/​Interop/​ElementPicker.cs Adds press-drag point-to-select element picker (screen point emitter).
src/​viewer/​LvtViewer/​Interop/​CrosshairPicker.cs Adds Inspect.exe-style window picker with live highlight overlay.
src/​viewer/​LvtViewer/​Converters/​InverseBooleanToVisibilityConverter.cs Adds inverse boolean visibility converter used by UI hints.
src/​viewer/​LvtViewer/​Converters/​FrameworkToBrushConverter.cs Maps framework labels to colors for tree dot indicators.
src/​viewer/​LvtViewer/​Converters/​EditKindToVisibilityConverter.cs Template visibility converter for property row edit UI.
src/​viewer/​LvtViewer/​Assets/​LvtViewer.svg Adds vector icon asset (design source).
src/​viewer/​LvtViewer/​AssemblyInfo.cs Adds WPF ThemeInfo assembly attributes.
src/​viewer/​LvtViewer/​App.xaml.cs Adds app-level exception logging hooks and startup log.
src/​viewer/​LvtViewer/​App.xaml Defines WPF App startup URI.
src/​tree_builder.h Extends build_tree API to support fastProperties + persistent ConnectionLookup.
src/​tree_builder.cpp Uses ConnectionLookup to reuse XAML/WinUI3/plugin persistent connections.
src/​tap/​xaml_property_filter.h Broadens property capture to arbitrary primitive-typed properties (with handle heuristic).
src/​providers/​xaml_provider.h Adds fastProperties + connection open/reuse APIs.
src/​providers/​xaml_provider.cpp Implements persistent connection support and desktop bridge handling.
src/​providers/​xaml_diag_common.h Documents fastProperties and declares persistent connection factory.
src/​providers/​winui3_provider.h Adds fastProperties + connection open/reuse APIs.
src/​providers/​winui3_provider.cpp Requires FrameworkUdk for WinUI3, adds persistent connection support.
src/​providers/​uia_provider.h Adds reusable UIA client connection (UiaConnection).
src/​providers/​uia_provider.cpp Implements UiaConnection with MTA usage cookie + timeout reapplication.
src/​providers/​uia_actions.h Allows reusing UiaConnection for action resolve/readback walks.
src/​providers/​uia_actions.cpp Reuses UiaConnection for tree walks around actions when available.
src/​providers/​framework_connection.h Introduces generic persistent-connection interface + event shape.
src/​providers/​connection_registry.h Introduces refcounted per-process registry + move-only ConnectionHandle.
src/​providers/​connection_registry.cpp Implements ConnectionRegistry and ConnectionHandle RAII behavior.
src/​plugin.h Bumps plugin API to v2 and defines optional persistent-connection ABI exports.
src/​plugin_loader.h Extends LoadedPlugin with optional v2 connection function pointers and declares open_plugin_connection.
src/​plugin_loader.cpp Adds v2 probing, plugin connection adapter, and refactors grafting helper.
src/​lvt_api.cpp Adds per-session connection reuse for MCP visual/UIA reads and actions.
src/​framework_detector.cpp Tightens WinUI3 detection to require FrameworkUdk signal in addition to Microsoft.UI.Xaml.dll.
src/​element_key.h Documents key algorithm and exposes stable_name_key for reconciliation use.
src/​element_key.cpp Implements compact xaml:0x… / winui3:0x… keys and reduces base key size.
skills/​lvt/​SKILL.md Updates guidance to mention --fast for rich XAML/WinUI3 apps.
README.md Documents viewer and --fast behavior in CLI usage and watch semantics.
mcp/​src/​server.rs Adds fast option to get_visual_tree tool args and params.
docs/​tap-dll-design.md Updates TAP DLL design docs to reflect persistent connection lifecycle + protocol.
docs/​mcp-server.md Documents fast:true and clarifies durable key forms (compact handles vs structural).
docs/​chromium-plugin.md Notes plugin ABI v2 persistent connections (not yet implemented by plugin).
docs/​avalonia-plugin.md Notes plugin ABI v2 persistent connections (not yet implemented by plugin).
docs/​architecture.md Documents reusable connections and registry in overall architecture.
CMakeLists.txt Adds LVT_BUILD_VIEWER and build/copy wiring for viewer and required artifacts.
.github/​skills/​lvt/​SKILL.md Mirrors --fast guidance update for GitHub skill copy.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lvt_api.cpp Outdated
Comment thread src/tree_builder.cpp
Comment thread src/plugin_loader.cpp Outdated
asklar added 2 commits August 27, 2026 14:29
Keep shared connection snapshots alive across concurrent MCP disconnect, serialize session teardown with the target guard, and reject requests whose session was removed while waiting. Add a regression that races visual reads against disconnect.

Forward pluginOption through persistent plugin get_tree calls so filtering matches the one-shot path. Enable plugin persistence only when open/get_tree/close and lvt_plugin_free are all present, and require the poll/free pair before draining plugin events.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
Build the framework-dependent x64 WPF viewer only on the x64 release leg and publish lvt-viewer-vX.Y.Z-x64.zip separately from the lean CLI archives. Include the complete matching x64 lvt runtime, TAP DLLs, managed walkers, plugins, license, and runtime instructions while excluding debug symbols.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 624b695e-57bc-426d-b037-4c0d6bd3db46
@asklar
asklar merged commit c0fa76d into main Aug 27, 2026
5 checks passed
@asklar
asklar deleted the lvt-visual-viewer branch August 27, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants