Repository: raphamorim/rio (language: rust)

Issue:
iTerm2 Image protocol



Discussion / hints:
Just wanna note a couple things. See:

- https://github.com/jerch/xterm-addon-image/issues/57#issuecomment-1979734859 concerning cursor placement, and
- https://github.com/jerch/xterm-addon-image/issues/58 concerning feature reporting / support detection.

These may have even been taken care of, pointing them out just in case.

Thanks :smiley:.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 1d2c900658..895087f45b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -534,7 +534,6 @@ version = "0.1.12"
 dependencies = [
  "arrayvec",
  "rio-proc-macros",
- "unicode-normalization",
 ]
 
 [[package]]
@@ -1363,8 +1362,23 @@ checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
 dependencies = [
  "bytemuck",
  "byteorder-lite",
+ "color_quant",
+ "gif",
+ "image-webp",
  "num-traits",
  "png",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
 ]
 
 [[package]]
@@ -2141,6 +2155,12 @@ dependencies = [
  "bytemuck",
 ]
 
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
 [[package]]
 name = "quick-xml"
 version = "0.34.0"
@@ -2375,6 +2395,7 @@ dependencies = [
  "regex",
  "regex-automata 0.4.7",
  "rio-window",
+ "rustc-hash 2.0.0",
  "serde",
  "smallvec",
  "sugarloaf",
@@ -3973,6 +3994,12 @@ dependencies = [
  "syn 2.0.76",
 ]
 
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
 [[package]]
 name = "zune-inflate"
 version = "0.2.54"
@@ -3981,3 +4008,12 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
 dependencies = [
  "simd-adler32",
 ]
+
+[[package]]
+name = "zune-jpeg"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768"
+dependencies = [
+ "zune-core",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 6c54767079..16d54bd7b0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,7 +31,7 @@ sugarloaf = { path = "sugarloaf", version = "0.1.12" }
 corcovado = { path = "corcovado", version = "0.1.12" }
 rio-config = { path = "rio-config", version = "0.1.12" }
 rio-proc-macros = { path = "rio-proc-macros", version = "0.1.12" }
-copa = { path = "copa", default-features = true, version = "0.1.12" }
+copa = { path = "copa", default-features = false, version = "0.1.12" }
 teletypewriter = { path = "teletypewriter", version = "0.1.12" }
 rio-backend = { path = "rio-backend", version = "0.1.12" }
 rio-window = { path = "rio-window", version = "0.1.12", default-features = false }
@@ -41,7 +41,8 @@ raw-window-handle = { version = "0.6.2", features = ["std"] }
 parking_lot = { version = "0.12.3", features = ["nightly", "hardware-lock-elision"] }
 rustc-hash = "2.0.0"
 unicode-width = "0.1.13"
-image_rs = { package = "image", version = "0.25.2", default-features = false, features = ["ico"] }
+base64 = "0.22.1"
+image_rs = { package = "image", version = "0.25.2", default-features = false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
 regex = "1.10.5"
 bytemuck = { version = "1.17.0", features = [ "derive" ] }
 swash = "0.1.18"
diff --git a/copa/Cargo.toml b/copa/Cargo.toml
index 8c067c3e51..0bcc1800bb 100644
--- a/copa/Cargo.toml
+++ b/copa/Cargo.toml
@@ -13,9 +13,6 @@ edition = "2021"
 rio-proc-macros = { workspace = true }
 arrayvec = { version = "0.7.6", default-features = false, optional = true }
 
-[dev-dependencies]
-unicode-normalization = "0.1.22"
-
 [features]
 default = ["no_std"]
 no_std = ["arrayvec"]
diff --git a/copa/src/lib.rs b/copa/src/lib.rs
index 69cdc2cf4f..bd96edbecf 100644
--- a/copa/src/lib.rs
+++ b/copa/src/lib.rs
@@ -657,35 +657,35 @@ mod tests {
         }
     }
 
-    #[test]
-    fn issue_191() {
-        use crate::std::string::{String, ToString};
-        use unicode_normalization::{is_nfc_quick, IsNormalized, UnicodeNormalization};
-
-        // https://github.com/raphamorim/rio/issues/191
-        // 한 isn't rendered correctly via printf '\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab\n'
-        let mut input =
-            std::str::from_utf8(b"\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab").unwrap();
-        let mut dispatcher = Dispatcher::default();
-        let mut parser = Parser::new();
-
-        let p = std::mem::take(&mut input).to_string();
-        let normalized: String;
-        let text = if is_nfc_quick(p.chars()) == IsNormalized::Yes {
-            p.as_str()
-        } else {
-            normalized = p.as_str().nfc().collect();
-            normalized.as_str()
-        };
-
-        let bytes: &[u8] = &text.bytes().collect::<Vec<u8>>();
-
-        for byte in bytes {
-            parser.advance(&mut dispatcher, *byte);
-        }
-
-        assert_eq!(text, "한");
-    }
+    // #[test]
+    // fn issue_191() {
+    //     use crate::std::string::{String, ToString};
+    //     use unicode_normalization::{is_nfc_quick, IsNormalized, UnicodeNormalization};
+
+    //     // https://github.com/raphamorim/rio/issues/191
+    //     // 한 isn't rendered correctly via printf '\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab\n'
+    //     let mut input =
+    //         std::str::from_utf8(b"\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab").unwrap();
+    //     let mut dispatcher = Dispatcher::default();
+    //     let mut parser = Parser::new();
+
+    //     let p = std::mem::take(&mut input).to_string();
+    //     let normalized: String;
+    //     let text = if is_nfc_quick(p.chars()) == IsNormalized::Yes {
+    //         p.as_str()
+    //     } else {
+    //         normalized = p.as_str().nfc().collect();
+    //         normalized.as_str()
+    //     };
+
+    //     let bytes: &[u8] = &text.bytes().collect::<Vec<u8>>();
+
+    //     for byte in bytes {
+    //         parser.advance(&mut dispatcher, *byte);
+    //     }
+
+    //     assert_eq!(text, "한");
+    // }
 
     #[test]
     fn exceed_max_buffer_size() {
diff --git a/docs/docs/features/index.md b/docs/docs/features/index.md
index 3fcb84ef13..dbfaffabd6 100644
--- a/docs/docs/features/index.md
+++ b/docs/docs/features/index.md
@@ -9,7 +9,7 @@ Short introduction of Rio terminal features.
 
 - [Vi mode](/docs/features/vi-mode)
 - [Hyperlinks](/docs/features/hyperlinks)
-- [iTerm image protocol](/docs/features/iterm-image-protocol)
+- [iTerm2 image protocol](/docs/features/iterm2-image-protocol)
 - [Kitty keyboard protocol](/docs/features/kitty-keyboard-protocol)
 - [Rio is fast](/docs/features/rio-is-fast)
 - [Adaptive theme](/docs/features/adaptive-theme)
diff --git a/docs/docs/features/iterm-image-protocol.md b/docs/docs/features/iterm2-image-protocol.md
similarity index 70%
rename from docs/docs/features/iterm-image-protocol.md
rename to docs/docs/features/iterm2-image-protocol.md
index c9cd792898..72ad0df6f3 100644
--- a/docs/docs/features/iterm-image-protocol.md
+++ b/docs/docs/features/iterm2-image-protocol.md
@@ -1,10 +1,8 @@
 ---
-title: 'iTerm Image Protocol'
+title: 'iTerm2 Image Protocol'
 language: 'en'
 ---
 
-**Note: iTerm Image Protocol is still under development**
-
 Rio implements support for the iTerm2 inline image protocol.
 
 To render an image inline in your terminal, you can use `imgcat` provided as a script by iTerm2 in [iterm2.com/utilities/imgcat](https://iterm2.com/utilities/imgcat) or from other sources like:
@@ -14,8 +12,6 @@ To render an image inline in your terminal, you can use `imgcat` provided as a s
 - [npmjs.com/package/imgcat](https://www.npmjs.com/package/imgcat)
 - ... and etecetera.
 
-```bash
-imgcat ./rio-logo.png
-```
+![Demo iTerm2 image protocol](/assets/features/demo-iterm2-image-protocol.png)
 
-More info about [iTerm image protocol](https://iterm2.com/documentation-images.html)
+More info regarding [iTerm image protocol](https://iterm2.com/documentation-images.html)
diff --git a/docs/docs/releases.md b/docs/docs/releases.md
index 7c25f5e2c1..f93d93c45b 100644
--- a/docs/docs/releases.md
+++ b/docs/docs/releases.md
@@ -10,6 +10,7 @@ language: 'en'
 <!-- - Fix: MacOS Delete key doesn't work in kitty mode [#513](https://github.com/raphamorim/rio/issues/513). -->
 <!-- - Fix: Kitty keyboard protocol doesn't work with tmux [#599](https://github.com/raphamorim/rio/issues/599). -->
 
+- Support to iTerm2 image protocol.
 - Fix: Issue building rio for Void Linux [#656](https://github.com/raphamorim/rio/issues/656).
 - Fix: Adaptive theme doesn't appear to work correctly on macOS [#660](https://github.com/raphamorim/rio/issues/660).
 - Fix: Image background support to OpenGL targets.
diff --git a/docs/static/assets/features/demo-iterm2-image-protocol.png b/docs/static/assets/features/demo-iterm2-image-protocol.png
new file mode 100644
index 0000000000..36cbe8deba
Binary files /dev/null and b/docs/static/assets/features/demo-iterm2-image-protocol.png differ
diff --git a/frontends/rioterm/src/bindings/kitty_keyboard.rs b/frontends/rioterm/src/bindings/kitty_keyboard.rs
index 237a74af48..43e3d46bce 100644
--- a/frontends/rioterm/src/bindings/kitty_keyboard.rs
+++ b/frontends/rioterm/src/bindings/kitty_keyboard.rs
@@ -15,14 +15,14 @@ pub fn build_key_sequence(key: &KeyEvent, mods: ModifiersState, mode: Mode) -> V
     let mut modifiers = mods.into();
 
     let kitty_seq = mode.intersects(
-        Mode::KEYBOARD_REPORT_ALL_KEYS_AS_ESC
-            | Mode::KEYBOARD_DISAMBIGUATE_ESC_CODES
-            | Mode::KEYBOARD_REPORT_EVENT_TYPES,
+        Mode::REPORT_ALL_KEYS_AS_ESC
+            | Mode::DISAMBIGUATE_ESC_CODES
+            | Mode::REPORT_EVENT_TYPES,
     );
 
-    let kitty_encode_all = mode.contains(Mode::KEYBOARD_REPORT_ALL_KEYS_AS_ESC);
+    let kitty_encode_all = mode.contains(Mode::REPORT_ALL_KEYS_AS_ESC);
     // The default parameter is 1, so we can omit it.
-    let kitty_event_type = mode.contains(Mode::KEYBOARD_REPORT_EVENT_TYPES)
+    let kitty_event_type = mode.contains(Mode::REPORT_EVENT_TYPES)
         && (key.repeat || key.state == ElementState::Released);
 
     let context = SequenceBuilder {
@@ -47,7 +47,7 @@ pub fn build_key_sequence(key: &KeyEvent, mods: ModifiersState, mode: Mode) -> V
     let text = key.text_with_all_modifiers();
 
     let associated_text = text.filter(|text| {
-        mode.contains(Mode::KEYBOARD_REPORT_ASSOCIATED_TEXT)
+        mode.contains(Mode::REPORT_ASSOCIATED_TEXT)
             && key.state != ElementState::Released
             && !text.is_empty()
             && !is_control_character(text)
@@ -143,7 +143,7 @@ impl SequenceBuilder {
 
             // NOTE: Base layouts are ignored, since winit doesn't expose this information
             // yet.
-            let payload = if self.mode.contains(Mode::KEYBOARD_REPORT_ALTERNATE_KEYS)
+            let payload = if self.mode.contains(Mode::REPORT_ALTERNATE_KEYS)
                 && alternate_key_code != unicode_key_code
             {
                 format!("{unicode_key_code}:{alternate_key_code}")
diff --git a/frontends/rioterm/src/bindings/mod.rs b/frontends/rioterm/src/bindings/mod.rs
index 1ea4d12e68..c97464bd05 100644
--- a/frontends/rioterm/src/bindings/mod.rs
+++ b/frontends/rioterm/src/bindings/mod.rs
@@ -174,11 +174,11 @@ impl BindingMode {
         binding_mode.set(BindingMode::SEARCH, search);
         binding_mode.set(
             BindingMode::DISAMBIGUATE_KEYS,
-            mode.contains(Mode::KEYBOARD_DISAMBIGUATE_ESC_CODES),
+            mode.contains(Mode::DISAMBIGUATE_ESC_CODES),
         );
         binding_mode.set(
             BindingMode::ALL_KEYS_AS_ESC,
-            mode.contains(Mode::KEYBOARD_REPORT_ALL_KEYS_AS_ESC),
+            mode.contains(Mode::REPORT_ALL_KEYS_AS_ESC),
         );
         binding_mode.set(BindingMode::VI, mode.contains(Mode::VI));
         binding_mode
diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs
index 1d4e8f0234..213721919f 100644
--- a/frontends/rioterm/src/screen/mod.rs
+++ b/frontends/rioterm/src/screen/mod.rs
@@ -457,7 +457,7 @@ impl Screen<'_> {
         let mods = self.modifiers.state();
 
         if is_kitty_keyboard_enabled && key.state == ElementState::Released {
-            if !mode.contains(Mode::KEYBOARD_REPORT_EVENT_TYPES)
+            if !mode.contains(Mode::REPORT_EVENT_TYPES)
                 || mode.contains(Mode::VI)
                 || self.search_active()
             {
@@ -476,7 +476,7 @@ impl Screen<'_> {
                 // NOTE: Echo the key back on release to follow kitty/foot behavior. When
                 // KEYBOARD_REPORT_ALL_KEYS_AS_ESC is used, we build proper escapes for
                 // the keys below.
-                _ if mode.contains(Mode::KEYBOARD_REPORT_ALL_KEYS_AS_ESC) => {
+                _ if mode.contains(Mode::REPORT_ALL_KEYS_AS_ESC) => {
                     crate::bindings::kitty_keyboard::build_key_sequence(key, mods, mode)
                         .into()
                 }
@@ -533,10 +533,10 @@ impl Screen<'_> {
             // 1. No keyboard input protocol is enabled.
             // 2. Mode is KEYBOARD_DISAMBIGUATE_ESC_CODES, but we have text + empty or Shift
             //    modifiers and the location of the key is not on the numpad, and it's not an `Esc`.
-            let write_legacy = !mode.contains(Mode::KEYBOARD_REPORT_ALL_KEYS_AS_ESC)
+            let write_legacy = !mode.contains(Mode::REPORT_ALL_KEYS_AS_ESC)
                 && !text.is_empty()
-                && (!mode.contains(Mode::KEYBOARD_DISAMBIGUATE_ESC_CODES)
-                    || (mode.contains(Mode::KEYBOARD_DISAMBIGUATE_ESC_CODES)
+                && (!mode.contains(Mode::DISAMBIGUATE_ESC_CODES)
+                    || (mode.contains(Mode::DISAMBIGUATE_ESC_CODES)
                         && (mods.is_empty() || mods == ModifiersState::SHIFT)
                         && key.location != KeyLocation::Numpad
                         // Special case escape here.
diff --git a/rio-backend/Cargo.toml b/rio-backend/Cargo.toml
index 02dd2691e5..2d329e90a9 100644
--- a/rio-backend/Cargo.toml
+++ b/rio-backend/Cargo.toml
@@ -18,10 +18,11 @@ tracing = { workspace = true }
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 toml = "0.8.19"
-base64 = "0.22.1"
+base64 = { workspace = true }
 bitflags = { workspace = true }
 bytemuck = { workspace = true }
 corcovado = { workspace = true }
+rustc-hash = { workspace = true }
 regex = { workspace = true }
 raw-window-handle = { workspace = true }
 copypasta = { version = "0.10.1", default-features = false }
diff --git a/rio-backend/src/ansi/graphics.rs b/rio-backend/src/ansi/graphics.rs
index 699a10d5b6..13ac24f9cc 100644
--- a/rio-backend/src/ansi/graphics.rs
+++ b/rio-backend/src/ansi/graphics.rs
@@ -2,6 +2,7 @@
 // Alacritty is licensed under Apache 2.0 license.
 // https://github.com/alacritty/alacritty/pull/4763/files
 
+use crate::ansi::sixel;
 use crate::config::colors::ColorRgb;
 use crate::crosswords::grid::Dimensions;
 use crate::sugarloaf::{GraphicData, GraphicId};
@@ -33,9 +34,6 @@ pub struct UpdateQueues {
     pub clear_subregions: Vec<ClearSubregion>,
 }
 
-/// Max allowed dimensions (width, height) for the graphic, in pixels.
-pub const MAX_GRAPHIC_DIMENSIONS: [usize; 2] = [4096, 4096];
-
 #[derive(Clone, Debug)]
 pub struct TextureRef {
     /// Graphic identifier.
@@ -127,7 +125,7 @@ pub enum TextureOperation {
 }
 
 /// Track changes in the grid to add or to remove graphics.
-#[derive(Clone, Debug, Default)]
+#[derive(Debug, Default)]
 pub struct Graphics {
     /// Last generated identifier.
     pub last_id: u64,
@@ -146,6 +144,9 @@ pub struct Graphics {
 
     /// Cell width in pixels.
     pub cell_width: f32,
+
+    /// Current Sixel parser.
+    pub sixel_parser: Option<Box<sixel::Parser>>,
 }
 
 impl Graphics {
@@ -214,6 +215,7 @@ fn check_opaque_region() {
         color_type: ColorType::Rgb,
         pixels: vec![255; 10 * 10 * 3],
         is_opaque: true,
+        resize: None,
     };
 
     assert!(graphic.is_filled(1, 1, 3, 3));
@@ -236,6 +238,7 @@ fn check_opaque_region() {
         height: 10,
         color_type: ColorType::Rgba,
         is_opaque: false,
+        resize: None,
     };
 
     assert!(graphic.is_filled(0, 0, 3, 3));
diff --git a/rio-backend/src/ansi/iterm2_image_protocol.rs b/rio-backend/src/ansi/iterm2_image_protocol.rs
new file mode 100644
index 0000000000..649a9f12cc
--- /dev/null
+++ b/rio-backend/src/ansi/iterm2_image_protocol.rs
@@ -0,0 +1,193 @@
+// Implementation made by ayosec
+// https://github.com/ayosec/alacritty/commit/661a64c2b35283c97bac71d29535393e909c7d19
+// This module implements support for the [iTerm2 images protocol](https://iterm2.com/documentation-images.html).
+//
+// iTerm2 uses the OSC 1337 for a many non-standard commands, but we only support
+// adding inline graphics.
+//
+// This implementation also supports `width` and `height` parameters to resize the image.
+
+use sugarloaf::{GraphicData, GraphicId, ResizeCommand, ResizeParameter};
+
+use rustc_hash::FxHashMap;
+use std::str;
+
+use base64::engine::general_purpose::STANDARD as Base64;
+use base64::Engine;
+
+/// Parse the OSC 1337 parameters to add a graphic to the grid.
+pub fn parse(params: &[&[u8]]) -> Option<GraphicData> {
+    let (params, contents) = param_values(params)?;
+
+    if params.get("inline") != Some(&"1") {
+        return None;
+    }
+
+    let buffer = match Base64.decode(contents) {
+        Ok(buffer) => buffer,
+        Err(err) => {
+            tracing::warn!("Can't decode base64 data: {}", err);
+            return None;
+        }
+    };
+
+    let image = match image_rs::load_from_memory(&buffer) {
+        Ok(image) => image,
+        Err(err) => {
+            tracing::warn!("Can't load image: {}", err);
+            return None;
+        }
+    };
+
+    let mut graphics = GraphicData::from_dynamic_image(GraphicId(0), image);
+    graphics.resize = resize_param(&params);
+    Some(graphics)
+}
+
+/// Extract parameter values.
+///
+/// The format defined by iTerm2 starts with a `File=` string, and the file
+/// contents are specified after a `:`.
+///
+/// ```notrust
+/// ESC ] 1337 ; File = [arguments] : base-64 encoded file contents ^G
+/// ```
+///
+/// This format is not expected by the parser in the `vte` crate.
+///
+/// The `File=` string is found in the first parameter, and the file contents are
+/// appended in the last one. We have to split these parameter to get the expected
+/// data.
+fn param_values<'a>(
+    params: &[&'a [u8]],
+) -> Option<(FxHashMap<&'a str, &'a str>, &'a [u8])> {
+    let mut map = FxHashMap::default();
+    let mut contents = None;
+
+    for (index, mut param) in params.iter().skip(1).copied().enumerate() {
+        // First parameter should start with "File="
+        if index == 0 {
+            if !param.starts_with(&b"File="[..]) {
+                return None;
+            }
+
+            param = &param[5..];
+        }
+
+        if let Some(separator) = param.iter().position(|&b| b == b'=') {
+            let (key, mut value) = param.split_at(separator);
+            value = &value[1..];
+
+            // Last parameter has the file contents after the first ':'.
+            // Add 2 because we are skipping the first param.
+            if index + 2 == params.len() {
+                if let Some(separator) = value.iter().position(|&b| b == b':') {
+                    let (a, b) = value.split_at(separator);
+                    value = a;
+                    contents = Some(&b[1..]);
+                }
+            }
+
+            if let (Ok(key), Ok(value)) = (str::from_utf8(key), str::from_utf8(value)) {
+                map.insert(key, value);
+            }
+        }
+    }
+
+    contents.map(|c| (map, c))
+}
+
+/// Compute the resize operation from the OSC parameters.
+///
+/// Accepted formats:
+///
+/// - N: N character cells.
+/// - Npx: N pixels.
+/// - N%: N percent of the window's width or height.
+/// - auto: Computed from the original graphic size.
+fn resize_param(params: &FxHashMap<&str, &str>) -> Option<ResizeCommand> {
+    fn parse(value: Option<&str>) -> Option<ResizeParameter> {
+        let value = match value {
+            None | Some("auto") => return Some(ResizeParameter::Auto),
+            Some(value) => value,
+        };
+
+        // Split the value after the first non-digit byte.
+        // If there is no unit, parse as number of cells.
+        let first_nondigit = value
+            .as_bytes()
+            .iter()
+            .position(|b: &u8| !b.is_ascii_digit());
+        // .position(|b| !(b'0'..=b'9').contains(&b));
+        let (number, unit) = match first_nondigit {
+            Some(position) => value.split_at(position),
+            None => return Some(ResizeParameter::Cells(str::parse(value).ok()?)),
+        };
+
+        match (str::parse(number), unit) {
+            (Ok(number), "%") => Some(ResizeParameter::WindowPercent(number)),
+            (Ok(number), "px") => Some(ResizeParameter::Pixels(number)),
+            _ => None,
+        }
+    }
+
+    let width = parse(params.get(&"width").copied())?;
+    let height = parse(params.get(&"height").copied())?;
+
+    let preserve_aspect_ratio = params.get(&"preserveAspectRatio") != Some(&"0");
+
+    Some(ResizeCommand {
+        width,
+        height,
+        preserve_aspect_ratio,
+    })
+}
+
+#[test]
+fn parse_osc1337_parameters() {
+    let params = [
+        b"1337".as_ref(),
+        b"File=name=ABCD".as_ref(),
+        b"size=3".as_ref(),
+        b"inline=1:AAAA".as_ref(),
+    ];
+
+    let (params, contents) = param_values(&params).unwrap();
+
+    assert_eq!(params["name"], "ABCD");
+    assert_eq!(params["size"], "3");
+    assert_eq!(params["inline"], "1");
+
+    assert_eq!(contents, b"AAAA".as_ref())
+}
+
+#[test]
+fn parse_osc1337_single_parameter() {
+    let params = [b"1337".as_ref(), b"File=inline=1:AAAA".as_ref()];
+
+    let (params, contents) = param_values(&params).unwrap();
+
+    assert_eq!(params["inline"], "1");
+    assert_eq!(contents, b"AAAA".as_ref())
+}
+
+#[test]
+fn resize_params() {
+    use ResizeParameter::{Auto, Cells, Pixels, WindowPercent};
+
+    macro_rules! assert_resize {
+        ($param_width:expr, $param_height:expr, $width:expr, $height:expr) => {
+            let mut params = FxHashMap::default();
+            params.insert("width", $param_width);
+            params.insert("height", $param_height);
+
+            let resize = resize_param(&params).unwrap();
+            assert_eq!(resize.width, $width);
+            assert_eq!(resize.height, $height);
+        };
+    }
+
+    assert_resize!("auto", "50%", Auto, WindowPercent(50));
+    assert_resize!("10", "20", Cells(10), Cells(20));
+    assert_resize!("10%", "50px", WindowPercent(10), Pixels(50));
+}
diff --git a/rio-backend/src/ansi/mod.rs b/rio-backend/src/ansi/mod.rs
index 784e1f9831..4fbf45dc90 100644
--- a/rio-backend/src/ansi/mod.rs
+++ b/rio-backend/src/ansi/mod.rs
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
 pub mod charset;
 pub mod control;
 pub mod graphics;
+pub mod iterm2_image_protocol;
 pub mod mode;
 pub mod sixel;
 
diff --git a/rio-backend/src/ansi/mode.rs b/rio-backend/src/ansi/mode.rs
index 0777790614..e79f3f42e5 100644
--- a/rio-backend/src/ansi/mode.rs
+++ b/rio-backend/src/ansi/mode.rs
@@ -1,8 +1,96 @@
-use tracing::warn;
-
-#[derive(Debug, Eq, PartialEq)]
+/// Wrapper for the ANSI modes.
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
 pub enum Mode {
-    /// ?1
+    /// Known ANSI mode.
+    Named(NamedMode),
+    /// Unidentified publc mode.
+    Unknown(u16),
+}
+
+impl Mode {
+    pub fn new(mode: u16) -> Self {
+        match mode {
+            4 => Self::Named(NamedMode::Insert),
+            20 => Self::Named(NamedMode::LineFeedNewLine),
+            _ => Self::Unknown(mode),
+        }
+    }
+
+    /// Get the raw value of the mode.
+    pub fn raw(self) -> u16 {
+        match self {
+            Self::Named(named) => named as u16,
+            Self::Unknown(mode) => mode,
+        }
+    }
+}
+
+impl From<NamedMode> for Mode {
+    fn from(value: NamedMode) -> Self {
+        Self::Named(value)
+    }
+}
+
+/// ANSI modes.
+#[repr(u16)]
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
+pub enum NamedMode {
+    /// IRM Insert Mode.
+    Insert = 4,
+    LineFeedNewLine = 20,
+}
+
+/// Wrapper for the private DEC modes.
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
+pub enum PrivateMode {
+    /// Known private mode.
+    Named(NamedPrivateMode),
+    /// Unknown private mode.
+    Unknown(u16),
+}
+
+impl PrivateMode {
+    pub fn new(mode: u16) -> Self {
+        match mode {
+            1 => Self::Named(NamedPrivateMode::CursorKeys),
+            3 => Self::Named(NamedPrivateMode::ColumnMode),
+            6 => Self::Named(NamedPrivateMode::Origin),
+            7 => Self::Named(NamedPrivateMode::LineWrap),
+            12 => Self::Named(NamedPrivateMode::BlinkingCursor),
+            25 => Self::Named(NamedPrivateMode::ShowCursor),
+            1000 => Self::Named(NamedPrivateMode::ReportMouseClicks),
+            1002 => Self::Named(NamedPrivateMode::ReportCellMouseMotion),
+            1003 => Self::Named(NamedPrivateMode::ReportAllMouseMotion),
+            1004 => Self::Named(NamedPrivateMode::ReportFocusInOut),
+            1005 => Self::Named(NamedPrivateMode::Utf8Mouse),
+            1006 => Self::Named(NamedPrivateMode::SgrMouse),
+            1007 => Self::Named(NamedPrivateMode::AlternateScroll),
+            1042 => Self::Named(NamedPrivateMode::UrgencyHints),
+            1049 => Self::Named(NamedPrivateMode::SwapScreenAndSetRestoreCursor),
+            2004 => Self::Named(NamedPrivateMode::BracketedPaste),
+            2026 => Self::Named(NamedPrivateMode::SyncUpdate),
+            _ => Self::Unknown(mode),
+        }
+    }
+
+    /// Get the raw value of the mode.
+    pub fn raw(self) -> u16 {
+        match self {
+            Self::Named(named) => named as u16,
+            Self::Unknown(mode) => mode,
+        }
+    }
+}
+
+impl From<NamedPrivateMode> for PrivateMode {
+    fn from(value: NamedPrivateMode) -> Self {
+        Self::Named(value)
+    }
+}
+
+/// Private DEC modes.
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
+pub enum NamedPrivateMode {
     CursorKeys = 1,
     /// Select 80 or 132 columns per page (DECCOLM).
     ///
@@ -15,96 +103,58 @@ pub enum Mode {
     /// * erases all data in page memory
     /// * resets DECLRMM to unavailable
     /// * clears data from the status line (if set to host-writable)
-    Column = 3,
-    /// IRM Insert Mode.
-    ///
-    /// NB should be part of non-private mode enum.
-    ///
-    /// * `CSI 4 h` change to insert mode
-    /// * `CSI 4 l` reset to replacement mode
-    Insert = 4,
-    /// ?6
+    ColumnMode = 3,
     Origin = 6,
-    /// ?7
     LineWrap = 7,
-    /// ?12
     BlinkingCursor = 12,
-    /// 20
-    ///
-    /// NB This is actually a private mode. We should consider adding a second
-    /// enumeration for public/private modesets.
-    LineFeedNewLine = 20,
-    /// ?25
     ShowCursor = 25,
-    /// ?80
-    SixelDisplay = 80,
-    /// ?1000
     ReportMouseClicks = 1000,
-    /// ?1002
-    ReportSquareMouseMotion = 1002,
-    /// ?1003
+    ReportCellMouseMotion = 1002,
     ReportAllMouseMotion = 1003,
-    /// ?1004
     ReportFocusInOut = 1004,
-    /// ?1005
     Utf8Mouse = 1005,
-    /// ?1006
     SgrMouse = 1006,
-    /// ?1007
     AlternateScroll = 1007,
-    /// ?1042
     UrgencyHints = 1042,
-    /// ?1049
     SwapScreenAndSetRestoreCursor = 1049,
-    /// Use a private palette for each new graphic.
-    SixelPrivateColorRegisters = 1070,
-    /// ?2004
     BracketedPaste = 2004,
-    /// Sixel scrolling leaves cursor to right of graphic.
-    SixelCursorToTheRight = 8452,
+    /// The mode is handled automatically by [`Processor`].
+    SyncUpdate = 2026,
 }
 
-impl Mode {
-    /// Create mode from a primitive.
-    pub fn from_primitive(intermediate: Option<&u8>, num: u16) -> Option<Mode> {
-        let private = match intermediate {
-            Some(b'?') => true,
-            None => false,
-            _ => return None,
-        };
+/// Mode for clearing line.
+///
+/// Relative to cursor.
+#[derive(Debug)]
+pub enum LineClearMode {
+    /// Clear right of cursor.
+    Right,
+    /// Clear left of cursor.
+    Left,
+    /// Clear entire line.
+    All,
+}
 
-        if private {
-            Some(match num {
-                1 => Mode::CursorKeys,
-                3 => Mode::Column,
-                6 => Mode::Origin,
-                7 => Mode::LineWrap,
-                12 => Mode::BlinkingCursor,
-                25 => Mode::ShowCursor,
-                80 => Mode::SixelDisplay,
-                1000 => Mode::ReportMouseClicks,
-                1002 => Mode::ReportSquareMouseMotion,
-                1003 => Mode::ReportAllMouseMotion,
-                1004 => Mode::ReportFocusInOut,
-                1005 => Mode::Utf8Mouse,
-                1006 => Mode::SgrMouse,
-                1007 => Mode::AlternateScroll,
-                1042 => Mode::UrgencyHints,
-                1049 => Mode::SwapScreenAndSetRestoreCursor,
-                1070 => Mode::SixelPrivateColorRegisters,
-                2004 => Mode::BracketedPaste,
-                8452 => Mode::SixelCursorToTheRight,
-                _ => {
-                    warn!("[unimplemented] primitive mode: {}", num);
-                    return None;
-                }
-            })
-        } else {
-            Some(match num {
-                4 => Mode::Insert,
-                20 => Mode::LineFeedNewLine,
-                _ => return None,
-            })
-        }
-    }
+/// Mode for clearing terminal.
+///
+/// Relative to cursor.
+#[derive(Debug)]
+pub enum ClearMode {
+    /// Clear below cursor.
+    Below,
+    /// Clear above cursor.
+    Above,
+    /// Clear entire terminal.
+    All,
+    /// Clear 'saved' lines (scrollback).
+    Saved,
+}
+
+/// Mode for clearing tab stops.
+#[derive(Debug)]
+pub enum TabulationClearMode {
+    /// Clear stop under cursor.
+    Current,
+    /// Clear all stops.
+    All,
 }
diff --git a/rio-backend/src/ansi/sixel.rs b/rio-backend/src/ansi/sixel.rs
index e18a1bea57..05dd5e25ad 100644
--- a/rio-backend/src/ansi/sixel.rs
+++ b/rio-backend/src/ansi/sixel.rs
@@ -25,9 +25,8 @@
 use std::cmp::max;
 use std::{fmt, mem};
 
-use crate::ansi::graphics::MAX_GRAPHIC_DIMENSIONS;
 use crate::config::colors::ColorRgb;
-use sugarloaf::{ColorType, GraphicData, GraphicId};
+use sugarloaf::{ColorType, GraphicData, GraphicId, MAX_GRAPHIC_DIMENSIONS};
 
 use copa::Params;
 use tracing::trace;
@@ -48,16 +47,24 @@ const MAX_COMMAND_PARAMS: usize = 5;
 #[derive(Debug)]
 pub enum Error {
     /// Image dimensions are too big.
-    TooBigImage { width: usize, height: usize },
+    TooBigImage {
+        width: usize,
+        height: usize,
+    },
 
     /// A component in a color introducer is not valid.
-    InvalidColorComponent { register: u16, component_value: u16 },
+    InvalidColorComponent {
+        register: u16,
+        component_value: u16,
+    },
 
     /// The coordinate system to define the color register is not valid.
     InvalidColorCoordinateSystem {
         register: u16,
         coordinate_system: u16,
     },
+
+    NonExistentParser,
 }
 
 impl fmt::Display for Error {
@@ -92,6 +99,10 @@ impl fmt::Display for Error {
                     coordinate_system, register
                 )
             }
+
+            Error::NonExistentParser => {
+                write!(fmt, "Parser does not exist",)
+            }
         }
     }
 }
@@ -543,6 +554,7 @@ impl Parser {
             color_type: ColorType::Rgba,
             pixels: rgba_pixels,
             is_opaque,
+            resize: None,
         };
 
         Ok((data, self.color_registers))
diff --git a/rio-backend/src/crosswords/mod.rs b/rio-backend/src/crosswords/mod.rs
index 3e0571bc50..784d8858b2 100644
--- a/rio-backend/src/crosswords/mod.rs
+++ b/rio-backend/src/crosswords/mod.rs
@@ -25,7 +25,9 @@ use crate::ansi::graphics::GraphicCell;
 use crate::ansi::graphics::Graphics;
 use crate::ansi::graphics::TextureRef;
 use crate::ansi::graphics::UpdateQueues;
-use crate::ansi::graphics::MAX_GRAPHIC_DIMENSIONS;
+use crate::ansi::mode::NamedMode;
+use crate::ansi::mode::NamedPrivateMode;
+use crate::ansi::mode::PrivateMode;
 use crate::ansi::sixel;
 use crate::ansi::{
     mode::Mode as AnsiMode, ClearMode, CursorShape, KeyboardModes,
@@ -57,7 +59,7 @@ use std::ops::{Index, IndexMut, Range};
 use std::option::Option;
 use std::ptr;
 use std::sync::Arc;
-use sugarloaf::GraphicData;
+use sugarloaf::{GraphicData, MAX_GRAPHIC_DIMENSIONS};
 use tracing::{debug, info, warn};
 use unicode_width::UnicodeWidthChar;
 use vi_mode::{ViModeCursor, ViMotion};
@@ -73,40 +75,63 @@ const MAX_GRAPHICS_PER_CELL: usize = 20;
 bitflags! {
     #[derive(Debug, Copy, Clone)]
      pub struct Mode: u32 {
-        const NONE                             = 0;
-        const SHOW_CURSOR                      = 0b0000_0000_0000_0000_0000_0001;
-        const APP_CURSOR                       = 0b0000_0000_0000_0000_0000_0010;
-        const APP_KEYPAD                       = 0b0000_0000_0000_0000_0000_0100;
-        const MOUSE_REPORT_CLICK               = 0b0000_0000_0000_0000_0000_1000;
-        const BRACKETED_PASTE                  = 0b0000_0000_0000_0000_0001_0000;
-        const SGR_MOUSE                        = 0b0000_0000_0000_0000_0010_0000;
-        const MOUSE_MOTION                     = 0b0000_0000_0000_0000_0100_0000;
-        const LINE_WRAP                        = 0b0000_0000_0000_0000_1000_0000;
-        const LINE_FEED_NEW_LINE               = 0b0000_0000_0000_0001_0000_0000;
-        const ORIGIN                           = 0b0000_0000_0000_0010_0000_0000;
-        const INSERT                           = 0b0000_0000_0000_0100_0000_0000;
-        const FOCUS_IN_OUT                     = 0b0000_0000_0000_1000_0000_0000;
-        const ALT_SCREEN                       = 0b0000_0000_0001_0000_0000_0000;
-        const MOUSE_DRAG                       = 0b0000_0000_0010_0000_0000_0000;
-        const MOUSE_MODE                       = 0b0000_0000_0010_0000_0100_1000;
-        const UTF8_MOUSE                       = 0b0000_0000_0100_0000_0000_0000;
-        const ALTERNATE_SCROLL                 = 0b0000_0000_1000_0000_0000_0000;
-        const VI                               = 0b0000_0001_0000_0000_0000_0000;
-        const URGENCY_HINTS                    = 0b0000_0010_0000_0000_0000_0000;
-        const KEYBOARD_DISAMBIGUATE_ESC_CODES  = 0b0000_0100_0000_0000_0000_0000;
-        const KEYBOARD_REPORT_EVENT_TYPES      = 0b0000_1000_0000_0000_0000_0000;
-        const KEYBOARD_REPORT_ALTERNATE_KEYS   = 0b0001_0000_0000_0000_0000_0000;
-        const KEYBOARD_REPORT_ALL_KEYS_AS_ESC  = 0b0010_0000_0000_0000_0000_0000;
-        const KEYBOARD_REPORT_ASSOCIATED_TEXT  = 0b0100_0000_0000_0000_0000_0000;
-        const KEYBOARD_PROTOCOL = Self::KEYBOARD_DISAMBIGUATE_ESC_CODES.bits()
-                                | Self::KEYBOARD_REPORT_EVENT_TYPES.bits()
-                                | Self::KEYBOARD_REPORT_ALTERNATE_KEYS.bits()
-                                | Self::KEYBOARD_REPORT_ALL_KEYS_AS_ESC.bits()
-                                | Self::KEYBOARD_REPORT_ASSOCIATED_TEXT.bits();
+        const NONE                    = 0;
+        const SHOW_CURSOR             = 0b0000_0000_0000_0000_0000_0001;
+        const APP_CURSOR              = 0b0000_0000_0000_0000_0000_0010;
+        const APP_KEYPAD              = 0b0000_0000_0000_0000_0000_0100;
+        const MOUSE_REPORT_CLICK      = 0b0000_0000_0000_0000_0000_1000;
+        const BRACKETED_PASTE         = 0b0000_0000_0000_0000_0001_0000;
+        const SGR_MOUSE               = 0b0000_0000_0000_0000_0010_0000;
+        const MOUSE_MOTION            = 0b0000_0000_0000_0000_0100_0000;
+        const LINE_WRAP               = 0b0000_0000_0000_0000_1000_0000;
+        const LINE_FEED_NEW_LINE      = 0b0000_0000_0000_0001_0000_0000;
+        const ORIGIN                  = 0b0000_0000_0000_0010_0000_0000;
+        const INSERT                  = 0b0000_0000_0000_0100_0000_0000;
+        const FOCUS_IN_OUT            = 0b0000_0000_0000_1000_0000_0000;
+        const ALT_SCREEN              = 0b0000_0000_0001_0000_0000_0000;
+        const MOUSE_DRAG              = 0b0000_0000_0010_0000_0000_0000;
+        const MOUSE_MODE              = 0b0000_0000_0010_0000_0100_1000;
+        const UTF8_MOUSE              = 0b0000_0000_0100_0000_0000_0000;
+        const ALTERNATE_SCROLL        = 0b0000_0000_1000_0000_0000_0000;
+        const VI                      = 0b0000_0001_0000_0000_0000_0000;
+        const URGENCY_HINTS           = 0b0000_0010_0000_0000_0000_0000;
+        const DISAMBIGUATE_ESC_CODES  = 0b0000_0100_0000_0000_0000_0000;
+        const REPORT_EVENT_TYPES      = 0b0000_1000_0000_0000_0000_0000;
+        const REPORT_ALTERNATE_KEYS   = 0b0001_0000_0000_0000_0000_0000;
+        const REPORT_ALL_KEYS_AS_ESC  = 0b0010_0000_0000_0000_0000_0000;
+        const REPORT_ASSOCIATED_TEXT  = 0b0100_0000_0000_0000_0000_0000;
+        const KITTY_KEYBOARD_PROTOCOL = Self::DISAMBIGUATE_ESC_CODES.bits()
+                                      | Self::REPORT_EVENT_TYPES.bits()
+                                      | Self::REPORT_ALTERNATE_KEYS.bits()
+                                      | Self::REPORT_ALL_KEYS_AS_ESC.bits()
+                                      | Self::REPORT_ASSOCIATED_TEXT.bits();
+        const ANY                    = u32::MAX;
+
         const SIXEL_DISPLAY             = 1 << 28;
         const SIXEL_PRIV_PALETTE        = 1 << 29;
         const SIXEL_CURSOR_TO_THE_RIGHT = 1 << 31;
-        const ANY                 = u32::MAX;
+    }
+}
+
+/// The state of the [`Mode`] and [`PrivateMode`].
+#[repr(u8)]
+#[derive(Debug, Clone, Copy)]
+enum ModeState {
+    /// The mode is not supported.
+    NotSupported = 0,
+    /// The mode is currently set.
+    Set = 1,
+    /// The mode is currently not set.
+    Reset = 2,
+}
+
+impl From<bool> for ModeState {
+    fn from(value: bool) -> Self {
+        if value {
+            Self::Set
+        } else {
+            Self::Reset
+        }
     }
 }
 
@@ -124,23 +149,23 @@ impl From<KeyboardModes> for Mode {
     fn from(value: KeyboardModes) -> Self {
         let mut mode = Self::empty();
         mode.set(
-            Mode::KEYBOARD_DISAMBIGUATE_ESC_CODES,
+            Mode::DISAMBIGUATE_ESC_CODES,
             value.contains(KeyboardModes::DISAMBIGUATE_ESC_CODES),
         );
         mode.set(
-            Mode::KEYBOARD_REPORT_EVENT_TYPES,
+            Mode::REPORT_EVENT_TYPES,
             value.contains(KeyboardModes::REPORT_EVENT_TYPES),
         );
         mode.set(
-            Mode::KEYBOARD_REPORT_ALTERNATE_KEYS,
+            Mode::REPORT_ALTERNATE_KEYS,
             value.contains(KeyboardModes::REPORT_ALTERNATE_KEYS),
         );
         mode.set(
-            Mode::KEYBOARD_REPORT_ALL_KEYS_AS_ESC,
+            Mode::REPORT_ALL_KEYS_AS_ESC,
             value.contains(KeyboardModes::REPORT_ALL_KEYS_AS_ESC),
         );
         mode.set(
-            Mode::KEYBOARD_REPORT_ASSOCIATED_TEXT,
+            Mode::REPORT_ASSOCIATED_TEXT,
             value.contains(KeyboardModes::REPORT_ASSOCIATED_TEXT),
         );
         mode
@@ -355,7 +380,7 @@ const TITLE_STACK_MAX_DEPTH: usize = 4096;
 // Max size of the keyboard modes.
 const KEYBOARD_MODE_STACK_MAX_DEPTH: usize = 16384;
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub struct Crosswords<U>
 where
     U: EventListener,
@@ -1184,8 +1209,8 @@ impl<U: EventListener> Crosswords<U> {
     #[inline]
     fn set_keyboard_mode(&mut self, mode: Mode, apply: KeyboardModesApplyBehavior) {
         // println!("{:?}", mode);
-        let active_mode = self.mode & Mode::KEYBOARD_PROTOCOL;
-        self.mode &= !Mode::KEYBOARD_PROTOCOL;
+        let active_mode = self.mode & Mode::KITTY_KEYBOARD_PROTOCOL;
+        self.mode &= !Mode::KITTY_KEYBOARD_PROTOCOL;
         let new_mode = match apply {
             KeyboardModesApplyBehavior::Replace => mode,
             KeyboardModesApplyBehavior::Union => active_mode.union(mode),
@@ -1229,144 +1254,291 @@ impl<U: EventListener> Crosswords<U> {
 impl<U: EventListener> Handler for Crosswords<U> {
     #[inline]
     fn set_mode(&mut self, mode: AnsiMode) {
+        let mode = match mode {
+            AnsiMode::Named(mode) => mode,
+            AnsiMode::Unknown(mode) => {
+                debug!("Ignoring unknown mode {} in set_mode", mode);
+                return;
+            }
+        };
+
+        tracing::trace!("Setting public mode: {:?}", mode);
         match mode {
-            AnsiMode::UrgencyHints => self.mode.insert(Mode::URGENCY_HINTS),
-            AnsiMode::SwapScreenAndSetRestoreCursor => {
+            NamedMode::Insert => self.mode.insert(Mode::INSERT),
+            NamedMode::LineFeedNewLine => self.mode.insert(Mode::LINE_FEED_NEW_LINE),
+        }
+    }
+
+    #[inline]
+    fn unset_mode(&mut self, mode: AnsiMode) {
+        let mode = match mode {
+            AnsiMode::Named(mode) => mode,
+            AnsiMode::Unknown(mode) => {
+                debug!("Ignoring unknown mode {} in unset_mode", mode);
+                return;
+            }
+        };
+
+        tracing::trace!("Setting public mode: {:?}", mode);
+        match mode {
+            NamedMode::Insert => {
+                self.mode.remove(Mode::INSERT);
+                self.mark_fully_damaged();
+            }
+            NamedMode::LineFeedNewLine => self.mode.remove(Mode::LINE_FEED_NEW_LINE),
+        }
+    }
+
+    #[inline]
+    fn report_mode(&mut self, mode: AnsiMode) {
+        tracing::trace!("Reporting mode {mode:?}");
+        let state = match mode {
+            AnsiMode::Named(mode) => match mode {
+                NamedMode::Insert => self.mode.contains(Mode::INSERT).into(),
+                NamedMode::LineFeedNewLine => {
+                    self.mode.contains(Mode::LINE_FEED_NEW_LINE).into()
+                }
+            },
+            AnsiMode::Unknown(_) => ModeState::NotSupported,
+        };
+
+        self.event_proxy.send_event(
+            RioEvent::PtyWrite(format!("\x1b[{};{}$y", mode.raw(), state as u8,)),
+            self.window_id,
+        );
+    }
+
+    #[inline]
+    fn set_private_mode(&mut self, mode: PrivateMode) {
+        let mode = match mode {
+            PrivateMode::Named(mode) => mode,
+
+            // SixelDisplay
+            PrivateMode::Unknown(80) => {
+                self.mode.insert(Mode::SIXEL_DISPLAY);
+                return;
+            }
+
+            // SixelPrivateColorRegisters
+            PrivateMode::Unknown(1070) => {
+                self.mode.insert(Mode::SIXEL_PRIV_PALETTE);
+                return;
+            }
+
+            // SixelCursorToTheRight
+            PrivateMode::Unknown(8452) => {
+                self.mode.insert(Mode::SIXEL_CURSOR_TO_THE_RIGHT);
+                return;
+            }
+
+            PrivateMode::Unknown(mode) => {
+                debug!("Ignoring unknown mode {} in set_private_mode", mode);
+                return;
+            }
+        };
+
+        tracing::trace!("Setting private mode: {:?}", mode);
+        match mode {
+            NamedPrivateMode::UrgencyHints => self.mode.insert(Mode::URGENCY_HINTS),
+            NamedPrivateMode::SwapScreenAndSetRestoreCursor => {
                 if !self.mode.contains(Mode::ALT_SCREEN) {
                     self.swap_alt();
                 }
             }
-            AnsiMode::ShowCursor => self.mode.insert(Mode::SHOW_CURSOR),
-            AnsiMode::CursorKeys => self.mode.insert(Mode::APP_CURSOR),
+            NamedPrivateMode::ShowCursor => self.mode.insert(Mode::SHOW_CURSOR),
+            NamedPrivateMode::CursorKeys => self.mode.insert(Mode::APP_CURSOR),
             // Mouse protocols are mutually exclusive.
-            AnsiMode::ReportMouseClicks => {
+            NamedPrivateMode::ReportMouseClicks => {
                 self.mode.remove(Mode::MOUSE_MODE);
                 self.mode.insert(Mode::MOUSE_REPORT_CLICK);
                 self.event_proxy
                     .send_event(RioEvent::MouseCursorDirty, self.window_id);
             }
-            AnsiMode::ReportSquareMouseMotion => {
+            NamedPrivateMode::ReportCellMouseMotion => {
                 self.mode.remove(Mode::MOUSE_MODE);
                 self.mode.insert(Mode::MOUSE_DRAG);
                 self.event_proxy
                     .send_event(RioEvent::MouseCursorDirty, self.window_id);
             }
-            AnsiMode::ReportAllMouseMotion => {
+            NamedPrivateMode::ReportAllMouseMotion => {
                 self.mode.remove(Mode::MOUSE_MODE);
                 self.mode.insert(Mode::MOUSE_MOTION);
                 self.event_proxy
                     .send_event(RioEvent::MouseCursorDirty, self.window_id);
             }
-            AnsiMode::ReportFocusInOut => self.mode.insert(Mode::FOCUS_IN_OUT),
-            AnsiMode::BracketedPaste => self.mode.insert(Mode::BRACKETED_PASTE),
+            NamedPrivateMode::ReportFocusInOut => self.mode.insert(Mode::FOCUS_IN_OUT),
+            NamedPrivateMode::BracketedPaste => self.mode.insert(Mode::BRACKETED_PASTE),
             // Mouse encodings are mutually exclusive.
-            AnsiMode::SgrMouse => {
+            NamedPrivateMode::SgrMouse => {
                 self.mode.remove(Mode::UTF8_MOUSE);
                 self.mode.insert(Mode::SGR_MOUSE);
             }
-            AnsiMode::Utf8Mouse => {
+            NamedPrivateMode::Utf8Mouse => {
                 self.mode.remove(Mode::SGR_MOUSE);
                 self.mode.insert(Mode::UTF8_MOUSE);
             }
-            AnsiMode::AlternateScroll => self.mode.insert(Mode::ALTERNATE_SCROLL),
-            AnsiMode::LineWrap => self.mode.insert(Mode::LINE_WRAP),
-            AnsiMode::LineFeedNewLine => self.mode.insert(Mode::LINE_FEED_NEW_LINE),
-            AnsiMode::Origin => self.mode.insert(Mode::ORIGIN),
-            AnsiMode::Column => self.deccolm(),
-            AnsiMode::Insert => self.mode.insert(Mode::INSERT),
-            AnsiMode::BlinkingCursor => {
+            NamedPrivateMode::AlternateScroll => self.mode.insert(Mode::ALTERNATE_SCROLL),
+            NamedPrivateMode::LineWrap => self.mode.insert(Mode::LINE_WRAP),
+            NamedPrivateMode::Origin => self.mode.insert(Mode::ORIGIN),
+            NamedPrivateMode::ColumnMode => self.deccolm(),
+            NamedPrivateMode::BlinkingCursor => {
                 self.blinking_cursor = true;
                 self.event_proxy
                     .send_event(RioEvent::CursorBlinkingChange, self.window_id);
             }
-            AnsiMode::SixelDisplay => self.mode.insert(Mode::SIXEL_DISPLAY),
-            AnsiMode::SixelPrivateColorRegisters => {
-                self.mode.insert(Mode::SIXEL_PRIV_PALETTE)
-            }
-            AnsiMode::SixelCursorToTheRight => {
-                self.mode.insert(Mode::SIXEL_CURSOR_TO_THE_RIGHT);
-            }
+            NamedPrivateMode::SyncUpdate => (),
         }
     }
 
     #[inline]
-    fn dynamic_color_sequence(&mut self, prefix: String, index: usize, terminator: &str) {
-        debug!(
-            "Requested write of escape sequence for color code {}: color[{}]",
-            prefix, index
-        );
+    fn unset_private_mode(&mut self, mode: PrivateMode) {
+        let mode = match mode {
+            PrivateMode::Named(mode) => mode,
 
-        let terminator = terminator.to_owned();
-        self.event_proxy.send_event(
-            RioEvent::ColorRequest(
-                index,
-                Arc::new(move |color| {
-                    format!(
-                        "\x1b]{};rgb:{1:02x}{1:02x}/{2:02x}{2:02x}/{3:02x}{3:02x}{4}",
-                        prefix, color.r, color.g, color.b, terminator
-                    )
-                }),
-            ),
-            self.window_id,
-        );
-    }
+            // SixelDisplay
+            PrivateMode::Unknown(80) => {
+                self.mode.remove(Mode::SIXEL_DISPLAY);
+                return;
+            }
 
-    #[inline]
-    fn unset_mode(&mut self, mode: AnsiMode) {
+            // SixelPrivateColorRegisters
+            PrivateMode::Unknown(1070) => {
+                self.graphics.sixel_shared_palette = None;
+                self.mode.remove(Mode::SIXEL_PRIV_PALETTE);
+                return;
+            }
+
+            // SixelCursorToTheRight
+            PrivateMode::Unknown(8452) => {
+                self.mode.remove(Mode::SIXEL_CURSOR_TO_THE_RIGHT);
+                return;
+            }
+
+            PrivateMode::Unknown(mode) => {
+                debug!("Ignoring unknown mode {} in unset_private_mode", mode);
+                return;
+            }
+        };
+
+        tracing::trace!("Unsetting private mode: {:?}", mode);
         match mode {
-            AnsiMode::UrgencyHints => self.mode.remove(Mode::URGENCY_HINTS),
-            AnsiMode::SwapScreenAndSetRestoreCursor => {
+            NamedPrivateMode::UrgencyHints => self.mode.remove(Mode::URGENCY_HINTS),
+            NamedPrivateMode::SwapScreenAndSetRestoreCursor => {
                 if self.mode.contains(Mode::ALT_SCREEN) {
                     self.swap_alt();
                 }
             }
-            AnsiMode::ShowCursor => self.mode.remove(Mode::SHOW_CURSOR),
-            AnsiMode::CursorKeys => self.mode.remove(Mode::APP_CURSOR),
-            AnsiMode::ReportMouseClicks => {
+            NamedPrivateMode::ShowCursor => self.mode.remove(Mode::SHOW_CURSOR),
+            NamedPrivateMode::CursorKeys => self.mode.remove(Mode::APP_CURSOR),
+            NamedPrivateMode::ReportMouseClicks => {
                 self.mode.remove(Mode::MOUSE_REPORT_CLICK);
                 self.event_proxy
                     .send_event(RioEvent::MouseCursorDirty, self.window_id);
             }
-            AnsiMode::ReportSquareMouseMotion => {
+            NamedPrivateMode::ReportCellMouseMotion => {
                 self.mode.remove(Mode::MOUSE_DRAG);
                 self.event_proxy
                     .send_event(RioEvent::MouseCursorDirty, self.window_id);
             }
-            AnsiMode::ReportAllMouseMotion => {
+            NamedPrivateMode::ReportAllMouseMotion => {
                 self.mode.remove(Mode::MOUSE_MOTION);
                 self.event_proxy
                     .send_event(RioEvent::MouseCursorDirty, self.window_id);
             }
-            AnsiMode::ReportFocusInOut => self.mode.remove(Mode::FOCUS_IN_OUT),
-            AnsiMode::BracketedPaste => self.mode.remove(Mode::BRACKETED_PASTE),
-            AnsiMode::SgrMouse => self.mode.remove(Mode::SGR_MOUSE),
-            AnsiMode::Utf8Mouse => self.mode.remove(Mode::UTF8_MOUSE),
-            AnsiMode::AlternateScroll => self.mode.remove(Mode::ALTERNATE_SCROLL),
-            AnsiMode::LineWrap => self.mode.remove(Mode::LINE_WRAP),
-            AnsiMode::LineFeedNewLine => self.mode.remove(Mode::LINE_FEED_NEW_LINE),
-            AnsiMode::Origin => self.mode.remove(Mode::ORIGIN),
-            AnsiMode::Column => self.deccolm(),
-            AnsiMode::Insert => {
-                self.mode.remove(Mode::INSERT);
-                self.mark_fully_damaged();
-            }
-            AnsiMode::BlinkingCursor => {
+            NamedPrivateMode::ReportFocusInOut => self.mode.remove(Mode::FOCUS_IN_OUT),
+            NamedPrivateMode::BracketedPaste => self.mode.remove(Mode::BRACKETED_PASTE),
+            NamedPrivateMode::SgrMouse => self.mode.remove(Mode::SGR_MOUSE),
+            NamedPrivateMode::Utf8Mouse => self.mode.remove(Mode::UTF8_MOUSE),
+            NamedPrivateMode::AlternateScroll => self.mode.remove(Mode::ALTERNATE_SCROLL),
+            NamedPrivateMode::LineWrap => self.mode.remove(Mode::LINE_WRAP),
+            NamedPrivateMode::Origin => self.mode.remove(Mode::ORIGIN),
+            NamedPrivateMode::ColumnMode => self.deccolm(),
+            NamedPrivateMode::BlinkingCursor => {
                 // TODO: Update it
                 // self.blinking_cursor = false;
                 // self.event_proxy
-                //     .send_event(RioEvent::CursorBlinkingChange, self.window_id);
-            }
-            AnsiMode::SixelDisplay => self.mode.remove(Mode::SIXEL_DISPLAY),
-            AnsiMode::SixelPrivateColorRegisters => {
-                self.graphics.sixel_shared_palette = None;
-                self.mode.remove(Mode::SIXEL_PRIV_PALETTE);
-            }
-            AnsiMode::SixelCursorToTheRight => {
-                self.mode.remove(Mode::SIXEL_CURSOR_TO_THE_RIGHT)
+                // .send_event(RioEvent::CursorBlinkingChange, self.window_id);
             }
+            NamedPrivateMode::SyncUpdate => (),
         }
     }
 
+    #[inline]
+    fn report_private_mode(&mut self, mode: PrivateMode) {
+        tracing::info!("Reporting private mode {mode:?}");
+        let state = match mode {
+            PrivateMode::Named(mode) => match mode {
+                NamedPrivateMode::CursorKeys => {
+                    self.mode.contains(Mode::APP_CURSOR).into()
+                }
+                NamedPrivateMode::Origin => self.mode.contains(Mode::ORIGIN).into(),
+                NamedPrivateMode::LineWrap => self.mode.contains(Mode::LINE_WRAP).into(),
+                NamedPrivateMode::BlinkingCursor => self.blinking_cursor.into(),
+                NamedPrivateMode::ShowCursor => {
+                    self.mode.contains(Mode::SHOW_CURSOR).into()
+                }
+                NamedPrivateMode::ReportMouseClicks => {
+                    self.mode.contains(Mode::MOUSE_REPORT_CLICK).into()
+                }
+                NamedPrivateMode::ReportCellMouseMotion => {
+                    self.mode.contains(Mode::MOUSE_DRAG).into()
+                }
+                NamedPrivateMode::ReportAllMouseMotion => {
+                    self.mode.contains(Mode::MOUSE_MOTION).into()
+                }
+                NamedPrivateMode::ReportFocusInOut => {
+                    self.mode.contains(Mode::FOCUS_IN_OUT).into()
+                }
+                NamedPrivateMode::Utf8Mouse => {
+                    self.mode.contains(Mode::UTF8_MOUSE).into()
+                }
+                NamedPrivateMode::SgrMouse => self.mode.contains(Mode::SGR_MOUSE).into(),
+                NamedPrivateMode::AlternateScroll => {
+                    self.mode.contains(Mode::ALTERNATE_SCROLL).into()
+                }
+                NamedPrivateMode::UrgencyHints => {
+                    self.mode.contains(Mode::URGENCY_HINTS).into()
+                }
+                NamedPrivateMode::SwapScreenAndSetRestoreCursor => {
+                    self.mode.contains(Mode::ALT_SCREEN).into()
+                }
+                NamedPrivateMode::BracketedPaste => {
+                    self.mode.contains(Mode::BRACKETED_PASTE).into()
+                }
+                NamedPrivateMode::SyncUpdate => ModeState::Reset,
+                NamedPrivateMode::ColumnMode => ModeState::NotSupported,
+            },
+            PrivateMode::Unknown(_) => ModeState::NotSupported,
+        };
+
+        self.event_proxy.send_event(
+            RioEvent::PtyWrite(format!("\x1b[?{};{}$y", mode.raw(), state as u8,)),
+            self.window_id,
+        );
+    }
+
+    #[inline]
+    fn dynamic_color_sequence(&mut self, prefix: String, index: usize, terminator: &str) {
+        debug!(
+            "Requested write of escape sequence for color code {}: color[{}]",
+            prefix, index
+        );
+
+        let terminator = terminator.to_owned();
+        self.event_proxy.send_event(
+            RioEvent::ColorRequest(
+                index,
+                Arc::new(move |color| {
+                    format!(
+                        "\x1b]{};rgb:{1:02x}{1:02x}/{2:02x}{2:02x}/{3:02x}{3:02x}{4}",
+                        prefix, color.r, color.g, color.b, terminator
+                    )
+                }),
+            ),
+            self.window_id,
+        );
+    }
+
     #[inline]
     fn goto(&mut self, line: Line, col: Column) {
         let (y_offset, max_y) = if self.mode.contains(Mode::ORIGIN) {
@@ -2400,9 +2572,41 @@ impl<U: EventListener> Handler for Crosswords<U> {
     }
 
     #[inline]
-    fn start_sixel_graphic(&mut self, params: &Params) -> Option<Box<sixel::Parser>> {
+    fn sixel_graphic_start(&mut self, params: &Params) {
         let palette = self.graphics.sixel_shared_palette.take();
-        Some(Box::new(sixel::Parser::new(params, palette)))
+        self.graphics.sixel_parser = Some(Box::new(sixel::Parser::new(params, palette)));
+    }
+
+    #[inline]
+    fn is_sixel_graphic_active(&self) -> bool {
+        self.graphics.sixel_parser.is_some()
+    }
+
+    #[inline]
+    fn sixel_graphic_put(&mut self, byte: u8) -> Result<(), sixel::Error> {
+        if let Some(parser) = &mut self.graphics.sixel_parser {
+            parser.put(byte)
+        } else {
+            Err(sixel::Error::NonExistentParser)
+        }
+    }
+
+    #[inline]
+    fn sixel_graphic_reset(&mut self) {
+        self.graphics.sixel_parser = None;
+    }
+
+    #[inline]
+    fn sixel_graphic_finish(&mut self) {
+        let parser = self.graphics.sixel_parser.take();
+        if let Some(parser) = parser {
+            match parser.finish() {
+                Ok((graphic, palette)) => self.insert_graphic(graphic, Some(palette)),
+                Err(err) => tracing::warn!("Failed to parse Sixel data: {}", err),
+            }
+        } else {
+            tracing::warn!("Failed to sixel_graphic_finish");
+        }
     }
 
     #[inline]
@@ -2417,6 +2621,16 @@ impl<U: EventListener> Handler for Crosswords<U> {
             }
         }
 
+        let graphic = match graphic.resized(
+            cell_width,
+            cell_height,
+            cell_width * self.grid.columns(),
+            cell_height * self.grid.screen_lines(),
+        ) {
+            Some(graphic) => graphic,
+            None => return,
+        };
+
         if graphic.width > MAX_GRAPHIC_DIMENSIONS[0]
             || graphic.height > MAX_GRAPHIC_DIMENSIONS[1]
         {
diff --git a/rio-backend/src/performer/handler.rs b/rio-backend/src/performer/handler.rs
index 55175af7d4..30f5d1c111 100644
--- a/rio-backend/src/performer/handler.rs
+++ b/rio-backend/src/performer/handler.rs
@@ -1,5 +1,6 @@
+use crate::ansi::iterm2_image_protocol;
 use crate::ansi::CursorShape;
-use crate::ansi::{mode::Mode, sixel, KeyboardModes, KeyboardModesApplyBehavior};
+use crate::ansi::{sixel, KeyboardModes, KeyboardModesApplyBehavior};
 use crate::config::colors::{AnsiColor, ColorRgb, NamedColor};
 use crate::crosswords::pos::{CharsetIndex, Column, Line, StandardCharset};
 use crate::crosswords::square::Hyperlink;
@@ -13,7 +14,10 @@ use tracing::{debug, warn};
 use crate::crosswords::attr::Attr;
 
 use crate::ansi::control::C0;
-use crate::ansi::{ClearMode, LineClearMode, TabulationClearMode};
+use crate::ansi::{
+    mode::{Mode, NamedPrivateMode, PrivateMode},
+    ClearMode, LineClearMode, TabulationClearMode,
+};
 use std::fmt::Write;
 
 // https://vt100.net/emu/dec_ansi_parser
@@ -25,16 +29,14 @@ const SYNC_UPDATE_TIMEOUT: Duration = Duration::from_millis(150);
 /// Maximum number of bytes read in one synchronized update (2MiB).
 const SYNC_BUFFER_SIZE: usize = 0x20_0000;
 
-/// Number of bytes in the synchronized update DCS sequence before the passthrough parameters.
-const SYNC_ESCAPE_START_LEN: usize = 5;
+/// Number of bytes in the BSU/ESU CSI sequences.
+const SYNC_ESCAPE_LEN: usize = 8;
 
-/// Start of the DCS sequence for beginning synchronized updates.
-const SYNC_START_ESCAPE_START: [u8; SYNC_ESCAPE_START_LEN] =
-    [b'\x1b', b'P', b'=', b'1', b's'];
+/// BSU CSI sequence for beginning or extending synchronized updates.
+const BSU_CSI: [u8; SYNC_ESCAPE_LEN] = *b"\x1b[?2026h";
 
-/// Start of the DCS sequence for terminating synchronized updates.
-const SYNC_END_ESCAPE_START: [u8; SYNC_ESCAPE_START_LEN] =
-    [b'\x1b', b'P', b'=', b'2', b's'];
+/// ESU CSI sequence for terminating synchronized updates.
+const ESU_CSI: [u8; SYNC_ESCAPE_LEN] = *b"\x1b[?2026l";
 
 fn xparse_color(color: &[u8]) -> Option<ColorRgb> {
     if !color.is_empty() && color[0] == b'#' {
@@ -75,19 +77,6 @@ fn parse_rgb_color(color: &[u8]) -> Option<ColorRgb> {
     })
 }
 
-/// Pending DCS sequence.
-#[derive(Debug)]
-enum Dcs {
-    /// Begin of the synchronized update.
-    SyncStart,
-
-    /// End of the synchronized update.
-    SyncEnd,
-
-    /// Sixel data
-    SixelData(Box<sixel::Parser>),
-}
-
 /// Parse colors in `#r(rrr)g(ggg)b(bbb)` format.
 fn parse_legacy_color(color: &[u8]) -> Option<ColorRgb> {
     let item_len = color.len() / 3;
@@ -282,7 +271,19 @@ pub trait Handler {
     fn set_mode(&mut self, _mode: Mode) {}
 
     /// Unset mode.
-    fn unset_mode(&mut self, _: Mode) {}
+    fn unset_mode(&mut self, _mode: Mode) {}
+
+    /// DECRPM - report mode.
+    fn report_mode(&mut self, _mode: Mode) {}
+
+    /// Set private mode.
+    fn set_private_mode(&mut self, _mode: PrivateMode) {}
+
+    /// Unset private mode.
+    fn unset_private_mode(&mut self, _mode: PrivateMode) {}
+
+    /// DECRPM - report private mode.
+    fn report_private_mode(&mut self, _mode: PrivateMode) {}
 
     /// DECSTBM - Set the terminal scrolling region.
     fn set_scrolling_region(&mut self, _top: usize, _bottom: Option<usize>) {}
@@ -342,9 +343,15 @@ pub trait Handler {
     fn graphics_attribute(&mut self, _: u16, _: u16) {}
 
     /// Create a parser for Sixel data.
-    fn start_sixel_graphic(&mut self, _params: &Params) -> Option<Box<sixel::Parser>> {
-        None
+    fn sixel_graphic_start(&mut self, _params: &Params) {}
+    fn is_sixel_graphic_active(&self) -> bool {
+        false
+    }
+    fn sixel_graphic_put(&mut self, _byte: u8) -> Result<(), sixel::Error> {
+        Ok(())
     }
+    fn sixel_graphic_reset(&mut self) {}
+    fn sixel_graphic_finish(&mut self) {}
 
     /// Insert a new graphic item.
     fn insert_graphic(&mut self, _data: GraphicData, _palette: Option<Vec<ColorRgb>>) {}
@@ -384,9 +391,6 @@ struct ProcessorState {
 
     /// State for synchronized terminal updates.
     sync_state: SyncState,
-
-    /// DCS sequence waiting for termination.
-    dcs: Option<Dcs>,
 }
 
 #[derive(Debug)]
@@ -396,9 +400,6 @@ struct SyncState {
 
     /// Bytes read during the synchronized update.
     buffer: Vec<u8>,
-
-    /// Sync DCS waiting for termination sequence.
-    pending_dcs: Option<Dcs>,
 }
 
 impl Default for SyncState {
@@ -406,7 +407,6 @@ impl Default for SyncState {
         Self {
             buffer: Vec::with_capacity(SYNC_BUFFER_SIZE),
             timeout: None,
-            pending_dcs: None,
         }
     }
 }
@@ -450,6 +450,8 @@ impl ParserProcessor {
             self.parser.advance(&mut performer, byte);
         }
 
+        // Report that update ended, since we could end due to timeout.
+        handler.unset_private_mode(NamedPrivateMode::SyncUpdate.into());
         // Resetting state after processing makes sure we don't interpret buffered sync escapes.
         self.state.sync_state.buffer.clear();
         self.state.sync_state.timeout = None;
@@ -475,48 +477,29 @@ impl ParserProcessor {
     {
         self.state.sync_state.buffer.push(byte);
 
-        // Handle sync DCS escape sequences.
-        match self.state.sync_state.pending_dcs {
-            Some(_) => self.advance_sync_dcs_end(handler, byte),
-            None => self.advance_sync_dcs_start(),
-        }
+        // Handle sync CSI escape sequences.
+        self.advance_sync_csi(handler);
     }
 
-    /// Find the start of sync DCS sequences.
-    fn advance_sync_dcs_start(&mut self) {
+    /// Handle BSU/ESU CSI sequences during synchronized update.
+    fn advance_sync_csi<H>(&mut self, handler: &mut H)
+    where
+        H: Handler,
+    {
         // Get the last few bytes for comparison.
         let len = self.state.sync_state.buffer.len();
-        let offset = len.saturating_sub(SYNC_ESCAPE_START_LEN);
+        let offset = len.saturating_sub(SYNC_ESCAPE_LEN);
         let end = &self.state.sync_state.buffer[offset..];
 
+        // NOTE: It is technically legal to specify multiple private modes in the same
+        // escape, but we only allow EXACTLY `\e[?2026h`/`\e[?2026l` to keep the parser
+        // reasonable.
+        //
         // Check for extension/termination of the synchronized update.
-        if end == SYNC_START_ESCAPE_START {
-            self.state.sync_state.pending_dcs = Some(Dcs::SyncStart);
-        } else if end == SYNC_END_ESCAPE_START || len >= SYNC_BUFFER_SIZE - 1 {
-            self.state.sync_state.pending_dcs = Some(Dcs::SyncEnd);
-        }
-    }
-
-    /// Parse the DCS termination sequence for synchronized updates.
-    fn advance_sync_dcs_end<H>(&mut self, handler: &mut H, byte: u8)
-    where
-        H: Handler,
-    {
-        match byte {
-            // Ignore DCS passthrough characters.
-            0x00..=0x17 | 0x19 | 0x1c..=0x7f | 0xa0..=0xff => (),
-            // Cancel the DCS sequence.
-            0x18 | 0x1a | 0x80..=0x9f => self.state.sync_state.pending_dcs = None,
-            // Dispatch on ESC.
-            0x1b => match self.state.sync_state.pending_dcs.take() {
-                Some(Dcs::SyncStart) => {
-                    self.state.sync_state.timeout =
-                        Some(Instant::now() + SYNC_UPDATE_TIMEOUT);
-                }
-                Some(Dcs::SyncEnd) => self.stop_sync(handler),
-                Some(Dcs::SixelData(_)) => (),
-                None => (),
-            },
+        if end == BSU_CSI {
+            self.state.sync_state.timeout = Some(Instant::now() + SYNC_UPDATE_TIMEOUT);
+        } else if end == ESU_CSI || len >= SYNC_BUFFER_SIZE - 1 {
+            self.stop_sync(handler);
         }
     }
 }
@@ -568,8 +551,7 @@ impl<U: Handler> copa::Perform for Performer<'_, U> {
     ) {
         match (action, intermediates) {
             ('q', []) => {
-                let parser = self.handler.start_sixel_graphic(params);
-                self.state.dcs = parser.map(Dcs::SixelData);
+                self.handler.sixel_graphic_start(params);
             }
             _ => debug!(
                 "[unhandled hook] params={:?}, ints: {:?}, ignore: {:?}, action: {:?}",
@@ -579,34 +561,22 @@ impl<U: Handler> copa::Perform for Performer<'_, U> {
     }
 
     fn put(&mut self, byte: u8) {
-        debug!("[put] {byte:02x}");
-        match self.state.dcs {
-            Some(Dcs::SixelData(ref mut parser)) => {
-                if let Err(err) = parser.put(byte) {
-                    tracing::warn!("Failed to parse Sixel data: {}", err);
-                    self.state.dcs = None;
-                }
+        if self.handler.is_sixel_graphic_active() {
+            if let Err(err) = self.handler.sixel_graphic_put(byte) {
+                tracing::warn!("Failed to parse Sixel data: {}", err);
+                self.handler.sixel_graphic_reset();
             }
-
-            _ => debug!("[unhandled put] byte={:?}", byte),
+        } else {
+            debug!("[unhandled put] byte={:?}", byte);
         }
     }
 
     #[inline]
     fn unhook(&mut self) {
-        match self.state.dcs.take() {
-            Some(Dcs::SyncStart) => {
-                self.state.sync_state.timeout =
-                    Some(Instant::now() + SYNC_UPDATE_TIMEOUT);
-            }
-            Some(Dcs::SyncEnd) => (),
-            Some(Dcs::SixelData(parser)) => match parser.finish() {
-                Ok((graphic, palette)) => {
-                    self.handler.insert_graphic(graphic, Some(palette))
-                }
-                Err(err) => tracing::warn!("Failed to parse Sixel data: {}", err),
-            },
-            _ => debug!("[unhandled unhook]"),
+        if self.handler.is_sixel_graphic_active() {
+            self.handler.sixel_graphic_finish();
+        } else {
+            debug!("[unhandled dcs_unhook]");
         }
     }
 
@@ -804,37 +774,12 @@ impl<U: Handler> copa::Perform for Performer<'_, U> {
 
             // OSC 1337 is not necessarily only used by iTerm2 protocol
             // OSC 1337 is equal to xterm OSC 50
-            // b"1337" => {
-            // \x1b]1337;File=[arguments]:[base-64 encoded file contents]^G
-            //
-            // Example:
-            // printf "\x1b]1337;File=;size=234;width=100:aGVsbG8=\x07"
-            //
-            // leads to
-            // name: None,
-            // size: Some(234),
-            // width: 100,
-            // height: Automatic,
-            // preserve_aspect_ratio: true,
-            // inline: false,
-            // do_not_move_cursor: false,
-            // data: b"hello".to_vec(),
-
-            //     if params.len() >= 2
-            //         && params[1].len() >= 5
-            //         && params[1][0..5] == *b"File="
-            //     {
-            //         let content = params[2].split(|&b| b == b':').collect::<Vec<_>>();
-            //         if content.len() == 2 {
-            //             let _arguments = content[0];
-
-            //             let _base_64_file_content = content[1];
-            //         }
-            //         // self.handler.set_cursor_shape(shape);
-            //         // return;
-            //     }
-            //     unhandled(params);
-            // }
+            b"1337" => {
+                if let Some(graphic) = iterm2_image_protocol::parse(params) {
+                    self.handler.insert_graphic(graphic, None);
+                }
+            }
+
             _ => unhandled(params),
         }
     }
@@ -914,19 +859,20 @@ impl<U: Handler> copa::Perform for Performer<'_, U> {
                 let x = next_param_or(1) as usize;
                 handler.goto(Line(y - 1), Column(x - 1));
             }
-            ('h', intermediates) => {
+            ('h', []) => {
+                for param in params_iter.map(|param| param[0]) {
+                    handler.set_mode(Mode::new(param))
+                }
+            }
+            ('h', [b'?']) => {
                 for param in params_iter.map(|param| param[0]) {
-                    let intermediate = intermediates.first();
-
                     // Handle sync updates opaquely.
-                    if intermediate == Some(&b'?') && param == 2026 {
-                        // self.state.sync_state.timeout.set_timeout(SYNC_UPDATE_TIMEOUT);
+                    if param == NamedPrivateMode::SyncUpdate as u16 {
+                        self.state.sync_state.timeout =
+                            Some(Instant::now() + SYNC_UPDATE_TIMEOUT);
                     }
 
-                    match Mode::from_primitive(intermediate, param) {
-                        Some(mode) => handler.set_mode(mode),
-                        None => csi_unhandled!(),
-                    }
+                    handler.set_private_mode(PrivateMode::new(param))
                 }
             }
             ('I', []) => handler.move_forward_tabs(next_param_or(1)),
@@ -958,12 +904,14 @@ impl<U: Handler> copa::Perform for Performer<'_, U> {
                 handler.clear_line(mode);
             }
             ('L', []) => handler.insert_blank_lines(next_param_or(1) as usize),
-            ('l', intermediates) => {
+            ('l', []) => {
                 for param in params_iter.map(|param| param[0]) {
-                    match Mode::from_primitive(intermediates.first(), param) {
-                        Some(mode) => handler.unset_mode(mode),
-                        None => csi_unhandled!(),
-                    }
+                    handler.unset_mode(Mode::new(param))
+                }
+            }
+            ('l', [b'?']) => {
+                for param in params_iter.map(|param| param[0]) {
+                    handler.unset_private_mode(PrivateMode::new(param))
                 }
             }
             ('M', []) => handler.delete_lines(next_param_or(1) as usize),
@@ -981,6 +929,14 @@ impl<U: Handler> copa::Perform for Performer<'_, U> {
             }
             ('n', []) => handler.device_status(next_param_or(0) as usize),
             ('P', []) => handler.delete_chars(next_param_or(1) as usize),
+            ('p', [b'$']) => {
+                let mode = next_param_or(0);
+                handler.report_mode(Mode::new(mode));
+            }
+            ('p', [b'?', b'$']) => {
+                let mode = next_param_or(0);
+                handler.report_private_mode(PrivateMode::new(mode));
+            }
             ('q', [b' ']) => {
                 // DECSCUSR (CSI Ps SP q) -- Set Cursor Style.
                 let cursor_style_id = next_param_or(0);
diff --git a/sugarloaf/src/components/core/image.rs b/sugarloaf/src/components/core/image.rs
index a7b343c948..ed2ba026c6 100644
--- a/sugarloaf/src/components/core/image.rs
+++ b/sugarloaf/src/components/core/image.rs
@@ -9,7 +9,7 @@ use std::sync::Arc;
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Handle {
     id: u64,
-    data: Data,
+    pub data: Data,
 }
 
 impl Handle {
diff --git a/sugarloaf/src/lib.rs b/sugarloaf/src/lib.rs
index 3bb56c152e..3b83795961 100644
--- a/sugarloaf/src/lib.rs
+++ b/sugarloaf/src/lib.rs
@@ -9,7 +9,10 @@ pub use font_introspector::{Stretch, Style, Weight};
 
 pub use crate::sugarloaf::{
     compositors::SugarCompositors,
-    graphics::{ColorType, Graphic, GraphicData, GraphicId, Graphics},
+    graphics::{
+        ColorType, Graphic, GraphicData, GraphicId, Graphics, ResizeCommand,
+        ResizeParameter, MAX_GRAPHIC_DIMENSIONS,
+    },
     primitives::*,
     Sugarloaf, SugarloafErrors, SugarloafRenderer, SugarloafWindow, SugarloafWindowSize,
     SugarloafWithErrors,
diff --git a/sugarloaf/src/sugarloaf/graphics.rs b/sugarloaf/src/sugarloaf/graphics.rs
index f8ca0cdf80..3df813b84e 100644
--- a/sugarloaf/src/sugarloaf/graphics.rs
+++ b/sugarloaf/src/sugarloaf/graphics.rs
@@ -5,7 +5,12 @@
 
 use crate::sugarloaf::types;
 use crate::sugarloaf::Handle;
+use image_rs::DynamicImage;
 use rustc_hash::FxHashMap;
+use std::cmp;
+
+/// Max allowed dimensions (width, height) for the graphic, in pixels.
+pub const MAX_GRAPHIC_DIMENSIONS: [usize; 2] = [4096, 4096];
 
 pub struct GraphicDataEntry {
     pub handle: Handle,
@@ -117,6 +122,9 @@ pub struct GraphicData {
 
     /// Indicate if there are no transparent pixels.
     pub is_opaque: bool,
+
+    /// Render graphic in a different size.
+    pub resize: Option<ResizeCommand>,
 }
 
 impl GraphicData {
@@ -157,4 +165,201 @@ impl GraphicData {
 
         true
     }
+
+    pub fn from_dynamic_image(id: GraphicId, image: DynamicImage) -> Self {
+        let color_type;
+        let width;
+        let height;
+        let pixels;
+
+        match image {
+            // Sugarloaf only accepts rgba8 now
+            // DynamicImage::ImageRgb8(image) => {
+            //     color_type = ColorType::Rgb;
+            //     width = image.width() as usize;
+            //     height = image.height() as usize;
+            //     pixels = image.into_raw();
+            // }
+            DynamicImage::ImageRgba8(image) => {
+                color_type = ColorType::Rgba;
+                width = image.width() as usize;
+                height = image.height() as usize;
+                pixels = image.into_raw();
+            }
+
+            _ => {
+                // Non-RGB image. Convert it to RGBA.
+                let image = image.into_rgba8();
+                color_type = ColorType::Rgba;
+                width = image.width() as usize;
+                height = image.height() as usize;
+                pixels = image.into_raw();
+            }
+        }
+
+        GraphicData {
+            id,
+            width,
+            height,
+            color_type,
+            pixels,
+            is_opaque: false,
+            resize: None,
+        }
+    }
+
+    /// Resize the graphic according to the dimensions in the `resize` field.
+    pub fn resized(
+        self,
+        cell_width: usize,
+        cell_height: usize,
+        view_width: usize,
+        view_height: usize,
+    ) -> Option<Self> {
+        let resize = match self.resize {
+            Some(resize) => resize,
+            None => return Some(self),
+        };
+
+        if (resize.width == ResizeParameter::Auto
+            && resize.height == ResizeParameter::Auto)
+            || self.height == 0
+            || self.width == 0
+        {
+            return Some(self);
+        }
+
+        let mut width = match resize.width {
+            ResizeParameter::Auto => 1,
+            ResizeParameter::Pixels(n) => n as usize,
+            ResizeParameter::Cells(n) => n as usize * cell_width,
+            ResizeParameter::WindowPercent(n) => n as usize * view_width / 100,
+        };
+
+        let mut height = match resize.height {
+            ResizeParameter::Auto => 1,
+            ResizeParameter::Pixels(n) => n as usize,
+            ResizeParameter::Cells(n) => n as usize * cell_height,
+            ResizeParameter::WindowPercent(n) => n as usize * view_height / 100,
+        };
+
+        if width == 0 || height == 0 {
+            return None;
+        }
+
+        // Compute "auto" dimensions.
+        if resize.width == ResizeParameter::Auto {
+            width = self.width * height / self.height;
+        }
+
+        if resize.height == ResizeParameter::Auto {
+            height = self.height * width / self.width;
+        }
+
+        // Limit size to MAX_GRAPHIC_DIMENSIONS.
+        width = cmp::min(width, MAX_GRAPHIC_DIMENSIONS[0]);
+        height = cmp::min(height, MAX_GRAPHIC_DIMENSIONS[1]);
+
+        tracing::trace!("Resize new graphic to width={}, height={}", width, height,);
+
+        // Create a new DynamicImage to resize the graphic.
+        let dynimage = match self.color_type {
+            ColorType::Rgb => {
+                let buffer = image_rs::RgbImage::from_raw(
+                    self.width as u32,
+                    self.height as u32,
+                    self.pixels,
+                )?;
+                DynamicImage::ImageRgb8(buffer)
+            }
+
+            ColorType::Rgba => {
+                let buffer = image_rs::RgbaImage::from_raw(
+                    self.width as u32,
+                    self.height as u32,
+                    self.pixels,
+                )?;
+                DynamicImage::ImageRgba8(buffer)
+            }
+        };
+
+        // Finally, use `resize` or `resize_exact` to make the new image.
+        let width = width as u32;
+        let height = height as u32;
+        // https://doc.servo.org/image/imageops/enum.FilterType.html
+        let filter = image_rs::imageops::FilterType::Triangle;
+
+        let new_image = if resize.preserve_aspect_ratio {
+            dynimage.resize(width, height, filter)
+        } else {
+            dynimage.resize_exact(width, height, filter)
+        };
+
+        Some(Self::from_dynamic_image(self.id, new_image))
+    }
+}
+
+/// Unit to specify a dimension to resize the graphic.
+#[derive(Eq, PartialEq, Clone, Copy, Debug)]
+pub enum ResizeParameter {
+    /// Dimension is computed from the original graphic dimensions.
+    Auto,
+
+    /// Size is specified in number of grid cells.
+    Cells(u32),
+
+    /// Size is specified in number pixels.
+    Pixels(u32),
+
+    /// Size is specified in a percent of the window.
+    WindowPercent(u32),
+}
+
+/// Dimensions to resize a graphic.
+#[derive(Eq, PartialEq, Clone, Copy, Debug)]
+pub struct ResizeCommand {
+    pub width: ResizeParameter,
+
+    pub height: ResizeParameter,
+
+    pub preserve_aspect_ratio: bool,
+}
+
+#[test]
+fn check_opaque_region() {
+    let graphic = GraphicData {
+        id: GraphicId(0),
+        width: 10,
+        height: 10,
+        color_type: ColorType::Rgb,
+        pixels: vec![255; 10 * 10 * 3],
+        is_opaque: true,
+        resize: None,
+    };
+
+    assert!(graphic.is_filled(1, 1, 3, 3));
+    assert!(!graphic.is_filled(8, 8, 10, 10));
+
+    let pixels = {
+        // Put a transparent 3x3 box inside the picture.
+        let mut data = vec![255; 10 * 10 * 4];
+        for y in 3..6 {
+            let offset = y * 10 * 4;
+            data[offset..offset + 3 * 4].fill(0);
+        }
+        data
+    };
+
+    let graphic = GraphicData {
+        id: GraphicId(0),
+        pixels,
+        width: 10,
+        height: 10,
+        color_type: ColorType::Rgba,
+        is_opaque: false,
+        resize: None,
+    };
+
+    assert!(graphic.is_filled(0, 0, 3, 3));
+    assert!(!graphic.is_filled(1, 1, 4, 4));
 }


Test patch: