Skip to main content

max / shop

VT parser: vte-driven Grid replaces raw scrollback crates/shop-grid: Cell + Cursor + main/alt storage, resize preserves top-left, scroll region tracking, deferred-wrap semantics (matters for vim's line drawing). vte::Perform impl covers what vim/less/nano need: * CUP, CUU, CUD, CUF, CUB, CNL, CPL, CHA, VPA (cursor motion) * ED, EL (erase display / line, all three modes each) * SU, SD, DECSTBM (scroll) * ESC 7 / ESC 8 / ESC M (save/restore, reverse index) * DECSET/DECRST for cursor visibility (25) and alt-screen (1049/47/1047) * BS, HT, LF/VT/FF, CR, BEL SGR (colors, attrs), OSC (title/hyperlinks/OSC 52), DCS, and cursor rendering are deliberately deferred to follow-up milestones. Binary: replaces scrollback Vec<u8> with a Grid + vte::Parser. PTY callback now feeds bytes to parser.advance(&mut grid, &bytes). Row-by-row render pulls chars from grid.row(r). Using vte off crates.io as an interim measure — the shop-vt crate is still on the roadmap for when kitty graphics needs a bigger OSC buffer and APC dispatch hooks.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:30 UTC
Signed with PGP, not checked
Commit: 7125a192391c1d8e054cf52888ab9236cc298603
Parent: 8d6957f
6 files changed, +494 insertions, -25 deletions
M Cargo.lock +20
@@ -813,16 +813,26 @@
813 813 "calloop",
814 814 "calloop-wayland-source",
815 815 "pollster",
816 + "shop-grid",
816 817 "shop-pty",
817 818 "shop-render",
818 819 "shop-wayland",
819 820 "smithay-client-toolkit",
820 821 "tracing",
821 822 "tracing-subscriber",
823 + "vte",
822 824 "wayland-client",
823 825 "wgpu",
824 826 ]
825 827
828 + [[package]]
829 + name = "shop-grid"
830 + version = "0.0.0"
831 + dependencies = [
832 + "tracing",
833 + "vte",
834 + ]
835 +
826 836 [[package]]
827 837 name = "shop-pty"
828 838 version = "0.0.0"
@@ -1062,6 +1072,16 @@
1062 1072 source = "registry+https://github.com/rust-lang/crates.io-index"
1063 1073 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1064 1074
1075 + [[package]]
1076 + name = "vte"
1077 + version = "0.15.0"
1078 + source = "registry+https://github.com/rust-lang/crates.io-index"
1079 + checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
1080 + dependencies = [
1081 + "arrayvec",
1082 + "memchr",
1083 + ]
1084 +
1065 1085 [[package]]
1066 1086 name = "wayland-backend"
1067 1087 version = "0.3.16"
M Cargo.toml +2
@@ -6,6 +6,7 @@
6 6 "crates/shop-wayland",
7 7 "crates/shop-render",
8 8 "crates/shop-pty",
9 + "crates/shop-grid",
9 10 ]
10 11
11 12 [workspace.dependencies]
@@ -23,6 +24,7 @@
23 24 guillotiere = "0.7"
24 25 bytemuck = { version = "1", features = ["derive"] }
25 26 nix = { version = "0.31", features = ["term", "process", "fs", "ioctl", "signal"] }
27 + vte = "0.15"
26 28
27 29 [workspace.package]
28 30 edition = "2024"
@@ -19,10 +19,12 @@
19 19 shop-wayland = { path = "../shop-wayland" }
20 20 shop-render = { path = "../shop-render" }
21 21 shop-pty = { path = "../shop-pty" }
22 + shop-grid = { path = "../shop-grid" }
22 23 smithay-client-toolkit.workspace = true
23 24 wayland-client.workspace = true
24 25 calloop.workspace = true
25 26 calloop-wayland-source.workspace = true
27 + vte.workspace = true
26 28 wgpu.workspace = true
27 29 pollster.workspace = true
28 30 anyhow.workspace = true
@@ -12,6 +12,7 @@
12 12 use calloop::EventLoop;
13 13 use calloop::generic::{FdWrapper, Generic};
14 14 use calloop_wayland_source::WaylandSource;
15 + use shop_grid::Grid;
15 16 use shop_pty::{Pty, PtySize};
16 17 use shop_render::TextRenderer;
17 18 use shop_wayland::{
@@ -30,7 +31,6 @@
30 31 const FONT_PX: f32 = 18.0;
31 32 const CELL_ADVANCE: f32 = 11.0;
32 33 const CELL_HEIGHT: f32 = 22.0;
33 - const SCROLLBACK_BYTES: usize = 64 * 1024;
34 34 const CLEAR: wgpu::Color = wgpu::Color {
35 35 r: 0.043,
36 36 g: 0.055,
@@ -137,6 +137,8 @@
137 137 };
138 138 let text = TextRenderer::new(&device, format, font_data, FONT_PX)?;
139 139
140 + let grid = Grid::new(cols_initial, rows_initial);
141 + let parser = vte::Parser::new();
140 142 let mut app = App {
141 143 chrome,
142 144 surface,
@@ -145,7 +147,8 @@
145 147 queue: Arc::new(queue),
146 148 text,
147 149 pty,
148 - scrollback: Vec::with_capacity(SCROLLBACK_BYTES),
150 + grid,
151 + parser,
149 152 pending_redraw: true,
150 153 };
151 154 app.surface.configure(&app.device, &app.surface_config);
@@ -179,7 +182,7 @@
179 182 break;
180 183 }
181 184 Ok(n) => {
182 - push_scrollback(&mut app.scrollback, &buf[..n]);
185 + app.parser.advance(&mut app.grid, &buf[..n]);
183 186 app.pending_redraw = true;
184 187 }
185 188 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
@@ -206,6 +209,7 @@
206 209 app.text.resize(&app.queue, width, height);
207 210 let cols = (width as f32 / CELL_ADVANCE).max(1.0) as u16;
208 211 let rows = (height as f32 / CELL_HEIGHT).max(1.0) as u16;
212 + app.grid.resize(cols, rows);
209 213 let _ = app.pty.resize(PtySize {
210 214 cols,
211 215 rows,
@@ -229,15 +233,6 @@
229 233 Ok(())
230 234 }
231 235
232 - /// Append bytes and trim from the left when past the scrollback cap.
233 - fn push_scrollback(buf: &mut Vec<u8>, bytes: &[u8]) {
234 - buf.extend_from_slice(bytes);
235 - if buf.len() > SCROLLBACK_BYTES {
236 - let drop = buf.len() - SCROLLBACK_BYTES;
237 - buf.drain(..drop);
238 - }
239 - }
240 -
241 236 struct App {
242 237 chrome: WaylandChrome,
243 238 surface: wgpu::Surface<'static>,
@@ -246,7 +241,8 @@
246 241 queue: Arc<wgpu::Queue>,
247 242 text: TextRenderer,
248 243 pty: Pty,
249 - scrollback: Vec<u8>,
244 + grid: Grid,
245 + parser: vte::Parser,
250 246 pending_redraw: bool,
251 247 }
252 248
@@ -278,28 +274,30 @@
278 274 ..Default::default()
279 275 });
280 276 }
281 - // Naive layout: split scrollback into lines, keep the last N that fit,
282 - // strip control bytes, draw each. The VT parser milestone replaces this.
283 - let text = String::from_utf8_lossy(&app.scrollback);
284 - let visible_rows = ((app.surface_config.height as f32 / CELL_HEIGHT) as usize).max(1);
285 - let lines: Vec<&str> = text.lines().collect();
286 - let start = lines.len().saturating_sub(visible_rows);
287 - let mut y = 4.0;
288 - for line in &lines[start..] {
289 - let sanitized: String = line.chars().filter(|c| !c.is_control()).collect();
290 - if !sanitized.is_empty() {
277 + // Render each row of the grid as a single line. Colors and per-cell
278 + // attributes come next milestone. The parser has already resolved wrap,
279 + // control bytes, and cursor motion, so this is just character emission.
280 + for r in 0..app.grid.rows() {
281 + let line: String = app
282 + .grid
283 + .row(r)
284 + .iter()
285 + .map(|c| if c.c == '\0' { ' ' } else { c.c })
286 + .collect();
287 + let trimmed = line.trim_end_matches(' ');
288 + if !trimmed.is_empty() {
289 + let y = r as f32 * CELL_HEIGHT;
291 290 app.text.draw(
292 291 &app.device,
293 292 &app.queue,
294 293 &view,
295 294 &mut encoder,
296 - &sanitized,
295 + trimmed,
297 296 4.0,
298 297 y,
299 298 TEXT_COLOR,
300 299 );
301 300 }
302 - y += CELL_HEIGHT;
303 301 }
304 302 app.queue.submit(std::iter::once(encoder.finish()));
305 303 app.queue.present(frame);
@@ -1,0 +1,17 @@
1 + [package]
2 + name = "shop-grid"
3 + version = "0.0.0"
4 + description = "Terminal grid + cursor + alt-screen for shop"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + vte.workspace = true
17 + tracing.workspace = true
@@ -1,0 +1,430 @@
1 + //! Terminal grid + cursor + alt-screen for shop.
2 + //!
3 + //! MVP: characters only. Per-cell colors and SGR attributes land in a
4 + //! follow-up. Alt-screen support is here from the start because vim without
5 + //! it makes a mess of scrollback on every `:q`.
6 + //!
7 + //! Implements [`vte::Perform`], so the binary can pipe PTY bytes through a
8 + //! `vte::Parser` straight into the grid.
9 +
10 + use tracing::trace;
11 + use vte::{Params, Perform};
12 +
13 + /// One cell of the display grid.
14 + #[derive(Copy, Clone, Debug, PartialEq)]
15 + pub struct Cell {
16 + pub c: char,
17 + }
18 +
19 + impl Default for Cell {
20 + fn default() -> Self {
21 + Self { c: ' ' }
22 + }
23 + }
24 +
25 + /// Cursor state (position + deferred-wrap flag).
26 + #[derive(Copy, Clone, Debug, Default)]
27 + pub struct Cursor {
28 + pub row: u16,
29 + pub col: u16,
30 + pub visible: bool,
31 + /// Deferred wrap: after writing to the rightmost column, the next
32 + /// printable char wraps to the next line. This mirrors DEC/xterm
33 + /// behavior and matters for vim's line drawing.
34 + pub wrap_next: bool,
35 + }
36 +
37 + pub struct Grid {
38 + cols: u16,
39 + rows: u16,
40 + // Row-major cells; index = row * cols + col.
41 + main: Vec<Cell>,
42 + alt: Vec<Cell>,
43 + on_alt: bool,
44 + cursor: Cursor,
45 + saved_main_cursor: Cursor,
46 + saved_alt_cursor: Cursor,
47 + scroll_top: u16, // 0-indexed, inclusive
48 + scroll_bottom: u16, // 0-indexed, inclusive
49 + }
50 +
51 + impl Grid {
52 + pub fn new(cols: u16, rows: u16) -> Self {
53 + let cols = cols.max(1);
54 + let rows = rows.max(1);
55 + let cell_count = cols as usize * rows as usize;
56 + Self {
57 + cols,
58 + rows,
59 + main: vec![Cell::default(); cell_count],
60 + alt: vec![Cell::default(); cell_count],
61 + on_alt: false,
62 + cursor: Cursor {
63 + visible: true,
64 + ..Cursor::default()
65 + },
66 + saved_main_cursor: Cursor::default(),
67 + saved_alt_cursor: Cursor::default(),
68 + scroll_top: 0,
69 + scroll_bottom: rows - 1,
70 + }
71 + }
72 +
73 + pub fn cols(&self) -> u16 {
74 + self.cols
75 + }
76 +
77 + pub fn rows(&self) -> u16 {
78 + self.rows
79 + }
80 +
81 + pub fn cursor(&self) -> Cursor {
82 + self.cursor
83 + }
84 +
85 + pub fn row(&self, r: u16) -> &[Cell] {
86 + let start = r as usize * self.cols as usize;
87 + let end = start + self.cols as usize;
88 + &self.active_cells()[start..end]
89 + }
90 +
91 + fn active_cells(&self) -> &[Cell] {
92 + if self.on_alt { &self.alt } else { &self.main }
93 + }
94 +
95 + fn active_cells_mut(&mut self) -> &mut [Cell] {
96 + if self.on_alt {
97 + &mut self.alt
98 + } else {
99 + &mut self.main
100 + }
101 + }
102 +
103 + /// Resize the grid, preserving as much of the top-left as fits. Truncates
104 + /// content outside the new bounds; does not reflow.
105 + pub fn resize(&mut self, cols: u16, rows: u16) {
106 + let cols = cols.max(1);
107 + let rows = rows.max(1);
108 + if cols == self.cols && rows == self.rows {
109 + return;
110 + }
111 + self.main = resize_buf(&self.main, self.cols, self.rows, cols, rows);
112 + self.alt = resize_buf(&self.alt, self.cols, self.rows, cols, rows);
113 + self.cols = cols;
114 + self.rows = rows;
115 + self.scroll_top = 0;
116 + self.scroll_bottom = rows - 1;
117 + self.cursor.row = self.cursor.row.min(rows - 1);
118 + self.cursor.col = self.cursor.col.min(cols - 1);
119 + self.cursor.wrap_next = false;
120 + }
121 +
122 + fn cell_index(&self, row: u16, col: u16) -> usize {
123 + row as usize * self.cols as usize + col as usize
124 + }
125 +
126 + fn place_char(&mut self, c: char) {
127 + // Deferred wrap: if the previous print landed on the rightmost cell,
128 + // the next visible char starts a new line.
129 + if self.cursor.wrap_next {
130 + self.newline();
131 + self.cursor.col = 0;
132 + self.cursor.wrap_next = false;
133 + }
134 + let idx = self.cell_index(self.cursor.row, self.cursor.col);
135 + self.active_cells_mut()[idx] = Cell { c };
136 + if self.cursor.col + 1 >= self.cols {
137 + self.cursor.wrap_next = true;
138 + } else {
139 + self.cursor.col += 1;
140 + }
141 + }
142 +
143 + fn newline(&mut self) {
144 + if self.cursor.row < self.scroll_bottom {
145 + self.cursor.row += 1;
146 + } else {
147 + self.scroll_up_in_region(1);
148 + }
149 + self.cursor.wrap_next = false;
150 + }
151 +
152 + fn scroll_up_in_region(&mut self, n: u16) {
153 + let top = self.scroll_top as usize;
154 + let bot = self.scroll_bottom as usize;
155 + let cols = self.cols as usize;
156 + let n = (n as usize).min(bot - top + 1);
157 + let cells = self.active_cells_mut();
158 + for r in top..=(bot - n) {
159 + let src = (r + n) * cols;
160 + let dst = r * cols;
161 + cells.copy_within(src..src + cols, dst);
162 + }
163 + for r in (bot - n + 1)..=bot {
164 + for c in 0..cols {
165 + cells[r * cols + c] = Cell::default();
166 + }
167 + }
168 + }
169 +
170 + fn scroll_down_in_region(&mut self, n: u16) {
171 + let top = self.scroll_top as usize;
172 + let bot = self.scroll_bottom as usize;
173 + let cols = self.cols as usize;
174 + let n = (n as usize).min(bot - top + 1);
175 + let cells = self.active_cells_mut();
176 + for r in (top + n..=bot).rev() {
177 + let src = (r - n) * cols;
178 + let dst = r * cols;
179 + cells.copy_within(src..src + cols, dst);
180 + }
181 + for r in top..(top + n) {
182 + for c in 0..cols {
183 + cells[r * cols + c] = Cell::default();
184 + }
185 + }
186 + }
187 +
188 + fn erase_line(&mut self, mode: u16) {
189 + let cols = self.cols as usize;
190 + let row_start = self.cursor.row as usize * cols;
191 + let col = self.cursor.col as usize;
192 + let cells = self.active_cells_mut();
193 + let (from, to) = match mode {
194 + 1 => (row_start, row_start + col + 1), // start to cursor
195 + 2 => (row_start, row_start + cols), // whole line
196 + _ => (row_start + col, row_start + cols), // 0: cursor to end
197 + };
198 + for cell in &mut cells[from..to] {
199 + *cell = Cell::default();
200 + }
201 + }
202 +
203 + fn erase_display(&mut self, mode: u16) {
204 + let cols = self.cols as usize;
205 + let idx = self.cell_index(self.cursor.row, self.cursor.col);
206 + let total = cols * self.rows as usize;
207 + let cells = self.active_cells_mut();
208 + let (from, to) = match mode {
209 + 1 => (0, idx + 1),
210 + 2 | 3 => (0, total),
211 + _ => (idx, total),
212 + };
213 + for cell in &mut cells[from..to] {
214 + *cell = Cell::default();
215 + }
216 + }
217 +
218 + fn swap_alt(&mut self, to_alt: bool) {
219 + if self.on_alt == to_alt {
220 + return;
221 + }
222 + if to_alt {
223 + self.saved_main_cursor = self.cursor;
224 + self.on_alt = true;
225 + for cell in &mut self.alt {
226 + *cell = Cell::default();
227 + }
228 + self.cursor = self.saved_alt_cursor;
229 + } else {
230 + self.saved_alt_cursor = self.cursor;
231 + self.on_alt = false;
232 + self.cursor = self.saved_main_cursor;
233 + }
234 + }
235 +
236 + fn set_cursor(&mut self, row: u16, col: u16) {
237 + // Terminal params are 1-indexed; convert.
238 + let row = row.saturating_sub(1).min(self.rows - 1);
239 + let col = col.saturating_sub(1).min(self.cols - 1);
240 + self.cursor.row = row;
241 + self.cursor.col = col;
242 + self.cursor.wrap_next = false;
243 + }
244 +
245 + fn move_by(&mut self, drow: i32, dcol: i32) {
246 + let r = (self.cursor.row as i32 + drow)
247 + .clamp(0, self.rows as i32 - 1) as u16;
248 + let c = (self.cursor.col as i32 + dcol)
249 + .clamp(0, self.cols as i32 - 1) as u16;
250 + self.cursor.row = r;
251 + self.cursor.col = c;
252 + self.cursor.wrap_next = false;
253 + }
254 + }
255 +
256 + fn resize_buf(
257 + old: &[Cell],
258 + old_cols: u16,
259 + old_rows: u16,
260 + new_cols: u16,
261 + new_rows: u16,
262 + ) -> Vec<Cell> {
263 + let mut new = vec![Cell::default(); new_cols as usize * new_rows as usize];
264 + let copy_cols = old_cols.min(new_cols) as usize;
265 + let copy_rows = old_rows.min(new_rows) as usize;
266 + for r in 0..copy_rows {
267 + let src_start = r * old_cols as usize;
268 + let dst_start = r * new_cols as usize;
269 + new[dst_start..dst_start + copy_cols]
270 + .copy_from_slice(&old[src_start..src_start + copy_cols]);
271 + }
272 + new
273 + }
274 +
275 + // -- vte::Perform impl -----------------------------------------------------
276 +
277 + fn param1(params: &Params, default: u16) -> u16 {
278 + let first = params.iter().next().and_then(|p| p.first().copied()).unwrap_or(0);
279 + if first == 0 { default } else { first }
280 + }
281 +
282 + fn param2(params: &Params, defaults: (u16, u16)) -> (u16, u16) {
283 + let mut it = params.iter();
284 + let a = it.next().and_then(|p| p.first().copied()).unwrap_or(0);
285 + let b = it.next().and_then(|p| p.first().copied()).unwrap_or(0);
286 + let a = if a == 0 { defaults.0 } else { a };
287 + let b = if b == 0 { defaults.1 } else { b };
288 + (a, b)
289 + }
290 +
291 + impl Perform for Grid {
292 + fn print(&mut self, c: char) {
293 + self.place_char(c);
294 + }
295 +
296 + fn execute(&mut self, byte: u8) {
297 + match byte {
298 + 0x08 => {
299 + // BS
300 + if self.cursor.col > 0 {
301 + self.cursor.col -= 1;
302 + }
303 + self.cursor.wrap_next = false;
304 + }
305 + 0x09 => {
306 + // HT — advance to next multiple of 8, clamped.
307 + let next = ((self.cursor.col / 8) + 1) * 8;
308 + self.cursor.col = next.min(self.cols - 1);
309 + self.cursor.wrap_next = false;
310 + }
311 + 0x0A | 0x0B | 0x0C => {
312 + // LF / VT / FF
313 + self.newline();
314 + }
315 + 0x0D => {
316 + // CR
317 + self.cursor.col = 0;
318 + self.cursor.wrap_next = false;
319 + }
320 + 0x07 => {} // BEL — ignore for now
321 + other => trace!("unhandled C0 {other:#x}"),
322 + }
323 + }
324 +
325 + fn csi_dispatch(
326 + &mut self,
327 + params: &Params,
328 + intermediates: &[u8],
329 + _ignore: bool,
330 + action: char,
331 + ) {
332 + let private = intermediates.first().copied() == Some(b'?');
333 + match (action, private) {
334 + ('H', false) | ('f', false) => {
335 + let (row, col) = param2(params, (1, 1));
336 + self.set_cursor(row, col);
337 + }
338 + ('A', false) => {
339 + let n = param1(params, 1) as i32;
340 + self.move_by(-n, 0);
341 + }
342 + ('B', false) => {
343 + let n = param1(params, 1) as i32;
344 + self.move_by(n, 0);
345 + }
346 + ('C', false) => {
347 + let n = param1(params, 1) as i32;
348 + self.move_by(0, n);
349 + }
350 + ('D', false) => {
351 + let n = param1(params, 1) as i32;
352 + self.move_by(0, -n);
353 + }
354 + ('E', false) => {
355 + let n = param1(params, 1) as i32;
356 + self.move_by(n, 0);
357 + self.cursor.col = 0;
358 + }
359 + ('F', false) => {
360 + let n = param1(params, 1) as i32;
361 + self.move_by(-n, 0);
362 + self.cursor.col = 0;
363 + }
364 + ('G', false) => {
365 + let col = param1(params, 1);
366 + self.cursor.col = col.saturating_sub(1).min(self.cols - 1);
367 + self.cursor.wrap_next = false;
368 + }
369 + ('d', false) => {
370 + let row = param1(params, 1);
371 + self.cursor.row = row.saturating_sub(1).min(self.rows - 1);
372 + self.cursor.wrap_next = false;
373 + }
374 + ('J', false) => {
375 + self.erase_display(param1(params, 0));
376 + }
377 + ('K', false) => {
378 + self.erase_line(param1(params, 0));
379 + }
380 + ('S', false) => {
381 + self.scroll_up_in_region(param1(params, 1));
382 + }
383 + ('T', false) => {
384 + self.scroll_down_in_region(param1(params, 1));
385 + }
386 + ('r', false) => {
387 + let (top, bot) = param2(params, (1, self.rows));
388 + self.scroll_top = top.saturating_sub(1).min(self.rows - 1);
389 + self.scroll_bottom = bot.saturating_sub(1).min(self.rows - 1);
390 + self.cursor.row = 0;
391 + self.cursor.col = 0;
392 + }
393 + ('m', false) => {
394 + // SGR — accept and ignore for now (colors come next milestone).
395 + }
396 + ('h', true) | ('l', true) => {
397 + for p in params.iter() {
398 + if let Some(&code) = p.first() {
399 + match code {
400 + 25 => self.cursor.visible = action == 'h',
401 + 1049 | 47 | 1047 => self.swap_alt(action == 'h'),
402 + _ => {}
403 + }
404 + }
405 + }
406 + }
407 + _ => trace!("unhandled CSI {action} private={private}"),
408 + }
409 + }
410 +
411 + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) {
412 + match byte {
413 + b'7' => self.saved_main_cursor = self.cursor,
414 + b'8' => self.cursor = self.saved_main_cursor,
415 + b'M' => {
416 + // RI — reverse index
417 + if self.cursor.row == self.scroll_top {
418 + self.scroll_down_in_region(1);
419 + } else {
420 + self.cursor.row = self.cursor.row.saturating_sub(1);
421 + }
422 + }
423 + _ => trace!("unhandled ESC {}", byte as char),
424 + }
425 + }
426 +
427 + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
428 + // Title (OSC 0/2), hyperlinks (OSC 8), clipboard (OSC 52) — later.
429 + }
430 + }