//! The side-effecting stream handler (`termio/stream_handler.zig`). //! //! This is the handler used by termio for the VT [`Stream`]. It applies every //! terminal-modifying action to the [`Terminal`] (the same behaviour as //! `ghostty_terminal::stream_readonly`, plus the termio-specific extras such //! as the default cursor style and the cursor-blink override) and additionally //! handles everything that has side effects outside of the terminal: //! query replies written back to the pty (through the termio mailbox), //! surface notifications (title, pwd, clipboard, bell, progress, ...) and the //! DCS/APC sub-protocols (XTGETTCAP, DECRQSS, kitty graphics). //! //! # Ownership / threading //! //! In Zig the handler holds pointers to the terminal and is wrapped by a //! long-lived `Stream`. Here the long-lived part is [`TerminalStream`] //! (the VT parser state plus [`HandlerState`]); on every //! [`TerminalStream::process`] call a short-lived [`StreamHandler`] borrowing //! the (locked) `Terminal` is built around it. //! //! The Zig handler may release the renderer mutex in the middle of an action //! when a mailbox is full. That is impossible here (the guard is owned by the //! caller), so a full mailbox instead spills into an in-order *outbox* inside //! [`HandlerState`], which the caller flushes after unlocking //! ([`TerminalStream::flush_outbox`]; `Termio::process_output` does this). use std::{ collections::VecDeque, fmt::Write as _, sync::{Arc, OnceLock}, }; use ghostty_apprt::{ structs::Clipboard, surface::{ DesktopNotification, Message as SurfaceMessage, ReportTitleStyle, SET_TITLE_LEN, WriteReq, }, }; use ghostty_config::types_b::{ClipboardAccess, OSCColorReportFormat}; use ghostty_datastruct::blocking_queue::Timeout; use ghostty_renderer::size::Size; use ghostty_terminal::{ cursor::Style as CursorStyle, terminal::{ DeccolmMode, Error, MouseEvents, MouseFormat, MouseShiftCapture, ScrollViewport, SwitchScreenMode, Terminal, }, }; use ghostty_vt::{ ansi::{CursorStyle as AnsiCursorStyle, DeviceAttributeReq, ModifyKeyFormat, ProtectedMode}, apc, color::{Dynamic as DynamicColor, Rgb}, csi, dcs, device_status, kitty::{self, key::SetMode as KittySetMode}, modes::Mode, mouse_shape::MouseShape, osc::{ ProgressReport, ProgressState, Terminator, color::{self as osc_color, Request, Target}, semantic_prompt::Action as PromptAction, }, parser::{Dcs, Parser}, stream::{self, Action}, utf8_decoder::Utf8Decoder, }; use crate::{ mailbox::Mailbox, message::{Message, SizeReport}, sinks::SurfaceMailbox, terminfo, wakeup::Wakeup, }; /// `build_config.version_string`. pub const VERSION_STRING: &str = ghostty_build_info::VERSION_STRING; /// The long-lived state of the handler (everything but the terminal). pub struct HandlerState { /// The cached size info (`size: *renderer.Size`). Kept up to date by /// `Termio::resize`. pub size: Size, /// Mailbox for data to the termio thread. pub termio_mailbox: Mailbox, /// Mailbox for the surface. pub surface_mailbox: Arc, /// A handle to wake up the renderer. This hints to the renderer that /// a repaint should happen. pub renderer_wakeup: Wakeup, /// The default cursor state. This is used with CSI q. This is /// set to true when we're currently in the default cursor state. pub default_cursor: bool, pub default_cursor_style: CursorStyle, pub default_cursor_blink: Option, /// The response to use for ENQ requests. pub enquiry_response: Vec, /// The color reporting format for OSC requests. pub osc_color_report_format: OSCColorReportFormat, /// The clipboard write access configuration. pub clipboard_write: ClipboardAccess, //--------------------------------------------------------------- // Internal state /// The APC command handler maintains the APC state. APC is like /// CSI or OSC, but it is a private escape sequence that is used /// to send commands to the terminal emulator. This is used by /// the kitty graphics protocol. pub apc: apc::Handler, /// The DCS handler maintains DCS state. DCS is like CSI or OSC, /// but requires more stateful parsing. This is used by functionality /// such as XTGETTCAP. pub dcs: dcs::Handler, /// The tmux control mode viewer state (`tmux_viewer`). Dropping it is /// the equivalent of Zig's `viewer.deinit()` + `destroy`. pub tmux_viewer: Option>, /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. pub termio_messaged: bool, /// This is set to true when we've seen a title escape sequence. We use /// this to determine if we need to default the window title. pub seen_title: bool, /// Messages that did not fit into a full mailbox (see module docs). outbox_termio: VecDeque, outbox_surface: VecDeque, } /// Messages that did not fit into a full mailbox, in order. #[derive(Default)] pub struct Outbox { termio: VecDeque, surface: VecDeque, } impl Outbox { pub fn is_empty(&self) -> bool { self.termio.is_empty() && self.surface.is_empty() } /// Deliver the messages, blocking until the receivers make room. pub fn deliver(self, termio: &Mailbox, surface: &dyn SurfaceMailbox) { let any_termio = !self.termio.is_empty(); for msg in self.termio { termio.send(msg); } if any_termio { termio.notify(); } for msg in self.surface { surface.push(msg, Timeout::Forever); } } } /// The inputs to build a [`HandlerState`]. pub struct HandlerOptions { pub size: Size, pub termio_mailbox: Mailbox, pub surface_mailbox: Arc, pub renderer_wakeup: Wakeup, pub default_cursor_style: CursorStyle, pub default_cursor_blink: Option, pub enquiry_response: Vec, pub osc_color_report_format: OSCColorReportFormat, pub clipboard_write: ClipboardAccess, } impl HandlerState { pub fn new(opts: HandlerOptions) -> Self { Self { size: opts.size, termio_mailbox: opts.termio_mailbox, surface_mailbox: opts.surface_mailbox, renderer_wakeup: opts.renderer_wakeup, default_cursor: true, default_cursor_style: opts.default_cursor_style, default_cursor_blink: opts.default_cursor_blink, enquiry_response: opts.enquiry_response, osc_color_report_format: opts.osc_color_report_format, clipboard_write: opts.clipboard_write, apc: apc::Handler::default(), dcs: dcs::Handler::default(), tmux_viewer: None, termio_messaged: false, seen_title: false, outbox_termio: VecDeque::new(), outbox_surface: VecDeque::new(), } } /// Queue a render with the renderer thread. The render isn't guaranteed /// to happen immediately but it will happen as soon as practical. pub fn queue_render(&self) { if let Err(err) = self.renderer_wakeup.notify() { log::warn!("failed to notify renderer err={err}"); } } fn surface_message_writer(&mut self, msg: SurfaceMessage) { // See message_writer: preserve order if we already spilled. if !self.outbox_surface.is_empty() { self.outbox_surface.push_back(msg); return; } if self.surface_mailbox.push(msg.clone(), Timeout::Instant) == 0 { self.outbox_surface.push_back(msg); } } fn message_writer(&mut self, msg: Message) { self.termio_messaged = true; if !self.outbox_termio.is_empty() { self.outbox_termio.push_back(msg); return; } if let Err(msg) = self.termio_mailbox.try_send(msg) { // Make sure the IO thread is awake to make room. self.termio_mailbox.notify(); self.outbox_termio.push_back(msg); } } /// True if there are messages that did not fit in a mailbox yet. pub fn has_outbox(&self) -> bool { !self.outbox_termio.is_empty() || !self.outbox_surface.is_empty() } /// Take the spilled messages out of the handler so they can be delivered /// after the locks are released (see [`Outbox::deliver`]). pub fn take_outbox(&mut self) -> Outbox { Outbox { termio: std::mem::take(&mut self.outbox_termio), surface: std::mem::take(&mut self.outbox_surface), } } /// Deliver the spilled messages, blocking until the receivers make room. /// MUST NOT be called with the terminal lock held (the receivers may /// need it to make progress). pub fn flush_outbox(&mut self) { let outbox = self.take_outbox(); outbox.deliver(&self.termio_mailbox, &*self.surface_mailbox); } /// Whether messages were written to the termio mailbox since the last /// call (and reset the flag), i.e. whether the IO thread needs a wakeup. pub fn take_termio_messaged(&mut self) -> bool { std::mem::take(&mut self.termio_messaged) } } /// The long-lived stream wrapper (`StreamHandler.Stream` plus its handler /// state): VT parser state that persists across reads. pub struct TerminalStream { pub state: HandlerState, parser: Option, utf8decoder: Utf8Decoder, } impl TerminalStream { pub fn new(state: HandlerState) -> Self { let mut parser = Parser::new(); // `Stream.initAlloc` parser.osc_parser.alloc = true; Self { state, parser: Some(parser), utf8decoder: Utf8Decoder::new(), } } /// Feed bytes through the parser, applying the actions to `terminal`. /// Errors from the handler abort the call (Zig `nextSlice` error). pub fn process(&mut self, terminal: &mut Terminal, bytes: &[u8]) -> Result<(), Error> { let mut parser = self.parser.take().unwrap_or_else(|| { let mut p = Parser::new(); p.osc_parser.alloc = true; p }); let mut s = stream::Stream { handler: StreamHandler { terminal, st: &mut self.state, }, parser: std::mem::take(&mut parser), utf8decoder: self.utf8decoder, }; let res = s.next_slice(bytes); self.parser = Some(s.parser); self.utf8decoder = s.utf8decoder; res } /// Scalar variant of [`TerminalStream::process`] (Zig `next` byte by /// byte; used when an inspector needs to record each byte). pub fn process_byte(&mut self, terminal: &mut Terminal, byte: u8) -> Result<(), Error> { let mut s = stream::Stream { handler: StreamHandler { terminal, st: &mut self.state, }, parser: self.parser.take().unwrap_or_default(), utf8decoder: self.utf8decoder, }; let res = s.next(byte); self.parser = Some(s.parser); self.utf8decoder = s.utf8decoder; res } /// See [`HandlerState::flush_outbox`]. pub fn flush_outbox(&mut self) { self.state.flush_outbox(); } /// Change the configuration for this handler (`changeConfig`). Must be /// called with the terminal locked. pub fn change_config( &mut self, terminal: &mut Terminal, config: &crate::termio::DerivedConfig, ) { let st = &mut self.state; st.osc_color_report_format = config.osc_color_report_format; st.clipboard_write = config.clipboard_write; st.enquiry_response = config.enquiry_response.clone().into_bytes(); st.default_cursor_style = config.cursor_style; st.default_cursor_blink = config.cursor_blink; let mut h = StreamHandler { terminal, st }; // If our cursor is the default, then we update it immediately. if h.st.default_cursor { h.set_cursor_style_default(); } // The config could have changed any of our colors so update mode 2031 h.st.message_writer(Message::ColorSchemeReport { force: false }); } } /// The handler (see module docs). pub struct StreamHandler<'a> { pub terminal: &'a mut Terminal, pub st: &'a mut HandlerState, } /// All the xtgettcap responses for the ghostty terminfo (complete DCS /// replies), keyed by the hex-encoded uppercase capability name. fn xtgettcap_map() -> &'static std::collections::HashMap> { static MAP: OnceLock>> = OnceLock::new(); MAP.get_or_init(|| { terminfo::ghostty::GHOSTTY .xtgettcap_map() .into_iter() .map(|(k, v)| (k, v.into_bytes())) .collect() }) } impl StreamHandler<'_> { fn message_writer(&mut self, msg: Message) { self.st.message_writer(msg); } fn surface_message_writer(&mut self, msg: SurfaceMessage) { self.st.surface_message_writer(msg); } fn write_req(&mut self, data: &[u8]) { self.message_writer(Message::write_req(data)); } fn horizontal_tab(&mut self, count: u16) { for _ in 0..count { let x = self.terminal.screen().cursor.x; self.terminal.horizontal_tab(); if x == self.terminal.screen().cursor.x { break; } } } fn horizontal_tab_back(&mut self, count: u16) { for _ in 0..count { let x = self.terminal.screen().cursor.x; self.terminal.horizontal_tab_back(); if x == self.terminal.screen().cursor.x { break; } } } fn full_reset(&mut self) { self.terminal.full_reset(); self.set_mouse_shape(MouseShape::Text); // Reset resets our palette so we report it for mode 2031. self.message_writer(Message::ColorSchemeReport { force: false }); // Clear the progress bar self.progress_report(ProgressReport { state: ProgressState::Remove, progress: None, }); } fn request_mode(&mut self, mode: Mode) { let code = if self.terminal.modes.get(mode) { 1 } else { 2 }; let resp = format!( "\x1B[{}{};{}$y", if mode.ansi() { "" } else { "?" }, mode.value(), code ); self.write_req(resp.as_bytes()); } fn request_mode_unknown(&mut self, mode_raw: u16, ansi: bool) { let resp = format!("\x1B[{}{};0$y", if ansi { "" } else { "?" }, mode_raw); self.write_req(resp.as_bytes()); } fn set_mode(&mut self, mode: Mode, enabled: bool) -> Result<(), Error> { // If we are setting cursor blinking, we ignore it if we have // a default cursor blink setting set. This is a really weird // behavior: there are two ways to set cursor blinks, DECSCUSR // (CSI _ q) and DEC mode 12. DECSCUSR has a way to revert to // the "default" (CSI 0 q) but mode 12 doesn't, so once a program // used mode 12 the blink could never be reset to the user's // `cursor-style-blink`. If the user specified an explicit default // we ignore mode 12 (DECSCUSR can still change it since programs // using it are well behaved and reset to the default on exit). if mode == Mode::CursorBlinking && self.st.default_cursor_blink.is_some() { return Ok(()); } // We first always set the raw mode on our mode state. self.terminal.modes.set(mode, enabled); // And then some modes require additional processing. match mode { // Just noting here that autorepeat has no effect on // the terminal. xterm ignores this mode and so do we. Mode::Autorepeat => {} // Schedule a render since we changed colors Mode::ReverseColors => self.terminal.flags.dirty.reverse_colors = true, // Origin resets cursor pos. This is called whether or not // we're enabling or disabling origin mode and whether or // not the value changed. Mode::Origin => self.terminal.set_cursor_pos(1, 1), Mode::EnableLeftAndRightMargin => { if !enabled { // When we disable left/right margin mode we need to // reset the left/right margins. self.terminal.scrolling_region.left = 0; self.terminal.scrolling_region.right = self.terminal.cols - 1; } } Mode::AltScreenLegacy => self .terminal .switch_screen_mode(SwitchScreenMode::M47, enabled)?, Mode::AltScreen => self .terminal .switch_screen_mode(SwitchScreenMode::M1047, enabled)?, Mode::AltScreenSaveCursorClearEnter => self .terminal .switch_screen_mode(SwitchScreenMode::M1049, enabled)?, // Mode 1048 is xterm's conditional save cursor depending // on if alt screen is enabled or not (at the terminal emulator // level). Alt screen is always enabled for us so this just // does a save/restore cursor. Mode::SaveCursor => { if enabled { self.terminal.save_cursor(); } else { self.terminal.restore_cursor(); } } // Force resize back to the window size Mode::EnableMode3 => { let grid_size = self.st.size.grid(); if let Err(err) = self.terminal.resize(grid_size.columns, grid_size.rows) { log::error!("error updating terminal size: {err:?}"); } } Mode::M132Column => self.terminal.deccolm(if enabled { DeccolmMode::Cols132 } else { DeccolmMode::Cols80 })?, // We need to start a timer to prevent the emulator being hung // forever. Mode::SynchronizedOutput => { if enabled { self.message_writer(Message::StartSynchronizedOutput); } } Mode::Linefeed => self.message_writer(Message::LinefeedMode(enabled)), Mode::InBandSizeReports => { if enabled { self.message_writer(Message::SizeReport(SizeReport::Mode2048)); } } Mode::FocusEvent => { if enabled { let focused = self.terminal.flags.focused; self.message_writer(Message::Focused(focused)); } } Mode::MouseEventX10 => self.set_mouse_event(enabled, MouseEvents::X10), Mode::MouseEventNormal => self.set_mouse_event(enabled, MouseEvents::Normal), Mode::MouseEventButton => self.set_mouse_event(enabled, MouseEvents::Button), Mode::MouseEventAny => self.set_mouse_event(enabled, MouseEvents::Any), Mode::MouseFormatUtf8 => self.set_mouse_format(enabled, MouseFormat::Utf8), Mode::MouseFormatSgr => self.set_mouse_format(enabled, MouseFormat::Sgr), Mode::MouseFormatUrxvt => self.set_mouse_format(enabled, MouseFormat::Urxvt), Mode::MouseFormatSgrPixels => self.set_mouse_format(enabled, MouseFormat::SgrPixels), _ => {} } Ok(()) } fn set_mouse_event(&mut self, enabled: bool, ev: MouseEvents) { if enabled { self.terminal.flags.mouse_event = ev; self.set_mouse_shape(MouseShape::Default); } else { self.terminal.flags.mouse_event = MouseEvents::None; self.set_mouse_shape(MouseShape::Text); } } fn set_mouse_format(&mut self, enabled: bool, f: MouseFormat) { self.terminal.flags.mouse_format = if enabled { f } else { MouseFormat::X10 }; } fn device_attributes(&mut self, req: DeviceAttributeReq) { // For the below, we quack as a VT220. We don't quack as // a 420 because we don't support DCS sequences. match req { DeviceAttributeReq::Primary => { // 62 = Level 2 conformance // 22 = Color text // 52 = Clipboard access let resp: &'static [u8] = if self.st.clipboard_write == ClipboardAccess::Deny { b"\x1B[?62;22c" } else { b"\x1B[?62;22;52c" }; self.message_writer(Message::write_stable(resp)); } DeviceAttributeReq::Secondary => { self.message_writer(Message::write_stable(b"\x1B[>1;10;0c")); } req @ DeviceAttributeReq::Tertiary => { log::warn!("unimplemented device attributes req: {req:?}"); } } } fn device_status_report(&mut self, req: device_status::Request) { match req { device_status::Request::OperatingStatus => { self.message_writer(Message::write_stable(b"\x1B[0n")); } device_status::Request::CursorPosition => { let cursor = &self.terminal.screen().cursor; let (x, y) = if self.terminal.modes.get(Mode::Origin) { ( cursor.x.saturating_sub(self.terminal.scrolling_region.left), cursor.y.saturating_sub(self.terminal.scrolling_region.top), ) } else { (cursor.x, cursor.y) }; let resp = format!("\x1B[{};{}R", usize::from(y) + 1, usize::from(x) + 1); self.write_req(resp.as_bytes()); } device_status::Request::ColorScheme => { self.message_writer(Message::ColorSchemeReport { force: true }); } } } fn set_cursor_style_default(&mut self) { self.st.default_cursor = true; self.terminal.screen_mut().cursor.cursor_style = self.st.default_cursor_style; let blink = self.st.default_cursor_blink.unwrap_or(true); self.terminal.modes.set(Mode::CursorBlinking, blink); } fn set_cursor_style(&mut self, style: AnsiCursorStyle) { // Assume we're setting to a non-default. self.st.default_cursor = false; let (cs, blink) = match style { AnsiCursorStyle::Default => { self.set_cursor_style_default(); return; } AnsiCursorStyle::BlinkingBlock => (CursorStyle::Block, true), AnsiCursorStyle::SteadyBlock => (CursorStyle::Block, false), AnsiCursorStyle::BlinkingUnderline => (CursorStyle::Underline, true), AnsiCursorStyle::SteadyUnderline => (CursorStyle::Underline, false), AnsiCursorStyle::BlinkingBar => (CursorStyle::Bar, true), AnsiCursorStyle::SteadyBar => (CursorStyle::Bar, false), }; self.terminal.screen_mut().cursor.cursor_style = cs; self.terminal.modes.set(Mode::CursorBlinking, blink); } fn query_kitty_keyboard(&mut self) { log::debug!("querying kitty keyboard mode"); let resp = format!( "\x1b[?{}u", self.terminal.screen().kitty_keyboard.current().int() ); self.write_req(resp.as_bytes()); } fn report_xtversion(&mut self) { log::debug!("reporting XTVERSION: ghostty {VERSION_STRING}"); let resp = format!("\x1BP>|ghostty {VERSION_STRING}\x1B\\"); self.write_req(resp.as_bytes()); } //------------------------------------------------------------------------- // DCS / APC fn dcs_command(&mut self, cmd: dcs::Command) { match cmd { dcs::Command::Tmux(tmux) => { use ghostty_terminal::tmux::{ control::Notification, viewer::{Action as ViewerAction, Input}, }; log::info!("tmux control mode event cmd={tmux:?}"); match tmux { Notification::Enter => { // Setup our viewer state assert!(self.st.tmux_viewer.is_none()); self.st.tmux_viewer = Some(Box::new(ghostty_terminal::tmux::Viewer::new())); return; } Notification::Exit => { // Free our viewer state if we have one self.st.tmux_viewer = None; return; } _ => {} } let Some(viewer) = self.st.tmux_viewer.as_mut() else { // This can only really happen if we failed to // initialize the viewer on enter. log::info!("received tmux control mode command without viewer: {tmux:?}"); return; }; let actions = viewer.next(Input::Tmux(tmux)); for action in actions { log::info!("tmux viewer action={action:?}"); match action { // We ignore this because we will fully exit when // our DCS connection ends. We may want to handle // this in the future to notify our GUI we're // disconnected though. ViewerAction::Exit => {} ViewerAction::Command(command) => { assert_eq!(command.last(), Some(&b'\n')); self.write_req(&command); } // TODO (Zig has the same TODO): windows are not // surfaced to the GUI yet. ViewerAction::Windows(_) => {} } } } dcs::Command::Xtgettcap(mut gettcap) => { let map = xtgettcap_map(); while let Some(key) = gettcap.next() { let Ok(key) = std::str::from_utf8(key) else { continue; }; let Some(response) = map.get(key) else { continue; }; self.write_req(response); } } dcs::Command::Decrqss(decrqss) => { // Our response is valid if we have a response payload let payload: Option = match decrqss { // Invalid or unhandled request dcs::Decrqss::None => None, dcs::Decrqss::Sgr => { let mut s = self.terminal.print_attributes(); s.push('m'); Some(s) } dcs::Decrqss::Decscusr => { let blink = self.terminal.modes.get(Mode::CursorBlinking); let style: u8 = match self.terminal.screen().cursor.cursor_style { CursorStyle::Block => { if blink { 1 } else { 2 } } CursorStyle::Underline => { if blink { 3 } else { 4 } } CursorStyle::Bar => { if blink { 5 } else { 6 } } // Below here, the cursor styles aren't represented by // DECSCUSR so we map it to some other style. CursorStyle::BlockHollow => { if blink { 1 } else { 2 } } }; Some(format!("{style} q")) } dcs::Decrqss::Decstbm => Some(format!( "{};{}r", u32::from(self.terminal.scrolling_region.top) + 1, u32::from(self.terminal.scrolling_region.bottom) + 1 )), dcs::Decrqss::Decslrm => { // We only send a valid response when left and right // margin mode (DECLRMM) is enabled. if self.terminal.modes.get(Mode::EnableLeftAndRightMargin) { Some(format!( "{};{}s", u32::from(self.terminal.scrolling_region.left) + 1, u32::from(self.terminal.scrolling_region.right) + 1 )) } else { None } } }; let valid = payload.as_ref().is_some_and(|p| !p.is_empty()); let mut response = format!("\x1bP{}$r", u8::from(valid)); if let Some(p) = payload { response.push_str(&p); } response.push_str("\x1b\\"); self.write_req(response.as_bytes()); } } } fn apc_end(&mut self) { let Some(cmd) = self.st.apc.end() else { return; }; match cmd { apc::Command::Kitty(mut kitty_cmd) => { if let Some(resp) = self.terminal.kitty_graphics(&mut kitty_cmd) { let mut buf: Vec = Vec::new(); if resp.encode(&mut buf).is_ok() && buf.len() > 2 { log::debug!("kitty graphics response: {buf:x?}"); self.write_req(&buf); } } } } } //------------------------------------------------------------------------- // OSC fn window_title(&mut self, title: &str) { let mut buf = [0u8; SET_TITLE_LEN]; let title = title.as_bytes(); if title.len() >= buf.len() { log::warn!("change title requested larger than our buffer size, ignoring"); return; } buf[..title.len()].copy_from_slice(title); // Special handling for the empty title. We treat the empty title // as resetting to as if we never saw a title. Other terminals // behave this way too (e.g. iTerm2). if title.is_empty() { // If we have a pwd then we set the title as the pwd else // we just set it to blank. if let Some(pwd) = self.terminal.get_pwd() && pwd.len() < buf.len() { buf[..pwd.len()].copy_from_slice(pwd); buf[pwd.len()] = 0; } self.surface_message_writer(SurfaceMessage::SetTitle(buf)); self.st.seen_title = false; return; } self.st.seen_title = true; self.surface_message_writer(SurfaceMessage::SetTitle(buf)); } fn set_mouse_shape(&mut self, shape: MouseShape) { // Avoid changing the shape it it is already set to avoid excess // cross-thread messaging. if self.terminal.mouse_shape == shape { return; } self.terminal.mouse_shape = shape; self.surface_message_writer(SurfaceMessage::SetMouseShape(shape)); } fn clipboard_contents(&mut self, kind: u8, data: &[u8]) { // Note: we ignore the "kind" field and always use the standard clipboard. // iTerm also appears to do this but other terminals seem to only allow // certain. Let's investigate more. let clipboard_type = match kind { b's' => Clipboard::Selection, b'p' => Clipboard::Primary, // 'c' and anything else _ => Clipboard::Standard, }; // Get clipboard contents if data == b"?" { self.surface_message_writer(SurfaceMessage::ClipboardRead(clipboard_type)); return; } // Write clipboard contents self.surface_message_writer(SurfaceMessage::ClipboardWrite { req: WriteReq::init(data), clipboard_type, }); } fn semantic_prompt(&mut self, cmd: &ghostty_vt::osc::SemanticPrompt) -> Result<(), Error> { match cmd.action { PromptAction::EndInputStartOutput => { self.surface_message_writer(SurfaceMessage::StartCommand); } PromptAction::EndCommand => { // The specification seems to not specify the type but // other terminals accept 32-bits, but exit codes are really // bytes, so we just do our best here. let raw: i32 = cmd.exit_code().unwrap_or(0); let code: u8 = u8::try_from(raw).unwrap_or(1); self.surface_message_writer(SurfaceMessage::StopCommand(Some(code))); } // Handled by Terminal, no special handling by us PromptAction::EndPromptStartInput | PromptAction::EndPromptStartInputTerminateEol | PromptAction::FreshLine | PromptAction::FreshLineNewPrompt | PromptAction::NewCommand | PromptAction::PromptStart => {} } // We do this last so failures are still processed correctly // above. self.terminal.semantic_prompt(cmd) } fn report_pwd(&mut self, url: &[u8]) -> Result<(), Error> { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any // other terminal that does this but it seems like a reasonable // behavior that enables some useful features. For example, the macOS // proxy icon can be hidden when a program reports it doesn't know // the pwd rather than showing a stale pwd. if url.is_empty() { // Blank value can never fail because no allocs happen. let _ = self.terminal.set_pwd(b""); // If we haven't seen a title, we're using the pwd as our title. // Set it to blank which will reset our title behavior. if !self.st.seen_title { self.window_title(""); debug_assert!(!self.st.seen_title); } // Report the change. self.surface_message_writer(SurfaceMessage::PwdChange(WriteReq::Stable(b""))); return Ok(()); } // Attempt to parse this file-style URI using options appropriate // for this OSC 7 context (e.g. kitty-shell-cwd expects the full, // unencoded path). Parsing is byte based so non-UTF-8 path bytes // survive percent-decoding. let uri = match ghostty_os::uri::parse_bytes( url, ghostty_os::uri::ParseOptions { mac_address: true, raw_path: url.starts_with(b"kitty-shell-cwd://"), }, ) { Ok(uri) => uri, Err(e) => { log::warn!("invalid url in OSC 7: {e}"); return Ok(()); } }; if uri.scheme != "file" && uri.scheme != "kitty-shell-cwd" { log::warn!( "OSC 7 scheme must be file or kitty-shell-cwd, got: {}", uri.scheme ); return Ok(()); } let Some(host) = uri.host.as_ref() else { log::warn!("OSC 7 uri must contain a hostname: UriMissingHost"); return Ok(()); }; // OSC 7 is a little sketchy because anyone can send any value from // any host (such an SSH session). The best practice terminals follow // is to valid the hostname to be local. let host_valid = match ghostty_os::hostname::is_local(host) { Ok(v) => v, Err(err) => { log::warn!("failed to get hostname for OSC 7 validation: {err:?}"); return Ok(()); } }; if !host_valid { log::warn!("OSC 7 host ({host}) must be local"); return Ok(()); } // We need the raw path, which might require unescaping. let path = uri.path; log::debug!("terminal pwd: {}", String::from_utf8_lossy(&path)); self.terminal.set_pwd(&path)?; // Report it to the surface. self.surface_message_writer(SurfaceMessage::PwdChange(WriteReq::init(&path))); // If we haven't seen a title, use our pwd as the title. if !self.st.seen_title { // Titles are text; only the title is lossy. self.window_title(&String::from_utf8_lossy(&path)); self.st.seen_title = false; } Ok(()) } fn color_operation(&mut self, requests: &osc_color::List<'_>, terminator: Terminator) { // return early if there is nothing to do if requests.is_empty() { return; } let mut response: Vec = Vec::new(); for req in requests.iter() { match req { Request::Set(set) => { match set.target { Target::Palette(i) => { self.terminal.flags.dirty.palette = true; self.terminal.colors.palette.set(i, set.color); } Target::Dynamic(dynamic) => match dynamic { DynamicColor::Foreground => { self.terminal.colors.foreground.set(set.color); } DynamicColor::Background => { self.terminal.colors.background.set(set.color); } DynamicColor::Cursor => self.terminal.colors.cursor.set(set.color), other => { log::info!("setting dynamic color {other:?} not implemented"); } }, Target::Special(_) => { log::info!("setting special colors not implemented"); } } // Notify the surface of the color change self.surface_message_writer(SurfaceMessage::ColorChange( osc_color::ColoredTarget { target: set.target, color: set.color, }, )); } Request::Reset(target) => match target { Target::Palette(i) => { self.terminal.flags.dirty.palette = true; self.terminal.colors.palette.reset(i); let color = self.terminal.colors.palette.current[usize::from(i)]; self.surface_message_writer(SurfaceMessage::ColorChange( osc_color::ColoredTarget { target, color }, )); } Target::Dynamic(dynamic) => { let slot = match dynamic { DynamicColor::Foreground => Some(&mut self.terminal.colors.foreground), DynamicColor::Background => Some(&mut self.terminal.colors.background), DynamicColor::Cursor => Some(&mut self.terminal.colors.cursor), other => { log::warn!("resetting dynamic color {other:?} not implemented"); None } }; if let Some(slot) = slot { slot.reset(); if let Some(color) = slot.default { self.surface_message_writer(SurfaceMessage::ColorChange( osc_color::ColoredTarget { target, color }, )); } } } Target::Special(_) => { log::info!("resetting special colors not implemented"); } }, Request::ResetPalette => { let indices: Vec = self.terminal.colors.palette.mask.iter().collect(); for i in indices { self.terminal.flags.dirty.palette = true; self.terminal.colors.palette.reset(i as u8); let color = self.terminal.colors.palette.current[i]; self.surface_message_writer(SurfaceMessage::ColorChange( osc_color::ColoredTarget { target: Target::Palette(i as u8), color, }, )); } self.terminal.colors.palette.mask = ghostty_vt::color::PaletteMask::init_empty(); } Request::ResetSpecial => { log::warn!("resetting all special colors not implemented"); } Request::Query(kind) => { if self.st.osc_color_report_format == OSCColorReportFormat::None { continue; } let color: Rgb = match kind { Target::Palette(i) => self.terminal.colors.palette.current[usize::from(i)], Target::Dynamic(dynamic) => match dynamic { DynamicColor::Foreground => { let Some(c) = self.terminal.colors.foreground.get() else { continue; }; c } DynamicColor::Background => { let Some(c) = self.terminal.colors.background.get() else { continue; }; c } DynamicColor::Cursor => { let Some(c) = self .terminal .colors .cursor .get() .or_else(|| self.terminal.colors.foreground.get()) else { continue; }; c } other => { log::info!("reporting dynamic color {other:?} not implemented"); continue; } }, Target::Special(_) => { log::info!("reporting special colors not implemented"); continue; } }; let mut s = String::new(); let (width, scale): (usize, u16) = match self.st.osc_color_report_format { OSCColorReportFormat::Bit16 => (4, 257), _ => (2, 1), }; let prefix = match kind { Target::Palette(i) => format!("\x1b]4;{i};"), Target::Dynamic(d) => format!("\x1b]{};", d as u8), Target::Special(_) => continue, }; let _ = write!( s, "{prefix}rgb:{:0width$x}/{:0width$x}/{:0width$x}", u16::from(color.r) * scale, u16::from(color.g) * scale, u16::from(color.b) * scale, ); response.extend_from_slice(s.as_bytes()); response.extend_from_slice(terminator.string()); } } } if !response.is_empty() { // If any of the operations were reports, finalize the report // string and send it to the terminal. self.write_req(&response); } } fn show_desktop_notification(&mut self, title: &[u8], body: &[u8]) { self.surface_message_writer(SurfaceMessage::DesktopNotification( DesktopNotification::new(title, body), )); } /// Send a report to the pty. fn send_size_report(&mut self, style: csi::SizeReportStyle) { match style { csi::SizeReportStyle::Csi14T => { self.message_writer(Message::SizeReport(SizeReport::Csi14T)); } csi::SizeReportStyle::Csi16T => { self.message_writer(Message::SizeReport(SizeReport::Csi16T)); } csi::SizeReportStyle::Csi18T => { self.message_writer(Message::SizeReport(SizeReport::Csi18T)); } csi::SizeReportStyle::Csi21T => { self.surface_message_writer(SurfaceMessage::ReportTitle(ReportTitleStyle::Csi21T)); } } } fn kitty_color_report(&mut self, request: &kitty::color::OSC<'_>) { use kitty::color::{Kind, Request as KReq, Special}; let mut out: Vec = Vec::new(); for item in request.list.iter() { match &item { KReq::Query(key) => { // If the writer buffer is empty, we need to write our prefix if out.is_empty() { out.extend_from_slice(b"\x1b]21"); } let color: Option = match key { Kind::Palette(p) => { Some(self.terminal.colors.palette.current[usize::from(*p)]) } Kind::Special(special) => match special { Special::Foreground => self.terminal.colors.foreground.get(), Special::Background => self.terminal.colors.background.get(), Special::Cursor => self.terminal.colors.cursor.get(), _ => { log::warn!("ignoring unsupported kitty color protocol key: {key}"); continue; } }, }; let Some(color) = color else { out.extend_from_slice(format!(";{key}=").as_bytes()); continue; }; out.extend_from_slice( format!(";{key}=rgb:{:02x}/{:02x}/{:02x}", color.r, color.g, color.b) .as_bytes(), ); } KReq::Set { key, color } => match key { Kind::Palette(palette) => { self.terminal.flags.dirty.palette = true; self.terminal.colors.palette.set(*palette, *color); } Kind::Special(special) => match special { Special::Foreground => self.terminal.colors.foreground.set(*color), Special::Background => self.terminal.colors.background.set(*color), Special::Cursor => self.terminal.colors.cursor.set(*color), _ => { log::warn!("ignoring unsupported kitty color protocol key: {key}"); } }, }, KReq::Reset(key) => match key { Kind::Palette(palette) => { self.terminal.flags.dirty.palette = true; self.terminal.colors.palette.reset(*palette); } Kind::Special(special) => match special { Special::Foreground => self.terminal.colors.foreground.reset(), Special::Background => self.terminal.colors.background.reset(), Special::Cursor => self.terminal.colors.cursor.reset(), _ => { log::warn!("ignoring unsupported kitty color protocol key: {key}"); } }, }, } } // If we had any writes to our buffer, we queue them now if !out.is_empty() { out.extend_from_slice(request.terminator.string()); self.write_req(&out); } // Note: we don't have to do a queue_render here because every // processed stream will queue a render once it is done processing // the read() syscall. } /// Display a GUI progress report. fn progress_report(&mut self, report: ProgressReport) { self.surface_message_writer(SurfaceMessage::ProgressReport(report)); } fn dcs_hook(&mut self, d: &Dcs) { if let Some(cmd) = self.st.dcs.hook(d) { self.dcs_command(cmd); } } fn dcs_put(&mut self, byte: u8) { if let Some(cmd) = self.st.dcs.put(byte) { self.dcs_command(cmd); } } fn dcs_unhook(&mut self) { if let Some(cmd) = self.st.dcs.unhook() { self.dcs_command(cmd); } } } impl stream::Handler for StreamHandler<'_> { type Error = Error; fn vt(&mut self, action: Action<'_>) -> Result<(), Error> { match action { Action::Print(v) => self.terminal.print(v.cp)?, Action::PrintRepeat(v) => self.terminal.print_repeat(v)?, Action::Bell => self.surface_message_writer(SurfaceMessage::RingBell), Action::Backspace => self.terminal.backspace(), Action::HorizontalTab(v) => self.horizontal_tab(v), Action::HorizontalTabBack(v) => self.horizontal_tab_back(v), Action::Linefeed | Action::Index => self.terminal.index()?, Action::CarriageReturn => self.terminal.carriage_return(), Action::Enquiry => { log::debug!("sending enquiry response"); let resp = self.st.enquiry_response.clone(); self.write_req(&resp); } Action::InvokeCharset(v) => { self.terminal.invoke_charset(v.bank, v.charset, v.locking); } Action::CursorUp(v) => self.terminal.cursor_up(usize::from(v.value)), Action::CursorDown(v) => self.terminal.cursor_down(usize::from(v.value)), Action::CursorLeft(v) => self.terminal.cursor_left(usize::from(v.value)), Action::CursorRight(v) => self.terminal.cursor_right(usize::from(v.value)), Action::CursorPos(v) => self .terminal .set_cursor_pos(usize::from(v.row), usize::from(v.col)), Action::CursorCol(v) => { let row = usize::from(self.terminal.screen().cursor.y) + 1; self.terminal.set_cursor_pos(row, usize::from(v.value)); } Action::CursorRow(v) => { let col = usize::from(self.terminal.screen().cursor.x) + 1; self.terminal.set_cursor_pos(usize::from(v.value), col); } Action::CursorColRelative(v) => { let row = usize::from(self.terminal.screen().cursor.y) + 1; let col = (usize::from(self.terminal.screen().cursor.x) + 1) .saturating_add(usize::from(v.value)); self.terminal.set_cursor_pos(row, col); } Action::CursorRowRelative(v) => { let row = (usize::from(self.terminal.screen().cursor.y) + 1) .saturating_add(usize::from(v.value)); let col = usize::from(self.terminal.screen().cursor.x) + 1; self.terminal.set_cursor_pos(row, col); } Action::CursorStyle(v) => self.set_cursor_style(v), Action::EraseDisplayBelow(p) => { self.terminal.erase_display(csi::EraseDisplay::Below, p); } Action::EraseDisplayAbove(p) => { self.terminal.erase_display(csi::EraseDisplay::Above, p); } Action::EraseDisplayComplete(p) => { self.terminal.scroll_viewport(ScrollViewport::Bottom); self.terminal.erase_display(csi::EraseDisplay::Complete, p); } Action::EraseDisplayScrollback(p) => { self.terminal .erase_display(csi::EraseDisplay::Scrollback, p); } Action::EraseDisplayScrollComplete(p) => { self.terminal .erase_display(csi::EraseDisplay::ScrollComplete, p); } Action::EraseLineRight(p) => self.terminal.erase_line(csi::EraseLine::Right, p), Action::EraseLineLeft(p) => self.terminal.erase_line(csi::EraseLine::Left, p), Action::EraseLineComplete(p) => self.terminal.erase_line(csi::EraseLine::Complete, p), Action::EraseLineRightUnlessPendingWrap(p) => { self.terminal .erase_line(csi::EraseLine::RightUnlessPendingWrap, p); } Action::DeleteChars(v) => self.terminal.delete_chars(v), Action::EraseChars(v) => self.terminal.erase_chars(v), Action::InsertLines(v) => self.terminal.insert_lines(v), Action::InsertBlanks(v) => self.terminal.insert_blanks(v), Action::DeleteLines(v) => self.terminal.delete_lines(v), Action::ScrollUp(v) => self.terminal.scroll_up(v)?, Action::ScrollDown(v) => self.terminal.scroll_down(v), Action::TabClearCurrent => self.terminal.tab_clear(csi::TabClear::Current), Action::TabClearAll => self.terminal.tab_clear(csi::TabClear::All), Action::TabSet => self.terminal.tab_set(), Action::TabReset => self.terminal.tab_reset(), Action::NextLine => { self.terminal.index()?; self.terminal.carriage_return(); } Action::ReverseIndex => self.terminal.reverse_index(), Action::FullReset => self.full_reset(), Action::SetMode(v) => self.set_mode(v.mode, true)?, Action::ResetMode(v) => self.set_mode(v.mode, false)?, Action::SaveMode(v) => self.terminal.modes.save(v.mode), Action::RestoreMode(v) => { // For restore mode we have to restore but if we set it, we // always have to call setMode because setting some modes have // side effects and we want to make sure we process those. let enabled = self.terminal.modes.restore(v.mode); self.set_mode(v.mode, enabled)?; } Action::RequestMode(v) => self.request_mode(v.mode), Action::RequestModeUnknown(v) => self.request_mode_unknown(v.mode, v.ansi), Action::TopAndBottomMargin(v) => { self.terminal.set_top_and_bottom_margin( usize::from(v.top_left), usize::from(v.bottom_right), ); } Action::LeftAndRightMargin(v) => { self.terminal.set_left_and_right_margin( usize::from(v.top_left), usize::from(v.bottom_right), ); } Action::LeftAndRightMarginAmbiguous => { if self.terminal.modes.get(Mode::EnableLeftAndRightMargin) { self.terminal.set_left_and_right_margin(0, 0); } else { self.terminal.save_cursor(); } } Action::SaveCursor => self.terminal.save_cursor(), Action::RestoreCursor => self.terminal.restore_cursor(), Action::ModifyKeyFormat(v) => { self.terminal.flags.modify_other_keys_2 = false; if v == ModifyKeyFormat::OtherKeysNumeric { self.terminal.flags.modify_other_keys_2 = true; } } Action::ProtectedModeOff => self.terminal.set_protected_mode(ProtectedMode::Off), Action::ProtectedModeIso => self.terminal.set_protected_mode(ProtectedMode::Iso), Action::ProtectedModeDec => self.terminal.set_protected_mode(ProtectedMode::Dec), Action::MouseShiftCapture(v) => { self.terminal.flags.mouse_shift_capture = if v { MouseShiftCapture::True } else { MouseShiftCapture::False }; } Action::SizeReport(v) => self.send_size_report(v), Action::Xtversion => self.report_xtversion(), Action::DeviceAttributes(v) => self.device_attributes(v), Action::DeviceStatus(v) => self.device_status_report(v.request), Action::KittyKeyboardQuery => self.query_kitty_keyboard(), Action::KittyKeyboardPush(v) => { log::debug!("pushing kitty keyboard mode: {:?}", v.flags); self.terminal.screen_mut().kitty_keyboard.push(v.flags); } Action::KittyKeyboardPop(v) => { log::debug!("popping kitty keyboard mode n={v}"); self.terminal .screen_mut() .kitty_keyboard .pop(usize::from(v)); } Action::KittyKeyboardSet(v) => { log::debug!("setting kitty keyboard mode: set {:?}", v.flags); self.terminal .screen_mut() .kitty_keyboard .set(KittySetMode::Set, v.flags); } Action::KittyKeyboardSetOr(v) => { log::debug!("setting kitty keyboard mode: or {:?}", v.flags); self.terminal .screen_mut() .kitty_keyboard .set(KittySetMode::Or, v.flags); } Action::KittyKeyboardSetNot(v) => { log::debug!("setting kitty keyboard mode: not {:?}", v.flags); self.terminal .screen_mut() .kitty_keyboard .set(KittySetMode::Not, v.flags); } Action::KittyColorReport(v) => self.kitty_color_report(v), Action::ColorOperation(v) => self.color_operation(v.requests, v.terminator), Action::EndHyperlink => self.terminal.screen_mut().end_hyperlink(), Action::ActiveStatusDisplay(v) => self.terminal.status_display = v, Action::Decaln => self.terminal.decaln()?, Action::WindowTitle(v) => self.window_title(v.title), Action::ReportPwd(v) => self.report_pwd(v.url)?, Action::ShowDesktopNotification(v) => self.show_desktop_notification(v.title, v.body), Action::ProgressReport(v) => self.progress_report(v), Action::StartHyperlink(v) => self.terminal.screen_mut().start_hyperlink(v.uri, v.id)?, Action::ClipboardContents(v) => self.clipboard_contents(v.kind, v.data), Action::SemanticPrompt(v) => self.semantic_prompt(v)?, Action::MouseShape(v) => self.set_mouse_shape(v), Action::ConfigureCharset(v) => self.terminal.configure_charset(v.slot, v.charset), Action::SetAttribute(v) => { if let ghostty_vt::sgr::Attribute::Unknown(unk) = &v { // We optimize for the happy path scenario here, since // unknown/invalid SGRs aren't that common in the wild. log::warn!("unimplemented or unknown SGR attribute: {unk:?}"); } else if let Err(err) = self.terminal.set_attribute(v) { log::warn!("error setting attribute: {err:?}"); } } Action::DcsHook(v) => self.dcs_hook(&v), Action::DcsPut(v) => self.dcs_put(v), Action::DcsUnhook => self.dcs_unhook(), Action::ApcStart => self.st.apc.start(), Action::ApcEnd => self.apc_end(), Action::ApcPut(v) => self.st.apc.feed(v), // Unimplemented Action::TitlePush(_) | Action::TitlePop(_) => {} } Ok(()) } } #[cfg(test)] mod tests { use std::sync::Mutex; use ghostty_terminal::terminal::Options; use super::*; /// Records surface messages. #[derive(Default)] struct Rec(Mutex>); impl SurfaceMailbox for Rec { fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { let mut v = self.0.lock().unwrap(); v.push(msg); v.len() } } struct Harness { term: Terminal, stream: TerminalStream, mailbox: Mailbox, surface: Arc, } impl Harness { fn new() -> Harness { Self::with(|_| {}) } fn with(f: impl FnOnce(&mut HandlerOptions)) -> Harness { let mailbox = Mailbox::new().unwrap(); let surface = Arc::new(Rec::default()); let mut opts = HandlerOptions { size: Size::default(), termio_mailbox: mailbox.clone(), surface_mailbox: surface.clone(), renderer_wakeup: Wakeup::new().unwrap(), default_cursor_style: CursorStyle::Block, default_cursor_blink: None, enquiry_response: Vec::new(), osc_color_report_format: OSCColorReportFormat::Bit16, clipboard_write: ClipboardAccess::Allow, }; f(&mut opts); let mut term = Terminal::new(Options::new(80, 24)).unwrap(); term.colors.foreground = DynamicRgb_init(255, 255, 255); term.colors.background = DynamicRgb_init(0, 0, 0); Harness { term, stream: TerminalStream::new(HandlerState::new(opts)), mailbox, surface, } } fn feed(&mut self, bytes: &[u8]) { self.stream.process(&mut self.term, bytes).unwrap(); } /// All bytes written to the pty (concatenated write requests). fn written(&self) -> Vec { let mut out = Vec::new(); while let Some(m) = self.mailbox.pop() { if let Message::Write(w) = m { out.extend_from_slice(w.slice()); } } out } fn termio_msgs(&self) -> Vec { std::iter::from_fn(|| self.mailbox.pop()).collect() } fn surface_msgs(&self) -> Vec { std::mem::take(&mut *self.surface.0.lock().unwrap()) } } #[allow(non_snake_case)] fn DynamicRgb_init(r: u8, g: u8, b: u8) -> ghostty_vt::color::DynamicRgb { ghostty_vt::color::DynamicRgb::init(Rgb { r, g, b }) } #[test] fn print_updates_terminal() { let mut h = Harness::new(); h.feed(b"hello\x1b[31mred"); assert_eq!("hellored", h.term.plain_string().unwrap()); } #[test] fn parser_state_persists_across_calls() { let mut h = Harness::new(); h.feed(b"ab\x1b[3"); h.feed(b"1mc\xe2\x82"); h.feed(b"\xacd"); assert_eq!("abc\u{20ac}d", h.term.plain_string().unwrap()); } #[test] fn da1_with_clipboard() { let mut h = Harness::new(); h.feed(b"\x1b[c"); assert_eq!(b"\x1b[?62;22;52c".as_slice(), h.written()); } #[test] fn da1_clipboard_deny() { let mut h = Harness::with(|o| o.clipboard_write = ClipboardAccess::Deny); h.feed(b"\x1b[0c"); assert_eq!(b"\x1b[?62;22c".as_slice(), h.written()); } #[test] fn da2() { let mut h = Harness::new(); h.feed(b"\x1b[>c"); assert_eq!(b"\x1b[>1;10;0c".as_slice(), h.written()); } #[test] fn dsr_operating_status() { let mut h = Harness::new(); h.feed(b"\x1b[5n"); assert_eq!(b"\x1b[0n".as_slice(), h.written()); } #[test] fn dsr_cursor_position() { let mut h = Harness::new(); h.feed(b"\x1b[3;5H\x1b[6n"); assert_eq!(b"\x1b[3;5R".as_slice(), h.written()); } #[test] fn dsr_cursor_position_origin_mode() { let mut h = Harness::new(); h.feed(b"\x1b[5;20r\x1b[?6h\x1b[2;3H\x1b[6n"); assert_eq!(b"\x1b[2;3R".as_slice(), h.written()); } #[test] fn dsr_color_scheme_is_forced_report() { let mut h = Harness::new(); h.feed(b"\x1b[?996n"); let msgs = h.termio_msgs(); assert!(matches!( msgs.as_slice(), [Message::ColorSchemeReport { force: true }] )); } #[test] fn xtversion() { let mut h = Harness::new(); h.feed(b"\x1b[>q"); let want = format!( "\x1bP>|ghostty {}\x1b\\", ghostty_build_info::VERSION_STRING ); assert_eq!(want.as_bytes(), h.written()); } #[test] fn enquiry_response() { let mut h = Harness::with(|o| o.enquiry_response = b"ghostty!".to_vec()); h.feed(b"\x05"); assert_eq!(b"ghostty!".as_slice(), h.written()); } #[test] fn enquiry_response_empty_writes_nothing_visible() { let mut h = Harness::new(); h.feed(b"\x05"); assert!(h.written().is_empty()); } #[test] fn decrqm_known_and_unknown() { let mut h = Harness::new(); // Wraparound (7) is on by default. h.feed(b"\x1b[?7$p"); assert_eq!(b"\x1b[?7;1$y".as_slice(), h.written()); h.feed(b"\x1b[?7l\x1b[?7$p"); assert_eq!(b"\x1b[?7;2$y".as_slice(), h.written()); h.feed(b"\x1b[?9999$p"); assert_eq!(b"\x1b[?9999;0$y".as_slice(), h.written()); } #[test] fn kitty_keyboard_query_and_push() { let mut h = Harness::new(); h.feed(b"\x1b[?u"); assert_eq!(b"\x1b[?0u".as_slice(), h.written()); h.feed(b"\x1b[>1u\x1b[?u"); assert_eq!(b"\x1b[?1u".as_slice(), h.written()); h.feed(b"\x1b[