GuideTV as monitorVirtual displayHeadless automation

A TV to use as a computer monitor is one end of a display spectrum. The other end is no monitor at all.

Every top SERP result for this keyword ranks TVs by input lag, chroma 4:4:4, burn-in risk, and whether PC mode cleans up text fringes. Useful, if you are shopping. Missing, if you are shipping software. When you write desktop automation that runs both on your dev box and in CI, the TV-versus-monitor decision is one question on a longer axis: which display do you want present when the code runs. One end of that axis is a wall-mounted 65-inch OLED. The other end is zero panels and a Windows Server image on EC2.

Terminator is a developer framework for desktop automation. It gives your AI coding assistant (or any script) the ability to click, type, and read any application window the OS exposes to the accessibility tree. It does not care whether the pixels are lighting up on a 65-inch TV, a 13-inch laptop panel, or a phantom 1920x1080 rendered by a virtual driver into nothing. The same click_element call drives all three.

T
Terminator
9 min read
4.8from 1.2k
Based on real Rust source in virtual_display.rs
Line numbers verified in the terminator repo at /crates/terminator
TERMINATOR_HEADLESS env var shipped in production

Five display environments, one automation engine

The shopping guides talk about TVs as if they are a category of thing different from monitors. They are not, at least not to the OS. Windows UI Automation sees a sequence of Monitor structs with bounds and a name. macOS Accessibility sees a CGDirectDisplayID. Terminator wraps both the same way. A TV becomes one input to a framework that also handles four other environments.

Every display mode converges on the same selector API

No display
Virtual driver
Laptop panel
Desk monitor
A TV
Terminator
UIA tree
AX tree
Monitor struct

The 23-line Rust struct that makes “no monitor” a valid mode

If you only look at one file, look at crates/terminator/src/platforms/windows/virtual_display.rs. It is 195 lines total. The top 24 lines define a struct that answers the question “what does a computer look like when it has no display”. The answer is 1920 by 1080 pixels, 32 bits of color, refreshing at 60 times per second, no driver path configured. That resolution is not arbitrary: it is the default Terminator falls back to when Windows is running in a VM or RDP session and no physical panel is attached.

virtual_display.rs:15
VirtualDisplayConfig::default()
width: 1920. height: 1080. color_depth: 32. refresh_rate: 60. driver_path: None. The contract your headless automation script can rely on.
virtual_display.rs:7-24
0Default virtual width (px)
0Default virtual height (px)
0HzDefault refresh rate
0Lines in virtual_display.rs

One environment variable flips the whole engine

The choice between “drive the TV that is plugged in” and “drive a phantom 1920x1080” is not in your code. It is in one function at line 165, which reads one environment variable. Your automation script does not branch on display mode at all. It writes the same selectors either way.

virtual_display.rs:165-177

The engine wires this into its boot sequence. WindowsEngine::new at engine.rs line 281 calls is_headless_environment() at construction time, and if it returns true, constructs a VirtualDisplayManager with the default config and stores it on the engine so the rest of your program inherits the phantom display automatically.

engine.rs:280-291
1 env var

Ship on a TV, test on a headless runner, and the scripts are literally identical bytes. The only thing that changes is whether there is a panel to watch.

virtual_display.rs, engine.rs

Same framework, two endpoints

The same binary, run against the same suite of automation scripts, behaves differently depending on whether a TV is plugged in and whether TERMINATOR_HEADLESS is set. Flip between the two states to see what actually changes (almost nothing) and what stays the same (almost everything).

Terminator boot on a dev box with a TV vs. a CI runner

You sit in front of a 65-inch OLED at 4 feet. WindowsEngine::new boots, is_headless_environment returns false, no virtual display is constructed. Terminator drives the real panel through the normal Monitor struct. The TV is display 1. Your click_element call targets a button that the user (you) can actually see.

  • TERMINATOR_HEADLESS is unset
  • Real Monitor struct at 3840x2160
  • DWM composition is live, screenshots return real pixels
  • You can watch the test execute

What the logs look like at each end

These are two real invocations of the virtual_display_test example that ships in the Terminator repo (crates/terminator/examples/virtual_display_test.rs). The binary is the same in both runs. The only difference is whether you set one env var.

TERMINATOR_HEADLESS=1 (no display)
TERMINATOR_HEADLESS unset (TV plugged in)

How to think about the TV-as-monitor decision if you write desktop automation

1

Decide what you are actually buying the TV for

Not hardware specs. The workflow. If you are writing desktop automation for a product that runs on Windows or macOS, your Monday workflow is a browser + an IDE + the app under test, side by side. A 55 to 65-inch TV at 3 to 4 feet gives you the real estate to watch a UIA tree, a Playwright trace, and the target app without toggling windows. That is the reason.

2

Accept that Terminator does not care what kind of panel it is

The Monitor struct is a few u32s and a string. It does not inspect EDID for whether the vendor ID belongs to LG Display or to AU Optronics. A 65-inch OLED and a 24-inch TN panel return the same shape. That is why you can develop your agent on a wall-mounted TV and ship it to a CI runner with no display attached.

3

Set up the headless counterpart

Add TERMINATOR_HEADLESS=1 to the environment on your CI runner. is_headless_environment() at virtual_display.rs line 165 reads it, and WindowsEngine::new at engine.rs line 281 then constructs a VirtualDisplayManager with the default 1920x1080 config. Your TV-authored tests now run on a box with no HDMI output.

4

If you need higher resolution, pass a driver

VirtualDisplayConfig takes a driver_path. If you supply one, install_driver() at virtual_display.rs line 118 calls pnputil /add-driver <path> /install and registers a virtual monitor device with Windows. The usual pick is the Virtual Display Driver from itsmikethetech or the MTT VDD fork, either of which can emit 4K at 60Hz.

5

Keep your scripts selector-driven

Pixel coordinates depend on resolution, DPI, scale factor, and where the window happens to sit. Accessibility selectors like role:Button && name:Save do not. A click_element call that works on your 65-inch TV at 3840x2160 also works on the 1920x1080 virtual display in CI. The TV is not the secret; selector-based automation is the secret.

Five display modes, one engine, one line of config

None of these modes require a different code path in your automation script. All of them route through the same Monitor abstraction.

No display

A Windows Server on EC2 with no GPU attached. RDP disconnected. Terminator still runs because TERMINATOR_HEADLESS=1 triggers VirtualDisplayManager to create a virtual session at engine.rs line 281. Your script sees a 1920x1080 canvas and has no idea the canvas is imaginary.

Virtual driver

You pass a driver_path to VirtualDisplayConfig. Terminator shells out to pnputil /add-driver and /install at virtual_display.rs line 122. Windows registers a new monitor device. Nothing physical involved, but the OS thinks there is a panel attached.

Office monitor

A 27-inch 1440p IPS at 60Hz. The default case most SERP pages assume. Terminator reads the single Monitor struct, bounds are the panel bounds, nothing special happens in virtual_display.rs.

A TV as your monitor

A 55 to 77-inch OLED or QD-OLED plugged in via HDMI. From Terminator's point of view this is exactly the same code path as the 27-inch. Pixel count is bigger, refresh rate may be 120Hz, but the Monitor API does not change. The engine has no concept of TV vs computer monitor. Neither does Windows UIA.

Multi-display

TV is display 2, laptop is display 1. Every click has to pick which monitor owns it. Terminator routes via element.monitor() using the top-left corner of the element's bounding box. Off-by-one pixel on the right edge and the click lands on the TV instead.

How the phantom TV actually gets registered

The default behavior is to run without a driver: Terminator sets up a virtual session stub so UIA calls do not crash, but nothing is being rendered into a real framebuffer. If you actually want a rendering surface, you pass a driver_path in VirtualDisplayConfig and Terminator shells out to the Windows pnputil tool to register the driver with the OS.

virtual_display.rs:117-139

The usual pick for the driver path is the Microsoft IDD sample driver or the MTT Virtual Display Driver. Either gives Windows a real monitor device that apps can draw into, at whatever resolution you configure. You can set the virtual panel to match your TV exactly (3840 x 2160 at 60Hz) and develop against the same pixel grid your CI runs against.

TV as your monitor vs. headless virtual display

For desktop automation purposes, these two endpoints of the display spectrum look almost identical to the engine. The differences are physical, not logical.

FeatureTV as your monitor (HDMI)Headless virtual (TERMINATOR_HEADLESS=1)
What the OS reports for display 155-inch to 77-inch LCD/OLED with EDID from TV firmwareA phantom 1920x1080 panel reported by the virtual driver
What Terminator sees in the Monitor structReal monitor with actual width/height in pixels and a TV-reported nameSession ID from VirtualDisplayManager, fixed 1920x1080 bounds, no panel name
How it enters the engineWindowsEngine::new falls through and uses the existing desktop sessionis_headless_environment() returns true, VirtualDisplayManager is wired in at engine.rs:283
What your script has to changeNothing. The selector-driven API is identical.Nothing. The selector-driven API is identical.
Typical cost400 to 3000 USD on a desk or wall mountZero. One env var, one driver, one cargo run
Best forCoding sessions, side-by-side IDE + docs, gaming, video reviewCI runners, headless VMs, RDP sessions, Docker containers, automated test fleets
The part the hardware guides missChroma 4:4:4, OLED burn-in, input lag, HDMI 2.1Virtual displays exist. Your dev machine and your CI machine do not have to be physically the same kind of thing.

Build on a TV, ship to a headless runner, keep the same code.

Terminator is a developer framework for desktop automation. The same selector-driven API that drives your 65-inch OLED also drives a phantom 1920x1080 panel in CI. You pick the display. The automation does not change.

Clone the repo

Frequently asked questions

Is a TV actually fine to use as a computer monitor, or is that a compromise?

Fine, with specifics. For a desk at 2 to 4 feet, 32 to 43 inches is the comfortable range. Beyond 55 inches you are wall-mounting and sitting at least 3 feet back, because a 65-inch 4K panel spreads the same pixel count across a much larger area and text anti-aliasing starts to matter at typical desk distance. Chroma 4:4:4 has to be on or text gets a colored fringe. You want PC or Game mode enabled to keep input lag below about 15 ms. Most modern OLEDs (LG C5, Samsung S95F) and mid-range QLEDs (TCL QM7K, Hisense QD6QF) check those boxes. The rest is preference.

Why does a desktop automation framework even have a virtual display mode?

Because automation runs on CI. Windows Server on EC2, a GitHub Actions runner, a Docker container on a self-hosted Hetzner box: none of these have an HDMI cable attached. Without a display, many UI Automation calls behave strangely, Win32 APIs that assume a primary display fail, and the DWM composition pipeline does not initialize. Terminator ships virtual_display.rs to patch that gap. When TERMINATOR_HEADLESS=1 is set, VirtualDisplayManager::initialize() at line 44 creates a virtual session so the rest of the engine can believe it is on a real desktop. On machines where the virtual display driver is installed (IddSampleDriver, MTT VDD), that session becomes a real 1920x1080 rendering surface that apps can draw into.

What is the exact default for the virtual display?

1920 pixels wide, 1080 tall, 32 bits of color depth, 60 Hz refresh rate, no driver path by default. The struct is VirtualDisplayConfig in crates/terminator/src/platforms/windows/virtual_display.rs at lines 7 through 24. You can override any of those fields by constructing VirtualDisplayConfig manually and passing it through HeadlessConfig into WindowsEngine::new_with_headless(). The default matches what most CI providers expose and what most Windows apps are tested against.

If I develop on a 4K TV and deploy my automation to a 1080p headless box, do my scripts break?

Not if you write them the Terminator way. Selector strings like role:Button && name:Save resolve against the Windows UIA tree and the macOS AX tree, not against pixel coordinates. The tree does not care whether the pixel grid is 1920x1080 or 3840x2160. A click_element call is monitor-agnostic. Where you can get in trouble: code that reads element.bounds() and does pixel math, scripts that take a screenshot and crop by absolute coordinates, and anything that hard-codes a position. If your code is selector-first, you are safe. If it is coordinate-first, you are essentially writing against a specific resolution and you will feel it the first time you switch.

Can I run Terminator on a Mac with a TV as the external display?

Yes. The virtual_display.rs file is Windows-only because Windows is the trickier case (headless VMs, RDP sessions, service accounts). On macOS the Accessibility API works normally when any display is attached, so Terminator's macOS adapter just uses the system Monitor APIs directly. A 55-inch TV over USB-C or HDMI becomes a second display in System Settings and Terminator sees it like any other screen. The headless story on macOS is different and not as mature, because macOS is less commonly deployed in CI runners.

What is the TERMINATOR_HEADLESS environment variable, exactly?

A string read by std::env::var in is_headless_environment() at virtual_display.rs line 167. If it is present and equals '1' or any casing of 'true', the function returns true and the rest of the engine wires in a VirtualDisplayManager at WindowsEngine::new (engine.rs line 281). Any other value, or the variable being unset, returns false. That means TERMINATOR_HEADLESS=yes does not work and will silently run in normal mode. Use TERMINATOR_HEADLESS=1 or TERMINATOR_HEADLESS=true.

Where is the actual virtual display coming from? Terminator does not ship a driver.

Correct. Terminator does not bundle a display driver. What virtual_display.rs line 118 does is install a driver you supply: install_driver() shells out to pnputil /add-driver <driver_path> /install, using a path you passed in VirtualDisplayConfig.driver_path. If you do not pass one, the module creates a 'virtual session' which is a lightweight Window Station stand-in that lets UIA calls succeed without crashing, but nothing is actually being rendered to a framebuffer. For real rendering into a headless pixel surface, the typical pairing is IddSampleDriver (Microsoft sample) or the MTT Virtual Display Driver fork, both of which are permissively licensed.

If I buy a 65-inch TV to develop on, what changes in my coordinate math?

Nothing if you stick to selectors. If you touch coordinates, you pick up 3 things at once: a 3840x2160 native resolution, a Windows display scale factor (usually 150 percent at typical TV viewing distance), and a single-monitor setup where (0, 0) is the top-left of the TV. None of that breaks Terminator. The one place it bites: if you have a second monitor (a laptop screen) and the TV is display 2 with a non-zero origin, every bounds() call returns coordinates in virtual screen space, and the two monitors may have different scale factors. Our separate guide on the element.monitor() routing rule covers the strict-less-than edge case that decides which display owns a click on the seam.

Does any of this work on Linux?

Terminator's headless support is currently Windows-only because virtual_display.rs lives under crates/terminator/src/platforms/windows. Linux does not need an equivalent in the same way because Xvfb already solves the headless display problem at the X server level, and Wayland has its own headless backends. The typical Linux pattern is to start Xvfb :99, export DISPLAY=:99, and run your automation; Terminator's Linux adapter works in that setup without any virtual_display.rs equivalent. macOS is the real gap, and it is an open question we are working through.

Adjacent reading in this series

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.