termlens 0.10.1

Headless PTY test harness for CLI/TUI apps — spawn in a real PTY, assert on the rendered screen
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# termlens

Integration testing for terminal programs, done the way you'd test a web
app: spawn the real thing in a **real PTY**, let a VT emulator render its
output into an in-memory **screen grid**, and **assert or snapshot on the
rendered screen** instead of scraping raw bytes. Playwright for the
terminal.

[![CI](https://github.com/vyncint/termlens/actions/workflows/ci.yml/badge.svg)](https://github.com/vyncint/termlens/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/termlens.svg)](https://crates.io/crates/termlens)
[![docs.rs](https://img.shields.io/docsrs/termlens)](https://docs.rs/termlens)
[![MSRV](https://img.shields.io/badge/MSRV-1.85-blue)](https://github.com/vyncint/termlens/blob/main/Cargo.toml)
[![license](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license)

```sh
cargo add termlens --dev
cargo add insta --dev    # used by the snapshot assertions below
```

Add `--features decode` if you test an application that draws inline images
and want to assert on the pixels it transmitted.

## Example

```rust
use std::time::Duration;
use termlens::{Key, Terminal};

#[test]
fn quits_from_the_main_screen() -> termlens::Result<()> {
    let mut t = Terminal::builder()
        .size(80, 24)
        .env_clear()                       // hermetic: no host env leaks in
        .timeout(Duration::from_secs(5))   // every wait_* has this deadline
        .spawn(env!("CARGO_BIN_EXE_myapp"))?;

    // Wait for the app's ready marker, let the picture settle, then snapshot
    // it with its styles — the three decisions every TUI snapshot needs.
    termlens::assert_screen_snapshot!(t, after = |s| s.contains("Ready"));

    t.send(Key::Char('q'))?;
    assert!(t.wait_exit()?.success());
    Ok(())
}
```

When a wait times out, the error embeds the screen — your CI log shows
exactly what the app was displaying, not "assertion failed: false".

A clock in the title bar, a PID in the status line — anything volatile —
would break that snapshot on every run, and a text filter over the rendering
shifts every column after it. Mask the **grid** instead, which keeps the
width, the styles and the cursor:

```rust,ignore
let s = t.snapshot_after(|s| s.contains("Ready"))?;
insta::assert_snapshot!(s.mask_matching("12:34:56", '▒'));   // a literal…
insta::assert_snapshot!(s.mask_rect(70.., ..1));              // …a rectangle…
// …or a pattern, with the `regex` feature:
insta::assert_snapshot!(s.mask_matches(&regex::Regex::new(r"\d\d:\d\d:\d\d")?, '▒'));
```

The builder chain above is what every test of a package's own binary
starts from, so it has a name: `termlens::bin!("myapp")` spawns
`CARGO_BIN_EXE_myapp` at 80x24 with a cleared environment and a
five-second deadline, and builder calls after the name override any of it —
`termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?`.

### AI-Assisted Testing (Claude Code / Cursor / Agents)

Coding agents write terminal tests badly in predictable ways: a `sleep`
where a wait belongs, a snapshot taken mid-repaint, a `1x1` terminal, a
`(row, col)` handed to a method that wants `(col, row)`. The skill at
[`skills/termlens/SKILL.md`](skills/termlens/SKILL.md) is the counter to
each: the model, the rules that keep a PTY test from flaking, the API in one
page, and four copy-paste recipes — a CLI snapshot, a ratatui navigation
flow, overriding defaults, targeted cell and style assertions. Every Rust
block in it is compiled against the crate in CI, so it cannot drift from the
API. Install it for Claude Code with one command:

```sh
mkdir -p ~/.claude/skills/termlens && curl -sSL https://raw.githubusercontent.com/vyncint/termlens/main/skills/termlens/SKILL.md -o ~/.claude/skills/termlens/SKILL.md
```

Other agents take the same file: add it to a Cursor rule or reference it
from `.github/copilot-instructions.md`. Inside this repository Claude Code
finds it without installing anything, through `.claude/skills/termlens`.

## What it is (and is not)

- **Not** an expect-style stream matcher — [rexpect] and [expectrl] already
  do that well. Byte streams can't answer "is the cursor on the third menu
  item?".
- **Not** an SVG transcript generator for pretty docs — that's
  [term-transcript].
- **It is**: a real PTY + an emulated screen + snapshot assertions, so you
  test what a user would *see*.

## How it works

```mermaid
flowchart TB
  test["your test<br/>drive · wait · assert"]
  subgraph proc["your test process · cargo test"]
    subgraph tt["termlens"]
      api["Terminal<br/>send · click · drag · paste · focus · signal · resize · wait_until / wait_frame / wait_idle / wait_exit"]
      reader["reader thread<br/>drains continuously — output is never lost between waits"]
      emu["VT emulator<br/>vt100 behind a small internal trait, swappable"]
      screen["Screen<br/>immutable grid snapshots · cells · cursor · styles · modes · repaints · bells · images"]
    end
  end
  subgraph kernel["kernel"]
    PTY["real PTY<br/>line discipline · TIOCSWINSZ → SIGWINCH"]
  end
  app["your app, unmodified<br/>believes it owns a terminal"]

  test -->|"send(Key) · click · paste"| api
  api -->|"xterm byte sequences"| PTY
  api -.->|"resize · kernel delivers SIGWINCH"| PTY
  PTY -->|stdin| app
  app -->|"stdout · escape sequences"| PTY
  PTY -->|bytes| reader
  reader -->|"process, under one lock"| emu
  emu -->|"snapshot"| screen
  screen -->|"predicates · insta snapshots · screen dumps in every timeout"| test
  classDef ours fill:#2563eb,color:#ffffff,stroke:#1d4ed8,stroke-width:1px;
  class api,reader,emu,screen ours
```

The reader thread drains the PTY into the emulator *continuously* — the
kernel buffer can't fill up and stall your app, and no output is lost
between assertions. It also **answers the queries real terminals
answer** — cursor position, device attributes, window size, background
colour, `DECRQM` mode probes, and terminfo capabilities via `XTGETTCAP` —
so capability-probing apps run instead of hanging, and anything left
unanswered is named inside the next timeout error. Every answer is
truthful or absent: nothing is claimed that the emulator cannot render.
The grid holds what a user would *see*: DEC Special Graphics — the
`ESC ( 0` line-drawing set every ncurses border is made of — is translated,
so a frame reads as `┌───┐` rather than `lqqqk`.

Input is **mode-aware**: mouse clicks and scrolls (with modifiers —
`Ctrl`-wheel zoom is `scroll_with(Scroll::Up.ctrl(), ..)`), pastes, modifier
chords, and cursor keys are encoded exactly as the application
configured its terminal (SGR mouse, bracketed paste, DECCKM) — because
the emulator knows which modes the app enabled. A `drag` reports one
motion **per cell crossed**, so an application that paints along the path
sees the path. The same knowledge is
readable from every `Screen`: the window title, the alternate-screen
flag, the input modes, the last `OSC 52` clipboard write, the cursor
shape the app asked for with `DECSCUSR`, and the `OSC 8` hyperlinks it
emitted are plain accessors, so "did the app enter the alt screen?",
"did it copy the right text?", "did it put the terminal into insert
mode — and put it back?" and "did it link the right URL?" are
assertions, not inferences. The last two matter because neither changes
a cell: a hyperlink's label renders as ordinary text with its URL
nowhere on the screen, so before `links()` a test asserting a link
passed identically against an application that emitted none. Focus events go the other way:
`focus_out()` reaches an application that enabled mode 1004, so the
unfocused branch of a UI can be driven at all.

**Behaviour that leaves the screen identical is still assertable.** A
repaint that drew nothing, a bell on a rejected key, an inline image — none
of these change a single cell, so no content predicate can see them. Every
`Screen` carries the counters instead: `repaints()` (completed DEC 2026
updates, so *one input became four repaints* is catchable), `bells()`, and
`graphics()` for kitty and sixel payloads — where the assertion is as often
the negative one, "this must render as text in every terminal and never go
out as an image". `frame_timings()` adds the cost of each repaint, so a
suite can hold a performance line as well as a correctness one.

**And an image is more than a byte count.** `graphics().payloads()` hands
back the transmissions themselves — where each was placed, the size and
cell extent it declared, its format and id — so an application that lays
out in characters and draws in pixels can be held to keeping the two in
step. Images are counted as *images*: a transmission split across the kitty
protocol's 4096-byte chunks is one, and a delete is counted apart, under
`deletes()`, because it carries no picture. With the `decode` feature a
payload decodes into a `Bitmap`, so the assertion can finally be about the
picture:

```rust
let seen = screen.graphics();
let image = seen.last().expect("the chart went out as an image");
assert_eq!(image.cells(), Some((106, 7)));       // on the cells reserved
assert_eq!(image.at(), (4, 5));                  // at the grid's origin
assert_eq!(image.decode()?.pixel(9, 9), Some([0x39, 0xd3, 0x53, 0xff]));
```

**Scrollback is retained** (1000 rows by default), so an application that
hands finished output *back* to the terminal — a pager, a log view, a TUI
that commits completed blocks into native scrollback and keeps a small
live region — stays testable. `full_text()` spans history and screen, so
an assertion need not know which region a block currently sits in — and
`contains`/`find` read the visible grid alone, so when a wait fails while
rows have scrolled off, the error says how many and points at `full_text`.

Screens are immutable snapshots taken under the same
lock the reader writes through, so every assertion sees a consistent
instant. Four layers, one small internal trait between emulator and screen
so the backend can be swapped; details in [docs/DESIGN.md](docs/DESIGN.md).

## Comparison

| Tool                  | Real PTY | Screen grid | Snapshots | Notes                                   |
| --------------------- | :------: | :---------: | :-------: | --------------------------------------- |
| **termlens**          |||| this crate                              |
| [rexpect] / [expectrl] |||| stream matching, no rendered screen; termlens's `regex` feature gives the same `wait_until_matches(pattern)` over a *row of the screen* |
| [term-transcript]     ||      ~      |   SVG     | transcripts for docs, not assertions    |
| ratatui `TestBackend` |||     ~     | in-process only: your real binary, PTY layer, and non-ratatui output stay untested |
| [teatest] Go        |||| same idea, Bubble Tea / Go ecosystem    |

### What `TestBackend` cannot see

`TestBackend` renders your widgets into a buffer in-process; it is the right
tool for layout and rendering logic, and termlens does not replace it. What
it structurally cannot observe is everything between `draw` and the user's
eyes — and each item has one termlens assertion that does:

| Invisible to `TestBackend`                      | The assertion that sees it                                         |
| ----------------------------------------------- | ------------------------------------------------------------------ |
| raw-mode entry and exit, the alternate screen   | `t.wait_until(\|s\| s.alternate_screen())`, and `!alternate_screen()` after `q` |
| a resize (`SIGWINCH`) reaching the application  | `t.resize(60, 14)?; t.wait_frame(\|s\| s.contains("60x14"))`      |
| output printed outside ratatui — a `println!`, a logger, a panic | `s.contains("panicked")`, or a snapshot of the whole grid  |
| a torn frame — a repaint observed half-drawn    | `wait_frame` returns complete DEC 2026 frames only, and says so when the app never brackets |
| capability probes and the modes they turn on    | `answer_queries` replies as a terminal would; `s.mouse_mode()`, `s.bracketed_paste()`, `s.focus_events()` say what was asked for |
| mouse and paste bytes under the enabled modes   | `t.click(col, row)`, `t.scroll(…)`, `t.paste(…)` encode for the mode the app turned on |
| a masked field that is really printed in clear  | `cell.style().conceal` — identical text, different picture         |
| the terminal state after exit                   | `t.wait_exit()?` then `t.screen()`: `!alternate_screen()`, cursor visible again |

`fixtures/ratatui-app` is the worked example: a ratatui counter/list whose
`draw` is rendered through the PTY by termlens and in-process by
`TestBackend`, and the two diffed cell by cell with `Screen::diff` at two
sizes with a resize between (`fixtures/ratatui-app/tests/fidelity.rs`). Where
they disagree the bug is in the terminal layer — crossterm's encoding, the
PTY, or termlens's emulation — which is the layer nothing else tests.

## At a shell prompt, and in CI

`cargo install termlens-cli` gives the same harness as a command:
`termlens inspect --size 120x40 myapp` prints what a program shows,
`termlens diff old.snap new.snap.new` prints the cell diff of two saved
screens (coloured on a terminal, exit 1 if anything changed), and
`termlens render --svg failing.snap` turns one into an image. A saved
screen is the text termlens prints — an insta `.snap`, the block a wait
error leaves in a log — read back by `Screen::parse`, or the JSON the
`serde` feature writes.

In CI, set `TERMLENS_ARTIFACT_DIR` on the test step and every screen a
failing wait embeds is also written there; then
`uses: vyncint/termlens/.github/actions/report@v0.10.0` with `if: failure()`
puts those screens, and every `.snap.new` with its diff, into the pull
request's step summary:

```yaml
- run: cargo test
  env:
    TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
- uses: vyncint/termlens/.github/actions/report@v0.10.0
  if: failure()
```

## Determinism

PTYs are asynchronous; a harness that pretends otherwise is flaky by
design. termlens's position:

- **Prefer `wait_until` on visible content.** It re-checks on every chunk
  of output and is exact: the condition either becomes true or you get a
  screen-carrying timeout. The three rules for race-free waits (and the
  resize stale-frame trap) are in [docs/DESIGN.md](docs/DESIGN.md) §2.
- **Styles are complete enough to catch a masked field.** `Style` carries
  `blink`, `conceal` and `strikethrough` alongside the usual attributes, so
  a test asserting that a password field is masked fails against an
  application that prints the secret in clear — the two are identical text.
- **Needles are matched by what the terminal draws, not by how it is
  spelled.** `contains` and `find` fold both sides to NFC, so a needle typed
  in an editor still finds text an application normalized the other way —
  `caf\u{e9}` and `cafe\u{301}` render identically, and so does the failure
  output, which made the mismatch a trap rather than a limitation. The grid
  itself keeps exactly the codepoints the application sent.
- **`wait_frame` gives exact frame boundaries** for apps that bracket
  repaints in DEC 2026 synchronized updates (crossterm's
  `BeginSynchronizedUpdate`/`EndSynchronizedUpdate`): the predicate only
  ever sees complete frames, never a torn repaint, and the call returns
  the frame it matched. Each call observes a frame no earlier call did, so
  a burst arriving in one read is assertable step by step in emission
  order, one repaint cannot satisfy two waits, and a superseded frame
  cannot answer a wait made after your input. Applications that *probe*
  for synchronized output before using it get a truthful `DECRQM` answer,
  so they enable it against termlens unmodified.
- **Every wait takes a per-call deadline** (`wait_until_for`,
  `wait_frame_for`, `wait_idle_for`, `wait_exit_for`), so one slow step
  doesn't force a generous timeout on the whole suite. Writes are bounded
  too, and every input call returns `Result`: typing into an application
  that has stopped reading, or into a child that has exited, is an error
  carrying the screen rather than a hang or a panic.
- **`wait_idle(quiet)` is an honest heuristic** for everything else. It
  resolves when nothing arrived for `quiet`, the stream isn't
  mid-escape-sequence, and no synchronized update is open. Silence is
  evidence a render finished — not proof. Use it for "the app settled",
  not for precise sequencing.
- **`snapshot_after(pred)` is the whole-screen snapshot with the rules
  built in**: it waits for the predicate, then for the picture to hold
  still, and returns that screen. `wait_stable(quiet)` is the settle on its
  own; unlike `wait_idle` it is reset by *changes*, not bytes, so a bell or
  a repaint that alters no cell does not keep it waiting.
- **Hermetic environments.** `env_clear()` blocks inheritance,
  `TERM=xterm-256color` is pinned by default, fixtures draw no clocks and
  no animations. The CI suite runs a 100-iteration
  [stress workflow](.github/workflows/stress.yml) on Linux, macOS and
  Windows — wait/timing changes don't merge without surviving it.

## Known limitations

- **Terminal dimensions are 2–1000 cells per axis.** At one column, a
  double-width character overflows the backend's arithmetic; at one row, a
  line that wraps does the same (a one-row terminal that scrolls by newline
  is fine). Larger grids are refused because every snapshot costs one entry
  per cell.
- Scrollback is **bounded** (1000 rows by default) and **text only unless
  asked**: `scrollback_styles(true)` on the builder retains cells too, so
  `Screen::scrollback_cell` keeps a masked-password assertion alive after
  the line scrolls off, at a measured cost the knob's docs quote. Either
  way `Screen::locate` says which region — grid or history — holds a
  needle, and a history column is the row's *as captured*: history is not
  reflowed, so it does not survive a narrowing resize. Otherwise a
  scrolled-off row has no styles and no cell addressing — and is **not
  reflowed** by a `resize`: rows keep the width they were captured at, by
  decision (`Terminal::resize` says why). The visible grid stays the
  fully-featured surface.
- **Character sets: G0–G3 designation, SO/SI locking shifts, SS2/SS3
  single shifts, and two sets translated.** `ESC ( ) * + Ps` designations,
  the `SO`/`SI` locking shifts, and `ESC N`/`ESC O` (SS2/SS3, one character)
  are modelled; the DEC Special Graphics set (`0`) and the UK set (`A`,
  `£` at `#`) are translated, and every other designation — the alternate
  ROMs, the other national sets — is acknowledged and reads as ASCII.
  `DECSC`/`DECRC` save and restore this state with the cursor. Locking
  shifts remain G0/G1 only (`LS2`/`LS3` are not modelled).
- **Insert mode (`IRM`, `CSI 4 h`) pushes the rest of the row right**, as
  ncurses's `insch` expects on a terminal advertising `smir`; `RIS` and
  `DECSTR` clear it, and `Screen::insert_mode()` reports an application
  that left it on. Other ANSI modes the backend drops (`LNM` and the rest)
  are not modelled — and *say so*: `Screen::unsupported()` lists every
  sequence the emulator did not implement, in the form `^[[20h`, so a test
  can tell a plausible-looking wrong grid from a right one.
- **A soft-wrapped line is two rows.** `contains` and `find` read the grid
  row by row and do not span the wrap; `Screen::row_wrapped(row)` reports
  the backend's record of where a line wrapped, and
  `Screen::logical_text()` joins wrapped rows back together for the
  assertion that spans one.
- **Tab stops are the application's to set.** `HTS` (`ESC H`), `TBC`
  (`CSI g`, `CSI 3 g`), `CHT` (`CSI I`) and `CBT` (`CSI Z`) all work, and a
  plain `\t` honours whatever stops are set rather than a fixed eight.
  `RIS` and `DECSTR` restore the every-eighth default, and a resize extends
  the set into its new columns with that pattern while leaving existing
  stops alone. Back-tab moves to the nearest stop *strictly* left of the
  cursor, as xterm does. Only `TBC 0` and `TBC 3` are modelled; the rest of
  that family clears *line* tab stops, which this crate has no notion of.
- `wait_frame` needs the application to bracket its repaints in DEC 2026
  synchronized updates, and only the last 8 completed frames are retained;
  everything else waits with `wait_until`, under the three rules in
  [docs/DESIGN.md](docs/DESIGN.md) §2.
- Some questions stay deliberately unanswered — kitty's `CSI ? u`, DECRQSS,
  DA3, `OSC 12`, `OSC 52` *reads*, and the non-pixel `CSI … t` reports —
  because a guessed reply is worse than none. An application blocked on one
  is **named in the next timeout** rather than left to hang unexplained.
- **Graphics are captured, not rendered.** termlens can tell an application
  that kitty or sixel is available (`graphics()`, `cell_size()`), collect
  what it then transmits, and — with the `decode` feature — decode a payload
  into pixels. It still draws none: an image never reaches the screen grid,
  so what a picture looks like *composited over the text under it* is not
  assertable, and `f=100` (PNG) payloads are reported unsupported rather
  than decoded, since termlens carries no image codec. Retention is bounded
  (4 MiB by default, `capture_graphics`); past it a payload is counted and
  described but its bytes are dropped, and it says so rather than decoding a
  prefix of itself. Support stays opt-in, so by default an application that
  probes is truthfully told there is none. Decoding also refuses anything
  above 4096x4096: every size in a payload is chosen by the program under
  test, and a sixel `!n` repeat or a declared `65535x65535` would otherwise
  set the allocation directly.
- **Hyperlinks are captured, not attributed to cells.** `links()` reports
  every `OSC 8` span with its target, its `id`, and the text it wrapped, so
  "did it link the right place?" is assertable — but a `Cell` does not carry
  its link, so *which* cells sit inside a span is not, and a span whose label
  was later overwritten is still reported, because this is a record of what
  the application emitted rather than a property of the grid. Retention is
  bounded to the most recent 64 spans, and a label longer than the capture
  bound is reported as unknown rather than as a prefix.
- **Out-of-band state is what the application last asked for, not what a
  terminal would infer.** The cursor shape follows `DECSCUSR` and is cleared
  by a hard reset (`RIS`); the window title is not, because in xterm the
  title is a window property that `RIS` does not restore, and guessing either
  way would be the same error. `DECSTR` (soft reset) resets what a `Screen`
  can observe — cursor keys, bracketed paste, mouse tracking, focus
  reporting, the cursor's visibility and shape, the character sets — and
  leaves the alternate screen alone; attributes, margins, origin and insert
  modes and the keypad are not modelled.
- **Two SGR style attributes are not modeled.** Overline (`SGR 53`) and double
  underline (`SGR 21`) do not reach [`Style`](https://docs.rs/termlens/latest/termlens/struct.Style.html),
  so `with_styles()` cannot distinguish those attributes from a plain cell.
  And bold and dim are **one intensity state**, not two: the last of
  `SGR 1`/`SGR 2` written wins, so a cell never reports both.
- **A reply the terminal's own input queue cannot hold may not arrive.**
  termlens no longer drops answers of its own accord, but the tty input
  queue is small (~1 KB on macOS, ~4 KB on Linux), so an application that
  asks thousands of questions without reading has to read as it asks — as
  it would against a real terminal. On Linux the kernel discards silently,
  so that loss is undetectable and goes unreported; macOS blocks instead,
  where it is counted and named.
- **Windows: screen assertions yes, frame assertions no.** The crate builds
  and the whole suite runs on `windows-latest` in CI, over ConPTY through
  `portable-pty`. ConPTY is not a passthrough — it renders the child's
  output into a screen of its own and re-emits *that* — so what termlens
  can honestly claim there is what survives the re-render: the grid (text,
  cells, styles, cursor, wide characters, box drawing, title, links by URL,
  clipboard, bracketed paste, cursor shape, bell, the alternate screen),
  resize, typed input, `wait_until` / `wait_stable` / `snapshot_after`,
  `bin!`. What it cannot claim, and documents as Unix-only: `wait_frame` and
  `frame_timings` (ConPTY closes a DEC 2026 bracket *before* the content it
  wrapped); `GraphicsPayload` and everything under `graphics` (kitty and
  sixel never arrive); the responder's outbound claims — `Graphics`,
  `background_rgb`, `foreground_rgb`, `cell_size` — since DA1, OSC 10/11,
  XTGETTCAP and DECRQM are answered by ConPTY itself and never reach
  termlens; `mouse_modes` and `mouse_mode`; `focus_events` (ConPTY turns
  1004 on for itself); link ids (ConPTY assigns its own); `Terminal::signal`;
  and bytes that are not UTF-8 (Rust's console stdio refuses to write
  them). The tests for each are `#[cfg_attr(windows, ignore = "…")]` with
  the reason in the attribute; the probe that measured all of this is
  `tests/conpty_probe.rs`, and the `windows` workflow re-runs it on demand.
  The `windows-latest` leg is a required check. This is decision 1 of
  [docs/STABILITY.md](docs/STABILITY.md).
- A child that writes and exits within its first milliseconds can lose
  output to the OS PTY teardown (macOS especially). Long-lived TUIs are
  unaffected; for run-and-exit programs, end the script with a `read` and
  release it after asserting — see the "instant-exit caveat" in
  [docs/DESIGN.md](docs/DESIGN.md).
- Exotic grapheme clusters render as the vt100 crate renders them; the
  unicode-torture fixture pins the current behavior.

## MSRV

Rust **1.85** (driven by the default `insta` feature's dependency tree;
checked in CI against the committed lockfile). MSRV bumps are minor
releases. The `ratatui-app` fixture alone needs 1.88, ratatui 0.30's floor;
it is a workspace member, not part of the published crate, and the MSRV
check excludes it.

## Contributing

PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) (dev setup, testing
policy, DCO sign-off, AI tooling policy) and
[docs/DESIGN.md](docs/DESIGN.md) before touching wait semantics. What 1.0
means — three decisions written down with their measurements, and which
public items the promise covers — is [docs/STABILITY.md](docs/STABILITY.md);
the emulator-backend comparison behind one of them is
[docs/BACKENDS.md](docs/BACKENDS.md). Security reports:
[SECURITY.md](SECURITY.md).

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option — the Rust ecosystem's standard
dual license. Apache-2.0 carries an express patent grant; MIT is maximally
simple and GPLv2-compatible. Offering both lets every downstream user pick
whichever their project or policy needs. Unless you explicitly state
otherwise, any contribution intentionally submitted for inclusion in the
work by you, as defined in the Apache-2.0 license, shall be dual licensed
as above, without any additional terms or conditions.

[rexpect]: https://crates.io/crates/rexpect
[expectrl]: https://crates.io/crates/expectrl
[term-transcript]: https://crates.io/crates/term-transcript
[teatest]: https://github.com/charmbracelet/x/tree/main/exp/teatest