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}