Skip to main content

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}