Fix Ghostty terminal support (#26)

This commit is contained in:
David Beesley
2026-01-25 23:46:22 -07:00
committed by GitHub
parent e2b20dce1c
commit b821abdd2a
7 changed files with 1737 additions and 335 deletions

View File

@@ -26,5 +26,6 @@ libc = "0.2"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
vt100 = "0.16"
termwiz = "0.23"
log = "0.4"
env_logger = "0.11"

View File

@@ -0,0 +1,580 @@
//! Whitelist-based filter for terminal escape sequences going into history.
//!
//! Uses termwiz to parse escape sequences and only allows known-safe
//! visual sequences through. This prevents mode-setting sequences
//! (focus reporting, mouse tracking, etc.) from being replayed.
//!
//! IMPORTANT: Every enum variant must be explicitly classified.
//! No catch-all fallbacks - we must consciously decide on each case.
use std::fmt::Write as FmtWrite;
use termwiz::escape::Action;
use termwiz::escape::csi::CSI;
use termwiz::escape::parser::Parser;
/// Filter that only allows safe visual sequences into history.
pub struct HistoryFilter {
parser: Parser,
}
impl Default for HistoryFilter {
fn default() -> Self {
Self::new()
}
}
impl HistoryFilter {
pub fn new() -> Self {
Self {
parser: Parser::new(),
}
}
/// Filter bytes, returning only safe sequences for history.
pub fn filter(&mut self, input: &[u8]) -> Vec<u8> {
let actions = self.parser.parse_as_vec(input);
let mut output = String::new();
for action in actions {
if is_safe_for_history(&action) {
// Re-encode the action
let _ = write!(output, "{}", action);
}
}
output.into_bytes()
}
}
/// Classification result - explicit whitelist or blacklist
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Classification {
/// Safe for history - purely visual, no side effects
Whitelist,
/// Unsafe for history - triggers responses or enables modes
Blacklist,
}
/// Check if an action is safe to include in history.
fn is_safe_for_history(action: &Action) -> bool {
classify_action(action) == Classification::Whitelist
}
/// Classify an action as whitelist or blacklist.
/// Every variant must be explicitly handled - no wildcards.
fn classify_action(action: &Action) -> Classification {
use Classification::*;
match action {
// === WHITELIST: Text output ===
Action::Print(_) => Whitelist,
Action::PrintString(_) => Whitelist,
// === WHITELIST: Graphics data ===
Action::Sixel(_) => Whitelist,
Action::KittyImage(_) => Whitelist,
// === MIXED: CSI sequences - check subcategory ===
Action::CSI(csi) => classify_csi(csi),
// === MIXED: Control codes - check individually ===
Action::Control(code) => classify_control(*code),
// === MIXED: Escape sequences - check individually ===
Action::Esc(esc) => classify_esc(esc),
// === MIXED: OSC commands - check for queries ===
Action::OperatingSystemCommand(osc) => classify_osc(osc),
// === BLACKLIST: Device control (DCS) - queries and modes ===
Action::DeviceControl(_) => Blacklist,
// === BLACKLIST: Termcap query ===
Action::XtGetTcap(_) => Blacklist,
}
}
/// Classify CSI sequences - every variant explicitly handled.
fn classify_csi(csi: &CSI) -> Classification {
use Classification::*;
match csi {
// === WHITELIST: SGR (colors, styling) ===
CSI::Sgr(_) => Whitelist,
// === WHITELIST: Cursor movement ===
CSI::Cursor(_) => Whitelist,
// === WHITELIST: Edit operations ===
CSI::Edit(_) => Whitelist,
// === WHITELIST: Character path (bidi) ===
CSI::SelectCharacterPath(_, _) => Whitelist,
// === BLACKLIST: Mode setting (DECSET/DECRST) ===
// Includes focus tracking, mouse modes, bracketed paste, etc.
CSI::Mode(_) => Blacklist,
// === BLACKLIST: Device queries ===
CSI::Device(_) => Blacklist,
// === BLACKLIST: Keyboard protocol ===
CSI::Keyboard(_) => Blacklist,
// === BLACKLIST: Mouse reports ===
// These come IN from terminal, shouldn't be in output anyway
CSI::Mouse(_) => Blacklist,
// === MIXED: Window operations ===
CSI::Window(w) => classify_window(w),
// === BLACKLIST: Unspecified/unknown ===
// We can't know if it's safe, so blacklist
CSI::Unspecified(_) => Blacklist,
}
}
/// Classify window operations - every variant explicitly handled.
fn classify_window(window: &termwiz::escape::csi::Window) -> Classification {
use Classification::*;
use termwiz::escape::csi::Window;
match window {
// === WHITELIST: Window state modifications ===
Window::DeIconify => Whitelist,
Window::Iconify => Whitelist,
Window::MoveWindow { .. } => Whitelist,
Window::ResizeWindowPixels { .. } => Whitelist,
Window::RaiseWindow => Whitelist,
Window::LowerWindow => Whitelist,
Window::RefreshWindow => Whitelist,
Window::ResizeWindowCells { .. } => Whitelist,
Window::RestoreMaximizedWindow => Whitelist,
Window::MaximizeWindow => Whitelist,
Window::MaximizeWindowVertically => Whitelist,
Window::MaximizeWindowHorizontally => Whitelist,
Window::UndoFullScreenMode => Whitelist,
Window::ChangeToFullScreenMode => Whitelist,
Window::ToggleFullScreen => Whitelist,
// === WHITELIST: Title stack operations ===
Window::PushIconAndWindowTitle => Whitelist,
Window::PushIconTitle => Whitelist,
Window::PushWindowTitle => Whitelist,
Window::PopIconAndWindowTitle => Whitelist,
Window::PopIconTitle => Whitelist,
Window::PopWindowTitle => Whitelist,
// === BLACKLIST: Query operations (terminal sends response) ===
Window::ReportWindowState => Blacklist,
Window::ReportWindowPosition => Blacklist,
Window::ReportTextAreaPosition => Blacklist,
Window::ReportTextAreaSizePixels => Blacklist,
Window::ReportWindowSizePixels => Blacklist,
Window::ReportScreenSizePixels => Blacklist,
Window::ReportCellSizePixels => Blacklist,
Window::ReportCellSizePixelsResponse { .. } => Blacklist,
Window::ReportTextAreaSizeCells => Blacklist,
Window::ReportScreenSizeCells => Blacklist,
Window::ReportIconLabel => Blacklist,
Window::ReportWindowTitle => Blacklist,
Window::ChecksumRectangularArea { .. } => Blacklist,
}
}
/// Classify control codes - every variant explicitly handled.
fn classify_control(code: termwiz::escape::ControlCode) -> Classification {
use Classification::*;
use termwiz::escape::ControlCode;
match code {
// === WHITELIST: Common formatting controls ===
ControlCode::Null => Whitelist,
ControlCode::Bell => Whitelist,
ControlCode::Backspace => Whitelist,
ControlCode::HorizontalTab => Whitelist,
ControlCode::LineFeed => Whitelist,
ControlCode::VerticalTab => Whitelist,
ControlCode::FormFeed => Whitelist,
ControlCode::CarriageReturn => Whitelist,
ControlCode::ShiftOut => Whitelist,
ControlCode::ShiftIn => Whitelist,
// === BLACKLIST: ENQ triggers terminal response ===
ControlCode::Enquiry => Blacklist,
// === BLACKLIST: Other C0 controls ===
ControlCode::StartOfHeading => Blacklist,
ControlCode::StartOfText => Blacklist,
ControlCode::EndOfText => Blacklist,
ControlCode::EndOfTransmission => Blacklist,
ControlCode::Acknowledge => Blacklist,
ControlCode::DataLinkEscape => Blacklist,
ControlCode::DeviceControlOne => Blacklist,
ControlCode::DeviceControlTwo => Blacklist,
ControlCode::DeviceControlThree => Blacklist,
ControlCode::DeviceControlFour => Blacklist,
ControlCode::NegativeAcknowledge => Blacklist,
ControlCode::SynchronousIdle => Blacklist,
ControlCode::EndOfTransmissionBlock => Blacklist,
ControlCode::Cancel => Blacklist,
ControlCode::EndOfMedium => Blacklist,
ControlCode::Substitute => Blacklist,
ControlCode::Escape => Blacklist, // ESC alone shouldn't appear
ControlCode::FileSeparator => Blacklist,
ControlCode::GroupSeparator => Blacklist,
ControlCode::RecordSeparator => Blacklist,
ControlCode::UnitSeparator => Blacklist,
// === BLACKLIST: C1 8-bit controls ===
ControlCode::BPH => Blacklist,
ControlCode::NBH => Blacklist,
ControlCode::IND => Blacklist, // Use ESC D instead
ControlCode::NEL => Blacklist, // Use ESC E instead
ControlCode::SSA => Blacklist,
ControlCode::ESA => Blacklist,
ControlCode::HTS => Blacklist, // Use ESC H instead
ControlCode::HTJ => Blacklist,
ControlCode::VTS => Blacklist,
ControlCode::PLD => Blacklist,
ControlCode::PLU => Blacklist,
ControlCode::RI => Blacklist, // Use ESC M instead
ControlCode::SS2 => Blacklist,
ControlCode::SS3 => Blacklist,
ControlCode::DCS => Blacklist, // Device control string
ControlCode::PU1 => Blacklist,
ControlCode::PU2 => Blacklist,
ControlCode::STS => Blacklist,
ControlCode::CCH => Blacklist,
ControlCode::MW => Blacklist,
ControlCode::SPA => Blacklist,
ControlCode::EPA => Blacklist,
ControlCode::SOS => Blacklist,
ControlCode::SCI => Blacklist,
ControlCode::CSI => Blacklist, // CSI alone shouldn't appear
ControlCode::ST => Blacklist, // String terminator
ControlCode::OSC => Blacklist, // OSC alone shouldn't appear
ControlCode::PM => Blacklist,
ControlCode::APC => Blacklist,
}
}
/// Classify escape sequences - every variant explicitly handled.
fn classify_esc(esc: &termwiz::escape::Esc) -> Classification {
use Classification::*;
use termwiz::escape::{Esc, EscCode};
match esc {
Esc::Code(code) => match code {
// === WHITELIST: Cursor save/restore ===
EscCode::DecSaveCursorPosition => Whitelist,
EscCode::DecRestoreCursorPosition => Whitelist,
// === WHITELIST: Character set designation (G0) ===
EscCode::DecLineDrawingG0 => Whitelist,
EscCode::AsciiCharacterSetG0 => Whitelist,
EscCode::UkCharacterSetG0 => Whitelist,
// === WHITELIST: Character set designation (G1) ===
EscCode::DecLineDrawingG1 => Whitelist,
EscCode::AsciiCharacterSetG1 => Whitelist,
EscCode::UkCharacterSetG1 => Whitelist,
// === WHITELIST: Line operations ===
EscCode::Index => Whitelist,
EscCode::ReverseIndex => Whitelist,
EscCode::NextLine => Whitelist,
// === WHITELIST: Tab set ===
EscCode::HorizontalTabSet => Whitelist,
// === WHITELIST: Keypad modes ===
EscCode::DecApplicationKeyPad => Whitelist,
EscCode::DecNormalKeyPad => Whitelist,
// === WHITELIST: String terminator ===
EscCode::StringTerminator => Whitelist,
// === WHITELIST: Full reset ===
EscCode::FullReset => Whitelist,
// === WHITELIST: Tmux title ===
EscCode::TmuxTitle => Whitelist,
// === WHITELIST: Cursor position ===
EscCode::CursorPositionLowerLeft => Whitelist,
// === BLACKLIST: Single shifts ===
EscCode::SingleShiftG2 => Blacklist,
EscCode::SingleShiftG3 => Blacklist,
// === BLACKLIST: Guarded area ===
EscCode::StartOfGuardedArea => Blacklist,
EscCode::EndOfGuardedArea => Blacklist,
// === BLACKLIST: Start of string ===
EscCode::StartOfString => Blacklist,
// === BLACKLIST: Return terminal ID (query) ===
EscCode::ReturnTerminalId => Blacklist,
// === BLACKLIST: Privacy message ===
EscCode::PrivacyMessage => Blacklist,
// === BLACKLIST: Application program command ===
EscCode::ApplicationProgramCommand => Blacklist,
// === BLACKLIST: Back index ===
EscCode::DecBackIndex => Blacklist,
// === BLACKLIST: DEC line width/height ===
EscCode::DecDoubleHeightTopHalfLine => Blacklist,
EscCode::DecDoubleHeightBottomHalfLine => Blacklist,
EscCode::DecSingleWidthLine => Blacklist,
EscCode::DecDoubleWidthLine => Blacklist,
EscCode::DecScreenAlignmentDisplay => Blacklist,
// === BLACKLIST: Application mode key presses (input, not output) ===
EscCode::ApplicationModeArrowUpPress => Blacklist,
EscCode::ApplicationModeArrowDownPress => Blacklist,
EscCode::ApplicationModeArrowRightPress => Blacklist,
EscCode::ApplicationModeArrowLeftPress => Blacklist,
EscCode::ApplicationModeHomePress => Blacklist,
EscCode::ApplicationModeEndPress => Blacklist,
EscCode::F1Press => Blacklist,
EscCode::F2Press => Blacklist,
EscCode::F3Press => Blacklist,
EscCode::F4Press => Blacklist,
},
// === BLACKLIST: Unspecified escape sequences ===
Esc::Unspecified { .. } => Blacklist,
}
}
/// Classify OSC commands - every variant explicitly handled.
fn classify_osc(osc: &termwiz::escape::OperatingSystemCommand) -> Classification {
use Classification::*;
use termwiz::escape::osc::OperatingSystemCommand;
match osc {
// === WHITELIST: Title setting ===
OperatingSystemCommand::SetIconNameAndWindowTitle(_) => Whitelist,
OperatingSystemCommand::SetIconName(_) => Whitelist,
OperatingSystemCommand::SetIconNameSun(_) => Whitelist,
OperatingSystemCommand::SetWindowTitle(_) => Whitelist,
OperatingSystemCommand::SetWindowTitleSun(_) => Whitelist,
// === WHITELIST: Hyperlinks ===
OperatingSystemCommand::SetHyperlink(_) => Whitelist,
// === WHITELIST: Color palette setting ===
OperatingSystemCommand::ChangeColorNumber(_) => Whitelist,
// === WHITELIST: Reset colors ===
OperatingSystemCommand::ResetColors(_) => Whitelist,
// === WHITELIST: Reset dynamic color ===
OperatingSystemCommand::ResetDynamicColor(_) => Whitelist,
// === WHITELIST: Selection setting (not query) ===
OperatingSystemCommand::SetSelection(_, _) => Whitelist,
// === WHITELIST: System notification ===
OperatingSystemCommand::SystemNotification(_) => Whitelist,
// === BLACKLIST: Selection clear ===
OperatingSystemCommand::ClearSelection(_) => Blacklist,
// === BLACKLIST: Selection query ===
OperatingSystemCommand::QuerySelection(_) => Blacklist,
// === MIXED: Dynamic colors (check for query) ===
OperatingSystemCommand::ChangeDynamicColors(_, colors) => {
// Whitelist only if ALL are Color, not Query
if colors
.iter()
.all(|c| matches!(c, termwiz::escape::osc::ColorOrQuery::Color(_)))
{
Whitelist
} else {
Blacklist
}
}
// === MIXED: iTerm proprietary ===
OperatingSystemCommand::ITermProprietary(iterm) => classify_iterm(iterm),
// === WHITELIST: FinalTerm semantic prompts ===
OperatingSystemCommand::FinalTermSemanticPrompt(_) => Whitelist,
// === BLACKLIST: Current directory (pwd) - could leak info ===
OperatingSystemCommand::CurrentWorkingDirectory(_) => Blacklist,
// === BLACKLIST: Rxvt extension ===
OperatingSystemCommand::RxvtExtension(_) => Blacklist,
// === BLACKLIST: ConEmu progress ===
OperatingSystemCommand::ConEmuProgress(_) => Blacklist,
// === BLACKLIST: Unspecified OSC ===
OperatingSystemCommand::Unspecified(_) => Blacklist,
}
}
/// Classify iTerm proprietary OSC commands.
fn classify_iterm(iterm: &termwiz::escape::osc::ITermProprietary) -> Classification {
use Classification::*;
use termwiz::escape::osc::ITermProprietary;
match iterm {
// === WHITELIST: File/image data ===
ITermProprietary::File(_) => Whitelist,
// === WHITELIST: Marks ===
ITermProprietary::SetMark => Whitelist,
// === WHITELIST: User variables ===
ITermProprietary::SetUserVar { .. } => Whitelist,
// === WHITELIST: Badge ===
ITermProprietary::SetBadgeFormat(_) => Whitelist,
// === WHITELIST: Profile ===
ITermProprietary::SetProfile(_) => Whitelist,
// === WHITELIST: Copy to pasteboard ===
ITermProprietary::CopyToClipboard(_) => Whitelist,
// === WHITELIST: Copy string ===
ITermProprietary::Copy(_) => Whitelist,
// === BLACKLIST: Current directory ===
ITermProprietary::CurrentDir(_) => Blacklist,
// === BLACKLIST: Cell size request (triggers response) ===
ITermProprietary::RequestCellSize => Blacklist,
// === BLACKLIST: Cell size response ===
ITermProprietary::ReportCellSize { .. } => Blacklist,
// === BLACKLIST: Report variable (triggers response) ===
ITermProprietary::ReportVariable(_) => Blacklist,
// === BLACKLIST: Unicode version ===
ITermProprietary::UnicodeVersion(_) => Blacklist,
// === BLACKLIST: Stealing focus ===
ITermProprietary::StealFocus => Blacklist,
// === BLACKLIST: Clear scrollback ===
ITermProprietary::ClearScrollback => Blacklist,
// === BLACKLIST: End copy ===
ITermProprietary::EndCopy => Blacklist,
// === BLACKLIST: Highlight cursor line ===
ITermProprietary::HighlightCursorLine(_) => Blacklist,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plain_text_passes() {
let mut filter = HistoryFilter::new();
let output = filter.filter(b"Hello, World!");
assert_eq!(output, b"Hello, World!");
}
#[test]
fn test_sgr_passes() {
let mut filter = HistoryFilter::new();
// Bold red text
let input = b"\x1b[1;31mRed\x1b[0m";
let output = filter.filter(input);
// termwiz re-encodes, so check it contains the key parts
assert!(!output.is_empty());
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("Red"));
}
#[test]
fn test_cursor_movement_passes() {
let mut filter = HistoryFilter::new();
// Move cursor to position 1,1
let input = b"\x1b[1;1H";
let output = filter.filter(input);
assert!(!output.is_empty());
}
#[test]
fn test_focus_tracking_filtered() {
let mut filter = HistoryFilter::new();
// Enable focus reporting (CSI ? 1004 h)
let input = b"\x1b[?1004h";
let output = filter.filter(input);
assert!(output.is_empty(), "Focus tracking should be filtered");
}
#[test]
fn test_mouse_mode_filtered() {
let mut filter = HistoryFilter::new();
// Enable mouse tracking (CSI ? 1000 h)
let input = b"\x1b[?1000h";
let output = filter.filter(input);
assert!(output.is_empty(), "Mouse tracking should be filtered");
}
#[test]
fn test_bracketed_paste_filtered() {
let mut filter = HistoryFilter::new();
// Enable bracketed paste (CSI ? 2004 h)
let input = b"\x1b[?2004h";
let output = filter.filter(input);
assert!(output.is_empty(), "Bracketed paste should be filtered");
}
#[test]
fn test_device_attributes_filtered() {
let mut filter = HistoryFilter::new();
// Primary DA query
let input = b"\x1b[c";
let output = filter.filter(input);
assert!(output.is_empty(), "DA query should be filtered");
}
#[test]
fn test_mixed_content() {
let mut filter = HistoryFilter::new();
// Mix of safe and unsafe
let input = b"Hello\x1b[?1004hWorld\x1b[31mRed\x1b[0m";
let output = filter.filter(input);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("Hello"));
assert!(output_str.contains("World"));
assert!(output_str.contains("Red"));
// Should not contain the focus tracking sequence
assert!(!output_str.contains("1004"));
}
#[test]
fn test_osc_title_passes() {
let mut filter = HistoryFilter::new();
// Set window title
let input = b"\x1b]0;My Title\x07";
let output = filter.filter(input);
assert!(!output.is_empty());
}
#[test]
fn test_osc_query_filtered() {
let mut filter = HistoryFilter::new();
// Query foreground color
let input = b"\x1b]10;?\x07";
let output = filter.filter(input);
assert!(output.is_empty(), "OSC query should be filtered");
}
}

View File

@@ -1,6 +1,7 @@
pub mod config;
pub mod escape_filter;
pub mod escape_sequences;
pub mod history_filter;
pub mod key_parser;
pub mod line_buffer;
pub mod proxy;

View File

@@ -1,9 +1,9 @@
use crate::escape_filter::TerminalQueryFilter;
use crate::escape_sequences::{
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY,
CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
SYNC_END, SYNC_START,
};
use crate::history_filter::HistoryFilter;
use crate::line_buffer::LineBuffer;
use anyhow::{Context, Result};
use log::debug;
@@ -21,6 +21,9 @@ use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use termwiz::escape::Action;
use termwiz::escape::csi::{CSI, Keyboard};
use termwiz::escape::parser::Parser as TermwizParser;
static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
@@ -97,7 +100,7 @@ pub struct Proxy {
child: Child,
original_termios: Option<Termios>,
history: LineBuffer,
history_filter: TerminalQueryFilter,
history_filter: HistoryFilter,
vt_parser: vt100::Parser,
vt_prev_screen: Option<vt100::Screen>,
last_output_time: Option<Instant>,
@@ -110,9 +113,8 @@ pub struct Proxy {
in_lookback_mode: bool,
in_alternate_screen: bool,
kitty_mode_supported: bool,
kitty_mode_enabled: bool,
kitty_input_buffer: Vec<u8>,
kitty_output_buffer: Vec<u8>,
kitty_mode_stack: u32,
kitty_output_parser: TermwizParser,
vt_render_pending: bool,
lookback_cache: Vec<u8>,
lookback_input_buffer: Vec<u8>,
@@ -127,70 +129,86 @@ pub struct Proxy {
alt_screen_exit_legacy_finder: memmem::Finder<'static>,
}
const MAX_KITTY_SEQ_LEN: usize = 16;
/// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode
fn detect_kitty_support() -> (bool, u32) {
use std::io::Write;
use termwiz::escape::csi::Device;
fn is_complete_kitty_output_seq(data: &[u8]) -> bool {
if data.len() < 4 || data[0] != 0x1b || data[1] != b'[' {
return false;
}
if data[2] == b'<' && data[3] == b'u' {
return true;
}
if data[2] == b'>' {
let mut j = 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
}
if j > 3 && j < data.len() && data[j] == b'u' {
return true;
}
}
false
}
// Query sequences:
// CSI ? u - Kitty keyboard protocol query
// CSI c - Primary Device Attributes (all terminals respond)
const KITTY_QUERY: &[u8] = b"\x1b[?u";
const DA_QUERY: &[u8] = b"\x1b[c";
fn is_complete_kitty_input_seq(data: &[u8]) -> bool {
if data.len() < 5 || data[0] != 0x1b || data[1] != b'[' || data[2] != b'?' {
return false;
// Send both queries
let stdout = std::io::stdout();
let mut stdout_lock = stdout.lock();
if stdout_lock.write_all(KITTY_QUERY).is_err() {
return (false, 0);
}
let mut j = 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
if stdout_lock.write_all(DA_QUERY).is_err() {
return (false, 0);
}
j > 3 && j < data.len() && data[j] == b'u'
}
if stdout_lock.flush().is_err() {
return (false, 0);
}
drop(stdout_lock);
fn kitty_output_split_point(buffer: &[u8]) -> usize {
let last_esc = buffer.iter().rposition(|&b| b == 0x1b);
// Read responses with timeout using termwiz parser
let stdin = std::io::stdin();
let mut parser = TermwizParser::new();
let mut buf = [0u8; 256];
let mut kitty_supported = false;
let mut kitty_flags: u32 = 0;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(500);
let poll_interval = PollTimeout::from(50u16);
match last_esc {
None => buffer.len(),
Some(pos) if pos + MAX_KITTY_SEQ_LEN <= buffer.len() => buffer.len(),
Some(pos) => {
let tail = &buffer[pos..];
if is_complete_kitty_output_seq(tail) {
buffer.len()
} else {
pos
while start.elapsed() < timeout {
let mut poll_fd = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)];
match poll(&mut poll_fd, poll_interval) {
Ok(0) => continue,
Ok(_) => {
match read(stdin.as_fd(), &mut buf) {
Ok(0) => continue,
Ok(n) => {
let actions = parser.parse_as_vec(&buf[..n]);
for action in actions {
if let Action::CSI(csi) = action {
match csi {
CSI::Keyboard(Keyboard::ReportKittyState(flags)) => {
kitty_supported = true;
kitty_flags = u32::from(flags.bits());
}
CSI::Device(dev)
if matches!(*dev, Device::DeviceAttributes(_)) =>
{
// DA response means all responses received
debug!(
"Kitty detection complete: supported={} flags={}",
kitty_supported, kitty_flags
);
return (kitty_supported, kitty_flags);
}
_ => {}
}
}
}
}
Err(e) if e == Errno::EAGAIN || e == Errno::EWOULDBLOCK => continue,
Err(_) => break,
}
}
Err(_) => continue,
}
}
}
fn kitty_input_split_point(buffer: &[u8]) -> usize {
let last_esc = buffer.iter().rposition(|&b| b == 0x1b);
match last_esc {
None => buffer.len(),
Some(pos) if pos + MAX_KITTY_SEQ_LEN <= buffer.len() => buffer.len(),
Some(pos) => {
let tail = &buffer[pos..];
if is_complete_kitty_input_seq(tail) {
buffer.len()
} else {
pos
}
}
}
debug!(
"Kitty detection timed out, supported={} flags={}",
kitty_supported, kitty_flags
);
(kitty_supported, kitty_flags)
}
impl Proxy {
@@ -201,6 +219,11 @@ impl Proxy {
let terminal_guard = TerminalGuard::new()?;
setup_signal_handlers()?;
// Detect Kitty support before spawning child
// If flags > 0, terminal is already in Kitty mode (inherited from parent)
let (kitty_supported, kitty_initial_flags) = detect_kitty_support();
let kitty_initial_stack = if kitty_initial_flags > 0 { 1 } else { 0 };
let slave_fd = pty.slave.as_raw_fd();
let child = unsafe {
@@ -247,7 +270,7 @@ impl Proxy {
Ok(Self {
history,
history_filter: TerminalQueryFilter::new(),
history_filter: HistoryFilter::new(),
config,
pty_master: pty.master,
child,
@@ -263,10 +286,9 @@ impl Proxy {
in_sync_block: false,
in_lookback_mode: false,
in_alternate_screen: false,
kitty_mode_supported: false,
kitty_mode_enabled: false,
kitty_input_buffer: Vec::with_capacity(16),
kitty_output_buffer: Vec::with_capacity(16),
kitty_mode_supported: kitty_supported,
kitty_mode_stack: kitty_initial_stack,
kitty_output_parser: TermwizParser::new(),
vt_render_pending: false,
lookback_cache: Vec::new(),
lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
@@ -378,14 +400,11 @@ impl Proxy {
feed_vt
);
// Track Kitty keyboard protocol state from output
self.update_kitty_mode_from_output(data);
if self.in_alternate_screen {
// Still feed VT and history while in alt screen so they stay in sync
// Feed VT but NOT history while in alt screen
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history
if feed_vt {
self.vt_parser.process(data);
self.push_to_history(data);
}
return self.process_output_alt_screen(data, stdout_fd);
}
@@ -425,7 +444,7 @@ impl Proxy {
self.in_alternate_screen = true;
let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]);
// Write alt screen enter directly
write_all(stdout_fd, &data[pos + alt_pos..pos + alt_pos + seq_len])?;
self.write_to_terminal(stdout_fd, &data[pos + alt_pos..pos + alt_pos + seq_len])?;
return self.process_output_alt_screen(&data[pos + alt_pos + seq_len..], stdout_fd);
}
@@ -467,9 +486,9 @@ impl Proxy {
"process_output_alt_screen: ALT_SCREEN_EXIT detected at pos={}",
exit_pos
);
write_all(stdout_fd, &data[..exit_pos])?;
self.write_to_terminal(stdout_fd, &data[..exit_pos])?;
let seq_len = self.alt_screen_exit_len(&data[exit_pos..]);
write_all(stdout_fd, &data[exit_pos..exit_pos + seq_len])?;
self.write_to_terminal(stdout_fd, &data[exit_pos..exit_pos + seq_len])?;
self.in_alternate_screen = false;
// Force full VT render to restore main screen content
@@ -489,7 +508,7 @@ impl Proxy {
}
return Ok(());
}
write_all(stdout_fd, data)
self.write_to_terminal(stdout_fd, data)
}
/// Check for alt screen transitions without re-feeding VT/history
@@ -501,7 +520,7 @@ impl Proxy {
);
self.in_alternate_screen = true;
let seq_len = self.alt_screen_enter_len(&data[alt_pos..]);
write_all(stdout_fd, &data[alt_pos..alt_pos + seq_len])?;
self.write_to_terminal(stdout_fd, &data[alt_pos..alt_pos + seq_len])?;
return self.process_output_alt_screen(&data[alt_pos + seq_len..], stdout_fd);
}
Ok(())
@@ -545,90 +564,62 @@ impl Proxy {
}
}
fn update_kitty_mode_from_output(&mut self, data: &[u8]) {
self.kitty_output_buffer.extend_from_slice(data);
let split_point = kitty_output_split_point(&self.kitty_output_buffer);
if split_point > 0 {
let to_parse: Vec<u8> = self.kitty_output_buffer.drain(..split_point).collect();
self.parse_kitty_output(&to_parse);
}
// Safety cap on carryover buffer
if self.kitty_output_buffer.len() > 64 {
let overflow: Vec<u8> = self.kitty_output_buffer.drain(..).collect();
self.parse_kitty_output(&overflow);
}
fn kitty_mode_enabled(&self) -> bool {
self.kitty_mode_stack > 0
}
fn parse_kitty_output(&mut self, data: &[u8]) {
let mut i = 0;
while i < data.len() {
if data[i] == 0x1b && i + 3 < data.len() && data[i + 1] == b'[' {
if data[i + 2] == b'<' && data[i + 3] == b'u' {
debug!("Kitty keyboard protocol disabled");
self.kitty_mode_enabled = false;
i += 4;
continue;
}
if data[i + 2] == b'>' && self.kitty_mode_supported {
let mut j = i + 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
/// Write data to the terminal and track Kitty keyboard protocol state
fn write_to_terminal<F: AsFd>(&mut self, stdout_fd: &F, data: &[u8]) -> Result<()> {
Self::update_kitty_mode_helper(
&mut self.kitty_output_parser,
&mut self.kitty_mode_stack,
self.kitty_mode_supported,
data,
);
write_all(stdout_fd, data)
}
fn update_kitty_mode_helper(
parser: &mut TermwizParser,
stack: &mut u32,
supported: bool,
data: &[u8],
) {
let actions = parser.parse_as_vec(data);
for action in actions {
if let Action::CSI(csi) = action {
match csi {
CSI::Keyboard(Keyboard::PushKittyState { flags, .. }) => {
if supported {
*stack = stack.saturating_add(1);
debug!(
"Kitty keyboard protocol push (flags={:?}, stack={})",
flags, stack
);
}
}
if j > i + 3 && j < data.len() && data[j] == b'u' {
debug!("Kitty keyboard protocol enabled");
self.kitty_mode_enabled = true;
i = j + 1;
continue;
CSI::Keyboard(Keyboard::SetKittyState { flags, .. }) => {
if supported && !flags.is_empty() && *stack == 0 {
*stack = 1;
debug!(
"Kitty keyboard protocol set (flags={:?}, stack={})",
flags, stack
);
} else if flags.is_empty() && *stack > 0 {
debug!("Kitty keyboard protocol set empty flags (stack={})", stack);
}
}
CSI::Keyboard(Keyboard::PopKittyState(n)) => {
let prev = *stack;
*stack = stack.saturating_sub(n);
debug!(
"Kitty keyboard protocol pop {} (stack {} -> {})",
n, prev, stack
);
}
_ => {}
}
}
i += 1;
}
}
fn update_kitty_support_from_input(&mut self, data: &[u8]) {
if self.kitty_mode_supported {
return;
}
self.kitty_input_buffer.extend_from_slice(data);
let split_point = kitty_input_split_point(&self.kitty_input_buffer);
if split_point > 0 {
let to_parse: Vec<u8> = self.kitty_input_buffer.drain(..split_point).collect();
self.parse_kitty_input(&to_parse);
}
// Safety cap on carryover buffer
if self.kitty_input_buffer.len() > 64 {
let overflow: Vec<u8> = self.kitty_input_buffer.drain(..).collect();
self.parse_kitty_input(&overflow);
}
}
fn parse_kitty_input(&mut self, data: &[u8]) {
if self.kitty_mode_supported {
return;
}
let mut i = 0;
while i < data.len() {
if data[i] == 0x1b && i + 4 < data.len() && data[i + 1] == b'[' && data[i + 2] == b'?' {
let mut j = i + 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
}
if j > i + 3 && j < data.len() && data[j] == b'u' {
debug!("Kitty keyboard protocol supported (saw query response)");
self.kitty_mode_supported = true;
return;
}
}
i += 1;
}
}
@@ -735,6 +726,14 @@ impl Proxy {
is_diff,
self.output_buffer.len()
);
// Can't use write_to_terminal here due to borrow checker - can't pass
// &self.output_buffer while also taking &mut self
Self::update_kitty_mode_helper(
&mut self.kitty_output_parser,
&mut self.kitty_mode_stack,
self.kitty_mode_supported,
&self.output_buffer,
);
write_all(stdout_fd, &self.output_buffer)?;
// Store current screen for next diff
@@ -795,8 +794,23 @@ impl Proxy {
self.output_buffer.clear();
self.history.append_all(&mut self.output_buffer);
write_all(stdout_fd, CLEAR_SCREEN)?;
write_all(stdout_fd, CURSOR_HOME)?;
// Debug: write history to file if CLAUDE_CHILL_HISTORY_FILE is set
if let Ok(path) = std::env::var("CLAUDE_CHILL_HISTORY_FILE")
&& let Err(e) = std::fs::write(&path, &self.output_buffer)
{
debug!("Failed to write history file: {}", e);
}
self.write_to_terminal(stdout_fd, CLEAR_SCREEN)?;
self.write_to_terminal(stdout_fd, CURSOR_HOME)?;
// Can't use write_to_terminal here due to borrow checker - can't pass
// &self.output_buffer while also taking &mut self
Self::update_kitty_mode_helper(
&mut self.kitty_output_parser,
&mut self.kitty_mode_stack,
self.kitty_mode_supported,
&self.output_buffer,
);
write_all(stdout_fd, &self.output_buffer)?;
// Force full VT render on next output since terminal now shows history
@@ -807,13 +821,13 @@ impl Proxy {
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
self.last_stdin_time = Some(Instant::now());
self.update_kitty_support_from_input(data);
debug!("process_input: stdin={:?}", data);
if self.in_alternate_screen {
return write_all(&self.pty_master, data);
}
let lookback_sequence = if self.kitty_mode_enabled {
let lookback_sequence = if self.kitty_mode_enabled() {
self.config.lookback_sequence_kitty.clone()
} else {
self.config.lookback_sequence_legacy.clone()
@@ -849,16 +863,17 @@ impl Proxy {
}
continue;
}
SequenceMatch::Partial => {}
SequenceMatch::None => {
if !lookback_sequence.starts_with(&self.lookback_input_buffer) {
self.lookback_input_buffer.clear();
}
SequenceMatch::Partial => {
// Still might be lookback sequence, don't forward yet
continue;
}
SequenceMatch::None => {
// Not a lookback sequence - forward all buffered bytes
if !self.in_lookback_mode {
write_all(&self.pty_master, &self.lookback_input_buffer)?;
}
self.lookback_input_buffer.clear();
}
}
if lookback_action == SequenceMatch::None && !self.in_lookback_mode {
write_all(&self.pty_master, &[byte])?;
}
}
Ok(())
@@ -1076,212 +1091,259 @@ fn nix_read<F: AsFd>(fd: &F, buf: &mut [u8]) -> Result<usize, Errno> {
mod tests {
use super::*;
// Tests for is_complete_kitty_output_seq
// Helper to test Kitty tracking using the real update_kitty_mode_helper
struct KittyTracker {
parser: TermwizParser,
mode_supported: bool,
mode_stack: u32,
}
impl KittyTracker {
fn new() -> Self {
Self {
parser: TermwizParser::new(),
mode_supported: false,
mode_stack: 0,
}
}
fn mode_enabled(&self) -> bool {
self.mode_stack > 0
}
fn process_output(&mut self, data: &[u8]) {
// Uses the real production function
Proxy::update_kitty_mode_helper(
&mut self.parser,
&mut self.mode_stack,
self.mode_supported,
data,
);
}
fn process_input(&mut self, data: &[u8]) {
// Kitty support detection from query response (CSI ? flags u)
// This is done separately in detect_kitty_support() at startup
if self.mode_supported {
return;
}
let actions = self.parser.parse_as_vec(data);
for action in actions {
if let Action::CSI(CSI::Keyboard(Keyboard::ReportKittyState(_))) = action {
self.mode_supported = true;
return;
}
}
}
}
// Tests for Kitty keyboard protocol tracking
#[test]
fn test_output_seq_disable_complete() {
assert!(is_complete_kitty_output_seq(b"\x1b[<u"));
fn test_kitty_initially_disabled() {
let tracker = KittyTracker::new();
assert!(!tracker.mode_enabled());
assert!(!tracker.mode_supported);
}
#[test]
fn test_output_seq_disable_with_trailing() {
assert!(is_complete_kitty_output_seq(b"\x1b[<uhello"));
fn test_kitty_support_detected_from_query_response() {
let mut tracker = KittyTracker::new();
// Terminal responds to query with CSI ? flags u
tracker.process_input(b"\x1b[?1u");
assert!(tracker.mode_supported);
}
#[test]
fn test_output_seq_enable_complete() {
assert!(is_complete_kitty_output_seq(b"\x1b[>1u"));
fn test_kitty_push_increments_stack() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// CSI > 1 u = push with flags
tracker.process_output(b"\x1b[>1u");
assert_eq!(tracker.mode_stack, 1);
assert!(tracker.mode_enabled());
}
#[test]
fn test_output_seq_enable_multi_digit() {
assert!(is_complete_kitty_output_seq(b"\x1b[>31u"));
fn test_kitty_push_requires_support() {
let mut tracker = KittyTracker::new();
// Push without support detection - should be ignored
tracker.process_output(b"\x1b[>1u");
assert_eq!(tracker.mode_stack, 0);
assert!(!tracker.mode_enabled());
}
#[test]
fn test_output_seq_enable_with_trailing() {
assert!(is_complete_kitty_output_seq(b"\x1b[>1umore"));
fn test_kitty_pop_decrements_stack() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[<u"); // pop 1
assert_eq!(tracker.mode_stack, 0);
assert!(!tracker.mode_enabled());
}
#[test]
fn test_output_seq_incomplete_esc_only() {
assert!(!is_complete_kitty_output_seq(b"\x1b"));
fn test_kitty_pop_with_count() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[>1u"); // push
assert_eq!(tracker.mode_stack, 3);
tracker.process_output(b"\x1b[<2u"); // pop 2
assert_eq!(tracker.mode_stack, 1);
assert!(tracker.mode_enabled());
}
#[test]
fn test_output_seq_incomplete_esc_bracket() {
assert!(!is_complete_kitty_output_seq(b"\x1b["));
fn test_kitty_pop_saturates_at_zero() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[<5u"); // pop 5 (more than we have)
assert_eq!(tracker.mode_stack, 0);
assert!(!tracker.mode_enabled());
}
#[test]
fn test_output_seq_incomplete_disable() {
assert!(!is_complete_kitty_output_seq(b"\x1b[<"));
fn test_kitty_split_sequence_across_buffers() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// Feed the sequence in parts
tracker.process_output(b"\x1b[>");
tracker.process_output(b"1u");
assert_eq!(tracker.mode_stack, 1);
}
#[test]
fn test_output_seq_incomplete_enable_no_digit() {
assert!(!is_complete_kitty_output_seq(b"\x1b[>"));
fn test_kitty_multiple_sequences_in_one_buffer() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// Push twice, pop once, all in one buffer
tracker.process_output(b"\x1b[>1u\x1b[>1u\x1b[<u");
assert_eq!(tracker.mode_stack, 1);
}
#[test]
fn test_output_seq_incomplete_enable_digit_no_u() {
assert!(!is_complete_kitty_output_seq(b"\x1b[>1"));
fn test_kitty_mixed_with_other_sequences() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// Kitty push mixed with cursor moves and SGR
tracker.process_output(b"\x1b[H\x1b[>1u\x1b[31m\x1b[2J");
assert_eq!(tracker.mode_stack, 1);
}
#[test]
fn test_output_seq_not_kitty() {
assert!(!is_complete_kitty_output_seq(b"\x1b[H"));
assert!(!is_complete_kitty_output_seq(b"\x1b[2J"));
fn test_kitty_typical_session_flow() {
let mut tracker = KittyTracker::new();
// 1. Terminal responds to query
tracker.process_input(b"\x1b[?1u");
assert!(tracker.mode_supported);
assert!(!tracker.mode_enabled());
// 2. App pushes keyboard mode
tracker.process_output(b"\x1b[>1u");
assert!(tracker.mode_enabled());
// 3. App pops keyboard mode on exit
tracker.process_output(b"\x1b[<u");
assert!(!tracker.mode_enabled());
}
// Tests for sequence matching (used for lookback key detection)
fn check_sequence(buffer: &[u8], byte: u8, sequence: &[u8]) -> SequenceMatch {
let mut buf = buffer.to_vec();
buf.push(byte);
if buf.len() > sequence.len() {
let excess = buf.len() - sequence.len();
buf.drain(..excess);
}
if buf.as_slice() == sequence {
SequenceMatch::Complete
} else if sequence.starts_with(&buf) {
SequenceMatch::Partial
} else {
SequenceMatch::None
}
}
#[test]
fn test_output_seq_empty() {
assert!(!is_complete_kitty_output_seq(b""));
fn test_sequence_match_complete_single_byte() {
// Single byte sequence (legacy Ctrl+6 = 0x1E)
let sequence = &[0x1E];
assert_eq!(check_sequence(&[], 0x1E, sequence), SequenceMatch::Complete);
}
#[test]
fn test_output_seq_no_esc() {
assert!(!is_complete_kitty_output_seq(b"hello"));
}
// Tests for is_complete_kitty_input_seq
#[test]
fn test_input_seq_query_response_complete() {
assert!(is_complete_kitty_input_seq(b"\x1b[?1u"));
fn test_sequence_match_complete_multi_byte() {
// Multi-byte Kitty sequence: ESC [ 5 4 ; 5 u
let sequence = b"\x1b[54;5u";
let mut buffer = Vec::new();
for &byte in &sequence[..sequence.len() - 1] {
let result = check_sequence(&buffer, byte, sequence);
assert_eq!(result, SequenceMatch::Partial);
buffer.push(byte);
if buffer.len() > sequence.len() {
buffer.drain(..buffer.len() - sequence.len());
}
}
// Final byte completes the sequence
assert_eq!(
check_sequence(&buffer, sequence[sequence.len() - 1], sequence),
SequenceMatch::Complete
);
}
#[test]
fn test_input_seq_query_response_multi_digit() {
assert!(is_complete_kitty_input_seq(b"\x1b[?31u"));
fn test_sequence_match_partial() {
let sequence = b"\x1b[54;5u";
assert_eq!(check_sequence(&[], 0x1b, sequence), SequenceMatch::Partial);
assert_eq!(
check_sequence(&[0x1b], b'[', sequence),
SequenceMatch::Partial
);
assert_eq!(
check_sequence(&[0x1b, b'['], b'5', sequence),
SequenceMatch::Partial
);
}
#[test]
fn test_input_seq_query_response_with_trailing() {
assert!(is_complete_kitty_input_seq(b"\x1b[?1umore"));
fn test_sequence_match_none_wrong_byte() {
let sequence = b"\x1b[54;5u";
// Start with wrong byte
assert_eq!(check_sequence(&[], b'a', sequence), SequenceMatch::None);
// Wrong byte after partial match
assert_eq!(check_sequence(&[0x1b], b'O', sequence), SequenceMatch::None);
}
#[test]
fn test_input_seq_incomplete_esc_only() {
assert!(!is_complete_kitty_input_seq(b"\x1b"));
fn test_sequence_match_buffer_rolling() {
// Test that the rolling buffer properly handles the case where
// random bytes precede the actual sequence. The buffer keeps
// only the last N bytes where N = sequence.len()
let sequence = b"\x1b[54;5u"; // 7 bytes
// User types random chars - no match
assert_eq!(check_sequence(&[], b'a', sequence), SequenceMatch::None);
assert_eq!(check_sequence(b"a", b'b', sequence), SequenceMatch::None);
// Buffer [a, b, ESC] doesn't start sequence (sequence starts with ESC)
assert_eq!(check_sequence(b"ab", 0x1b, sequence), SequenceMatch::None);
// After more typing, old bytes get trimmed from buffer
// When buffer finally contains just ESC at the right position, it matches
// But with rolling buffer, we need the EXACT prefix
// Fresh start: ESC alone is a partial match
assert_eq!(check_sequence(&[], 0x1b, sequence), SequenceMatch::Partial);
}
#[test]
fn test_input_seq_incomplete_esc_bracket() {
assert!(!is_complete_kitty_input_seq(b"\x1b["));
}
#[test]
fn test_input_seq_incomplete_esc_bracket_question() {
assert!(!is_complete_kitty_input_seq(b"\x1b[?"));
}
#[test]
fn test_input_seq_incomplete_no_u() {
assert!(!is_complete_kitty_input_seq(b"\x1b[?1"));
}
#[test]
fn test_input_seq_not_kitty() {
assert!(!is_complete_kitty_input_seq(b"\x1b[H"));
assert!(!is_complete_kitty_input_seq(b"\x1b[?25h"));
}
#[test]
fn test_input_seq_empty() {
assert!(!is_complete_kitty_input_seq(b""));
}
// Tests for kitty_output_split_point
#[test]
fn test_output_split_no_esc() {
assert_eq!(kitty_output_split_point(b"hello world"), 11);
}
#[test]
fn test_output_split_empty() {
assert_eq!(kitty_output_split_point(b""), 0);
}
#[test]
fn test_output_split_complete_disable() {
// Complete sequence - split at end (process all)
assert_eq!(kitty_output_split_point(b"\x1b[<u"), 4);
}
#[test]
fn test_output_split_complete_enable() {
assert_eq!(kitty_output_split_point(b"\x1b[>1u"), 5);
}
#[test]
fn test_output_split_incomplete_esc_only() {
// Just ESC - incomplete, split at 0 (keep all)
assert_eq!(kitty_output_split_point(b"\x1b"), 0);
}
#[test]
fn test_output_split_incomplete_enable() {
// Incomplete enable - split at ESC position
assert_eq!(kitty_output_split_point(b"\x1b[>"), 0);
assert_eq!(kitty_output_split_point(b"\x1b[>1"), 0);
}
#[test]
fn test_output_split_data_then_incomplete() {
// Data followed by incomplete - split before ESC
let data = b"hello\x1b[>";
assert_eq!(kitty_output_split_point(data), 5);
}
#[test]
fn test_output_split_complete_then_incomplete() {
// Complete disable followed by incomplete enable
let data = b"\x1b[<u\x1b[>";
assert_eq!(kitty_output_split_point(data), 4);
}
#[test]
fn test_output_split_esc_far_from_end() {
// ESC more than 16 bytes from end - process all
let mut data = b"\x1b[<u".to_vec();
data.extend_from_slice(b"this is padding longer than 16 bytes!");
assert_eq!(kitty_output_split_point(&data), data.len());
}
// Tests for kitty_input_split_point
#[test]
fn test_input_split_no_esc() {
assert_eq!(kitty_input_split_point(b"hello world"), 11);
}
#[test]
fn test_input_split_empty() {
assert_eq!(kitty_input_split_point(b""), 0);
}
#[test]
fn test_input_split_complete_query_response() {
assert_eq!(kitty_input_split_point(b"\x1b[?1u"), 5);
}
#[test]
fn test_input_split_incomplete() {
assert_eq!(kitty_input_split_point(b"\x1b[?"), 0);
assert_eq!(kitty_input_split_point(b"\x1b[?1"), 0);
}
#[test]
fn test_input_split_data_then_incomplete() {
let data = b"hello\x1b[?";
assert_eq!(kitty_input_split_point(data), 5);
}
#[test]
fn test_input_split_complete_then_incomplete() {
let data = b"\x1b[?1u\x1b[?";
assert_eq!(kitty_input_split_point(data), 5);
fn test_sequence_match_interleaved_typing() {
// User types "ab" then the lookback sequence
let sequence = &[0x1E];
assert_eq!(check_sequence(&[], b'a', sequence), SequenceMatch::None);
assert_eq!(check_sequence(b"a", b'b', sequence), SequenceMatch::None);
assert_eq!(
check_sequence(b"ab", 0x1E, sequence),
SequenceMatch::Complete
);
}
}