Fix Ghostty terminal support (#26)
This commit is contained in:
@@ -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"
|
||||
|
||||
580
crates/claude-chill/src/history_filter.rs
Normal file
580
crates/claude-chill/src/history_filter.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user