termwright 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {termwright-0.2.0 → termwright-0.3.0}/PKG-INFO +99 -47
  2. {termwright-0.2.0 → termwright-0.3.0}/README.md +96 -44
  3. {termwright-0.2.0 → termwright-0.3.0}/pyproject.toml +3 -3
  4. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/__init__.py +51 -7
  5. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/client.py +78 -127
  6. termwright-0.3.0/src/termwright/evidence.py +403 -0
  7. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/limits.py +1 -1
  8. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/marker.py +4 -5
  9. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/messages.py +51 -78
  10. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/roles.py +15 -4
  11. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/textual.py +1 -1
  12. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/tree.py +154 -30
  13. termwright-0.3.0/src/termwright/validate.py +1043 -0
  14. {termwright-0.2.0 → termwright-0.3.0}/src/termwright_probe/__init__.py +41 -3
  15. {termwright-0.2.0 → termwright-0.3.0}/src/termwright_probe/__main__.py +0 -1
  16. {termwright-0.2.0 → termwright-0.3.0}/src/termwright_probe/bootstrap.py +99 -17
  17. termwright-0.3.0/src/termwright_probe/session.py +247 -0
  18. termwright-0.3.0/src/termwright_probe/textual_probe.py +500 -0
  19. {termwright-0.2.0 → termwright-0.3.0}/src/termwright_probe/textual_tree.py +149 -109
  20. termwright-0.3.0/tests/conftest.py +69 -0
  21. termwright-0.3.0/tests/fixtures/vanilla_textual_app.py +52 -0
  22. {termwright-0.2.0 → termwright-0.3.0}/tests/test_client.py +25 -119
  23. {termwright-0.2.0 → termwright-0.3.0}/tests/test_debug.py +0 -16
  24. termwright-0.3.0/tests/test_evidence.py +184 -0
  25. {termwright-0.2.0 → termwright-0.3.0}/tests/test_logs.py +6 -3
  26. {termwright-0.2.0 → termwright-0.3.0}/tests/test_messages.py +45 -34
  27. {termwright-0.2.0 → termwright-0.3.0}/tests/test_observation_vectors.py +3 -2
  28. {termwright-0.2.0 → termwright-0.3.0}/tests/test_probe_bootstrap.py +248 -3
  29. {termwright-0.2.0 → termwright-0.3.0}/tests/test_probe_golden.py +67 -29
  30. {termwright-0.2.0 → termwright-0.3.0}/tests/test_probe_session.py +238 -99
  31. {termwright-0.2.0 → termwright-0.3.0}/tests/test_probe_tree.py +112 -41
  32. {termwright-0.2.0 → termwright-0.3.0}/tests/test_textual_annotations.py +14 -17
  33. termwright-0.3.0/tests/test_textual_probe_hook.py +445 -0
  34. termwright-0.3.0/tests/test_validate.py +212 -0
  35. {termwright-0.2.0 → termwright-0.3.0}/uv.lock +3 -3
  36. termwright-0.2.0/src/termwright/diffing.py +0 -118
  37. termwright-0.2.0/src/termwright/validate.py +0 -846
  38. termwright-0.2.0/src/termwright_probe/session.py +0 -197
  39. termwright-0.2.0/src/termwright_probe/textual_probe.py +0 -239
  40. termwright-0.2.0/tests/conftest.py +0 -32
  41. termwright-0.2.0/tests/fixtures/vanilla_textual_app.py +0 -32
  42. termwright-0.2.0/tests/test_deltas.py +0 -117
  43. termwright-0.2.0/tests/test_diffing.py +0 -233
  44. termwright-0.2.0/tests/test_textual_probe_hook.py +0 -54
  45. termwright-0.2.0/tests/test_validate.py +0 -142
  46. {termwright-0.2.0 → termwright-0.3.0}/.gitignore +0 -0
  47. {termwright-0.2.0 → termwright-0.3.0}/examples/permission_app.py +0 -0
  48. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/debug.py +0 -0
  49. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/errors.py +0 -0
  50. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/framing.py +0 -0
  51. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/logging_bridge.py +0 -0
  52. {termwright-0.2.0 → termwright-0.3.0}/src/termwright/logs.py +0 -0
  53. {termwright-0.2.0 → termwright-0.3.0}/src/termwright_probe/defer.py +0 -0
  54. {termwright-0.2.0 → termwright-0.3.0}/tests/test_framing.py +0 -0
  55. {termwright-0.2.0 → termwright-0.3.0}/tests/test_marker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: termwright
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Semantic side-channel client, automatic Textual probe, and annotation SDK for Termwright
5
5
  Project-URL: Homepage, https://github.com/gorce-ai/termwright
6
6
  Project-URL: Source, https://github.com/gorce-ai/termwright/tree/main/clients/python
@@ -17,9 +17,9 @@ Requires-Python: >=3.9
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
19
19
  Requires-Dist: pytest>=7; extra == 'dev'
20
- Requires-Dist: textual>=0.60; extra == 'dev'
20
+ Requires-Dist: textual>=8.2.8; extra == 'dev'
21
21
  Provides-Extra: textual
22
- Requires-Dist: textual>=0.60; extra == 'textual'
22
+ Requires-Dist: textual>=8.2.8; extra == 'textual'
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # termwright (Python)
@@ -28,8 +28,12 @@ Semantic side-channel client, automatic probe and optional annotation SDK for
28
28
  [Textual](https://textual.textualize.io).
29
29
 
30
30
  An instrumented app publishes its widget tree over a unix socket and commits
31
- each render with a signed OSC marker, so the driver can assert on *roles and
32
- names* instead of screen-scraping cells.
31
+ each render with a signed OSC marker, so the driver can assert on _roles and
32
+ names_ instead of screen-scraping cells.
33
+
34
+ The protocol client speaks `termwright/2`. Every published semantic revision
35
+ is a complete v2 snapshot with evidence-qualified geometry and pointer
36
+ observations.
33
37
 
34
38
  **Dormant rule.** Without `TERMWRIGHT_ENDPOINT` and `TERMWRIGHT_TOKEN` the
35
39
  probe installs nothing, opens no socket, writes no marker, and renders exactly
@@ -42,7 +46,10 @@ pip install termwright # protocol client + probe + annotation SDK
42
46
  pip install "termwright[textual]" # + Textual itself
43
47
  ```
44
48
 
45
- Requires Python 3.9+. The protocol modules have no third-party dependencies.
49
+ Requires Python 3.9+. The Textual extra accepts Textual 8.2.8 or newer as an
50
+ advisory packaging floor. Runtime capability checks and behavioral
51
+ conformance, not the version string, decide whether strong semantics engage;
52
+ the protocol modules have no third-party dependencies.
46
53
 
47
54
  ## Automatic Textual semantics
48
55
 
@@ -70,15 +77,15 @@ useful for a custom runner, is:
70
77
  python -m termwright_probe -- python app.py
71
78
  ```
72
79
 
73
- Under the driver this publishes, after every flushed frame:
80
+ Under the driver this publishes after every causally ordered frame enqueue:
74
81
 
75
82
  ```
76
83
  application "PermissionApp"
77
- region bounds=(0,0,80,24)
78
- text "Allow bash to run?" bounds=(0,0,80,1) testId=prompt
79
- button "Approve" bounds=(1,0,80,3) testId=approve [focused]
80
- button "Reject" bounds=(4,0,80,3) testId=reject
81
- textbox "Reason" bounds=(7,0,80,3) testId=reason
84
+ region visibleRect=(0,0,80,24)
85
+ text "Allow bash to run?" visibleRect=(0,0,80,1) testId=prompt
86
+ button "Approve" visibleRect=(1,0,80,3) testId=approve [focused]
87
+ button "Reject" visibleRect=(4,0,80,3) testId=reject
88
+ textbox "Reason" visibleRect=(7,0,80,3) testId=reason
82
89
  ```
83
90
 
84
91
  ### Roles and names
@@ -131,7 +138,7 @@ recreate. `actions` uses the protocol's closed descriptive vocabulary; it never
131
138
  registers an out-of-band callback, and interaction still becomes real PTY
132
139
  input.
133
140
 
134
- The API intentionally has no bounds, focus, visibility, rendered-text or
141
+ The API intentionally has no geometry, focus, visibility, rendered-text or
135
142
  portable-state arguments. Those physical facts remain probe-owned, and merge
136
143
  tests enforce that the annotation cannot replace them.
137
144
 
@@ -147,7 +154,22 @@ Any TUI can drive the client directly. You own the render; the client owns the
147
154
  revision numbers and hands you the marker to write after the render's last byte.
148
155
 
149
156
  ```python
150
- from termwright import SemanticNode, SemanticSnapshot, Rect, client_from_env
157
+ from termwright import (
158
+ NodeGeometryObservations,
159
+ Observation,
160
+ Rect,
161
+ SemanticNode,
162
+ SemanticSnapshot,
163
+ client_from_env,
164
+ framework_evidence,
165
+ )
166
+
167
+ def geometry(rect: Rect) -> NodeGeometryObservations:
168
+ return NodeGeometryObservations(
169
+ displayed=Observation("known", True, evidence=framework_evidence("my-adapter")),
170
+ intendedRect=Observation("known", rect, evidence=framework_evidence("my-adapter")),
171
+ visibleRect=Observation("known", rect, evidence=framework_evidence("my-adapter")),
172
+ )
151
173
 
152
174
  client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0")
153
175
  if client is not None and await client.start():
@@ -156,10 +178,14 @@ if client is not None and await client.start():
156
178
  sessionId="", revision=0, columns=80, rows=24, # both are overwritten
157
179
  rootIds=["root"],
158
180
  nodes=[
159
- SemanticNode(id="root", role="dialog", name="Permission"),
181
+ SemanticNode(id="root", role="dialog", name="Permission",
182
+ geometry=geometry(Rect(0, 0, 80, 24))),
160
183
  SemanticNode(id="ok", parentId="root", role="button", name="Approve",
161
- bounds=Rect(row=1, column=2, width=9, height=1)),
184
+ geometry=geometry(Rect(row=1, column=2, width=9, height=1))),
162
185
  ],
186
+ coordinateSpace=Observation("known", "viewport-cells", evidence=framework_evidence("my-adapter")),
187
+ hitGrid=Observation("unsupported", capability="pointer-hit-grid",
188
+ reason="framework-unobservable"),
163
189
  )
164
190
  )
165
191
  sys.stdout.write(marker) # only after the render is fully written
@@ -208,8 +234,8 @@ or, on a session that came up:
208
234
 
209
235
  ```text
210
236
  tw:sem [p41207] 0.002s dial unix:/tmp/tw-8f21/s timeout=5000ms
211
- tw:sem [p41207] 0.003s hello sent adapter=textual/1.0.0 caps=tree,bounds,…
212
- tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=diffs logs=off
237
+ tw:sem [p41207] 0.003s hello sent adapter=textual/1.0.0 caps=tree,states,actions,render-revisions,intended-geometry,clipped-geometry,pointer-hit-grid
238
+ tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=snapshots logs=off
213
239
  tw:io [3f9c1a04] 0.048s r1 snapshot nodes=17
214
240
  ```
215
241
 
@@ -241,8 +267,16 @@ python -m termwright_probe -- python app.py
241
267
  `app.py` imports no termwright, calls nothing of ours, and is not edited. The
242
268
  launcher puts a generated `sitecustomize.py` on `PYTHONPATH`; CPython imports
243
269
  it during startup, before the script's own directory reaches `sys.path`; the
244
- probe waits there until the application imports Textual and attaches to
245
- `App.post_display_hook`. A driver that already sets `TERMWRIGHT_ENDPOINT` and
270
+ probe waits there until the application imports Textual and observes the
271
+ capability-checked `App._display` / driver enqueue / `post_display_hook`
272
+ boundary. The
273
+ marker is appended without waiting to the same WriterThread FIFO, after the
274
+ frame; a full queue fails the semantic channel instead of blocking Textual's
275
+ event loop. No Textual version is allowlisted. Strong probing accepts only a
276
+ live built-in `LinuxDriver` or `WindowsDriver` whose concrete WriterThread and
277
+ bounded FIFO pass the runtime checks; custom
278
+ `driver_class` values and inline mode fail the semantic channel explicitly
279
+ rather than publishing an unprovable commit. A driver that already sets `TERMWRIGHT_ENDPOINT` and
246
280
  `TERMWRIGHT_TOKEN` can compose the same thing itself:
247
281
 
248
282
  ```python
@@ -265,21 +299,22 @@ the claim that the probe observes rather than redraws.
265
299
 
266
300
  **What it reports automatically.**
267
301
 
268
- | Fact | Where it comes from |
269
- |---|---|
270
- | `bounds` = what is on screen | `MapGeometry.visible_region`, Textual's `clip ∩ region` |
271
- | `occlusion: "known"` | widgets ranked by `MapGeometry.order`, the compositor's own sort key |
272
- | roles for your own widget classes | the MRO, so `SaveButton(Button)` is a button with no registration |
273
- | `frameworkType` on anything unrecognised | the widget's class name |
302
+ | Fact | Where it comes from |
303
+ | ----------------------------------------- | ---------------------------------------------------------------------- |
304
+ | intended and visible rectangles | `MapGeometry.region` and `MapGeometry.visible_region` |
305
+ | exact pointer recipient | `Screen.get_widget_at`, compressed into the snapshot hit grid |
306
+ | roles for your own widget classes | the MRO, so `SaveButton(Button)` is a button with no registration |
307
+ | `frameworkType` on anything unrecognised | the widget's class name |
274
308
  | scrolled out of view vs `display = False` | both `hidden`; the first also `state.offscreen`, with a zero-area rect |
275
309
 
276
- Because paint order is real here, the driver allows pointer actions against
277
- Textual nodes; it refuses them for producers that cannot say whether a node's
278
- cells are covered.
310
+ The driver allows pointer actions only when the snapshot's hit grid names the
311
+ node as the recipient at the target cell. Paint order alone is not sufficient.
279
312
 
280
313
  **Where the injection reaches**, measured on CPython 3.12 (see
281
314
  `docs/architecture/audit/textual.md` for the full table): a plain script,
282
- `-m`, `-c`, a console-script entry point and `uv run` all work. `python -S`
315
+ `-m`, `-c`, a console-script entry point, `uv run` and `poetry run` all work. The
316
+ Python 3.12 CI lane installs both environment managers and executes these real
317
+ subprocess cases. `python -S`
283
318
  and `python -E` do not, and are not meant to — the first disables `site`
284
319
  entirely and the second makes the interpreter ignore `PYTHONPATH`. Both are
285
320
  the person running the interpreter opting out.
@@ -301,28 +336,45 @@ not listed here follows them.
301
336
  type for that reason and for no other — no other state here is inferred.
302
337
  - **Widgets on an inactive screen are absent, not `hidden`** (rule 4). The
303
338
  probe walks `app.screen`, so a pushed-over screen's widgets are not in the
304
- tree at all. A widget hidden on the *active* screen (`display = False`) does
339
+ tree at all. A widget hidden on the _active_ screen (`display = False`) does
305
340
  publish `hidden: true`. Textual owns the screen stack; reaching into it would
306
- mean publishing widgets that no longer receive events.
307
- - **`poetry` is unverified.** Poetry was not installed on the machine where the
308
- injection table was measured. It runs the interpreter from the project venv
309
- as a subprocess and passes the environment through, so it is *expected* to
310
- behave like the venv row, and that expectation has not been confirmed. Treat
311
- a poetry-run application as untested for the probe until somebody watches it
312
- work.
313
- - **The probe instruments grandchildren too.** `PYTHONPATH` is inherited, so a
314
- process the application spawns is also instrumented unless the variable is
315
- scrubbed. Visible to the application as well: it can read its own
316
- environment. This is a property of the injection mechanism, not a decision.
317
- - **The probe does not report `frame-begin`** (probe capability). Textual calls
318
- `post_display_hook` from the `finally` of `App._display`, *after* the frame
319
- has been flushed, so there is no instant the probe could honestly call the
320
- start of a frame. Consumers must not read a missing `frame-begin` as "no
321
- frame in progress".
341
+ mean publishing widgets that no longer receive events. The handshake records
342
+ this narrower limitation as the machine-readable
343
+ `inactive-screen-tree` degradation.
344
+ - **Ownership is process-local and one-shot.** The first application
345
+ interpreter atomically owns the generated bootstrap. Before the
346
+ application's first line, the probe captures its credentials privately and
347
+ removes the endpoint, token, owner marker, and bootstrap path from the
348
+ inherited environment. Python children and grandchildren therefore cannot
349
+ attach to the parent's semantic session. `poetry run` receives a one-hop
350
+ launcher marker so ownership is claimed by its target interpreter, not by
351
+ Poetry's own console process.
352
+ - **The probe does not report `frame-begin`** (probe capability). A frame is
353
+ accepted only when the same `_display` attempt successfully enqueued output
354
+ through a capability-checked non-headless built-in driver before
355
+ `post_display_hook`. The writer is preflighted before snapshot publication,
356
+ then its marker is appended to that same FIFO. A full or replaced writer
357
+ fails closed without blocking on queue capacity. This exposes no instant the
358
+ probe could honestly call the start of a frame. Consumers must not read a
359
+ missing `frame-begin` as "no frame in progress".
322
360
  - **A `Static` subclass with a custom `render()` is named by its `content`**
323
361
  (rule 2), which is the markup it was given rather than what it draws. Textual
324
362
  renders to a strip of segments with no text handle the adapter can read.
325
363
 
364
+ ## Application evidence providers
365
+
366
+ `termwright.evidence` exposes closed pointer, focus, scroll, paint,
367
+ terminal-input-mode, and action-strategy provider families.
368
+ `ApplicationFocusEvidenceProvider.observe` returns a semantic
369
+ recipient mapping or authoritative `None`; the wire keeps `focused` and `none`
370
+ distinct from an unnegotiated provider. Registration must happen before the
371
+ session freeze, and providers publish evidence/recipes only—physical input is
372
+ still sent through the PTY.
373
+
374
+ `ApplicationTerminalInputModeEvidenceProvider.observe` reports the production
375
+ parser's exact mouse and focus modes. It is authoritative evidence for hidden
376
+ ConPTY state, not a callback or a request to synthesize terminal modes.
377
+
326
378
  ## Conformance
327
379
 
328
380
  `tests/` runs against `clients/test-vectors/`, which is generated from the
@@ -4,8 +4,12 @@ Semantic side-channel client, automatic probe and optional annotation SDK for
4
4
  [Textual](https://textual.textualize.io).
5
5
 
6
6
  An instrumented app publishes its widget tree over a unix socket and commits
7
- each render with a signed OSC marker, so the driver can assert on *roles and
8
- names* instead of screen-scraping cells.
7
+ each render with a signed OSC marker, so the driver can assert on _roles and
8
+ names_ instead of screen-scraping cells.
9
+
10
+ The protocol client speaks `termwright/2`. Every published semantic revision
11
+ is a complete v2 snapshot with evidence-qualified geometry and pointer
12
+ observations.
9
13
 
10
14
  **Dormant rule.** Without `TERMWRIGHT_ENDPOINT` and `TERMWRIGHT_TOKEN` the
11
15
  probe installs nothing, opens no socket, writes no marker, and renders exactly
@@ -18,7 +22,10 @@ pip install termwright # protocol client + probe + annotation SDK
18
22
  pip install "termwright[textual]" # + Textual itself
19
23
  ```
20
24
 
21
- Requires Python 3.9+. The protocol modules have no third-party dependencies.
25
+ Requires Python 3.9+. The Textual extra accepts Textual 8.2.8 or newer as an
26
+ advisory packaging floor. Runtime capability checks and behavioral
27
+ conformance, not the version string, decide whether strong semantics engage;
28
+ the protocol modules have no third-party dependencies.
22
29
 
23
30
  ## Automatic Textual semantics
24
31
 
@@ -46,15 +53,15 @@ useful for a custom runner, is:
46
53
  python -m termwright_probe -- python app.py
47
54
  ```
48
55
 
49
- Under the driver this publishes, after every flushed frame:
56
+ Under the driver this publishes after every causally ordered frame enqueue:
50
57
 
51
58
  ```
52
59
  application "PermissionApp"
53
- region bounds=(0,0,80,24)
54
- text "Allow bash to run?" bounds=(0,0,80,1) testId=prompt
55
- button "Approve" bounds=(1,0,80,3) testId=approve [focused]
56
- button "Reject" bounds=(4,0,80,3) testId=reject
57
- textbox "Reason" bounds=(7,0,80,3) testId=reason
60
+ region visibleRect=(0,0,80,24)
61
+ text "Allow bash to run?" visibleRect=(0,0,80,1) testId=prompt
62
+ button "Approve" visibleRect=(1,0,80,3) testId=approve [focused]
63
+ button "Reject" visibleRect=(4,0,80,3) testId=reject
64
+ textbox "Reason" visibleRect=(7,0,80,3) testId=reason
58
65
  ```
59
66
 
60
67
  ### Roles and names
@@ -107,7 +114,7 @@ recreate. `actions` uses the protocol's closed descriptive vocabulary; it never
107
114
  registers an out-of-band callback, and interaction still becomes real PTY
108
115
  input.
109
116
 
110
- The API intentionally has no bounds, focus, visibility, rendered-text or
117
+ The API intentionally has no geometry, focus, visibility, rendered-text or
111
118
  portable-state arguments. Those physical facts remain probe-owned, and merge
112
119
  tests enforce that the annotation cannot replace them.
113
120
 
@@ -123,7 +130,22 @@ Any TUI can drive the client directly. You own the render; the client owns the
123
130
  revision numbers and hands you the marker to write after the render's last byte.
124
131
 
125
132
  ```python
126
- from termwright import SemanticNode, SemanticSnapshot, Rect, client_from_env
133
+ from termwright import (
134
+ NodeGeometryObservations,
135
+ Observation,
136
+ Rect,
137
+ SemanticNode,
138
+ SemanticSnapshot,
139
+ client_from_env,
140
+ framework_evidence,
141
+ )
142
+
143
+ def geometry(rect: Rect) -> NodeGeometryObservations:
144
+ return NodeGeometryObservations(
145
+ displayed=Observation("known", True, evidence=framework_evidence("my-adapter")),
146
+ intendedRect=Observation("known", rect, evidence=framework_evidence("my-adapter")),
147
+ visibleRect=Observation("known", rect, evidence=framework_evidence("my-adapter")),
148
+ )
127
149
 
128
150
  client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0")
129
151
  if client is not None and await client.start():
@@ -132,10 +154,14 @@ if client is not None and await client.start():
132
154
  sessionId="", revision=0, columns=80, rows=24, # both are overwritten
133
155
  rootIds=["root"],
134
156
  nodes=[
135
- SemanticNode(id="root", role="dialog", name="Permission"),
157
+ SemanticNode(id="root", role="dialog", name="Permission",
158
+ geometry=geometry(Rect(0, 0, 80, 24))),
136
159
  SemanticNode(id="ok", parentId="root", role="button", name="Approve",
137
- bounds=Rect(row=1, column=2, width=9, height=1)),
160
+ geometry=geometry(Rect(row=1, column=2, width=9, height=1))),
138
161
  ],
162
+ coordinateSpace=Observation("known", "viewport-cells", evidence=framework_evidence("my-adapter")),
163
+ hitGrid=Observation("unsupported", capability="pointer-hit-grid",
164
+ reason="framework-unobservable"),
139
165
  )
140
166
  )
141
167
  sys.stdout.write(marker) # only after the render is fully written
@@ -184,8 +210,8 @@ or, on a session that came up:
184
210
 
185
211
  ```text
186
212
  tw:sem [p41207] 0.002s dial unix:/tmp/tw-8f21/s timeout=5000ms
187
- tw:sem [p41207] 0.003s hello sent adapter=textual/1.0.0 caps=tree,bounds,…
188
- tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=diffs logs=off
213
+ tw:sem [p41207] 0.003s hello sent adapter=textual/1.0.0 caps=tree,states,actions,render-revisions,intended-geometry,clipped-geometry,pointer-hit-grid
214
+ tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=snapshots logs=off
189
215
  tw:io [3f9c1a04] 0.048s r1 snapshot nodes=17
190
216
  ```
191
217
 
@@ -217,8 +243,16 @@ python -m termwright_probe -- python app.py
217
243
  `app.py` imports no termwright, calls nothing of ours, and is not edited. The
218
244
  launcher puts a generated `sitecustomize.py` on `PYTHONPATH`; CPython imports
219
245
  it during startup, before the script's own directory reaches `sys.path`; the
220
- probe waits there until the application imports Textual and attaches to
221
- `App.post_display_hook`. A driver that already sets `TERMWRIGHT_ENDPOINT` and
246
+ probe waits there until the application imports Textual and observes the
247
+ capability-checked `App._display` / driver enqueue / `post_display_hook`
248
+ boundary. The
249
+ marker is appended without waiting to the same WriterThread FIFO, after the
250
+ frame; a full queue fails the semantic channel instead of blocking Textual's
251
+ event loop. No Textual version is allowlisted. Strong probing accepts only a
252
+ live built-in `LinuxDriver` or `WindowsDriver` whose concrete WriterThread and
253
+ bounded FIFO pass the runtime checks; custom
254
+ `driver_class` values and inline mode fail the semantic channel explicitly
255
+ rather than publishing an unprovable commit. A driver that already sets `TERMWRIGHT_ENDPOINT` and
222
256
  `TERMWRIGHT_TOKEN` can compose the same thing itself:
223
257
 
224
258
  ```python
@@ -241,21 +275,22 @@ the claim that the probe observes rather than redraws.
241
275
 
242
276
  **What it reports automatically.**
243
277
 
244
- | Fact | Where it comes from |
245
- |---|---|
246
- | `bounds` = what is on screen | `MapGeometry.visible_region`, Textual's `clip ∩ region` |
247
- | `occlusion: "known"` | widgets ranked by `MapGeometry.order`, the compositor's own sort key |
248
- | roles for your own widget classes | the MRO, so `SaveButton(Button)` is a button with no registration |
249
- | `frameworkType` on anything unrecognised | the widget's class name |
278
+ | Fact | Where it comes from |
279
+ | ----------------------------------------- | ---------------------------------------------------------------------- |
280
+ | intended and visible rectangles | `MapGeometry.region` and `MapGeometry.visible_region` |
281
+ | exact pointer recipient | `Screen.get_widget_at`, compressed into the snapshot hit grid |
282
+ | roles for your own widget classes | the MRO, so `SaveButton(Button)` is a button with no registration |
283
+ | `frameworkType` on anything unrecognised | the widget's class name |
250
284
  | scrolled out of view vs `display = False` | both `hidden`; the first also `state.offscreen`, with a zero-area rect |
251
285
 
252
- Because paint order is real here, the driver allows pointer actions against
253
- Textual nodes; it refuses them for producers that cannot say whether a node's
254
- cells are covered.
286
+ The driver allows pointer actions only when the snapshot's hit grid names the
287
+ node as the recipient at the target cell. Paint order alone is not sufficient.
255
288
 
256
289
  **Where the injection reaches**, measured on CPython 3.12 (see
257
290
  `docs/architecture/audit/textual.md` for the full table): a plain script,
258
- `-m`, `-c`, a console-script entry point and `uv run` all work. `python -S`
291
+ `-m`, `-c`, a console-script entry point, `uv run` and `poetry run` all work. The
292
+ Python 3.12 CI lane installs both environment managers and executes these real
293
+ subprocess cases. `python -S`
259
294
  and `python -E` do not, and are not meant to — the first disables `site`
260
295
  entirely and the second makes the interpreter ignore `PYTHONPATH`. Both are
261
296
  the person running the interpreter opting out.
@@ -277,28 +312,45 @@ not listed here follows them.
277
312
  type for that reason and for no other — no other state here is inferred.
278
313
  - **Widgets on an inactive screen are absent, not `hidden`** (rule 4). The
279
314
  probe walks `app.screen`, so a pushed-over screen's widgets are not in the
280
- tree at all. A widget hidden on the *active* screen (`display = False`) does
315
+ tree at all. A widget hidden on the _active_ screen (`display = False`) does
281
316
  publish `hidden: true`. Textual owns the screen stack; reaching into it would
282
- mean publishing widgets that no longer receive events.
283
- - **`poetry` is unverified.** Poetry was not installed on the machine where the
284
- injection table was measured. It runs the interpreter from the project venv
285
- as a subprocess and passes the environment through, so it is *expected* to
286
- behave like the venv row, and that expectation has not been confirmed. Treat
287
- a poetry-run application as untested for the probe until somebody watches it
288
- work.
289
- - **The probe instruments grandchildren too.** `PYTHONPATH` is inherited, so a
290
- process the application spawns is also instrumented unless the variable is
291
- scrubbed. Visible to the application as well: it can read its own
292
- environment. This is a property of the injection mechanism, not a decision.
293
- - **The probe does not report `frame-begin`** (probe capability). Textual calls
294
- `post_display_hook` from the `finally` of `App._display`, *after* the frame
295
- has been flushed, so there is no instant the probe could honestly call the
296
- start of a frame. Consumers must not read a missing `frame-begin` as "no
297
- frame in progress".
317
+ mean publishing widgets that no longer receive events. The handshake records
318
+ this narrower limitation as the machine-readable
319
+ `inactive-screen-tree` degradation.
320
+ - **Ownership is process-local and one-shot.** The first application
321
+ interpreter atomically owns the generated bootstrap. Before the
322
+ application's first line, the probe captures its credentials privately and
323
+ removes the endpoint, token, owner marker, and bootstrap path from the
324
+ inherited environment. Python children and grandchildren therefore cannot
325
+ attach to the parent's semantic session. `poetry run` receives a one-hop
326
+ launcher marker so ownership is claimed by its target interpreter, not by
327
+ Poetry's own console process.
328
+ - **The probe does not report `frame-begin`** (probe capability). A frame is
329
+ accepted only when the same `_display` attempt successfully enqueued output
330
+ through a capability-checked non-headless built-in driver before
331
+ `post_display_hook`. The writer is preflighted before snapshot publication,
332
+ then its marker is appended to that same FIFO. A full or replaced writer
333
+ fails closed without blocking on queue capacity. This exposes no instant the
334
+ probe could honestly call the start of a frame. Consumers must not read a
335
+ missing `frame-begin` as "no frame in progress".
298
336
  - **A `Static` subclass with a custom `render()` is named by its `content`**
299
337
  (rule 2), which is the markup it was given rather than what it draws. Textual
300
338
  renders to a strip of segments with no text handle the adapter can read.
301
339
 
340
+ ## Application evidence providers
341
+
342
+ `termwright.evidence` exposes closed pointer, focus, scroll, paint,
343
+ terminal-input-mode, and action-strategy provider families.
344
+ `ApplicationFocusEvidenceProvider.observe` returns a semantic
345
+ recipient mapping or authoritative `None`; the wire keeps `focused` and `none`
346
+ distinct from an unnegotiated provider. Registration must happen before the
347
+ session freeze, and providers publish evidence/recipes only—physical input is
348
+ still sent through the PTY.
349
+
350
+ `ApplicationTerminalInputModeEvidenceProvider.observe` reports the production
351
+ parser's exact mouse and focus modes. It is authoritative evidence for hidden
352
+ ConPTY state, not a callback or a request to synthesize terminal modes.
353
+
302
354
  ## Conformance
303
355
 
304
356
  `tests/` runs against `clients/test-vectors/`, which is generated from the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "termwright"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Semantic side-channel client, automatic Textual probe, and annotation SDK for Termwright"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -22,8 +22,8 @@ classifiers = [
22
22
  dependencies = []
23
23
 
24
24
  [project.optional-dependencies]
25
- textual = ["textual>=0.60"]
26
- dev = ["pytest>=7", "pytest-asyncio>=0.21", "textual>=0.60"]
25
+ textual = ["textual>=8.2.8"]
26
+ dev = ["pytest>=7", "pytest-asyncio>=0.21", "textual>=8.2.8"]
27
27
 
28
28
  [project.urls]
29
29
  Homepage = "https://github.com/gorce-ai/termwright"
@@ -20,13 +20,29 @@ from .client import (
20
20
  CAPABILITIES_WITH_LOGS,
21
21
  DEFAULT_CAPABILITIES,
22
22
  ENV_ENDPOINT,
23
- ENV_PROTOCOL,
24
23
  ENV_TOKEN,
25
24
  SemanticClient,
26
25
  client_from_env,
27
26
  )
28
27
  from .debug import ENV_DEBUG, ENV_DEBUG_FILE, DebugLog, debug_path
29
28
  from .errors import ProtocolViolation, TermwrightError
29
+ from .evidence import (
30
+ ApplicationActionStrategyProvider,
31
+ ApplicationFocusEvidenceProvider,
32
+ ApplicationScrollEvidenceProvider,
33
+ ApplicationPaintEvidenceProvider,
34
+ ApplicationTerminalInputModeEvidenceProvider,
35
+ EvidenceProviderLifecycleError,
36
+ EvidenceProviderRegistry,
37
+ EvidenceProviderRegistration,
38
+ EvidenceRevisionContext,
39
+ default_evidence_provider_registry,
40
+ register_action_strategy_provider,
41
+ register_focus_evidence_provider,
42
+ register_scroll_evidence_provider,
43
+ register_paint_evidence_provider,
44
+ register_terminal_input_mode_evidence_provider,
45
+ )
30
46
  from .framing import FRAME_HEADER_BYTES, FrameDecoder, encode_frame, project_dto
31
47
  from .limits import ABSOLUTE_LIMITS, DEFAULT_LIMITS, ProtocolLimits
32
48
  from .logs import (
@@ -53,20 +69,30 @@ from .messages import (
53
69
  parse_adapter_message,
54
70
  parse_driver_message,
55
71
  )
56
- from .roles import ADAPTER_CAPABILITIES, SEMANTIC_ACTIONS, SEMANTIC_ROLES
72
+ from .roles import (
73
+ ADAPTER_CAPABILITIES,
74
+ EVIDENCE_PROVIDER_CAPABILITIES,
75
+ SEMANTIC_ACTIONS,
76
+ SEMANTIC_ROLES,
77
+ )
57
78
  from .tree import (
79
+ EvidenceProvenance,
58
80
  CursorInfo,
59
81
  NodeGeometryObservations,
60
82
  Observation,
83
+ framework_evidence,
61
84
  Rect,
62
85
  SemanticNode,
63
86
  SemanticSnapshot,
87
+ SemanticScrollState,
88
+ SemanticPaintedRegion,
64
89
  SemanticState,
65
90
  SemanticTextRange,
91
+ SemanticValueObservation,
66
92
  )
67
- from .validate import ValidationResult, apply_tree_delta, validate_snapshot, validate_tree_delta
93
+ from .validate import ValidationResult, validate_snapshot
68
94
 
69
- __version__ = "0.2.0"
95
+ __version__ = "0.3.0"
70
96
 
71
97
  __all__ = [
72
98
  "ABSOLUTE_LIMITS",
@@ -79,14 +105,23 @@ __all__ = [
79
105
  "flatten_attrs",
80
106
  "validate_log_record",
81
107
  "ADAPTER_CAPABILITIES",
108
+ "ApplicationActionStrategyProvider",
109
+ "ApplicationFocusEvidenceProvider",
110
+ "ApplicationScrollEvidenceProvider",
111
+ "ApplicationPaintEvidenceProvider",
112
+ "ApplicationTerminalInputModeEvidenceProvider",
82
113
  "DEFAULT_CAPABILITIES",
83
114
  "DEFAULT_LIMITS",
84
115
  "DebugLog",
85
116
  "ENV_DEBUG",
86
117
  "ENV_DEBUG_FILE",
87
118
  "ENV_ENDPOINT",
88
- "ENV_PROTOCOL",
89
119
  "ENV_TOKEN",
120
+ "EvidenceProviderLifecycleError",
121
+ "EvidenceProviderRegistry",
122
+ "EvidenceProviderRegistration",
123
+ "EvidenceRevisionContext",
124
+ "EVIDENCE_PROVIDER_CAPABILITIES",
90
125
  "FRAME_HEADER_BYTES",
91
126
  "FrameDecoder",
92
127
  "MARKER_MAC_BYTES",
@@ -99,27 +134,36 @@ __all__ = [
99
134
  "ProtocolViolation",
100
135
  "RenderMarker",
101
136
  "CursorInfo",
137
+ "EvidenceProvenance",
102
138
  "NodeGeometryObservations",
103
139
  "Observation",
140
+ "framework_evidence",
104
141
  "Rect",
105
142
  "SEMANTIC_ACTIONS",
106
143
  "SEMANTIC_ROLES",
107
144
  "SemanticClient",
108
145
  "SemanticNode",
109
146
  "SemanticSnapshot",
147
+ "SemanticScrollState",
148
+ "SemanticPaintedRegion",
110
149
  "SemanticState",
111
150
  "SemanticTextRange",
151
+ "SemanticValueObservation",
112
152
  "TermwrightError",
113
153
  "ValidationResult",
114
154
  "client_from_env",
115
155
  "debug_path",
156
+ "default_evidence_provider_registry",
116
157
  "encode_frame",
117
158
  "encode_marker",
118
159
  "parse_adapter_message",
119
160
  "parse_driver_message",
120
161
  "project_dto",
121
- "apply_tree_delta",
162
+ "register_action_strategy_provider",
163
+ "register_focus_evidence_provider",
164
+ "register_scroll_evidence_provider",
165
+ "register_paint_evidence_provider",
166
+ "register_terminal_input_mode_evidence_provider",
122
167
  "validate_snapshot",
123
- "validate_tree_delta",
124
168
  "verify_marker_payload",
125
169
  ]