Skip to main content

justerm_core/
lib.rs

1// The crate-level docs and the compiled usage example both live in README.md,
2// pulled in here so the published crates.io front page and this doctest are one
3// source: the `cargo test` doc pass compiles the README's `rust` block, so the
4// published usage snippet cannot drift from the real API (#483, and the #473
5// rule that a shipped usage snippet must compile against the real types).
6#![doc = include_str!("../README.md")]
7
8mod cell;
9mod color;
10mod cursor;
11mod damage;
12mod event;
13mod grapheme;
14mod grid;
15mod input;
16mod logical;
17mod search;
18mod selection;
19mod serialize;
20mod term;
21
22pub use cell::{Cell, CellFlags};
23pub use color::Color;
24pub use cursor::{Cursor, CursorShape, Pen};
25pub use damage::{LineDamage, ScrollOp, TermDamage};
26pub use event::TermEvent;
27pub use grid::{Grid, Row};
28pub use input::{
29    Key, KeyAction, KeyEvent, KeypadKey, Modifiers, MouseAction, MouseButton, MouseEvent,
30    MouseEvents,
31};
32pub use logical::LogicalLine;
33pub use search::{Match, SearchOptions, is_valid_regex};
34pub use selection::{SelectionSpan, SelectionType, Side};
35pub use serialize::{
36    CELL_RECORD_LEN, DecodeError, Frame, FrameKind, MarkerId, MarkerKind, MarkerPosition, Overlay,
37    Span, WIRE_VERSION, decode, encode, encode_cell_record, encode_color,
38};
39
40pub use term::{
41    CommandLine, DEFAULT_WORD_SEPARATORS, Hyperlink, MAX_COLUMNS, MAX_MARKERS, MAX_ROWS,
42    MIN_COLUMNS, MarkerEntry, MarkerIndex, Term, TrackedId,
43};
44
45use vte::Parser;
46
47/// The terminal engine: pairs the `vte` parser with our state model.
48///
49/// `Parser` and `Term` are kept as separate fields because `Parser::advance`
50/// borrows both the parser and the performer mutably at once — a single struct
51/// owning both could not satisfy the borrow checker.
52pub struct Engine {
53    parser: Parser,
54    term: Term,
55}
56
57impl Engine {
58    /// A blank engine with a `cols` × `rows` screen and a default scrollback cap.
59    ///
60    /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot represent a
61    /// width-2 glyph, so the engine clamps rather than accepting a size it would
62    /// have three different answers for (#547).
63    pub fn new(cols: usize, rows: usize) -> Self {
64        Engine {
65            parser: Parser::new(),
66            term: Term::new(cols, rows),
67        }
68    }
69
70    /// Like [`Engine::new`] but with an explicit scrollback line limit. `cols` is
71    /// clamped to [`MIN_COLUMNS`] the same way.
72    pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
73        Engine {
74            parser: Parser::new(),
75            term: Term::with_scrollback(cols, rows, scrollback_limit),
76        }
77    }
78
79    /// Push a slice of VT bytes. The caller owns the PTY/SSH/socket I/O — the
80    /// engine only consumes the bytes it is handed.
81    pub fn feed(&mut self, bytes: &[u8]) {
82        self.parser.advance(&mut self.term, bytes);
83    }
84
85    /// Resize the screen to `cols` x `rows`. Rows that scroll off the top enter
86    /// scrollback; the whole screen is damaged.
87    ///
88    /// **The primary screen reflows; the alternate screen does not (#567).** On the
89    /// primary, soft-wrapped logical lines are re-split at the new width — scrollback
90    /// included, since it is one buffer with the screen — so a long line keeps its tail
91    /// instead of being truncated. Reflow is *not* gated on DECAWM: the wrap flag records
92    /// that a row continues into the next one, which stays true after a re-split, and
93    /// re-reading a momentary mode at resize time would decide the fate of history written
94    /// under the opposite setting. The alt screen is re-fit only — rows are dropped or added
95    /// to reach the new size and nothing re-wraps, because a full-screen application places
96    /// its own lines and re-wrapping them would change what it drew.
97    ///
98    /// **What a consumer must redo afterwards.** Query-derived state is *invalidated* and
99    /// user-authored state is *re-anchored*: search highlights are dropped (re-run the
100    /// search at the new width — a reflow moves match coordinates and can change the match
101    /// set), while the selection is carried to its new coordinates for you.
102    ///
103    /// `cols` is widened to [`MIN_COLUMNS`] **silently**: a `resize(1, rows)` during
104    /// a pane drag yields a two-column screen with no error. Read the resulting
105    /// width back from [`Engine::grid`] or the frame header rather than assuming the
106    /// value passed here, and size the PTY from that same width (#547).
107    pub fn resize(&mut self, cols: usize, rows: usize) {
108        self.term.resize(cols, rows);
109    }
110
111    /// The current screen grid.
112    pub fn grid(&self) -> &Grid {
113        self.term.grid()
114    }
115
116    /// The current cursor (position, pending-wrap, pen).
117    pub fn cursor(&self) -> &Cursor {
118        self.term.cursor()
119    }
120
121    /// Whether bracketed-paste mode (DEC ?2004) is enabled. A consumer's input
122    /// encoder reads this to decide whether to wrap pasted text in markers.
123    pub fn bracketed_paste(&self) -> bool {
124        self.term.bracketed_paste()
125    }
126
127    /// Encode a key event to the bytes an application expects, honouring the
128    /// engine's cursor-key mode (DECCKM). The inverse of [`Engine::feed`] — the
129    /// consumer hands a decoded key event and writes the bytes to its PTY.
130    /// Returns `None` for a key with no defined encoding.
131    pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
132        self.term.encode_key(ev)
133    }
134
135    /// Encode a mouse event using the engine's active tracking mode + encoding.
136    /// Returns `None` when mouse reporting is off, or when the event is filtered
137    /// out by the mode (e.g. a bare move while only ?1000 is set).
138    pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
139        self.term.encode_mouse(ev)
140    }
141
142    /// Encode pasted text — wrapped in bracketed-paste markers when ?2004 is on,
143    /// raw otherwise.
144    pub fn encode_paste(&self, text: &str) -> Vec<u8> {
145        self.term.encode_paste(text)
146    }
147
148    /// Encode a focus change (`CSI I` on focus-in, `CSI O` on focus-out), or
149    /// `None` when focus reporting (?1004) is off.
150    pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
151        self.term.encode_focus(focused)
152    }
153
154    /// Take the consumer events accumulated since the last drain (title / bell /
155    /// cwd — see [`TermEvent`]), emptying the queue. The pull counterpart to a
156    /// callback: poll this alongside [`Engine::frame`].
157    pub fn drain_events(&mut self) -> Vec<TermEvent> {
158        self.term.drain_events()
159    }
160
161    /// Take the reply bytes the engine produced for app queries (DA / DSR /
162    /// DECRQM) since the last drain — the consumer writes them straight back to
163    /// the PTY. The inbound-query counterpart to [`Engine::drain_events`].
164    pub fn drain_replies(&mut self) -> Vec<u8> {
165        self.term.drain_replies()
166    }
167
168    /// The OSC 8 hyperlink **URI** at **screen** `(row, col)` — the live grid, same
169    /// coordinates as [`Engine::grid`]'s `cell(row, col)` — or `None` if that cell
170    /// carries no declared link.
171    ///
172    /// **One call, not two, since #628.** This returned a `NonZeroU32` index that a
173    /// second method resolved against a buffer-wide pool; the pool is gone (it was never
174    /// reclaimed, and nothing interned across opens that a shared `Arc` does not), so
175    /// there is no index left to hand out.
176    ///
177    /// **Owned, not borrowed** — a `&str` into the row's map would be tied to `&Engine`,
178    /// so a hover handler could not keep it across the next [`Engine::feed`]. Measured:
179    /// the borrow reads at 0.75 ns but cannot be held at all, and the caller's workaround
180    /// (copying the string) costs 62.6 ns against this handle's 17.9 ns. See
181    /// [`Hyperlink`].
182    ///
183    /// Do **not** confuse this with a decoded `Span`'s `links`, which is a *frame-local*
184    /// index into that frame's `link_table` and belongs to the wire, not to the engine.
185    /// The old two-call form invited exactly that mix-up and its doc-comment recommended
186    /// it: the two index spaces coincide only when a frame carries a single link.
187    pub fn link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
188        self.term.screen_link_at(row, col)
189    }
190
191    /// The underline colour (SGR 58, #520) at **screen** `(row, col)` — same
192    /// coordinates as [`Engine::grid`]'s `cell(row, col)`. A theme-agnostic
193    /// [`Color`] reference; [`Color::Default`] means the underline follows the
194    /// glyph's foreground (the common case, and what a cell with no SGR 58 returns).
195    /// Like the hyperlink, the colour rides a per-row side table, not the 12-byte
196    /// [`Cell`] (#520).
197    pub fn underline_color_at(&self, row: usize, col: usize) -> Color {
198        self.term.screen_underline_color_at(row, col)
199    }
200
201    /// The OSC 8 hyperlink **URI** at **viewport** `(row, col)` — the visible window
202    /// including scrollback at the current scroll, same coordinates as
203    /// [`Engine::viewport_line`] — or `None`. Mirror of [`Engine::link_at`], including
204    /// its #628 note about the vanished index.
205    pub fn viewport_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
206        self.term.viewport_link_at(row, col)
207    }
208
209    /// Number of lines currently held in scrollback history.
210    pub fn scrollback_len(&self) -> usize {
211        self.term.scrollback_len()
212    }
213
214    /// Whether the app has an open **synchronized-output** block (DEC `?2026`):
215    /// it has asked that the next frame of output be painted atomically. The
216    /// engine only *reports* this — **the consumer owns the paint-hold and the
217    /// spec-mandated timeout** (a buggy app that never closes the block must not
218    /// freeze the screen forever, and the engine has no clock). Poll this after
219    /// `feed`; while it is `true`, defer applying frames, and apply once it
220    /// clears (or your own timeout fires). (#73)
221    pub fn synchronized_output(&self) -> bool {
222        self.term.synchronized_output()
223    }
224
225    /// Whether the app enabled color-scheme-update notifications (DEC `?2031`).
226    /// The engine is theme-agnostic — it never knows the scheme. The consumer
227    /// answers a [`TermEvent::ColorSchemeQuery`] (from `?996`) and, when its
228    /// scheme changes *and* this is `true`, sends an unsolicited notification, in
229    /// both cases by calling [`Engine::report_color_scheme`] (#85).
230    pub fn color_scheme_updates(&self) -> bool {
231        self.term.color_scheme_updates()
232    }
233
234    /// Report the current light/dark color scheme to the app as `CSI ? 997 ; 1 n`
235    /// (dark) / `; 2 n` (light), drained via [`Engine::drain_replies`]. Call this
236    /// to answer a [`TermEvent::ColorSchemeQuery`], or — guarded by
237    /// [`Engine::color_scheme_updates`] — when the scheme changes. The engine only
238    /// formats the bit you pass; it stores no scheme (#85).
239    pub fn report_color_scheme(&mut self, dark: bool) {
240        self.term.report_color_scheme(dark);
241    }
242
243    /// Answer an OSC 11 `QueryBackground` event (#122): the consumer hands back
244    /// the current background spec (it owns the palette) and the engine queues
245    /// the OSC 11 reply for `drain_replies`. Theme-agnostic — the engine never
246    /// knows the colour, only formats the envelope.
247    pub fn report_background(&mut self, spec: &str) {
248        self.term.report_background(spec);
249    }
250
251    /// Answer an OSC 10 `QueryForeground` event (#122): queue the OSC 10 reply
252    /// from the consumer-supplied spec. Theme-agnostic envelope-only.
253    pub fn report_foreground(&mut self, spec: &str) {
254        self.term.report_foreground(spec);
255    }
256
257    /// Answer an OSC 4 `QueryPaletteColor` event (#122): queue the OSC 4 reply for
258    /// `index` from the consumer-supplied spec. Theme-agnostic envelope-only.
259    pub fn report_palette_color(&mut self, index: u8, spec: &str) {
260        self.term.report_palette_color(index, spec);
261    }
262
263    /// Whether the app enabled **win32-input-mode** (DEC `?9001`): it asked for
264    /// keys as raw Windows key-records. The engine only tracks the flag — encoding
265    /// the records (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no
266    /// semantic conversion), so [`Engine::encode_key`] is unchanged. A ConPTY
267    /// consumer reads this to decide whether to emit the records itself (#86).
268    pub fn win32_input_mode(&self) -> bool {
269        self.term.win32_input_mode()
270    }
271
272    /// What changed since the last [`Engine::reset_damage`] — line ranges each
273    /// with a changed column span (see ADR-0003).
274    pub fn damage(&self) -> TermDamage {
275        self.term.damage()
276    }
277
278    /// Build a serializable [`Frame`] of the current diff — the damaged spans
279    /// (or every row, when `Full`), the recorded scroll op, and a frame-local
280    /// grapheme side-table. Pass it to [`encode`] for the wire (see #6). Reading
281    /// a frame does not clear damage; call [`Engine::reset_damage`] on ack.
282    pub fn frame(&self) -> Frame {
283        self.term.frame()
284    }
285
286    /// Clear accumulated damage after a frame is applied (the consumer's ack).
287    pub fn reset_damage(&mut self) {
288        self.term.reset_damage();
289    }
290
291    /// Force the next [`Engine::frame`] to be a `Full` frame (every row), even if
292    /// little changed. The use case is **reattach / late subscribe**: a renderer
293    /// that connects after output has already been parsed needs the whole current
294    /// viewport once, then incremental diffs. Marks the screen fully damaged; the
295    /// next `frame()` reports `FrameKind::Full`.
296    pub fn mark_fully_damaged(&mut self) {
297        self.term.mark_fully_damaged();
298    }
299
300    /// The first-class scroll recorded since the last [`Engine::reset_damage`],
301    /// if any — lets the renderer shift rows instead of redrawing them.
302    ///
303    /// **`count` is capped at the scroll region's own height (#661).** Repeated
304    /// scrolls of one region accumulate into a single op between acks, and a flood
305    /// accumulates far past the region: 32 KB of newlines in one [`Engine::feed`] is
306    /// enough. Shifting a region by more than its height already moves every source
307    /// row outside it, so the surplus names nothing a consumer can act on — while it
308    /// did overflow the `i16` this value rides on the wire and arrive as a scroll in
309    /// the *opposite* direction. Suppressed entirely while the viewport is scrolled
310    /// up, since a content scroll must not shift a frozen view.
311    pub fn scroll_delta(&self) -> Option<ScrollOp> {
312        self.term.scroll_delta()
313    }
314
315    /// The cells of visible row `i` (0..rows) at the current scroll position.
316    pub fn viewport_line(&self, i: usize) -> &[Cell] {
317        self.term.viewport_line(i)
318    }
319
320    /// Scroll the viewport up by `n` lines into scrollback history.
321    pub fn scroll_up(&mut self, n: usize) {
322        self.term.scroll_up(n);
323    }
324
325    /// Scroll the viewport down by `n` lines toward the live screen.
326    pub fn scroll_down(&mut self, n: usize) {
327        self.term.scroll_down(n);
328    }
329
330    /// Jump the viewport back to the live screen (follow the bottom).
331    pub fn scroll_to_bottom(&mut self) {
332        self.term.scroll_to_bottom();
333    }
334
335    /// Begin a selection of `ty` at viewport cell `(row, col)`, on `side` of the
336    /// cell. Coordinates are viewport-relative (what a mouse event carries).
337    pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
338        self.term.selection_begin(row, col, side, ty);
339    }
340
341    /// Extend the live selection to viewport cell `(row, col)`, on `side`.
342    pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
343        self.term.selection_extend(row, col, side);
344    }
345
346    /// Replace the characters that end a word for [`SelectionType::Word`] — consumer
347    /// policy injected into a core mechanism (ADR-0017). Defaults to
348    /// [`DEFAULT_WORD_SEPARATORS`]. `' '` is forced in; see [`Term::set_word_separators`]
349    /// for why that floor is load-bearing rather than defensive.
350    pub fn set_word_separators(&mut self, separators: &str) {
351        self.term.set_word_separators(separators);
352    }
353
354    /// The word-boundary set currently in force (including the forced `' '`).
355    pub fn word_separators(&self) -> &str {
356        self.term.word_separators()
357    }
358
359    /// Clear the selection.
360    pub fn selection_clear(&mut self) {
361        self.term.selection_clear();
362    }
363
364    /// The selection projected onto the viewport: one inclusive-column span per
365    /// visible row, for the renderer to highlight. Empty when nothing is
366    /// selected or the selection is fully scrolled off-screen.
367    pub fn selection_range(&self) -> Vec<SelectionSpan> {
368        self.term.selection_range()
369    }
370
371    /// The selected text for copy (respects scrollback), or `None` if no
372    /// selection.
373    pub fn selection_text(&self) -> Option<String> {
374        self.term.selection_text()
375    }
376
377    /// Literal search over the grid + scrollback, returning every match in
378    /// absolute buffer coordinates (top-to-bottom). Smart-case: a query with no
379    /// uppercase matches case-insensitively. The consumer drives next/prev by
380    /// walking the returned `Vec` and calling [`Engine::scroll_to_match`].
381    pub fn search(&self, query: &str) -> Vec<Match> {
382        self.term.search(query)
383    }
384
385    /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
386    /// beyond the literal + smart-case [`search`](Self::search) (#314).
387    pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
388        self.term.search_with(query, opts)
389    }
390
391    /// The viewport's logical lines (#113/ADR-0017): each soft-wrap-joined line's
392    /// text plus a per-char map to its viewport `(row, col)`. The buffer-wide
393    /// mechanism for consumer-side URL detection — the consumer runs its own
394    /// regex / `new URL()` over the text and maps matches back through `cells`.
395    /// Also serves the a11y mirror (#119).
396    pub fn viewport_logical_lines(&self) -> Vec<LogicalLine> {
397        self.term.viewport_logical_lines()
398    }
399
400    /// The whole buffer (scrollback + screen) as one text document for a
401    /// screen-reader accessible view (#150) — soft-wrap-joined, wide-spacers
402    /// skipped, trailing blanks trimmed at the logical end, `\n` between logical
403    /// lines. A query seam the consumer summons (frame mode: over IPC, like
404    /// [`selection_text`](Self::selection_text)); no wire-format change. On the
405    /// alt screen only the alt buffer is shown.
406    pub fn accessible_text(&self) -> String {
407        self.term.accessible_text()
408    }
409
410    /// Scroll the viewport so `m` is visible (next/prev navigation: the consumer
411    /// picks the match, the engine scrolls to it).
412    pub fn scroll_to_match(&mut self, m: &Match) {
413        self.term.search_scroll_to(m);
414    }
415
416    /// The match projected onto the viewport as inclusive-column spans per
417    /// visible row, for the renderer to highlight.
418    pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
419        self.term.match_spans(m)
420    }
421
422    /// Set the search highlights the frame should carry (#108). The
423    /// consumer owns match navigation, so it hands the set to highlight back
424    /// here; [`Engine::frame`] then projects them onto the viewport overlay
425    /// alongside the selection. An empty vec clears the highlights.
426    pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
427        self.term.set_search_highlights(matches);
428    }
429
430    /// Designate which member of the held highlight set is the *active* match
431    /// (#428) — the one next/prev navigation currently points at (that choice is
432    /// the consumer's policy). [`Engine::frame`] projects it into the overlay's
433    /// `active_match` group; it also stays in `matches`, and the renderer's
434    /// highlight ranking resolves the overlap (#424). `None` or an out-of-range
435    /// index projects nothing. Passing a new set to
436    /// [`set_search_highlights`](Self::set_search_highlights) resets the
437    /// designation, so re-designate after every hand-over.
438    pub fn set_active_search_highlight(&mut self, index: Option<usize>) {
439        self.term.set_active_search_highlight(index);
440    }
441
442    /// Designate the *active* match by its absolute span (#436), independent of
443    /// the held highlight set — the past-cap path. A backend that caps its
444    /// hand-over (the documented 1000, xterm's `highlightLimit`) can still give
445    /// the current match its active emphasis: xterm builds its active
446    /// decoration from the found result *outside* the capped list, and this is
447    /// that model. The span projects through the same wrap-aware viewport math
448    /// as any match; past the cap it paints the ACTIVE colour only (no plain
449    /// highlight underneath — honest about the cap). `None` clears. Same
450    /// lifecycle as the index form: reset on every
451    /// [`set_search_highlights`](Self::set_search_highlights) hand-over and on
452    /// any coordinate-shifting invalidation (eviction, region scroll, reflow,
453    /// alt-screen swaps), so re-designate after each hand-over.
454    pub fn set_active_search_match(&mut self, m: Option<Match>) {
455        self.term.set_active_search_match(m);
456    }
457
458    /// Register a decoration marker at viewport `row`, returning its stable id
459    /// (#118). The marker anchors the content currently on that row and tracks
460    /// it through scroll/eviction/reflow; [`Engine::frame`] reports its viewport
461    /// position while visible. Use the id to remove it or to match the
462    /// `TermEvent::MarkerDisposed` fired when its line leaves the buffer.
463    ///
464    /// A buffer holds at most [`MAX_MARKERS`] live markers (#721) — the population is
465    /// also grown by the *stream*, through OSC 133 command marks, so it is bounded.
466    /// Past the cap the **oldest** marker is retired and announced through the same
467    /// `MarkerDisposed` event, so a consumer that already handles disposal needs no new
468    /// handling; a consumer that ignores it can leave a decoration bound to a dead id.
469    pub fn add_marker(&mut self, row: usize) -> MarkerId {
470        self.term.add_marker(row)
471    }
472
473    /// Remove a marker by id (#118), firing `TermEvent::MarkerDisposed`. A no-op
474    /// for an unknown or already-disposed id.
475    pub fn remove_marker(&mut self, id: MarkerId) {
476        self.term.remove_marker(id);
477    }
478
479    /// Track absolute buffer `(line, col)`, returning a stable id (#691): the
480    /// engine keeps the position on the content that is there now, through
481    /// scrollback eviction, region scrolls and reflow.
482    ///
483    /// This is what an absolute coordinate held *outside* the engine needs to stay
484    /// meaningful — a search anchor carrying an emphasis across a re-search is the
485    /// case it exists for. The engine renumbers this space (evicting the oldest
486    /// history line shifts every index down by one), and it renumbers it in the
487    /// consumer's absence, so a remembered `Match` silently comes to name
488    /// different text.
489    ///
490    /// Mechanism only: which position is worth remembering, and what to do once it
491    /// is gone, stay with the consumer (ADR-0017). Release it with
492    /// [`Engine::untrack_point`] — the engine cannot know when you are done.
493    ///
494    /// **The line is maintained; the column is carried, not tracked.** In-row edits
495    /// (ICH / DCH) shift cells past a tracked column without moving it, so a point
496    /// on text that was pushed sideways names the wrong cell in that row. No
497    /// reference maintains a column here either — xterm's markers carry none at
498    /// all, and ghostty's pins are untouched by its `insertChars`/`deleteChars` —
499    /// so this is the convergent behaviour rather than an omission.
500    pub fn track_point(&mut self, line: usize, col: usize) -> TrackedId {
501        self.term.track_point(line, col)
502    }
503
504    /// Where the point registered as `id` sits now, in the **active** screen's
505    /// coordinates — or `None` (#691).
506    ///
507    /// `None` covers three cases, and a caller does not need to tell them apart:
508    /// the content has left the buffer, the id is unknown or released, or the point
509    /// belongs to *the other screen*. The last one is not a limitation but the only
510    /// honest answer: the primary grid and the alt grid occupy the **same** absolute
511    /// indices, so a number alone cannot say which screen it means. All three say
512    /// *do not move anything on account of this point*.
513    ///
514    /// An out-of-range coordinate is clamped rather than rejected, at both ends
515    /// (ADR-0026 D2/D3): the line into the buffer's range, the column to the grid
516    /// width. That bound is applied here, at the read; a coordinate that was never
517    /// in range to begin with is also **resolved by a reflow** (it maps to the top
518    /// of the buffer), so "bounded once" holds for the site, not for the value.
519    pub fn tracked_point(&self, id: TrackedId) -> Option<(usize, usize)> {
520        self.term.tracked_point(id)
521    }
522
523    /// Release a tracked point (#691). A no-op for an unknown or already-released
524    /// id.
525    pub fn untrack_point(&mut self, id: TrackedId) {
526        self.term.untrack_point(id);
527    }
528
529    /// The OSC 133 shell-integration command marks in buffer order — `(id,
530    /// absolute line, kind)` (#158). Excludes plain `add_marker` decorations.
531    /// The consumer pairs prompt/command/finished marks to drive prompt-to-prompt
532    /// navigation and command/exit announcements (#160); the engine only parses
533    /// the `133;A/B/C/D` sequences and anchors the marks.
534    pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
535        self.term.command_marks()
536    }
537
538    /// The executed shell commands recovered from OSC-133 marks, in buffer order
539    /// (#166) — the query behind screen-reader command navigation. Each
540    /// [`CommandLine`] carries the typed command text (prompt/output excluded via
541    /// the captured columns), its jump line (CommandStart), and the exit code.
542    /// This is a full-buffer query, wired to the frame-mode consumer over IPC like
543    /// [`Engine::accessible_text`]; the web side has no scrollback cells to derive
544    /// it (ADR-0017 — buffer-wide text is core's).
545    pub fn command_lines(&self) -> Vec<CommandLine> {
546        self.term.command_lines()
547    }
548
549    /// Every live marker of the active buffer with its **absolute** buffer line, plus
550    /// the basis that says how long the answer stays usable (#490).
551    ///
552    /// The pull half of the marker surface, and the same shape as
553    /// [`Engine::command_lines`]: the consumer asks, keeps the answer, and rebases it
554    /// per frame by the `evicted_total` delta — rather than being handed every live
555    /// marker inside every frame, which is `O(M)` payload per frame for a quantity
556    /// unrelated to what changed (ADR-0020 R3).
557    ///
558    /// Ask again when [`MarkerIndex::epoch`] differs from the one you hold. Drop an
559    /// entry when its `TermEvent::MarkerDisposed` arrives — a disposal deliberately
560    /// does *not* move the epoch, so it costs no re-pull.
561    pub fn marker_index(&self) -> MarkerIndex {
562        self.term.marker_index()
563    }
564}