tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! [`ScreenEvent`] and its variant payload types.

use std::time::Duration;

use crate::host_reply::ModeStatus;

use super::modes::TerminalMode;
use super::types::CursorStyle;

/// Semantic prompt mark emitted by a shell integration via [OSC 133][osc133].
///
/// fish, starship, powerlevel10k, and similar prompt frameworks emit OSC
/// 133 sequences to delineate prompt and command-output regions. Each
/// mark applies to the cursor's row at the time the sequence is parsed,
/// allowing embedders to identify "this row is part of the prompt" or
/// "this row is command output" for jump-to-prompt, fold-output, and
/// region-aware search.
///
/// [osc133]: https://gitlab.freedesktop.org/Per_Bothner/specifications/-/blob/master/proposals/semantic-prompts.md
#[doc(alias = "OSC 133")]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum SemanticPrompt {
    /// `OSC 133;A`.
    PromptStart,
    /// `OSC 133;B`.
    PromptEnd,
    /// `OSC 133;C`.
    OutputStart,
    /// `OSC 133;D`, with the optional positional exit code parsed from
    /// the wire.
    ///
    /// `exit_code` is `None` when the shell omits the integer (the bare
    /// `OSC 133;D` form). `i32` accommodates shells that report `-1` for
    /// signal-cancelled commands; values outside `i32` and non-numeric
    /// payloads are rejected at parse time and never reach this variant.
    OutputEnd {
        /// Exit code reported by the shell, when present on the wire.
        exit_code: Option<i32>,
    },
}

/// One [OSC 52][osc52] clipboard buffer target. The OSC 52 selection
/// parameter `Pc` is a list of single-letter codes; each letter maps to
/// one target and unknown bytes are dropped by the parser.
///
/// [osc52]: https://gist.github.com/egmontkob/eb8d45597f7db55ec41d6c0ffc6f3bb3
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ClipboardTarget {
    /// System clipboard (`c`).
    Clipboard,
    /// Primary selection (`p`).
    Primary,
    /// Secondary selection (`q`).
    Secondary,
    /// Configurable primary/clipboard selection (`s`); the xterm default.
    Select,
    /// Numeric cut buffer (`0`..=`7`).
    CutBuffer(u8),
}

/// Events that the embedding application should handle.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScreenEvent {
    /// Program wrote clipboard data via [OSC 52][osc52].
    ///
    /// `data` is the [base64][rfc4648]-decoded payload. Writes with
    /// malformed base64 are dropped by the parser and never reach this
    /// event.
    ///
    /// Embedders are responsible for policy; the parser enforces no size
    /// limits or allow/deny gating on clipboard writes.
    ///
    /// [osc52]: https://gist.github.com/egmontkob/eb8d45597f7db55ec41d6c0ffc6f3bb3
    /// [rfc4648]: https://www.rfc-editor.org/rfc/rfc4648
    ClipboardWrite {
        /// Buffers addressed by the request. An empty `Pc` on the wire is
        /// normalized to `[Select, CutBuffer(0)]` per xterm; the original
        /// empty form is not preserved.
        targets: Vec<ClipboardTarget>,
        /// Decoded payload bytes. Not guaranteed to be UTF-8.
        data: Vec<u8>,
    },
    /// Program asked the terminal to send the current clipboard contents.
    ///
    /// Sans-I/O: the parser surfaces the intent and does not auto-reply.
    /// Embedders that honor the query must write
    /// `OSC 52 ; <Pc> ; <base64> ST` back into the PTY input stream.
    ClipboardQuery {
        /// Buffers to read. An empty `Pc` on the wire is normalized to
        /// `[Select, CutBuffer(0)]` per xterm, matching `ClipboardWrite`.
        targets: Vec<ClipboardTarget>,
    },
    /// Program asked the terminal to clear the clipboard (empty payload).
    ClipboardClear {
        /// Buffers to clear. An empty `Pc` on the wire is normalized to
        /// `[Select, CutBuffer(0)]` per xterm, matching `ClipboardWrite`.
        targets: Vec<ClipboardTarget>,
    },
    /// Program tried to write the clipboard via [OSC 52][osc52], but
    /// the payload exceeded
    /// [`HostProfile::clipboard_max_bytes`](crate::HostProfile::clipboard_max_bytes).
    /// The write is dropped: no [`ClipboardWrite`](Self::ClipboardWrite)
    /// is emitted for the same frame.
    ///
    /// `decoded_len` is the size, in bytes, of the would-be decoded
    /// payload. When the cap fires before base64 decoding, this is the
    /// conservative upper bound `encoded.len() * 3 / 4`; when it fires
    /// after decoding, this is the exact decoded length.
    ///
    /// [osc52]: https://gist.github.com/egmontkob/eb8d45597f7db55ec41d6c0ffc6f3bb3
    ClipboardWriteRejected {
        /// Buffers the program addressed. Same normalization rules as
        /// [`ClipboardWrite`](Self::ClipboardWrite).
        targets: Vec<ClipboardTarget>,
        /// Decoded byte count of the payload that triggered rejection.
        decoded_len: usize,
    },
    /// [Kitty keyboard][kitty-kbd] enhancement flags changed.
    ///
    /// [kitty-kbd]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
    #[doc(alias = "kitty keyboard")]
    KittyFlagsChanged(u8),

    // Host query events. The program is asking about terminal capabilities.
    //
    // Each variant carries the data
    // [`tastty_core::host_reply::auto_reply_bytes`] needs to construct the
    // wire reply. Bare-`Parser` embedders pipe events through that function
    // directly; `tastty::Terminal` embedders register `on_host_query` on
    // their `SessionOptions` to intercept per-query (claim DA1 = xterm in a
    // fixture, suppress XTVERSION in a hardened deployment, substitute a
    // typed `HostReply` per query) without rebuilding the dispatch loop.
    /// Primary Device Attributes query (CSI c).
    Da1,
    /// Secondary Device Attributes query (CSI > c).
    Da2,
    /// Tertiary Device Attributes query (CSI = c).
    Da3,
    /// DEC private mode query (CSI ? Ps $ p). The terminal computes the
    /// current `status` against its own mode bits at parse time; embedders
    /// that want the default reply can re-encode via
    /// [`HostReply::Decrqm`](crate::HostReply::Decrqm).
    Decrqm {
        /// DEC private mode number.
        mode: u16,
        /// Reported mode status.
        status: ModeStatus,
    },
    /// ANSI mode query (CSI Ps $ p). The terminal computes the current
    /// `status` against its own mode bits at parse time.
    AnsiModeReport {
        /// ANSI mode number.
        mode: u16,
        /// Reported mode status.
        status: ModeStatus,
    },
    /// Device status report for operating status (CSI 5 n).
    DsrStatus,
    /// Device status report for cursor position (CSI 6 n). `row` and `col`
    /// are 1-based, matching the wire reply (`CSI {row} ; {col} R`).
    DsrCursorPosition {
        /// One-based cursor row at the moment the query was parsed.
        row: u16,
        /// One-based cursor column at the moment the query was parsed.
        col: u16,
    },
    /// Terminal version query (CSI > q).
    Xtversion,
    /// Termcap query (DCS + q ...). Each entry maps a hex-encoded capability
    /// name to its hex-encoded value (or `None` when the terminal does not
    /// recognize the cap).
    XtGetTcap {
        /// Resolved per-key results. Replies are emitted one DCS frame per
        /// entry; a single XTGETTCAP query may hold multiple entries.
        entries: Vec<XtGetTcapEntry>,
    },
    /// Setting/selection query (DCS $ q ...).
    Decrqss {
        /// Raw queried setting bytes (e.g. `b"m"` for SGR, `b" q"` for
        /// cursor style).
        query: Vec<u8>,
        /// DCS payload (without framing) that the terminal would reply
        /// with. `None` indicates the query was not recognized; the wire
        /// reply is `\x1bP0$r\x1b\\`.
        response: Option<Vec<u8>>,
    },
    /// Color query (OSC 4/10/11/12 with ?). Carries the live color the
    /// terminal would report (palette entry for OSC 4, screen-level
    /// `default_fg` / `default_bg` / `default_cursor_color` for OSC
    /// 10/11/12 -- which may differ from
    /// [`HostProfile`](crate::HostProfile) after a prior OSC set).
    ColorQuery {
        /// Color slot being queried.
        target: ColorTarget,
        /// Color the terminal would report. Always [`Color::Rgb`] when
        /// emitted by the parser.
        ///
        /// [`Color::Rgb`]: crate::Color::Rgb
        color: crate::attrs::Color,
    },
    /// [Kitty keyboard][kitty-kbd] flags query (CSI ? u). `flags` is the
    /// current Kitty keyboard stack top at parse time.
    ///
    /// [kitty-kbd]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
    #[doc(alias = "CSI ? u")]
    #[doc(alias = "kitty keyboard")]
    KittyKeyboardQuery {
        /// Current Kitty keyboard flag byte.
        flags: u8,
    },
    /// XTWINOPS pixel/cell-size query reply (CSI 14 / 16 / 18 t). These
    /// queries fire mid-parse and produce immediate wire replies; the
    /// event carries the dimensions the terminal would report so
    /// [`auto_reply_bytes`](crate::host_reply::auto_reply_bytes) can
    /// reconstruct the bytes without consulting screen state.
    XtWinOpsReport(XtWinOpsReport),

    // State change events. The program changed visible terminal state.
    /// Window title changed (OSC 0/1/2).
    TitleChanged,
    /// Current working directory changed ([OSC 7][xterm-ctlseqs]).
    /// Embedders that want "new window same directory" or a path
    /// display in the chrome can react to this and read the new value
    /// via [`Screen::cwd`](super::Screen::cwd).
    ///
    /// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
    WorkingDirectoryChanged,
    /// Shell integration mark received ([OSC 133][osc133] A/B/C/D). The
    /// mark is attached to the cursor's row at parse time on a
    /// most-recent-wins basis; the event fires on every successful parse
    /// so consumers tracking transitions see each emission, including
    /// marks that replace a prior mark on the same row.
    ///
    /// [osc133]: https://gitlab.freedesktop.org/Per_Bothner/specifications/-/blob/master/proposals/semantic-prompts.md
    #[doc(alias = "OSC 133")]
    ShellIntegration {
        /// Parsed semantic mark.
        mark: SemanticPrompt,
    },
    /// Program asked the terminal to deliver a desktop notification.
    ///
    /// Emitted for the bare growl-style `OSC 9 ; <body> ST` (single
    /// string body, no embedded `;`) and for
    /// `OSC 777 ; notify ; <title> ; <body> ST` (explicit title and
    /// body). For OSC 9 the title is the empty string. OSC 777
    /// sub-commands other than `notify` are ignored; the prefix is
    /// reserved for additional future sub-commands.
    ///
    /// OSC 9 with empty body is dropped (a notification of nothing
    /// has no useful render); OSC 777 `notify` with empty body is
    /// passed through because the explicit `notify` sub-command
    /// signals deliberate intent. OSC 9 with a sub-command selector
    /// routes to [`Sleep`] or [`ProgressReport`] instead;
    /// unrecognized sub-commands drop.
    ///
    /// Sans-I/O: the parser surfaces the intent and does not deliver
    /// the notification. Embedders decide policy.
    ///
    /// See [xterm control sequences][xterm-ctlseqs] for the OSC
    /// dispatcher conventions.
    ///
    /// [`Sleep`]: ScreenEvent::Sleep
    /// [`ProgressReport`]: ScreenEvent::ProgressReport
    /// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
    #[doc(alias = "OSC 9")]
    #[doc(alias = "OSC 777")]
    DesktopNotification {
        /// Notification title.
        title: String,
        /// Notification body.
        body: String,
    },
    /// Program asked the terminal to pause via the ConEmu sub-command
    /// `OSC 9 ; 1 ; <ms> ST`. Values above 10000 ms are clamped;
    /// malformed payloads drop on the parser floor.
    ///
    /// Sans-I/O: the parser surfaces the intent and does not block.
    /// Embedders that honor the request schedule the wait themselves.
    ///
    /// See [xterm control sequences][xterm-ctlseqs] for the OSC
    /// dispatcher conventions.
    ///
    /// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
    #[doc(alias = "OSC 9 ; 1")]
    Sleep {
        /// Pause length, clamped to 10000 ms.
        duration: Duration,
    },
    /// Program reported task progress via the ConEmu sub-command
    /// `OSC 9 ; 4 ; <state> ; <progress> ST`. Values above 100 are
    /// clamped; unknown state codes drop on the parser floor.
    ///
    /// See [xterm control sequences][xterm-ctlseqs] for the OSC
    /// dispatcher conventions.
    ///
    /// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
    #[doc(alias = "OSC 9 ; 4")]
    ProgressReport {
        /// Lifecycle state. See [`ProgressState`].
        state: ProgressState,
        /// Progress percentage in `0..=100`.
        value: u8,
    },
    /// Program asked the terminal to change the mouse pointer shape
    /// ([`OSC 22 ; <css-cursor-name> ST`][xterm-ctlseqs]).
    ///
    /// The name is passed through verbatim. The CSS cursor namespace
    /// is open-ended (e.g. `pointer`, `text`, `crosshair`, `wait`,
    /// `progress`, `help`, `default`, `none`, future additions); the
    /// parser does not validate it because the embedder decides which
    /// shapes to honor.
    ///
    /// An empty body means "restore the default pointer", per the OSC
    /// 22 / CSS cursor convention; the event is emitted with `name`
    /// equal to the empty string and the embedder maps that to its
    /// idle cursor.
    ///
    /// Sans-I/O: the parser surfaces the intent and does not change
    /// any pointer state on its own.
    ///
    /// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
    #[doc(alias = "OSC 22")]
    MousePointerShape {
        /// CSS cursor name as received on the wire, or empty for
        /// "restore default".
        name: String,
    },
    /// Terminal bell (BEL / 0x07) received. The embedder may flash the UI,
    /// play a sound, or ignore it entirely. The terminal does nothing on
    /// its own.
    Bell,
    /// Cursor style changed (DECSCUSR). Carries the new style so consumers
    /// can apply it without re-reading `screen().cursor_style()`.
    CursorStyleChanged(CursorStyle),
    /// Screen was fully cleared (ED 2 / RIS). Embedders with their own
    /// overlays (scroll indicators, selection, widgets drawn over the grid)
    /// may want to reset them to match.
    ScreenCleared,
    /// Terminal mode toggled.
    ModeChanged {
        /// Mode that changed.
        mode: TerminalMode,
        /// Whether the mode is now enabled.
        enabled: bool,
    },
    /// Dynamic color set by program (OSC 10/11/12). The new color is
    /// always a concrete [`Color::Rgb`] parsed from the OSC payload.
    ///
    /// [`Color::Rgb`]: crate::Color::Rgb
    ColorSet {
        /// Color slot being set. Producers emit only the dynamic slots
        /// (`Foreground`, `Background`, `CursorColor`); palette writes
        /// flow through [`PaletteColorSet`].
        ///
        /// [`PaletteColorSet`]: ScreenEvent::PaletteColorSet
        target: ColorTarget,
        /// New color. Always [`Color::Rgb`] when emitted by the parser.
        ///
        /// [`Color::Rgb`]: crate::Color::Rgb
        color: crate::attrs::Color,
    },
    /// Palette color set by program (OSC 4). The new color is always a
    /// concrete [`Color::Rgb`] parsed from the OSC payload.
    ///
    /// [`Color::Rgb`]: crate::Color::Rgb
    PaletteColorSet {
        /// Palette index.
        index: u8,
        /// New color. Always [`Color::Rgb`] when emitted by the parser.
        ///
        /// [`Color::Rgb`]: crate::Color::Rgb
        color: crate::attrs::Color,
    },
    /// Dynamic color reset by program (OSC 110/111/112). Producers emit
    /// only the dynamic slots; palette resets flow through
    /// [`PaletteColorReset`].
    ///
    /// [`PaletteColorReset`]: ScreenEvent::PaletteColorReset
    ColorReset(ColorTarget),
    /// Palette color(s) reset by program (OSC 104). None = reset all.
    PaletteColorReset {
        /// Palette index to reset, or `None` to reset all palette entries.
        index: Option<u8>,
    },
    /// [`Screen::set_size`](super::Screen::set_size) dropped the active
    /// selection because reflow may have moved its anchored rows.
    ///
    /// Not emitted on a no-op resize, and not emitted by
    /// [`Screen::selection_clear`](super::Screen::selection_clear).
    SelectionInvalidated,
}

/// One resolved capability inside a [`ScreenEvent::XtGetTcap`] event.
///
/// `key_hex` is the upper-cased hex name from the query; `value_hex` carries
/// the hex-encoded capability value when the terminal recognizes the cap, and
/// is `None` for unknown capabilities. Each entry maps 1:1 onto a
/// [`HostReply::XtGetTcap`](crate::HostReply::XtGetTcap) reply frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct XtGetTcapEntry {
    /// Upper-cased hex name from the original query.
    pub key_hex: String,
    /// Hex-encoded value of the capability, or `None` if not recognized.
    pub value_hex: Option<String>,
}

/// Payload for the three XTWINOPS pixel/cell-size queries.
///
/// Each variant corresponds to one `CSI Ps t` sub-query and carries the
/// dimensions the terminal would report. The wire reply is computed by
/// [`auto_reply_bytes`](crate::host_reply::auto_reply_bytes); embedders that
/// want to override (e.g. claim a different pixel cell size in a test
/// fixture) inspect the variant and emit their own bytes instead.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum XtWinOpsReport {
    /// Reply to `CSI 14 t`: total pixel size of the text area.
    /// Encoded as `CSI 4 ; height_pixels ; width_pixels t`. Both
    /// dimensions are `u32` because `rows * cell_height` can exceed
    /// `u16::MAX` on a tall window with a large cell.
    TextAreaPixels {
        /// Total height of the text area in pixels.
        height_pixels: u32,
        /// Total width of the text area in pixels.
        width_pixels: u32,
    },
    /// Reply to `CSI 16 t`: pixel size of one cell.
    /// Encoded as `CSI 6 ; height ; width t`.
    CellPixels {
        /// Cell height in pixels.
        height: u16,
        /// Cell width in pixels.
        width: u16,
    },
    /// Reply to `CSI 18 t`: text area size in cells.
    /// Encoded as `CSI 8 ; rows ; cols t`.
    TextAreaCells {
        /// Text area row count.
        rows: u16,
        /// Text area column count.
        cols: u16,
    },
}

/// Which color slot an [OSC 4/10/11/12 (and 104/110/111/112)][xterm-osc]
/// operation addresses. Used by [`ScreenEvent::ColorQuery`], [`ColorSet`],
/// [`ColorReset`], and [`HostReply::ColorQuery`].
///
/// [`ColorSet`]: ScreenEvent::ColorSet
/// [`ColorReset`]: ScreenEvent::ColorReset
/// [`HostReply::ColorQuery`]: crate::HostReply::ColorQuery
/// [xterm-osc]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[doc(alias = "OSC 4")]
#[doc(alias = "OSC 10")]
#[doc(alias = "OSC 11")]
#[doc(alias = "OSC 12")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ColorTarget {
    /// Default foreground color (OSC 10).
    Foreground,
    /// Default background color (OSC 11).
    Background,
    /// Default cursor color (OSC 12).
    CursorColor,
    /// Indexed palette color (OSC 4), where the inner value is the
    /// palette index.
    Palette(u8),
}

/// Lifecycle state reported alongside a [`ScreenEvent::ProgressReport`].
///
/// Codes follow the ConEmu `OSC 9 ; 4` convention; see [xterm control
/// sequences][xterm-ctlseqs] for the OSC dispatcher conventions that
/// subsume this protocol.
///
/// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[doc(alias = "OSC 9 ; 4")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ProgressState {
    /// `state = 0`: clear any progress indicator.
    Remove,
    /// `state = 1`: definite percentage.
    Set,
    /// `state = 2`: error condition.
    Error,
    /// `state = 3`: indeterminate (busy without a percentage).
    Indeterminate,
    /// `state = 4`: warning condition.
    Warning,
}