Skip to main content

tear_types/
control.rs

1//! The [`MultiplexerControl`] trait — every tear backend implements it.
2//!
3//! - `tear-core::InProcess` — embedded state machine. The native
4//!   backend used when `tear` runs without a daemon, and the backend
5//!   mado will eventually compose with directly (M5 — see project
6//!   plan in CLAUDE.md).
7//! - `tear-daemon` — owns sessions across client disconnects, exposes
8//!   a UDS RPC façade. Wraps `InProcess` rather than reimplementing.
9//! - `tear-client` — typed RPC client connected to a local or
10//!   SSH-tunnelled `tear-daemon`. Implements `MultiplexerControl` so
11//!   from the consumer's perspective there's no syntactic difference
12//!   between local and remote.
13//! - `tear-tmux-backend` — passthrough to vanilla tmux. The escape
14//!   hatch for remote hosts that have tmux but not tear.
15//!
16//! All four backends share this trait so consumers (the CLI, mado,
17//! third-party drivers) author against ONE surface.
18
19use thiserror::Error;
20
21use crate::{
22    direction::Direction,
23    id::{PaneId, SessionId, WindowId},
24    layout::LayoutKind,
25    pane::TearPane,
26    pane_snapshot::PaneSnapshot,
27    session::TearSession,
28    window::TearWindow,
29};
30
31/// Result alias for `MultiplexerControl` operations.
32pub type ControlResult<T> = Result<T, ControlError>;
33
34/// Failure modes a [`MultiplexerControl`] op can return. Variants
35/// stay narrow so consumers can pattern-match on the recoverable
36/// vs not-recoverable ones (e.g. RPC retries on `Transport` but
37/// gives up on `NoSuchSession`).
38#[derive(Debug, Error)]
39pub enum ControlError {
40    #[error("no such session: {0}")]
41    NoSuchSession(SessionId),
42    #[error("no such window: {0}")]
43    NoSuchWindow(WindowId),
44    #[error("no such pane: {0}")]
45    NoSuchPane(PaneId),
46    #[error("backend transport error: {0}")]
47    Transport(String),
48    #[error("backend rejected operation: {0}")]
49    Rejected(String),
50    /// The backend on the other end of the wire does not implement a
51    /// named [`crate::capability::Capability`] this call needs.
52    ///
53    /// Raised **client-side, before the request goes out** — the
54    /// point is to refuse legibly instead of sending a frame the
55    /// daemon will decode successfully and then silently ignore
56    /// part of. `capability` is the wire name (`"spawn-args"`), so
57    /// a caller can match on it without string-scraping `detail`.
58    ///
59    /// Deliberately has **no [`crate::wire::WireError`] mirror**:
60    /// adding a `WireError` variant would be a wire change that
61    /// older clients could not decode, and this error is never
62    /// produced by a daemon. If one ever needs to, it degrades to
63    /// `Rejected` on the wire (see the `From` impl in `wire.rs`).
64    #[error("daemon lacks capability `{capability}`: {detail}")]
65    Unsupported {
66        capability: &'static str,
67        detail: String,
68    },
69    #[error("backend internal: {0}")]
70    Internal(#[from] anyhow::Error),
71}
72
73/// The trait every tear backend implements. Operations are
74/// intentionally **async-free** at the trait level — backends that
75/// want async runtimes spin them up internally; consumers (CLI,
76/// mado) call these directly on a worker thread.
77///
78/// Designed for the COMMON case (one session, a few windows, a few
79/// panes). Backends that need to batch operations (e.g. tear-daemon
80/// over a slow link) wrap individual calls themselves.
81pub trait MultiplexerControl: Send + Sync {
82    // ── Identity ─────────────────────────────────────────────────
83
84    /// What this backend can actually do, and who it is.
85    ///
86    /// The default answer is **this build's own capability set**,
87    /// which is correct by construction for any backend that
88    /// executes in-process: there is no other peer whose age could
89    /// differ from ours. `tear-core`'s `InProcess` takes this
90    /// default.
91    ///
92    /// A backend that talks to a **separate process** MUST override
93    /// — its peer may be an older build, and assuming otherwise is
94    /// exactly the silent-degradation this exists to stop.
95    /// `tear-client`'s `Client` overrides with the result of the
96    /// `Request::Hello` probe.
97    ///
98    /// Consumers holding a `&dyn MultiplexerControl` (mado) can
99    /// therefore gate on a capability without knowing which backend
100    /// they hold.
101    fn capabilities(&self) -> crate::capability::DaemonIdentity {
102        crate::capability::DaemonIdentity::local(env!("CARGO_PKG_VERSION"))
103    }
104
105    // ── Discovery ────────────────────────────────────────────────
106
107    /// List every active session. Sorted by creation time (oldest
108    /// first); the CLI's `tear list` renders this directly.
109    fn list_sessions(&self) -> ControlResult<Vec<TearSession>>;
110
111    /// Look up a single session by id.
112    fn get_session(&self, id: SessionId) -> ControlResult<TearSession>;
113
114    /// Look up a single window. Returns the window + its parent
115    /// session id (handy for rendering window paths like `work:1`).
116    fn get_window(&self, id: WindowId) -> ControlResult<(SessionId, TearWindow)>;
117
118    /// Look up a single pane.
119    fn get_pane(&self, id: PaneId) -> ControlResult<TearPane>;
120
121    // ── Sessions ─────────────────────────────────────────────────
122
123    /// Create a new session. `name` is the operator-visible label;
124    /// the returned [`SessionId`] is the stable handle. Defaults
125    /// to `SessionSource::Human` — agent-driven consumers (mado
126    /// MCP, automation) should call
127    /// [`Self::new_session_with_source`] so `tear list --source`
128    /// can audit provenance.
129    fn new_session(&self, name: &str, shell: &str) -> ControlResult<SessionId> {
130        self.new_session_with_source(name, shell, crate::session::SessionSource::Human)
131    }
132
133    /// Create a session with a typed [`SessionSource`] tag. The
134    /// daemon stores this on the [`TearSession`] so `tear list`
135    /// can group by provenance and operators can audit
136    /// agent-created sessions.
137    ///
138    /// Pane size defaults to 80×24. For consumers that already
139    /// know the target geometry (mado, attaching renderers),
140    /// prefer [`Self::new_session_with_source_and_size`] so the
141    /// shell spawns at the right size from t=0 — no
142    /// SIGWINCH-during-first-prompt redraw.
143    fn new_session_with_source(
144        &self,
145        name: &str,
146        shell: &str,
147        source: crate::session::SessionSource,
148    ) -> ControlResult<SessionId> {
149        self.new_session_with_source_and_size(name, shell, &[], source, (80, 24))
150    }
151
152    /// Same as [`Self::new_session_with_source`] but the first
153    /// pane is created at the given `(cols, rows)` size. The
154    /// child shell's TIOCGWINSZ returns these values on first
155    /// query — TUI apps render at the correct grid from
156    /// the very first prompt, no resize-flicker on attach.
157    ///
158    /// Defaults to (80, 24) when the consumer doesn't know the
159    /// target geometry; otherwise the consumer should pass the
160    /// renderer's exact cell grid (e.g. mado's
161    /// `TerminalRenderer::cells_for_window_phys(...)`).
162    ///
163    /// `args` is `shell`'s argv[1..]. It is passed to the child
164    /// **as an argument vector**, never through a shell — so a
165    /// consumer that wants to run `nvim -u NONE file.rs` passes
166    /// three elements rather than smuggling them into one command
167    /// string. Pass `&[]` for a bare shell.
168    fn new_session_with_source_and_size(
169        &self,
170        name: &str,
171        shell: &str,
172        args: &[String],
173        source: crate::session::SessionSource,
174        size_cells: (u16, u16),
175    ) -> ControlResult<SessionId>;
176
177    /// Rename a session. Idempotent — renaming to the current name
178    /// returns Ok(()) without side effects.
179    fn rename_session(&self, id: SessionId, new_name: &str) -> ControlResult<()>;
180
181    /// Kill a session and all its children.
182    fn kill_session(&self, id: SessionId) -> ControlResult<()>;
183
184    // ── Windows ──────────────────────────────────────────────────
185
186    /// Create a new window in a session, spawning `shell` as its
187    /// first pane. `args` is that program's argv[1..] — passed as a
188    /// vector, never through a shell. `&[]` for a bare shell.
189    fn new_window(
190        &self,
191        session: SessionId,
192        name: &str,
193        shell: &str,
194        args: &[String],
195    ) -> ControlResult<WindowId>;
196
197    fn kill_window(&self, id: WindowId) -> ControlResult<()>;
198
199    fn select_window(&self, id: WindowId) -> ControlResult<()>;
200
201    // ── Panes ────────────────────────────────────────────────────
202
203    /// Split a pane in the given direction. `shell` is the program
204    /// spawned in the new pane and `args` is its argv[1..] — passed
205    /// as a vector, never through a shell. `&[]` for a bare shell.
206    fn split_pane(
207        &self,
208        origin: PaneId,
209        direction: Direction,
210        shell: &str,
211        args: &[String],
212    ) -> ControlResult<PaneId>;
213
214    fn kill_pane(&self, id: PaneId) -> ControlResult<()>;
215
216    fn select_pane(&self, id: PaneId) -> ControlResult<()>;
217
218    /// Resize a pane along one axis. `delta_cells` is signed — negative
219    /// shrinks. tmux's `resize-pane -L/-R/-U/-D`.
220    fn resize_pane(&self, id: PaneId, direction: Direction, delta_cells: i16) -> ControlResult<()>;
221
222    /// Re-arrange a window's existing panes into a named [`LayoutKind`]
223    /// preset (tmux `select-layout`). The panes keep their PTYs and
224    /// scrollback — only the window's layout tree changes, then geometry
225    /// reflows. [`LayoutKind::Custom`] (and an empty window) is a no-op:
226    /// there is no canonical arrangement to impose, so the operator's
227    /// manual tree wins. Built on `LayoutNode::from_kind`.
228    fn apply_layout(&self, window: WindowId, kind: LayoutKind) -> ControlResult<()>;
229
230    /// Set a pane's PTY size to an absolute `(cols, rows)`. Used by
231    /// GPU consumers (mado at Phase 3.1) when the window the pane
232    /// is rendered in resizes — the multiplexer must SIGWINCH the
233    /// child shell so TUI apps re-layout. Distinct from
234    /// [`Self::resize_pane`] which is the tmux-style delta-on-an-axis
235    /// op for keyboard-driven splits.
236    ///
237    /// Default impl returns `Rejected` so passthrough backends
238    /// (tear-tmux-backend) can opt out.
239    fn pane_resize_absolute(&self, id: PaneId, cols: u16, rows: u16) -> ControlResult<()> {
240        let _ = (id, cols, rows);
241        Err(ControlError::Rejected(
242            "this backend does not support absolute pane resize".into(),
243        ))
244    }
245
246    /// Send keystrokes (already-bytes-encoded — caller resolved the
247    /// chord) to a pane's PTY.
248    fn send_keys(&self, id: PaneId, bytes: &[u8]) -> ControlResult<()>;
249
250    /// How many byte-stream subscribers are currently attached to
251    /// the named pane. Returns 0 if the pane has no subscribers
252    /// (or doesn't exist — wraps the typed NoSuchPane miss into
253    /// a 0 count so the migration call site doesn't have to
254    /// distinguish "no subscribers" from "pane gone").
255    fn pane_subscriber_count(&self, id: PaneId) -> ControlResult<u32>;
256
257    /// Replace a pane's typed [`crate::pane::InputPolicy`]. Default
258    /// behavior is `Free`; setting `Locked` causes every
259    /// subsequent `send_keys` for that pane to return
260    /// `ControlError::Rejected`. Idempotent — same policy is a
261    /// no-op.
262    fn set_input_policy(
263        &self,
264        id: PaneId,
265        policy: crate::pane::InputPolicy,
266    ) -> ControlResult<()>;
267
268    // ── Rendering (Phase 2) ──────────────────────────────────────
269
270    /// Return a serializable snapshot of the named pane's currently-
271    /// rendered cell grid + cursor position. Consumers (mado at
272    /// Phase 4) walk the snapshot to draw pixels without holding a
273    /// reference into the live parser state.
274    ///
275    /// Default impl returns `Rejected` so backends that don't track
276    /// rendered state (e.g. tear-tmux-backend, which passes through
277    /// to tmux) can opt out. tear-core's `InProcess` overrides
278    /// with the real implementation.
279    fn pane_snapshot(&self, id: PaneId) -> ControlResult<PaneSnapshot> {
280        let _ = id;
281        Err(ControlError::Rejected(
282            "this backend does not expose per-pane snapshots".into(),
283        ))
284    }
285
286    /// Lightweight query for DECCKM (DEC mode 1 — cursor-keys
287    /// application mode) on a pane. Consumers translating host
288    /// keystrokes to PTY bytes — mado's `keybind::madori_key_to_
289    /// pty_bytes` is the canonical caller — hit this on every
290    /// arrow-key press to decide between `ESC O A/B/C/D`
291    /// (application mode) and `ESC [ A/B/C/D` (normal mode).
292    ///
293    /// Default impl falls back to the full `pane_snapshot` and
294    /// reads `cursor_keys_mode` off the returned struct. Backends
295    /// that can answer cheaply (tear-core's `InProcess` reads one
296    /// `bool` off the live `PaneGrid`) override for the no-alloc
297    /// path. tear-tmux-backend reasonably returns `Rejected`
298    /// since tmux doesn't expose DECCKM through its control
299    /// protocol.
300    fn pane_cursor_keys_mode(&self, id: PaneId) -> ControlResult<bool> {
301        Ok(self.pane_snapshot(id)?.cursor_keys_mode)
302    }
303}