Selkie.git | docs/api/ | Selkie--App--Internal--TerminalReport.md
NAME
====
Selkie::App::Internal::TerminalReport - recognise fragmented terminal reports
DESCRIPTION
===========
Implementation detail for `Selkie::App`'s input dispatch. Application code never calls this; see the `Fragmented terminal reports` section of `Selkie::App` for the user-facing description of what it defends against.
The problem
-----------
A terminal answers capability queries (DA1, DA2, CPR, XTWINOPS, XTGETTCAP, XTVERSION, XTSMGRAPHICS, DECRPM, the kitty keyboard and graphics protocols, OSC colour queries) by writing an escape sequence back on the tty as if the user had typed it. notcurses recognises those replies and swallows them — **but only when the whole reply lands in a single `read(2)`**. Its own rule, from `src/lib/automaton.c`, is that "an escape must arrive as a single unit to be interpreted as an escape"; a reply that straddles two reads is treated as an aborted sequence and **replayed to the application verbatim, byte by byte, as ordinary keypresses**.
The gap needed to trigger that is sub-millisecond. Measured against notcurses 3.0.17 in a pty: writing `\e[?64;1;2;6;9;15;18;21;22c` in one `write(2)` is absorbed silently, while splitting it in two with a 0.5ms pause delivers 26 key events — `Escape`, then `[`, `?`, `6`, `4`, `;` … `c`. Into a focused text field, that is 25 characters of garbage the user never typed; into a *masked* field, 25 bullets nobody can read back. The regime that produces the fragmentation is exactly a saturated CPU (Cantina's post-login module warm-up child, say) delaying the reading thread between the terminal's two writes.
The defence
-----------
`terminal-report-prefix` is a strict recogniser for complete control sequences that only a terminal can have produced. `Selkie::App`'s input dispatch runs it over the text of an `Escape`-led input burst and drops what it matches, so a fragmented reply is discarded instead of being typed into whatever has focus.
Two independent properties keep it away from real keystrokes:
* **Shape.** A match must be `Escape`, an introducer, a body built only from the character class that introducer allows, and a *report* final byte. `Escape` followed by ordinary text, by an arrow key, by a bare `[`, or by anything with a character outside the parameter class fails to match and is dispatched untouched.
* **Timing.** The caller only ever offers characters that were already sitting in notcurses's queue at one instant — pulled with the non-blocking `notcurses_get_nblock`, never waited for. Filling that queue with a six-plus character control sequence between two consecutive polls of a loop that runs at 60Hz is not something a human hand does; a terminal answering a query does it every time.
Neither property is load-bearing alone, which is the point: matching requires both a byte sequence nobody types and a delivery rate nobody can type at.
Grammar
-------
report := ESC ( csi | string )
csi := '[' private? params intermediates final
private := one of ? > = <
params := ( digit | ';' | ':' )+ # at least one
intermediates := ( '$' | ' ' | '!' | '"' )*
final := one of c R t u S y n # any private introducer
mouse := '<' button ';' coordinate ';' coordinate ( 'M' | 'm' )
button := digit+
coordinate := '-'? digit+
string := ( 'P' | ']' | '_' | '^' ) body terminator # DCS / OSC / APC / PM
body := any run of characters containing no ESC
terminator := ESC '\' # ST
| "\a" # BEL, OSC only
`params` being mandatory and non-empty is what excludes the cursor keys: `\e[A` has no parameters and `A` is not a report final, so an arrow key that somehow arrived in pieces is passed through rather than eaten.
The `M`/`m` finals are admitted only for the exact three-field SGR mouse shape. Coordinates may be negative: terminals can report a pointer outside the drawable plane with sequences such as `\e[E<lt>0;3510;-10M`. The sign is deliberately part of the mouse grammar, not the generic CSI parameter class. Bare `\e[…m` is an SGR colour sequence — output, never input — and `\e[…M` is the X10 mouse encoding, whose payload bytes are not restricted to the parameter class and so cannot match anyway.
Deliberate losses
-----------------
`u` and `R` finals are shared between terminal reports and the kitty keyboard protocol's encoding of real keypresses (`\e[97;2u` is Shift+A; `\e[1;5R` is Ctrl+F3). A fragmented kitty keypress therefore gets dropped rather than delivered. That is the right trade: without the filter the same fragment does not arrive as Shift+A either — it arrives as the six literal characters `[97;2u` inserted into the focused widget. Losing one keystroke beats typing six.
Bounds
------
`MAX-REPORT-CHARS` caps how far the recogniser will scan. A string sequence (DCS/OSC/APC/PM) whose terminator never arrives cannot make the scan run away over a large paste, and an unterminated fragment is passed through as input rather than being held back.
SEE ALSO
========
`Selkie::App`, `Selkie::App::Internal::Dispatch`
### sub terminal-report-prefix
```raku
sub terminal-report-prefix(
Str:D $text
) returns Int
```
Longest control sequence the recogniser will consider. Real replies are far shorter — the longest in the wild is an XTGETTCAP answer carrying a hex-encoded terminfo string, comfortably under 128 characters. The cap exists so an unterminated `\eP…` fragment at the head of a large paste cannot turn the scan into an O(paste) walk on every burst. Private-parameter introducers permitted directly after `ESC [`. Characters a CSI parameter list may be built from. Decimal digits used by the stricter SGR mouse grammar. Intermediate bytes permitted between the parameters and the final. Final bytes that mark a CSI as a *report*: `c` device attributes, `R` cursor position, `t` window manipulation, `u` kitty keyboard flags, `S` XTSMGRAPHICS, `y` DECRPM, `n` device status. Extra finals allowed only behind the C<E<lt>> (SGR mouse) introducer. Introducers for the ST-terminated string sequences. Length in characters of the complete terminal report at the head of `$text`, or `0` when `$text` does not begin with one. The scan is strictly prefix-anchored and never looks past `MAX-REPORT-CHARS`: an incomplete sequence returns `0` exactly like a non-matching one, so a caller that keeps the unmatched text is keeping everything it was given. =begin code :lang<raku> terminal-report-prefix("\e[?64;1;2;6;9;15;18;21;22c"); # 26 terminal-report-prefix("\e[?1u" ~ 'hunter2'); # 5 terminal-report-prefix("\eP1+r5463=78\e\\"); # 13 terminal-report-prefix("\e[A"); # 0 — cursor up terminal-report-prefix("\e[?64;1;2"); # 0 — incomplete terminal-report-prefix('hunter2'); # 0 =end code
### sub csi-report-prefix
```raku
sub csi-report-prefix(
Str:D $text
) returns Int
```
CSI branch of `terminal-report-prefix`. Walks private introducer, parameters, intermediates and final in that order, bailing out to `0` the moment a character falls outside what the position allows.
### sub sgr-mouse-report-prefix
```raku
sub sgr-mouse-report-prefix(
Str:D $text
) returns Int
```
SGR mouse branch of `terminal-report-prefix`. The button code is unsigned; each coordinate may carry a leading minus sign. Exactly three non-empty decimal fields separated by semicolons are required.
### sub terminal-report-strip-length
```raku
sub terminal-report-strip-length(
Str:D $text
) returns Int
```
Total length of the run of **consecutive** complete terminal reports at the head of `$text` — `0` when it does not start with one. Replies do not arrive one at a time. A terminal answering the start-up probe burst sends DA1, DA2, the cursor position and the window geometry back to back, and if the whole run is replayed as input then the whole run has to come off the front, not just its first member. =begin code :lang<raku> terminal-report-strip-length("\e[?62;22c\e[>1;10;0c"); # 20 terminal-report-strip-length("\e[?1u" ~ 'hunter2'); # 5 terminal-report-strip-length('hunter2'); # 0 =end code
### sub terminal-report-viable
```raku
sub terminal-report-viable(
Str:D $text
) returns Bool
```
True when `$text` is not yet a complete terminal report but could still become one if more characters arrive — every character so far is legal for its position and no terminating byte has been seen. This is what lets the caller **wait** rather than guess. The reply to a capability query does not just arrive late; under load it arrives in pieces, and notcurses replays each piece separately, so the fragments reach Selkie in different input bursts. Recognising a half-arrived report is what turns "dispatch `[?64;1;2` into the password field, then `;6;9;15;18;21;22c` a frame later" into "hold briefly, reassemble, drop". =begin code :lang<raku> terminal-report-viable("\e[?64;1;2"); # True — mid-parameters terminal-report-viable("\e["); # True — mid-CSI terminal-report-viable("\eP1+r5463"); # True — string, no ST yet terminal-report-viable("\e[?64;1;2c"); # False — already complete terminal-report-viable("\e[?64;1;2h"); # False — terminated, not a report terminal-report-viable("\e[A"); # False — terminated, not a report terminal-report-viable("\ehunter2"); # False — never was one =end code
### sub csi-viable
```raku
sub csi-viable(
Str:D $text
) returns Bool
```
CSI branch of `terminal-report-viable`: viable while every character past the introducer is a private marker, a parameter character or an intermediate — i.e. while no final byte has been reached. Reaching a final byte means the sequence is over, and `terminal-report-prefix` has already ruled it out as a report.
### sub sgr-mouse-viable
```raku
sub sgr-mouse-viable(
Str:D $text
) returns Bool
```
True while an exact SGR mouse report is incomplete but every byte received so far is legal for its position.
### sub string-report-prefix
```raku
sub string-report-prefix(
Str:D $text
) returns Int
```
DCS / OSC / APC / PM branch: everything up to the first ST (`ESC \`), or — for OSC only, which is the one sequence terminals still terminate the old way — a BEL. An embedded ESC that is not the start of an ST aborts the match: that is a new sequence beginning, not payload.