A Windows CLI tool that inspects the visual tree of running applications. Designed for AI agents (e.g. GitHub Copilot) that need a textual representation of an app's UI content.
- Targets any running Windows app by HWND, PID, process name, or window title
- Detects UI frameworks in use: Win32, ComCtl, Windows XAML (UWP), WinUI 3, WPF, Avalonia, Chrome/Edge
- Outputs a unified element tree as JSON or XML markup
- Optionally emits the app's UI Automation tree instead, with
AutomationIds, control types and supported patterns - Captures annotated PNG screenshots with element IDs overlaid
- Elements get stable IDs (
e0,e1, …) so AI agents can reference specific parts of the UI
Grab the latest release from GitHub Releases — extract the zip and run lvt.exe from any terminal.
The easiest way to add the lvt skill to GitHub Copilot CLI is to install it as a plugin:
/plugin install asklar/lvt
This gives Copilot the ability to inspect any running Windows app's UI when you ask it to. Verify with /skills list.
- Visual Studio 2022+ (C++ Desktop workload)
- vcpkg with
VCPKG_ROOTenvironment variable set - CMake 3.20+
- x64 Developer Command Prompt
# x64 build (default)
cmake --preset default
cmake --build build
# ARM64 build
cmake --preset arm64
cmake --build build-arm64Produces build/lvt.exe and build/lvt_tap_x64.dll (or build-arm64/lvt.exe and build-arm64/lvt_tap_arm64.dll for ARM64).
Note: lvt.exe must match the target process's architecture. If you target an ARM64 app, use the ARM64 build. A clear error message is shown on mismatch.
Framework support is per-provider, so you can drop pieces you don't need. Win32 and ComCtl are always built — every other provider enriches that base layer.
| Option | Default | Effect |
|---|---|---|
LVT_ENABLE_XAML |
ON | System XAML (UWP / DesktopWindowXamlSource) |
LVT_ENABLE_WINUI3 |
ON | WinUI 3 (Windows App SDK) |
LVT_ENABLE_WPF |
ON | WPF |
LVT_ENABLE_WINFORMS |
ON | WinForms |
LVT_ENABLE_AVALONIA |
ON | Avalonia plugin |
LVT_ENABLE_CHROMIUM |
ON | Chromium (Chrome/Edge DOM) plugin |
LVT_BUILD_TOOL |
ON | Build the lvt CLI as well as the library |
LVT_BUILD_MANAGED |
ON | Build the managed .NET helper assemblies |
LVT_BUILD_TESTS |
ON | Build the test executables |
LVT_BUILD_MANAGED is the only thing that requires the .NET SDK. WPF, WinForms and Avalonia each have a native half that hosts the CLR plus a managed tree-walker assembly; only the latter needs dotnet. With -DLVT_BUILD_MANAGED=OFF the whole native build still works, including those TAP DLLs — you just lose managed enrichment for those three frameworks. XAML and WinUI 3 are pure C++ either way.
The XAML and WinUI 3 providers need C++/WinRT headers. These come from two places:
winrt/base.hand theWindows.*projection — from thecppwinrtvcpkg port- the
Microsoft.*(WinUI 3) projection — generated at configure time intosrc/tap/winui3/from the Windows App SDK NuGet package, which has no vcpkg port
Both halves must be produced by the same cppwinrt version, or the generated headers fail to compile against base.h's macros ("Mismatched C++/WinRT headers"). Taking the generator from the cppwinrt port rather than the Windows SDK keeps them in lockstep — and because vcpkg installs only one version of a port per triplet, a consumer that already depends on cppwinrt picks the version for both.
The generated projection is cached in src/tap/winui3/ (gitignored) and regenerated whenever the generator or the Windows App SDK inputs change, tracked via src/tap/winui3/.cppwinrt-signature. Two escape hatches:
# Use a specific generator or Windows App SDK package
cmake --preset default -DLVT_CPPWINRT_EXE=... -DLVT_WASDK_WINMD_DIR=...
# Force a full regeneration
Remove-Item -Recurse src/tap/winui3lvt installs a CMake package exposing lvt::core, the same library the CLI is built on.
This repository doubles as a vcpkg registry. Add a vcpkg-configuration.json next to your vcpkg.json:
{
"default-registry": {
"kind": "git",
"repository": "https://github.com/microsoft/vcpkg",
"baseline": "<a microsoft/vcpkg commit sha>"
},
"registries": [
{
"kind": "git",
"repository": "https://github.com/asklar/lvt",
"baseline": "<an asklar/lvt commit sha>",
"packages": [ "lvt" ]
}
]
}then depend on it from your vcpkg.json:
{
"dependencies": [
{ "name": "lvt", "features": [ "winui3", "wpf" ] }
]
}Features map one-to-one onto the framework options above: xaml, winui3, wpf, winforms, avalonia, chromium, plus tools for the CLI. All but avalonia are on by default. The port always builds with LVT_BUILD_MANAGED=OFF, because vcpkg builds have neither network access nor a .NET SDK — see ports/lvt/usage.
find_package(lvt CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE lvt::core)
# lvt looks for its injectable TAP DLLs next to the binary it is linked into,
# so copy them beside your executable:
lvt_copy_tap_dlls(my_app)#include <lvt/target.h>
#include <lvt/framework_detector.h>
#include <lvt/tree_builder.h>
#include <lvt/json_serializer.h>
#include <lvt/lvt_config.h> // which frameworks were compiled in
auto matches = lvt::find_by_process_name("notepad");
auto target = lvt::resolve_target(matches[0].hwnd, matches[0].pid);
auto frameworks = lvt::detect_frameworks(target.hwnd, target.pid);
auto tree = lvt::build_tree(target.hwnd, target.pid, frameworks);
lvt::assign_element_ids(tree);Instead of lvt_copy_tap_dlls() you can call lvt::set_tap_directory() at runtime, or point the LVT_TAP_DIR environment variable at the installed tools directory. Plugins are searched for in $LVT_PLUGIN_DIR, then <module dir>/plugins, then %USERPROFILE%\.lvt\plugins.
lvt takes a verb, then arguments, then options. The verb defaults to dump.
# Dump Notepad's visual tree as JSON (dump is implied)
lvt --name notepad
# XML output
lvt dump --name notepad --format xml
# Capture annotated screenshot
lvt screenshot --pid 1234 --output out.png
# Just detect frameworks
lvt frameworks --hwnd 0x1A0B3C
# Scope to a subtree
lvt dump --name myapp --element e5 --depth 3
# Query an element by durable key or eN id
lvt query "win32|Window|MyWindow/win32|Button|Button|Name:OK" text --name myapp
# Watch for live tree changes as JSON diff events
lvt watch --name notepad --interval 250Driving an app is the same shape — see Interaction for the full list:
lvt click e6 --name myapp
lvt set-value e4 "hello" --name myapp
lvt wait-for e9 --wait-prop IsEnabled=true --name myapp| Verb | Description |
|---|---|
dump |
Output the element tree (default when no verb is given) |
screenshot |
Capture an annotated PNG to --output |
frameworks |
List the UI frameworks the target uses |
watch |
Emit live JSON tree diff events until Ctrl+C |
query <ref> [prop] |
Output one element, or one of its properties |
| Flag | Description |
|---|---|
--hwnd <handle> |
Target window by HWND (hex) |
--pid <pid> |
Target process by PID |
--name <exe> |
Target by process name (e.g. notepad or notepad.exe) |
--title <text> |
Target by window title substring |
--output <file> |
Write to a file instead of stdout, or the PNG path for screenshot |
--format <fmt> |
json (default) or xml |
--interval <ms> |
Polling interval for watch (default: 500) |
--element <ref> |
Scope to a specific element subtree by positional eN id, durable key, or uia:<RuntimeId> |
--uia |
Use the UI Automation tree instead of the visual tree |
--uia-view <view> |
UIA tree view: control (default), raw, or content |
--uia-props <list> |
Comma-separated extra UIA properties to include |
--uia-timeout <ms> |
Walk deadline (default: 10000). Caps how long UIA waits for any single provider response, and the traversal itself; a truncated tree is marked with a Truncated property on its root. 0 removes lvt's deadline, leaving UIA's own 20s default |
--depth <n> |
Max tree traversal depth |
Upgrading from 0.2.x:
--dump,--screenshot,--frameworks,--watchand--queryare now verbs. Running the old flag prints the replacement. Target and output flags are unchanged, andlvt --name notepadstill dumps the tree.
The visual tree answers "what is this UI made of?". --uia answers "how do I
drive it?" — it emits the target's UI Automation tree, so every element carries
the identifiers an automation client needs:
# Automation-grade view of an app
lvt dump --uia --name myapp
# Everything UIA can see, including framework scaffolding
lvt dump --uia --uia-view raw --name myapp
# Add properties that are not in the default set
lvt dump --uia --uia-props ProviderDescription,IsPassword --name myapp
# Address an element by its UIA RuntimeId
lvt query uia:42.3150138.4.5 AutomationId --name myappElements report AutomationId, ControlType, LocalizedControlType,
FrameworkId, RuntimeId, interaction state (IsEnabled, IsOffscreen,
HasKeyboardFocus, …), the SupportedPatterns list, and the state belonging to
those patterns (Value.Value, Toggle.ToggleState, ExpandCollapse.State,
RangeValue.*, Scroll.*, …).
Pattern-backed properties are only emitted where the pattern is supported.
UIA will otherwise tell you a Window's Toggle.ToggleState is
Indeterminate — not because it has one, but because GetCachedPropertyValue
substitutes the type's default for unsupported properties. lvt reads them with
ignoreDefaultValue so UIA reports "not supported" instead, and a Button
shows SupportedPatterns="Invoke,…" with no toggle state while a CheckBox
shows Toggle.ToggleState="On".
Everything that works on the visual tree works here: eN ids, durable keys,
--element, --query, --depth, --watch, --format xml, and annotated
screenshots.
--uia injects nothing into the target, so unlike the visual-tree providers it
is not restricted to processes matching lvt's own architecture:
# x64 lvt.exe reading a 32-bit process — refused for the visual tree, fine for UIA
lvt dump --uia --pid 51748The visual tree remains the default and is unchanged by this: it is built from
framework-native APIs and never depends on UIA. --uia replaces it for that
invocation rather than enriching it — the two are separate views of the same
window. Reach for the visual tree for framework-native structure and internals,
and --uia when you need automation identity and actionable state.
lvt can also drive an app. Every interaction verb implies --uia, because
element references are resolved against a UIA walk and acting needs the patterns
only that view exposes.
lvt click e6 --name myapp # Invoke, else default action, else a real click
lvt toggle e7 --name myapp # flip a checkbox
lvt set-value e4 "hello" --name myapp # text, or a numeric slider/spinner value
lvt press-key "Ctrl+S" --name myapp # or "Enter;Tab" for a sequence
lvt wait-for e9 --wait-prop IsEnabled=true --name myapp| Verb | Pattern used | Falls back to |
|---|---|---|
click <ref> |
Invoke, then LegacyIAccessible.DoDefaultAction |
synthetic click |
right-click <ref> / double-click <ref> |
— | always synthetic |
invoke <ref> |
Invoke only |
nothing; fails instead |
toggle <ref> |
Toggle |
— |
set-value <ref> <text> |
Value, then RangeValue for numbers |
focus + select-all + type |
expand / collapse <ref> |
ExpandCollapse |
— |
select <ref> |
SelectionItem.Select |
— |
add-to-selection / remove-from-selection <ref> |
SelectionItem |
— |
select-text <ref> [text] |
Text.FindText + Select |
— |
focus <ref> |
SetFocus |
— |
scroll <ref> <dir> |
Scroll, then ScrollItem.ScrollIntoView |
mouse wheel |
type <text> |
— | synthetic; --focus-first <ref> to target |
press-key <chord> |
— | synthetic |
close / minimize / maximize / restore [<ref>] |
Window |
— |
wait-for / wait-gone <ref> |
polls a walk | — |
Pattern first, input second. A UIA pattern does not steal focus, does not move the cursor, and works when the window is not on top. Synthetic input does all three, so it is only used when nothing else will do the job. The result JSON reports which was used:
{ "action": "click", "ok": true, "method": "InvokePattern",
"result": { "AutomationId": "PrimaryButton", ... } }result is the element after the action, so you can confirm the effect without
a second walk. On failure, error says whether the pattern was missing or present
but refused.
Virtualized items are realized automatically — an item in a long list does not
exist as an element until then, and method reports VirtualizedItem.Realize+…
when that happened.
Waiting. wait-for blocks until an element appears, or with --wait-prop <name>=<value> until it reports that value; wait-gone waits for it to
disappear. Both stop at --wait-timeout (default 5000 ms) and exit non-zero on
timeout, so a script can branch on it.
Repeating an action is just repeating the command — there is no repeat count, since only the OS-interpreted sequences (double-click, key chords) need precise timing, and those are their own verbs.
Choosing a reference. eN is positional and uia:<RuntimeId> is tied to the
element's current host window, so both can break when the UI changes shape —
not merely its values. Expanding a combo box, for instance, reparents it into a
popup: its eN moves and its RuntimeId changes, while its durable key does
not. Use eN for one-shot commands against a static UI, and the durable key
for anything that acts across a structural change.
--watch repeatedly rebuilds the target tree and writes newline-delimited JSON
events to stdout. The first tick emits the current tree as added events; later
ticks emit added, removed, and changed events with old/new field values.
Element matching uses stable framework/type/class/path-derived keys instead of
the positional e0, e1, ... ids, so unique moved elements are reported as
changed events with a path field change.
{
"target": { "hwnd": "0x001A0B3C", "pid": 12345, "processName": "Notepad.exe" },
"frameworks": ["win32", "winui3"],
"root": {
"id": "e0",
"type": "Window",
"framework": "win32",
"className": "Notepad",
"text": "Untitled - Notepad",
"bounds": { "x": 100, "y": 100, "width": 800, "height": 600 },
"children": [
{
"id": "e1",
"type": "ContentPresenter",
"framework": "winui3",
"bounds": { "x": 108, "y": 140, "width": 784, "height": 552 }
}
]
}
}<LiveVisualTree hwnd="0x001A0B3C" pid="12345" process="Notepad.exe" frameworks="win32,winui3">
<Window id="e0" framework="win32" className="Notepad" text="Untitled - Notepad" bounds="100,100,800,600">
<ContentPresenter id="e1" framework="winui3" bounds="108,140,784,552" />
</Window>
</LiveVisualTree>The tool uses a 4-stage pipeline:
- Target resolution — resolve HWND/PID/name/title to a target window
- Framework detection — enumerate loaded DLLs to detect UI frameworks
- Tree building — Win32 HWND walk as base, framework providers layer on top
- Serialization — output as JSON/XML, optionally capture screenshot
Framework providers:
- Win32Provider — base HWND tree (always present)
- ComCtlProvider — enriches ComCtl32 controls (ListView items, TreeView nodes, etc.)
- XamlProvider — injects TAP DLL to walk Windows XAML visual trees
- WinUI3Provider — injects TAP DLL to walk WinUI 3 visual trees
- WpfProvider — walks WPF visual trees via managed DLL injection
- Plugins — extensible framework support (e.g. Avalonia) via C ABI plugin interface
See docs/architecture.md for details.
- Framework-native by default — the visual tree uses framework-native APIs directly for speed and accuracy, and never depends on UI Automation.
--uiais an opt-in, automation-grade second view, not a replacement for that - Graceful degradation — if a framework provider fails, falls back to HWND-level info
- AI-first — output formats and element IDs designed for machine consumption
- Minimal footprint — single exe + one DLL, no installers, no runtime dependencies
# Run unit tests
build\lvt_unit_tests.exe
# Run integration tests (launches Notepad automatically)
build\lvt_integration_tests.exe
# Via CTest
ctest --test-dir buildlvt supports a plugin architecture for adding new framework providers. Plugins are DLLs that implement a simple C interface and are loaded automatically from %USERPROFILE%\.lvt\plugins\.
See src/plugin.h for the plugin interface.
| Plugin | Framework | Docs |
|---|---|---|
| Avalonia | Avalonia UI desktop apps | docs/avalonia-plugin.md |
| Chromium | Chrome/Edge browser DOM trees | docs/chromium-plugin.md |
These plugins are built from source alongside lvt and deployed to %USERPROFILE%\.lvt\plugins\. See each plugin's documentation for installation and usage details.
- WebView2 provider
- MAUI provider
MIT — see LICENSE.

