tear_types/host_role.rs
1//! Who answers a VT query on a pane — the Relay→Host transition.
2//!
3//! ## Why this is a type and not a behaviour change
4//!
5//! A running program can ASK the terminal things: "where is the cursor?"
6//! (`CSI 6n`), "what are you?" (`CSI c`). Exactly one participant may
7//! answer. Two answers is not a cosmetic bug — the second reply arrives on
8//! the PTY as if the operator had typed it, so a shell sees a line of
9//! garbage like `^[[24;80R` injected into its input.
10//!
11//! Today tear is a **relay**: it passes query bytes through to whatever
12//! terminal is attached, and mado answers from its own parser. That is
13//! pinned by `tear-core`'s espelho conformance rows, whose header states it
14//! outright — *"`feed()` has no write-back surface at all, so it cannot
15//! answer `ESC[6n` itself … the host duty lives one layer DOWN."*
16//!
17//! [`SHUKEN`](https://github.com/pleme-io/tear/blob/main/docs/SHUKEN.md)
18//! changes that. Once `PaneGrid` is the sole VT authority, mado has no
19//! parser and *cannot* answer — so if tear also does not, nothing does, and
20//! every program that probes the terminal hangs waiting for a reply that no
21//! longer exists. Prompt libraries using CPR and DA-based capability
22//! detection are the common casualties.
23//!
24//! So the role must move Relay → Host. But it cannot move *today*, because
25//! mado still parses: tear answering now would mean BOTH answer. This enum
26//! is that transition made explicit and typed, defaulting to the shipped
27//! behaviour, per ★★ MODULARIZE, DON'T DELETE — the relay path is
28//! configured off, never removed.
29
30use serde::{Deserialize, Serialize};
31
32/// Which participant answers VT queries for a pane.
33#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum HostRole {
36 /// tear relays query bytes downstream and answers **nothing**. The
37 /// attached terminal is the host.
38 ///
39 /// The DEFAULT, and the currently shipped behaviour — a pane in this
40 /// role is byte-for-byte what tear did before the response path
41 /// existed, which is what makes landing that path a no-op today.
42 #[default]
43 Relay,
44 /// tear answers queries itself and queues the reply for write-back to
45 /// the PTY.
46 ///
47 /// Required once the attached client has no parser of its own. Setting
48 /// this while a parsing terminal is still attached produces DOUBLE
49 /// replies — see the module doc.
50 Host,
51}
52
53impl HostRole {
54 #[must_use]
55 pub const fn answers_queries(self) -> bool {
56 matches!(self, Self::Host)
57 }
58}
59
60/// What tear advertises about itself when it is the [`HostRole::Host`].
61///
62/// ## These constants are a PROMISE, not a copy
63///
64/// A DA reply is a capability advertisement: a program reads it and then
65/// *uses* what it claims. So each field here has to be true of tear's own
66/// renderer, and the temptation to paste mado's constants is a trap.
67///
68/// mado advertises `\x1b[?62;4;22c`. The `4` means **sixel**, and mado's own
69/// comment notes it was added "since the decode path landed". tear has no
70/// sixel: `GridState` implements no `hook`/`put`/`unhook`, so a DCS image
71/// payload is swallowed by vte's default no-op. Advertising `4` would tell
72/// every program on the system to send image data tear cannot draw.
73///
74/// So tear advertises VT220 + ANSI colour and nothing it cannot honour.
75/// **When graphics land in `PaneGrid`, this constant moves in the same
76/// commit** — that coupling is the point of it living beside the role.
77pub struct TearCaps;
78
79impl TearCaps {
80 /// Primary DA (`CSI c`): VT220 (62) with ANSI colour (22).
81 ///
82 /// Deliberately WITHOUT `4` (sixel) — see the type doc.
83 pub const PRIMARY_DA: &'static [u8] = b"\x1b[?62;22c";
84
85 /// Secondary DA (`CSI > c`): terminal id 1, version 0, cartridge 0 —
86 /// the same shape mado reports.
87 pub const SECONDARY_DA: &'static [u8] = b"\x1b[>1;0;0c";
88
89 /// Device status report, "terminal OK" (`CSI 5n`).
90 pub const STATUS_OK: &'static [u8] = b"\x1b[0n";
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn relay_is_the_default_so_landing_the_host_path_changes_nothing() {
99 assert_eq!(HostRole::default(), HostRole::Relay);
100 assert!(!HostRole::default().answers_queries());
101 }
102
103 #[test]
104 fn host_answers() {
105 assert!(HostRole::Host.answers_queries());
106 }
107
108 /// tear must not advertise a capability it cannot honour. The `4`
109 /// parameter is sixel; tear has no DCS hook, so claiming it would tell
110 /// programs to send image data that gets silently swallowed.
111 ///
112 /// When sixel lands in `PaneGrid`, this test is what forces the
113 /// advertisement to move with it.
114 #[test]
115 fn primary_da_does_not_advertise_sixel_tear_cannot_render() {
116 let da = std::str::from_utf8(TearCaps::PRIMARY_DA).unwrap();
117 let params: Vec<&str> = da
118 .trim_start_matches("\x1b[?")
119 .trim_end_matches('c')
120 .split(';')
121 .collect();
122 assert!(
123 !params.contains(&"4"),
124 "PRIMARY_DA advertises sixel (4) but PaneGrid implements no \
125 hook/put/unhook — either add the DCS path or drop the claim: {da:?}"
126 );
127 assert!(params.contains(&"62"), "should advertise VT220");
128 assert!(params.contains(&"22"), "should advertise ANSI colour");
129 }
130}