Skip to main content

tear_types/
pane.rs

1//! Pane — the atomic unit that runs a shell and owns its PTY.
2
3use serde::{Deserialize, Serialize};
4
5use crate::id::PaneId;
6use crate::yurai::Yurai;
7
8/// One pane: the typed metadata about a running terminal session +
9/// its renderable state. The actual PTY handle, terminal-state-machine
10/// grid, and reader/writer tasks live in `tear-core::InProcess` —
11/// this struct is the serde-friendly typed surface that crosses the
12/// daemon-RPC boundary and is consumed by mado.
13#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
14pub struct TearPane {
15    pub id: PaneId,
16    /// Shell command executed in this pane (e.g. `"/run/current-system/sw/bin/zsh"`).
17    pub shell: String,
18    /// Optional arguments passed after the shell command.
19    #[serde(default)]
20    pub args: Vec<String>,
21    /// Working directory at spawn time. `None` means inherit from the
22    /// session.
23    #[serde(default)]
24    pub cwd: Option<String>,
25    /// Environment overrides applied to this pane's child only —
26    /// e.g. `TERM=xterm-ghostty`, `COLORTERM=truecolor`. Merged on top
27    /// of the parent environment.
28    #[serde(default)]
29    pub env: Vec<(String, String)>,
30    /// Current pane size in terminal cells (cols, rows). The
31    /// multiplexer keeps this in sync with the actual PTY winsize.
32    pub size_cells: (u16, u16),
33    /// Cell at the top-left within the parent window (0, 0)-based.
34    /// Computed by the layout engine; serialised so the daemon can
35    /// hand mado a render-ready snapshot.
36    pub origin_cells: (u16, u16),
37    /// Lifecycle state.
38    pub state: PaneState,
39    /// Title — operator-set (via OSC 2) or derived from the running
40    /// program. Drives status-bar segment rendering.
41    #[serde(default)]
42    pub title: String,
43    /// Input acceptance policy. Default `Free` accepts input from
44    /// every connected client (mado, `tear send-keys`, MCP); the
45    /// daemon writes the bytes verbatim to the PTY in the order
46    /// they arrive. `Locked` rejects every send_keys with a typed
47    /// error — useful for "demo / observer" sessions, AI-driven
48    /// panes where human input would interleave with the agent,
49    /// or for the migration handoff window. New variants
50    /// (`Leader`, `OwnerOnly`) land alongside Subscribe-assigned
51    /// client identity in a future iteration.
52    #[serde(default)]
53    pub input_policy: InputPolicy,
54    /// Provenance — what kind of actor spawned this pane.
55    ///
56    /// Stamped ONCE at spawn from the creating connection's
57    /// [`crate::shutai::Shutai`] and never rewritten. This is the field
58    /// `freio` filters on: the brake stops automation-driven panes and
59    /// leaves the operator's own alone.
60    ///
61    /// It is a lossy PROJECTION of a shutai, not a shutai, and the reason
62    /// is structural — a pane outlives the connection that made it, and
63    /// `Shutai` deliberately has no `Deserialize` while this struct
64    /// derives one, so holding a live identity here does not compile. See
65    /// [`crate::yurai`].
66    ///
67    /// `#[serde(default)]` → [`Yurai::Unknown`], which is exactly what a
68    /// record from a pre-yurai daemon honestly means.
69    #[serde(default)]
70    pub yurai: Yurai,
71}
72
73/// Input acceptance policy for a single pane. Per-pane (not
74/// per-client) so the daemon enforces with one lookup on every
75/// `send_keys`.
76#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(tag = "kind", rename_all = "snake_case")]
78pub enum InputPolicy {
79    /// Default. Every connected client's `send_keys` is written to
80    /// the PTY in arrival order. Multi-renderer attach is allowed
81    /// to interleave input.
82    Free,
83    /// Rejects every `send_keys` with `WireError::Rejected`. The
84    /// pane is observe-only — every PaneBytes subscriber still
85    /// gets output, but no one can type. Lifted via
86    /// `Request::SetInputPolicy(pane, InputPolicy::Free)`.
87    Locked,
88    /// #2 — only the client whose connection identified itself with
89    /// `Request::IdentifyClient(id)` matching this leader id may
90    /// send keys; every other client is `Rejected`. Use case: an AI
91    /// agent owns a pane and a human can watch (mado subscriber)
92    /// without interleaving keystrokes. The leader id is operator-
93    /// chosen — a stable 64-bit token your agent surfaces via
94    /// `TEAR_CLIENT_ID`.
95    ///
96    /// Encoded as a struct variant so serde's internally-tagged
97    /// representation can carry the integer payload (tagged
98    /// representation can't hold a primitive in a tuple variant).
99    Leader { id: u64 },
100}
101
102impl Default for InputPolicy {
103    fn default() -> Self {
104        Self::Free
105    }
106}
107
108impl InputPolicy {
109    /// Short label for `tear list` / `tear pane status` text output.
110    #[must_use]
111    pub fn label(&self) -> &'static str {
112        match self {
113            InputPolicy::Free => "free",
114            InputPolicy::Locked => "locked",
115            InputPolicy::Leader { .. } => "leader",
116        }
117    }
118
119    /// Convenience constructor for `Leader { id }` so call sites
120    /// don't repeat the struct-literal shape.
121    #[must_use]
122    pub fn leader(id: u64) -> Self {
123        InputPolicy::Leader { id }
124    }
125
126    /// Returns the gating client id if this policy is `Leader`,
127    /// else `None`. Cheaper than `if let` at call sites that only
128    /// want to peek at the leader id.
129    #[must_use]
130    pub fn leader_id(&self) -> Option<u64> {
131        match self {
132            InputPolicy::Leader { id } => Some(*id),
133            _ => None,
134        }
135    }
136}
137
138/// Pane lifecycle states.
139#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
140#[serde(rename_all = "lowercase")]
141pub enum PaneState {
142    /// Process is alive and accepting input.
143    Running,
144    /// Process exited; pane stays visible until closed. tmux's
145    /// `remain-on-exit` semantics.
146    Exited { code: i32 },
147    /// Pane was created but the child hasn't started yet (rare —
148    /// only during the short window between `TearPane::spawn` and
149    /// the first PTY read).
150    Spawning,
151}
152
153impl Default for PaneState {
154    fn default() -> Self {
155        Self::Spawning
156    }
157}
158
159/// Lightweight statistics surfaced by `tear list` and by the daemon's
160/// status-bar refresh loop. Doesn't include the full grid contents —
161/// for that the consumer reaches into `tear-core` directly.
162#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
163pub struct PaneStats {
164    /// Bytes consumed by the VT parser since pane spawn. Used for
165    /// `% active` displays and for sampling decisions in tier-3
166    /// (mado embedding tear-core).
167    pub bytes_consumed: u64,
168    /// Number of complete scrollback lines pushed off the visible
169    /// grid.
170    pub scrollback_lines: u32,
171    /// Wall-clock-seconds since the last byte arrived. The status
172    /// bar can render `idle 12m` directly from this.
173    pub seconds_since_last_byte: u32,
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn pane_state_default_is_spawning() {
182        assert_eq!(PaneState::default(), PaneState::Spawning);
183    }
184
185    #[test]
186    fn input_policy_leader_serialises_as_kind_tag() {
187        let p = InputPolicy::Leader { id: 42 };
188        let s = serde_json::to_string(&p).unwrap();
189        assert!(s.contains("\"kind\":\"leader\""), "got: {s}");
190        assert!(s.contains("42"), "got: {s}");
191    }
192
193    #[test]
194    fn input_policy_label_covers_every_variant() {
195        assert_eq!(InputPolicy::Free.label(), "free");
196        assert_eq!(InputPolicy::Locked.label(), "locked");
197        assert_eq!(InputPolicy::leader(7).label(), "leader");
198    }
199
200    #[test]
201    fn input_policy_leader_constructor_matches_struct_form() {
202        assert_eq!(InputPolicy::leader(99), InputPolicy::Leader { id: 99 });
203    }
204
205    #[test]
206    fn input_policy_leader_id_returns_some_for_leader_and_none_otherwise() {
207        assert_eq!(InputPolicy::leader(5).leader_id(), Some(5));
208        assert_eq!(InputPolicy::Free.leader_id(), None);
209        assert_eq!(InputPolicy::Locked.leader_id(), None);
210    }
211
212    #[test]
213    fn input_policy_serde_round_trips_every_variant() {
214        for p in [
215            InputPolicy::Free,
216            InputPolicy::Locked,
217            InputPolicy::leader(42),
218        ] {
219            let json = serde_json::to_string(&p).unwrap();
220            let back: InputPolicy = serde_json::from_str(&json).unwrap();
221            assert_eq!(p, back, "round-trip failed for {p:?}");
222        }
223    }
224
225    #[test]
226    fn pane_default_fields_are_constructible() {
227        let p = TearPane {
228            id: PaneId(42),
229            shell: "/bin/zsh".into(),
230            args: vec![],
231            cwd: Some("/tmp".into()),
232            env: vec![],
233            size_cells: (120, 40),
234            origin_cells: (0, 0),
235            state: PaneState::Running,
236            title: "zsh".into(),
237            input_policy: InputPolicy::default(),
238            yurai: Yurai::Unknown,
239        };
240        assert_eq!(p.state, PaneState::Running);
241        assert_eq!(p.size_cells, (120, 40));
242        assert_eq!(p.input_policy, InputPolicy::Free);
243    }
244
245    #[test]
246    fn input_policy_default_is_free() {
247        assert_eq!(InputPolicy::default(), InputPolicy::Free);
248    }
249}