1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
//! [`SessionOptions`] and [`KeyAction`].
use std::fmt;
use std::sync::Arc;
use portable_pty::PtySize;
use tastty_core::{
CellPixelSize, ClipboardTarget, HostQuery, KeyEvent, KeyScreenState, ReplyAction, TerminalSize,
};
use crate::HostProfile;
use crate::osc_policy::{ClipboardPolicy, OscPolicy};
pub(crate) const DEFAULT_SCROLLBACK: u32 = 1000;
pub(crate) const DEFAULT_ROWS: u16 = 24;
pub(crate) const DEFAULT_COLS: u16 = 80;
pub(crate) type OutputCallback = Arc<dyn Fn(&[u8]) + Send + Sync + 'static>;
pub(crate) type InputCallback = Arc<dyn Fn(&[u8]) + Send + Sync + 'static>;
pub(crate) type RedrawCallback = Arc<dyn Fn() + Send + Sync + 'static>;
pub(crate) type KeyCallback =
Arc<dyn Fn(&KeyEvent, KeyScreenState) -> KeyAction + Send + Sync + 'static>;
pub(crate) type HostQueryCallback =
Arc<dyn Fn(&HostQuery, &HostProfile) -> ReplyAction + Send + Sync + 'static>;
/// Action returned by the [`on_key`](SessionOptions::on_key) callback.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum KeyAction {
/// Encode and send the key normally.
Send,
/// Send these bytes instead of the normal encoding.
Replace(Vec<u8>),
/// Discard the key silently.
Drop,
}
/// Options used when creating a [`Terminal`](super::Terminal).
pub struct SessionOptions {
pub(crate) scrollback: u32,
pub(crate) rows: u16,
pub(crate) cols: u16,
pub(crate) pixel_cell_size: CellPixelSize,
pub(crate) output_callback: Option<OutputCallback>,
pub(crate) input_callback: Option<InputCallback>,
pub(crate) redraw_callback: Option<RedrawCallback>,
pub(crate) key_callback: Option<KeyCallback>,
pub(crate) virtual_cols: Option<u16>,
pub(crate) host_profile: Option<HostProfile>,
pub(crate) host_query_callback: HostQueryCallback,
pub(crate) echo: bool,
pub(crate) clipboard_policy: ClipboardPolicy,
}
impl Default for SessionOptions {
fn default() -> Self {
Self {
scrollback: DEFAULT_SCROLLBACK,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
pixel_cell_size: CellPixelSize::default(),
output_callback: None,
input_callback: None,
redraw_callback: None,
key_callback: None,
virtual_cols: None,
host_profile: None,
host_query_callback: default_host_query_callback(),
echo: false,
clipboard_policy: ClipboardPolicy::default(),
}
}
}
fn default_host_query_callback() -> HostQueryCallback {
Arc::new(|_, _| ReplyAction::Send)
}
impl SessionOptions {
/// Set the maximum number of scrollback rows retained by the parser.
pub fn scrollback(mut self, lines: u32) -> Self {
self.scrollback = lines;
self
}
/// Set the PTY size.
///
/// `size` carries the non-zero-dimensions invariant by construction
/// (see [`tastty_core::TerminalSize::new`]). For runtime resizes
/// driven by user signals, use [`Terminal::resize`](super::Terminal::resize)
/// which returns [`Error::InvalidResize`](crate::Error::InvalidResize)
/// when the requested dimensions are zero.
pub fn size(mut self, size: TerminalSize) -> Self {
self.rows = size.rows;
self.cols = size.cols;
self
}
/// Set the reported pixel size of one terminal cell.
pub fn pixel_cell_size(mut self, size: CellPixelSize) -> Self {
self.pixel_cell_size = size;
self
}
/// Register a callback that receives raw PTY output bytes before
/// they enter the parser. Called on the reader thread. The callback
/// must not panic; a panic will terminate the reader thread silently.
///
/// # Example
///
/// ```no_run
/// use tastty::SessionOptions;
///
/// let opts = SessionOptions::default()
/// .on_output(|bytes| {
/// eprintln!("raw: {} bytes", bytes.len());
/// });
/// ```
pub fn on_output<F>(mut self, f: F) -> Self
where
F: Fn(&[u8]) + Send + Sync + 'static,
{
self.output_callback = Some(Arc::new(f));
self
}
/// Register a callback that receives raw PTY input bytes after they are
/// written to and flushed through the PTY writer. Called on the writer
/// thread. The callback must not panic; a panic will terminate the writer
/// thread.
pub fn on_input<F>(mut self, f: F) -> Self
where
F: Fn(&[u8]) + Send + Sync + 'static,
{
self.input_callback = Some(Arc::new(f));
self
}
/// Register a callback fired once per parser tick, after the reader
/// thread has finished applying a chunk of PTY output to the parser.
///
/// The callback receives no payload: it announces "screen state may
/// have changed", giving embedders a payload-free wake source for
/// notifier-style integrations (driver wait futures, custom render
/// triggers) without paying for the byte-stream observation cost of
/// [`on_output`](Self::on_output).
///
/// Called on the reader thread, after `Parser::process` and after the
/// per-tick lock on the inner parser has been released, so the
/// callback may take its own locks on the screen without deadlocking.
/// The callback must not panic and must not block; a slow callback
/// stalls the reader thread and every subsequent screen update.
pub fn on_redraw<F>(mut self, f: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.redraw_callback = Some(Arc::new(f));
self
}
/// Register a callback that intercepts key events before encoding.
///
/// The callback receives the key event and a snapshot of the screen
/// state relevant to encoding. It returns a [`KeyAction`] that
/// controls whether the key is sent normally, replaced, or dropped.
///
/// Called on the thread that calls
/// [`Terminal::send_key`](super::Terminal::send_key).
/// The callback must not panic.
///
/// # Example
///
/// ```no_run
/// use tastty::{KeyAction, KeyScreenState, SessionOptions};
/// use tastty::input::KeyCode;
///
/// let opts = SessionOptions::default()
/// .on_key(|key, _state| {
/// if matches!(key.code, KeyCode::Insert) {
/// KeyAction::Drop
/// } else {
/// KeyAction::Send
/// }
/// });
/// ```
pub fn on_key<F>(mut self, f: F) -> Self
where
F: Fn(&KeyEvent, KeyScreenState) -> KeyAction + Send + Sync + 'static,
{
self.key_callback = Some(Arc::new(f));
self
}
/// Set a virtual column width for the parser that differs from the PTY width.
/// The child process sees the PTY width (from size()), but the parser uses
/// virtual_cols for its buffer, enabling horizontal scrolling without wrapping.
pub fn virtual_cols(mut self, cols: u16) -> Self {
self.virtual_cols = Some(cols);
self
}
/// Set a custom host profile for terminal identity and default colors.
/// Without this, tastty uses sensible VT220-compatible defaults.
pub fn host_profile(mut self, profile: HostProfile) -> Self {
self.host_profile = Some(profile);
self
}
/// Register a callback that decides the wire reply for each host
/// query [`ScreenEvent`](tastty_core::ScreenEvent) drained by the
/// reader thread.
///
/// The callback receives the [`HostQuery`] the parser projected from
/// the event (DA1/DA2/DA3, XTVERSION, DSR, DECRQM, DECRQSS, XTGETTCAP,
/// OSC color, XTWINOPS, Kitty keyboard) and a borrow of the session's
/// [`HostProfile`]. It returns a [`ReplyAction`]:
///
/// - [`ReplyAction::Send`] writes the canonical reply
/// ([`auto_reply_bytes`](tastty_core::host_reply::auto_reply_bytes)
/// against the host profile). The default callback returns this for
/// every query, matching the historical "auto-reply on" behavior.
/// - [`ReplyAction::Replace`] writes the encoded bytes of the
/// provided [`HostReply`](crate::HostReply) instead. Typical use
/// cases: claim a different identity in a test fixture, swap an
/// XTGETTCAP answer for one the host knows.
/// - [`ReplyAction::Drop`] writes no bytes for this query. Typical
/// use cases: suppress fingerprintable replies in a hardened
/// deployment (XTVERSION, XTGETTCAP), gate replies on policy.
///
/// # Threading and contract
///
/// The callback runs on the reader thread, after the parser write
/// guard has been released and after [`ClipboardPolicy`] has been
/// applied to drained events. It must not panic; a panic terminates
/// the reader thread silently. It must not block; a slow callback
/// stalls every subsequent screen update. It must not reacquire the
/// parser write guard (deadlock with the reader thread).
///
/// # Example
///
/// ```no_run
/// use tastty::host_reply::{HostQuery, HostReply, ReplyAction};
/// use tastty::SessionOptions;
///
/// let opts = SessionOptions::default().on_host_query(|q, _host| match q {
/// HostQuery::Xtversion => ReplyAction::Drop,
/// HostQuery::Da1 => ReplyAction::Replace(HostReply::Da1(b"\x1b[?42c".to_vec())),
/// _ => ReplyAction::Send,
/// });
/// ```
///
/// [`HostQuery`]: tastty_core::HostQuery
/// [`ReplyAction`]: tastty_core::ReplyAction
/// [`ReplyAction::Send`]: tastty_core::ReplyAction::Send
/// [`ReplyAction::Replace`]: tastty_core::ReplyAction::Replace
/// [`ReplyAction::Drop`]: tastty_core::ReplyAction::Drop
pub fn on_host_query<F>(mut self, f: F) -> Self
where
F: Fn(&HostQuery, &HostProfile) -> ReplyAction + Send + Sync + 'static,
{
self.host_query_callback = Arc::new(f);
self
}
/// Enable or disable PTY input echo at spawn time. Default: `false`.
///
/// Controls the [POSIX termios `ECHO`][termios] flag on the PTY
/// when the child is spawned. With the default, bytes written via
/// [`Terminal::send`](super::Terminal::send) are not reflected back
/// unless the child itself writes them, so embedders rendering their
/// own input feed do not see every keystroke twice.
///
/// Set to `true` for children that do not manage their own
/// termios (`cat`, `sleep`) when the embedder relies on the
/// kernel line discipline to render typed input.
///
/// On Windows this setter has no effect: `portable-pty`'s ConPTY
/// backend exposes no host-side echo control, and the Windows
/// analogue `ENABLE_ECHO_INPUT` is owned by the child via
/// `SetConsoleMode`.
///
/// [termios]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/termios.h.html
pub fn echo(mut self, enabled: bool) -> Self {
self.echo = enabled;
self
}
/// Replace the entire OSC 52 clipboard policy. Default:
/// [`ClipboardPolicy::default`] -- read = deny, write = allow across
/// every buffer.
///
/// Per-target overrides via [`with_clipboard_read`](Self::with_clipboard_read)
/// and [`with_clipboard_write`](Self::with_clipboard_write) are usually
/// preferable; reach for this setter when the embedder already owns a
/// fully-specified policy value to install.
pub fn clipboard_policy(mut self, policy: ClipboardPolicy) -> Self {
self.clipboard_policy = policy;
self
}
/// Override the clipboard read policy for one buffer.
///
/// Reads (`OSC 52 ; <Pc> ; ?`) default to [`OscPolicy::Deny`] for every
/// buffer to prevent shell-injection exfiltration of clipboard contents.
/// Embedders that surface a clipboard read flow (a paste-approval UI,
/// a sandboxed integration test) opt the relevant buffers back in here.
///
/// # Example
///
/// ```no_run
/// use tastty::{ClipboardTarget, OscPolicy, SessionOptions};
///
/// let opts = SessionOptions::default()
/// .with_clipboard_read(ClipboardTarget::Primary, OscPolicy::Allow);
/// ```
pub fn with_clipboard_read(mut self, target: ClipboardTarget, policy: OscPolicy) -> Self {
self.clipboard_policy.read.set(target, policy);
self
}
/// Override the clipboard write policy for one buffer.
///
/// Writes and clears (`OSC 52 ; <Pc> ; <base64>` and
/// `OSC 52 ; <Pc> ;`) default to [`OscPolicy::Allow`] because most
/// applications legitimately copy to the clipboard. Embedders running
/// in hardened deployments can deny specific buffers here.
pub fn with_clipboard_write(mut self, target: ClipboardTarget, policy: OscPolicy) -> Self {
self.clipboard_policy.write.set(target, policy);
self
}
pub(crate) fn pty_size(&self) -> PtySize {
PtySize {
rows: self.rows,
cols: self.cols,
pixel_width: self.cols.saturating_mul(self.pixel_cell_size.width),
pixel_height: self.rows.saturating_mul(self.pixel_cell_size.height),
}
}
}
impl fmt::Debug for SessionOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SessionOptions")
.field("scrollback", &self.scrollback)
.field("rows", &self.rows)
.field("cols", &self.cols)
.field("pixel_cell_size", &self.pixel_cell_size)
.field(
"output_callback",
&self.output_callback.as_ref().map(|_| "<callback>"),
)
.field(
"input_callback",
&self.input_callback.as_ref().map(|_| "<callback>"),
)
.field(
"redraw_callback",
&self.redraw_callback.as_ref().map(|_| "<callback>"),
)
.field(
"key_callback",
&self.key_callback.as_ref().map(|_| "<callback>"),
)
.field("host_query_callback", &"<callback>")
.field("virtual_cols", &self.virtual_cols)
.field("clipboard_policy", &self.clipboard_policy)
.finish()
}
}