use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::OnceLock;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::terminal_state::{
ClipboardPolicy, KittyKbdFlags, MouseEncoding, MouseMode, MouseProtocol, Osc52Decision,
Osc52GetPolicy, Osc52SetPolicy, PaneTerminalState, ThemePalette,
};
static WAKE_TX: OnceLock<mpsc::Sender<()>> = OnceLock::new();
pub fn init_wake_channel() -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
let _ = WAKE_TX.set(tx);
rx
}
pub fn wake_main_loop() {
if let Some(tx) = WAKE_TX.get() {
let _ = tx.send(());
}
}
use std::path::PathBuf;
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneLaunch {
Shell,
Command(String),
}
const REPORTED_CWD_FRESH_FOR: Duration = Duration::from_secs(30);
pub struct Pane {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn Child + Send + Sync>,
reader_rx: Receiver<Vec<u8>>,
parser: vt100::Parser,
alive: bool,
launch: PaneLaunch,
scroll_offset: usize, name: Option<String>,
exit_code: Option<u32>,
pub osc52_pending: Vec<Vec<u8>>,
sync_depth: u16,
sync_opened_at: Option<Instant>,
state: PaneTerminalState,
osc_carry: Vec<u8>,
csi_carry: Vec<u8>,
initial_cwd: Option<PathBuf>,
initial_env: HashMap<String, String>,
initial_shell: Option<String>,
clipboard_policy: ClipboardPolicy,
theme_palette: ThemePalette,
}
impl Pane {
pub fn with_scrollback(
shell: &str,
launch: PaneLaunch,
cols: u16,
rows: u16,
scrollback: usize,
) -> anyhow::Result<Self> {
Self::spawn_inner(
shell,
launch,
cols,
rows,
scrollback,
None,
&std::collections::HashMap::new(),
)
}
#[allow(dead_code)]
pub fn with_cwd(
shell: &str,
launch: PaneLaunch,
cols: u16,
rows: u16,
scrollback: usize,
cwd: &std::path::Path,
) -> anyhow::Result<Self> {
Self::spawn_inner(
shell,
launch,
cols,
rows,
scrollback,
Some(cwd),
&std::collections::HashMap::new(),
)
}
pub fn with_full_config(
shell: &str,
launch: PaneLaunch,
cols: u16,
rows: u16,
scrollback: usize,
cwd: Option<&std::path::Path>,
env: &std::collections::HashMap<String, String>,
) -> anyhow::Result<Self> {
Self::spawn_inner(shell, launch, cols, rows, scrollback, cwd, env)
}
fn spawn_inner(
shell: &str,
launch: PaneLaunch,
cols: u16,
rows: u16,
scrollback: usize,
cwd: Option<&std::path::Path>,
env: &std::collections::HashMap<String, String>,
) -> anyhow::Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = CommandBuilder::new(shell);
if let PaneLaunch::Command(command) = &launch {
cmd.arg("-l");
cmd.arg("-c");
cmd.arg(command);
}
if let Some(dir) = cwd {
cmd.cwd(dir);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("EZPN", "1"); for (k, v) in env {
cmd.env(k, v);
}
let child = pair.slave.spawn_command(cmd)?;
drop(pair.slave);
let reader = pair.master.try_clone_reader()?;
let writer = pair.master.take_writer()?;
let (tx, rx) = mpsc::sync_channel(32); std::thread::spawn(move || {
let mut reader = reader;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
wake_main_loop();
}
Err(_) => break,
}
}
});
let parser = vt100::Parser::new(rows, cols, scrollback);
Ok(Self {
master: pair.master,
writer,
child,
reader_rx: rx,
parser,
alive: true,
launch,
scroll_offset: 0,
name: None,
exit_code: None,
osc52_pending: Vec::new(),
sync_depth: 0,
sync_opened_at: None,
state: PaneTerminalState::new(),
osc_carry: Vec::new(),
csi_carry: Vec::new(),
initial_cwd: cwd.map(|p| p.to_path_buf()),
initial_env: env.clone(),
initial_shell: None,
clipboard_policy: ClipboardPolicy::default(),
theme_palette: ThemePalette::default(),
})
}
pub fn set_clipboard_policy(&mut self, policy: ClipboardPolicy) {
self.clipboard_policy = policy;
}
#[allow(dead_code)]
pub fn set_theme_palette(&mut self, palette: ThemePalette) {
self.theme_palette = palette;
}
pub fn read_output(&mut self) -> bool {
const MAX_DRAIN: usize = 8; let was_alive = self.alive;
let mut got_data = false;
let mut count = 0;
loop {
if count >= MAX_DRAIN {
break;
}
match self.reader_rx.try_recv() {
Ok(data) => {
self.intercept(&data);
scan_sync_brackets(&data, &mut self.sync_depth, &mut self.sync_opened_at);
self.parser.process(&data);
if self.scroll_offset > 0 {
self.scroll_offset = 0;
}
got_data = true;
count += 1;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.alive = false;
break;
}
}
}
if self.alive {
if let Ok(Some(status)) = self.child.try_wait() {
self.exit_code = Some(status.exit_code());
self.alive = false;
}
}
if !self.alive && self.sync_depth > 0 {
tracing::warn!(
pane_sync_depth = self.sync_depth,
"DECSET 2026 sync window force-closed on pane EOF",
);
self.sync_depth = 0;
self.sync_opened_at = None;
}
got_data || was_alive != self.alive
}
pub fn in_sync(&self) -> bool {
self.sync_depth > 0
}
#[allow(dead_code)]
pub fn sync_opened_at(&self) -> Option<Instant> {
self.sync_opened_at
}
#[allow(dead_code)]
pub fn force_close_sync(&mut self) {
if self.sync_depth > 0 {
tracing::warn!(
pane_sync_depth = self.sync_depth,
"DECSET 2026 sync window force-closed by 33 ms timeout",
);
self.sync_depth = 0;
self.sync_opened_at = None;
}
}
pub fn write_key(&mut self, key: KeyEvent) {
let bytes = encode_key(key);
if !bytes.is_empty()
&& (self.writer.write_all(&bytes).is_err() || self.writer.flush().is_err())
{
self.alive = false;
}
}
pub fn write_bytes(&mut self, bytes: &[u8]) {
if self.writer.write_all(bytes).is_err() || self.writer.flush().is_err() {
self.alive = false;
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
if cols == 0 || rows == 0 {
return;
}
let result = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
if let Err(e) = &result {
eprintln!("ezpn: PTY resize failed: {e}");
}
self.parser.set_size(rows, cols);
#[cfg(unix)]
if self.alive {
if let Some(pid) = self.child.process_id() {
unsafe {
libc::kill(-(pid as libc::pid_t), libc::SIGWINCH);
}
}
}
}
pub fn screen(&self) -> &vt100::Screen {
self.parser.screen()
}
pub fn sync_scrollback(&mut self) {
self.parser.set_scrollback(self.scroll_offset);
}
pub fn reset_scrollback_view(&mut self) {
self.parser.set_scrollback(0);
}
pub fn is_alive(&self) -> bool {
self.alive
}
pub fn kill(&mut self) {
let _ = self.child.kill();
self.alive = false;
}
pub fn scroll_up(&mut self, lines: usize) {
let probe = self.scroll_offset + lines;
self.parser.set_scrollback(probe);
self.scroll_offset = self.parser.screen().scrollback();
self.parser.set_scrollback(0);
}
pub fn scroll_down(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
}
#[allow(dead_code)]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn is_scrolled(&self) -> bool {
self.scroll_offset > 0
}
pub fn snap_to_bottom(&mut self) {
self.scroll_offset = 0;
}
pub fn wants_mouse(&self) -> bool {
if !self.state.mouse_mode.is_off() {
return true;
}
self.parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None
}
pub fn send_mouse_event(&mut self, button: u16, col: u16, row: u16, release: bool) {
let use_sgr = match self.state.mouse_mode.encoding {
MouseEncoding::Sgr => true,
MouseEncoding::X10 => {
matches!(
self.parser.screen().mouse_protocol_encoding(),
vt100::MouseProtocolEncoding::Sgr
)
}
};
if use_sgr {
let end = if release { 'm' } else { 'M' };
let seq = format!("\x1b[<{};{};{}{}", button, col + 1, row + 1, end);
self.write_bytes(seq.as_bytes());
} else {
let b = if release { 3u8 } else { (button as u8) & 0x7f };
let b = b.wrapping_add(32);
let c = ((col + 1).min(222) as u8).wrapping_add(32);
let r = ((row + 1).min(222) as u8).wrapping_add(32);
self.write_bytes(&[0x1b, b'[', b'M', b, c, r]);
}
}
pub fn send_mouse_scroll(&mut self, up: bool, col: u16, row: u16) {
let button: u16 = if up { 64 } else { 65 };
self.send_mouse_event(button, col, row, false);
}
pub fn launch(&self) -> &PaneLaunch {
&self.launch
}
pub fn launch_label(&self, shell: &str) -> String {
if let Some(name) = &self.name {
return name.clone();
}
let osc_title = self.parser.screen().title();
if !osc_title.is_empty() {
return osc_title.to_string();
}
match &self.launch {
PaneLaunch::Shell => shell.to_string(),
PaneLaunch::Command(command) => command.clone(),
}
}
pub fn exit_code(&self) -> Option<u32> {
self.exit_code
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn set_name(&mut self, name: Option<String>) {
self.name = name;
}
pub fn take_osc52(&mut self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.osc52_pending)
}
pub fn bracketed_paste(&self) -> bool {
self.state.bracketed_paste
}
pub fn wants_focus(&self) -> bool {
self.state.focus_reporting
}
#[allow(dead_code)]
pub fn mouse_mode(&self) -> MouseMode {
self.state.mouse_mode
}
#[allow(dead_code)]
pub fn kitty_kbd_active(&self) -> KittyKbdFlags {
self.state.kitty_kbd.active()
}
#[allow(dead_code)]
pub fn osc52_decision(&self) -> Osc52Decision {
self.state.osc52_decision
}
#[allow(dead_code)]
pub fn set_osc52_decision(&mut self, decision: Osc52Decision) {
self.state.osc52_decision = decision;
}
#[allow(dead_code)]
pub fn take_osc52_pending_confirm(&mut self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.state.osc52_pending_confirm)
}
pub fn initial_cwd(&self) -> Option<&std::path::Path> {
self.initial_cwd.as_deref()
}
pub fn initial_env(&self) -> &HashMap<String, String> {
&self.initial_env
}
pub fn initial_shell(&self) -> Option<&str> {
self.initial_shell.as_deref()
}
pub fn set_initial_shell(&mut self, shell: Option<String>) {
self.initial_shell = shell;
}
#[allow(dead_code)]
pub fn reported_cwd(&self) -> Option<&std::path::Path> {
self.state
.reported_cwd
.as_ref()
.filter(|(_, ts)| ts.elapsed() < REPORTED_CWD_FRESH_FOR)
.map(|(p, _)| p.as_path())
}
pub fn live_cwd(&self) -> Option<PathBuf> {
if let Some(p) = self.reported_cwd() {
return Some(p.to_path_buf());
}
self.live_cwd_procfs()
}
#[cfg(target_os = "macos")]
fn live_cwd_procfs(&self) -> Option<PathBuf> {
if self.alive {
if let Some(pid) = self.child.process_id() {
let mut vinfo: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
let ret = unsafe {
libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDVNODEPATHINFO,
0,
&mut vinfo as *mut _ as *mut libc::c_void,
std::mem::size_of::<libc::proc_vnodepathinfo>() as libc::c_int,
)
};
if ret > 0 {
let cstr = unsafe {
std::ffi::CStr::from_ptr(vinfo.pvi_cdir.vip_path.as_ptr() as *const i8)
};
if let Ok(s) = cstr.to_str() {
if !s.is_empty() {
return Some(PathBuf::from(s));
}
}
}
}
}
self.initial_cwd.clone()
}
#[cfg(not(target_os = "macos"))]
fn live_cwd_procfs(&self) -> Option<PathBuf> {
if self.alive {
if let Some(pid) = self.child.process_id() {
let link = format!("/proc/{}/cwd", pid);
if let Ok(cwd) = std::fs::read_link(&link) {
return Some(cwd);
}
}
}
self.initial_cwd.clone()
}
fn intercept(&mut self, chunk: &[u8]) {
let mut ctx = InterceptCtx {
state: &mut self.state,
osc52_pending: &mut self.osc52_pending,
osc_carry: &mut self.osc_carry,
csi_carry: &mut self.csi_carry,
writer: &mut *self.writer,
policy: &self.clipboard_policy,
palette: &self.theme_palette,
};
intercept_chunk(&mut ctx, chunk);
}
}
struct InterceptCtx<'a> {
state: &'a mut PaneTerminalState,
osc52_pending: &'a mut Vec<Vec<u8>>,
osc_carry: &'a mut Vec<u8>,
csi_carry: &'a mut Vec<u8>,
writer: &'a mut dyn Write,
policy: &'a ClipboardPolicy,
palette: &'a ThemePalette,
}
fn intercept_chunk(ctx: &mut InterceptCtx<'_>, chunk: &[u8]) {
track_dec_modes(chunk, ctx.state);
let mut buf: Vec<u8> = Vec::with_capacity(ctx.osc_carry.len() + chunk.len());
buf.extend_from_slice(ctx.osc_carry);
buf.extend_from_slice(ctx.csi_carry);
buf.extend_from_slice(chunk);
ctx.osc_carry.clear();
ctx.csi_carry.clear();
let mut i = 0;
while i < buf.len() {
if buf[i] != 0x1b || i + 1 >= buf.len() {
i += 1;
continue;
}
match buf[i + 1] {
b']' => {
match scan_osc_terminator(&buf[i + 2..]) {
ScanOsc::Complete { end } => {
let payload = &buf[i + 2..i + 2 + end];
handle_osc(ctx, payload);
let term_len = if buf.get(i + 2 + end) == Some(&0x07) {
1
} else {
2
};
i += 2 + end + term_len;
continue;
}
ScanOsc::Incomplete => {
const OSC_CARRY_MAX: usize = 8192;
if buf.len() - i <= OSC_CARRY_MAX {
ctx.osc_carry.extend_from_slice(&buf[i..]);
}
return;
}
}
}
b'[' => match scan_csi_terminator(&buf[i + 2..]) {
ScanCsi::Complete { end, final_byte } => {
let body = &buf[i + 2..i + 2 + end];
if final_byte == b'u' {
handle_csi_u(ctx, body);
}
i += 2 + end + 1;
continue;
}
ScanCsi::Incomplete => {
const CSI_CARRY_MAX: usize = 256;
if buf.len() - i <= CSI_CARRY_MAX {
ctx.csi_carry.extend_from_slice(&buf[i..]);
}
return;
}
},
_ => {
i += 1;
}
}
}
}
fn handle_osc(ctx: &mut InterceptCtx<'_>, payload: &[u8]) {
if let Some(rest) = payload.strip_prefix(b"52;") {
handle_osc52(ctx, payload, rest);
return;
}
if let Some(rest) = payload.strip_prefix(b"7;") {
handle_osc7(ctx, rest);
return;
}
for (prefix, slot) in [
(&b"10;"[..], ColorSlot::Fg),
(&b"11;"[..], ColorSlot::Bg),
(&b"12;"[..], ColorSlot::Cursor),
] {
if let Some(rest) = payload.strip_prefix(prefix) {
handle_osc_color(ctx, slot, rest);
return;
}
}
if let Some(rest) = payload.strip_prefix(b"4;") {
handle_osc4(ctx, rest);
return;
}
}
fn handle_csi_u(ctx: &mut InterceptCtx<'_>, body: &[u8]) {
let Some((sigil, rest)) = body.split_first() else {
return;
};
match sigil {
b'>' => {
let flags = parse_u8(rest).unwrap_or(0);
ctx.state
.kitty_kbd
.push(KittyKbdFlags(flags & KittyKbdFlags::ALL));
}
b'<' => {
let n = parse_u8(rest).unwrap_or(0) as usize;
ctx.state.kitty_kbd.pop(n);
}
b'=' => {
let mut parts = rest.split(|&b| b == b';');
let flags = parts.next().and_then(parse_u8).unwrap_or(0);
let mode = parts.next().and_then(parse_u8).unwrap_or(1);
ctx.state
.kitty_kbd
.modify_top(KittyKbdFlags(flags & KittyKbdFlags::ALL), mode);
}
b'?' => {
let active = ctx.state.kitty_kbd.active().bits();
let reply = format!("\x1b[?{}u", active);
let _ = ctx.writer.write_all(reply.as_bytes());
let _ = ctx.writer.flush();
}
_ => {}
}
}
fn handle_osc52(ctx: &mut InterceptCtx<'_>, full_payload: &[u8], rest: &[u8]) {
let mut split = rest.splitn(2, |&b| b == b';');
let _selection = split.next().unwrap_or(&[]);
let data = split.next().unwrap_or(&[]);
if data == b"?" {
match ctx.policy.get {
Osc52GetPolicy::Allow => {
let mut env = Vec::with_capacity(full_payload.len() + 4);
env.extend_from_slice(b"\x1b]");
env.extend_from_slice(full_payload);
env.extend_from_slice(b"\x07");
ctx.osc52_pending.push(env);
}
Osc52GetPolicy::Deny => {
tracing::warn!(
target: "osc52",
"blocked OSC 52 clipboard read from pane (policy=deny)"
);
}
}
return;
}
if data.len() > ctx.policy.max_bytes {
tracing::warn!(
target: "osc52",
bytes = data.len(),
cap = ctx.policy.max_bytes,
"dropped oversized OSC 52 set"
);
return;
}
let effective = match ctx.state.osc52_decision {
Osc52Decision::Allowed => Osc52SetPolicy::Allow,
Osc52Decision::Denied => Osc52SetPolicy::Deny,
Osc52Decision::Pending => ctx.policy.set,
};
let mut env = Vec::with_capacity(full_payload.len() + 4);
env.extend_from_slice(b"\x1b]");
env.extend_from_slice(full_payload);
env.extend_from_slice(b"\x07");
match effective {
Osc52SetPolicy::Allow => ctx.osc52_pending.push(env),
Osc52SetPolicy::Deny => {
tracing::warn!(
target: "osc52",
bytes = data.len(),
"blocked OSC 52 clipboard set (policy=deny)"
);
}
Osc52SetPolicy::Confirm => {
const PENDING_QUEUE_MAX: usize = 8;
if ctx.state.osc52_pending_confirm.len() < PENDING_QUEUE_MAX {
ctx.state.osc52_pending_confirm.push(env);
} else {
tracing::warn!(
target: "osc52",
"dropped OSC 52 set: confirm queue full"
);
}
}
}
}
fn handle_osc7(ctx: &mut InterceptCtx<'_>, rest: &[u8]) {
let s = match std::str::from_utf8(rest) {
Ok(s) => s,
Err(_) => return,
};
let after_scheme = match s.strip_prefix("file://") {
Some(s) => s,
None => return,
};
let path_part = match after_scheme.find('/') {
Some(idx) => &after_scheme[idx..],
None => after_scheme,
};
let decoded = percent_decode(path_part);
if decoded.is_empty() {
return;
}
ctx.state.reported_cwd = Some((PathBuf::from(decoded), Instant::now()));
}
fn handle_osc_color(ctx: &mut InterceptCtx<'_>, slot: ColorSlot, rest: &[u8]) {
if rest != b"?" {
return;
}
if !ctx.palette.is_active() {
return;
}
let value = match slot {
ColorSlot::Fg => ctx.palette.fg,
ColorSlot::Bg => ctx.palette.bg,
ColorSlot::Cursor => ctx.palette.cursor,
};
let Some(rgb) = value else {
return;
};
let osc_num = match slot {
ColorSlot::Fg => 10,
ColorSlot::Bg => 11,
ColorSlot::Cursor => 12,
};
let reply = format!("\x1b]{};{}\x07", osc_num, rgb.to_xterm_rgb_str());
let _ = ctx.writer.write_all(reply.as_bytes());
let _ = ctx.writer.flush();
}
fn handle_osc4(ctx: &mut InterceptCtx<'_>, rest: &[u8]) {
let mut parts = rest.splitn(2, |&b| b == b';');
let idx_bytes = parts.next().unwrap_or(&[]);
let spec = parts.next().unwrap_or(&[]);
if spec != b"?" {
return;
}
if !ctx.palette.is_active() {
return;
}
let idx = match parse_u32(idx_bytes) {
Some(n) if n < 256 => n as usize,
_ => return,
};
let Some(rgb) = ctx.palette.palette[idx] else {
return;
};
let reply = format!("\x1b]4;{};{}\x07", idx, rgb.to_xterm_rgb_str());
let _ = ctx.writer.write_all(reply.as_bytes());
let _ = ctx.writer.flush();
}
#[derive(Clone, Copy)]
enum ColorSlot {
Fg,
Bg,
Cursor,
}
enum ScanOsc {
Complete { end: usize },
Incomplete,
}
fn scan_osc_terminator(tail: &[u8]) -> ScanOsc {
let mut i = 0;
while i < tail.len() {
if tail[i] == 0x07 {
return ScanOsc::Complete { end: i };
}
if tail[i] == 0x1b && i + 1 < tail.len() && tail[i + 1] == b'\\' {
return ScanOsc::Complete { end: i };
}
if tail[i] == 0x1b && i + 1 == tail.len() {
return ScanOsc::Incomplete;
}
i += 1;
}
ScanOsc::Incomplete
}
enum ScanCsi {
Complete { end: usize, final_byte: u8 },
Incomplete,
}
fn scan_csi_terminator(tail: &[u8]) -> ScanCsi {
for (i, &b) in tail.iter().enumerate() {
if (0x40..=0x7e).contains(&b) {
return ScanCsi::Complete {
end: i,
final_byte: b,
};
}
}
ScanCsi::Incomplete
}
fn parse_u8(s: &[u8]) -> Option<u8> {
parse_u32(s).and_then(|n| u8::try_from(n).ok())
}
fn parse_u32(s: &[u8]) -> Option<u32> {
if s.is_empty() {
return None;
}
let mut n: u32 = 0;
for &b in s {
if !b.is_ascii_digit() {
return None;
}
n = n.checked_mul(10)?.checked_add((b - b'0') as u32)?;
}
Some(n)
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = hex_val(bytes[i + 1]);
let lo = hex_val(bytes[i + 2]);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(h * 16 + l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn track_dec_modes(chunk: &[u8], state: &mut PaneTerminalState) {
let mut i = 0;
while i + 3 < chunk.len() {
if chunk[i] != 0x1b || chunk[i + 1] != b'[' || chunk[i + 2] != b'?' {
i += 1;
continue;
}
let body_start = i + 3;
let mut j = body_start;
while j < chunk.len() && chunk[j] != b'h' && chunk[j] != b'l' {
j += 1;
}
if j == chunk.len() {
return; }
let on = chunk[j] == b'h';
let body = &chunk[body_start..j];
for num_bytes in body.split(|&b| b == b';') {
if let Some(num) = parse_u32(num_bytes) {
apply_decset(state, num, on);
}
}
i = j + 1;
}
}
fn apply_decset(state: &mut PaneTerminalState, num: u32, on: bool) {
match num {
2004 => state.bracketed_paste = on,
1004 => state.focus_reporting = on,
1000 => {
if on {
state.mouse_mode.protocol = MouseProtocol::X10;
} else if state.mouse_mode.protocol == MouseProtocol::X10 {
state.mouse_mode.protocol = MouseProtocol::Off;
}
}
1002 => {
if on {
state.mouse_mode.protocol = MouseProtocol::Btn;
} else if state.mouse_mode.protocol == MouseProtocol::Btn {
state.mouse_mode.protocol = MouseProtocol::Off;
}
}
1003 => {
if on {
state.mouse_mode.protocol = MouseProtocol::Any;
} else if state.mouse_mode.protocol == MouseProtocol::Any {
state.mouse_mode.protocol = MouseProtocol::Off;
}
}
1006 => {
state.mouse_mode.encoding = if on {
MouseEncoding::Sgr
} else {
MouseEncoding::X10
};
}
_ => {}
}
}
fn encode_key(key: KeyEvent) -> Vec<u8> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let mods_param =
1 + if shift { 1 } else { 0 } + if alt { 2 } else { 0 } + if ctrl { 4 } else { 0 };
let has_mods = shift || alt || ctrl;
match key.code {
KeyCode::Char(c) if ctrl && !shift && !alt => match c {
' ' => vec![0x00], '[' => vec![0x1b], '\\' => vec![0x1c], ']' => vec![0x1d], '^' => vec![0x1e], '_' => vec![0x1f], 'a'..='z' => vec![c as u8 - b'a' + 1],
_ => vec![(c.to_ascii_lowercase() as u8)
.wrapping_sub(b'a')
.wrapping_add(1)],
},
KeyCode::Char(c) if ctrl && alt => {
let byte = match c {
' ' => 0x00,
'[' => 0x1b,
'\\' => 0x1c,
']' => 0x1d,
'^' => 0x1e,
'_' => 0x1f,
'a'..='z' => c as u8 - b'a' + 1,
_ => (c.to_ascii_lowercase() as u8)
.wrapping_sub(b'a')
.wrapping_add(1),
};
vec![0x1b, byte]
}
KeyCode::Char(c) => {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
if alt && !shift {
let mut v = vec![0x1b];
v.extend_from_slice(s.as_bytes());
v
} else if shift && (alt || ctrl) {
format!("\x1b[{};{}u", c as u32, mods_param).into_bytes()
} else {
s.as_bytes().to_vec()
}
}
KeyCode::Enter => {
if shift {
format!("\x1b[13;{}u", mods_param).into_bytes()
} else if alt {
vec![0x1b, b'\r'] } else if ctrl {
format!("\x1b[13;{}u", mods_param).into_bytes()
} else {
vec![b'\r']
}
}
KeyCode::Backspace => {
if alt && !ctrl && !shift {
vec![0x1b, 0x7f] } else if ctrl && !alt && !shift {
vec![0x08] } else if has_mods {
format!("\x1b[127;{}u", mods_param).into_bytes()
} else {
vec![0x7f]
}
}
KeyCode::Tab => {
if shift && !alt && !ctrl {
vec![0x1b, b'[', b'Z'] } else if has_mods {
format!("\x1b[9;{}u", mods_param).into_bytes()
} else {
vec![b'\t']
}
}
KeyCode::Esc => {
if has_mods {
format!("\x1b[27;{}u", mods_param).into_bytes()
} else {
vec![0x1b]
}
}
KeyCode::Up => arrow_with_mods(b'A', has_mods, mods_param),
KeyCode::Down => arrow_with_mods(b'B', has_mods, mods_param),
KeyCode::Right => arrow_with_mods(b'C', has_mods, mods_param),
KeyCode::Left => arrow_with_mods(b'D', has_mods, mods_param),
KeyCode::Home => {
if has_mods {
format!("\x1b[1;{}H", mods_param).into_bytes()
} else {
vec![0x1b, b'[', b'H']
}
}
KeyCode::End => {
if has_mods {
format!("\x1b[1;{}F", mods_param).into_bytes()
} else {
vec![0x1b, b'[', b'F']
}
}
KeyCode::Delete => tilde_with_mods(3, has_mods, mods_param),
KeyCode::PageUp => tilde_with_mods(5, has_mods, mods_param),
KeyCode::PageDown => tilde_with_mods(6, has_mods, mods_param),
KeyCode::Insert => tilde_with_mods(2, has_mods, mods_param),
KeyCode::F(n) => encode_f_key_with_mods(n, has_mods, mods_param),
_ => vec![],
}
}
fn arrow_with_mods(code: u8, has_mods: bool, mods_param: u8) -> Vec<u8> {
if has_mods {
format!("\x1b[1;{}{}", mods_param, code as char).into_bytes()
} else {
vec![0x1b, b'[', code]
}
}
fn tilde_with_mods(n: u8, has_mods: bool, mods_param: u8) -> Vec<u8> {
if has_mods {
format!("\x1b[{};{}~", n, mods_param).into_bytes()
} else {
format!("\x1b[{}~", n).into_bytes()
}
}
fn encode_f_key_with_mods(n: u8, has_mods: bool, mods_param: u8) -> Vec<u8> {
if has_mods {
let code = match n {
1 => 11,
2 => 12,
3 => 13,
4 => 14,
5 => 15,
6 => 17,
7 => 18,
8 => 19,
9 => 20,
10 => 21,
11 => 23,
12 => 24,
_ => return vec![],
};
format!("\x1b[{};{}~", code, mods_param).into_bytes()
} else {
match n {
1 => b"\x1bOP".to_vec(),
2 => b"\x1bOQ".to_vec(),
3 => b"\x1bOR".to_vec(),
4 => b"\x1bOS".to_vec(),
5 => b"\x1b[15~".to_vec(),
6 => b"\x1b[17~".to_vec(),
7 => b"\x1b[18~".to_vec(),
8 => b"\x1b[19~".to_vec(),
9 => b"\x1b[20~".to_vec(),
10 => b"\x1b[21~".to_vec(),
11 => b"\x1b[23~".to_vec(),
12 => b"\x1b[24~".to_vec(),
_ => vec![],
}
}
}
fn scan_sync_brackets(data: &[u8], depth: &mut u16, opened_at: &mut Option<Instant>) {
const SYNC_OPEN: &[u8] = b"\x1b[?2026h";
const SYNC_CLOSE: &[u8] = b"\x1b[?2026l";
debug_assert_eq!(SYNC_OPEN.len(), SYNC_CLOSE.len());
let n = SYNC_OPEN.len();
if data.len() < n {
return;
}
for window in data.windows(n) {
if window == SYNC_OPEN {
if *depth == 0 {
*opened_at = Some(Instant::now());
}
*depth = depth.saturating_add(1);
} else if window == SYNC_CLOSE {
*depth = depth.saturating_sub(1);
if *depth == 0 {
*opened_at = None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal_state::Rgb;
struct VecWriter(Vec<u8>);
impl Write for VecWriter {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn run_intercept(
chunk: &[u8],
state: &mut PaneTerminalState,
osc52_pending: &mut Vec<Vec<u8>>,
policy: &ClipboardPolicy,
palette: &ThemePalette,
) -> Vec<u8> {
let mut writer = VecWriter(Vec::new());
let mut osc_carry = Vec::new();
let mut csi_carry = Vec::new();
{
let mut ctx = InterceptCtx {
state,
osc52_pending,
osc_carry: &mut osc_carry,
csi_carry: &mut csi_carry,
writer: &mut writer,
policy,
palette,
};
intercept_chunk(&mut ctx, chunk);
}
writer.0
}
#[test]
fn track_dec_modes_bracketed_paste() {
let mut s = PaneTerminalState::new();
track_dec_modes(b"\x1b[?2004h", &mut s);
assert!(s.bracketed_paste);
track_dec_modes(b"\x1b[?2004l", &mut s);
assert!(!s.bracketed_paste);
}
#[test]
fn track_dec_modes_combined_modes() {
let mut s = PaneTerminalState::new();
track_dec_modes(b"\x1b[?1000;1006h", &mut s);
assert_eq!(s.mouse_mode.protocol, MouseProtocol::X10);
assert_eq!(s.mouse_mode.encoding, MouseEncoding::Sgr);
track_dec_modes(b"\x1b[?1000;1006l", &mut s);
assert!(s.mouse_mode.is_off());
assert_eq!(s.mouse_mode.encoding, MouseEncoding::X10);
}
#[test]
fn track_dec_modes_focus_events() {
let mut s = PaneTerminalState::new();
track_dec_modes(b"\x1b[?1004h", &mut s);
assert!(s.focus_reporting);
track_dec_modes(b"\x1b[?1004l", &mut s);
assert!(!s.focus_reporting);
}
#[test]
fn track_dec_modes_mouse_protocol_progression() {
let mut s = PaneTerminalState::new();
track_dec_modes(b"\x1b[?1000h", &mut s);
assert_eq!(s.mouse_mode.protocol, MouseProtocol::X10);
track_dec_modes(b"\x1b[?1002h", &mut s);
assert_eq!(s.mouse_mode.protocol, MouseProtocol::Btn);
track_dec_modes(b"\x1b[?1003h", &mut s);
assert_eq!(s.mouse_mode.protocol, MouseProtocol::Any);
track_dec_modes(b"\x1b[?1003l", &mut s);
assert!(s.mouse_mode.is_off());
}
#[test]
fn track_dec_modes_disable_inactive_protocol_no_op() {
let mut s = PaneTerminalState::new();
track_dec_modes(b"\x1b[?1003h", &mut s);
assert_eq!(s.mouse_mode.protocol, MouseProtocol::Any);
track_dec_modes(b"\x1b[?1000l", &mut s);
assert_eq!(
s.mouse_mode.protocol,
MouseProtocol::Any,
"disabling X10 must not affect active Any protocol"
);
}
#[test]
fn percent_decode_basic() {
assert_eq!(percent_decode("/tmp"), "/tmp");
assert_eq!(percent_decode("/path%20with%20spaces"), "/path with spaces");
assert_eq!(percent_decode("%2Fweird%2Fpath"), "/weird/path");
assert_eq!(percent_decode("%ZZ"), "%ZZ");
}
#[test]
fn parse_helpers() {
assert_eq!(parse_u8(b"42"), Some(42));
assert_eq!(parse_u8(b""), None);
assert_eq!(parse_u8(b"abc"), None);
assert_eq!(parse_u8(b"300"), None); assert_eq!(parse_u32(b"1024"), Some(1024));
}
#[test]
fn scan_csi_finds_final_byte() {
match scan_csi_terminator(b"5u") {
ScanCsi::Complete { end, final_byte } => {
assert_eq!(end, 1);
assert_eq!(final_byte, b'u');
}
ScanCsi::Incomplete => panic!("should be complete"),
}
}
#[test]
fn scan_osc_terminators() {
match scan_osc_terminator(b"7;file:///tmp\x07") {
ScanOsc::Complete { end } => assert_eq!(end, b"7;file:///tmp".len()),
ScanOsc::Incomplete => panic!("BEL terminator missed"),
}
match scan_osc_terminator(b"7;file:///tmp\x1b\\") {
ScanOsc::Complete { end } => assert_eq!(end, b"7;file:///tmp".len()),
ScanOsc::Incomplete => panic!("ST terminator missed"),
}
match scan_osc_terminator(b"7;file:///tmp") {
ScanOsc::Incomplete => {}
ScanOsc::Complete { .. } => panic!("should be incomplete"),
}
}
#[test]
fn intercept_kitty_push_then_query_replies_with_top() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
run_intercept(b"\x1b[>5u", &mut state, &mut pending, &policy, &palette);
assert_eq!(state.kitty_kbd.active().bits(), 5);
let reply = run_intercept(b"\x1b[?u", &mut state, &mut pending, &policy, &palette);
assert_eq!(reply, b"\x1b[?5u");
}
#[test]
fn intercept_kitty_push_pop_modify_sequence() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
run_intercept(
b"\x1b[>1u\x1b[>15u\x1b[=4;3u",
&mut state,
&mut pending,
&policy,
&palette,
);
assert_eq!(state.kitty_kbd.active().bits(), 11);
assert_eq!(state.kitty_kbd.depth(), 2);
run_intercept(b"\x1b[<1u", &mut state, &mut pending, &policy, &palette);
assert_eq!(state.kitty_kbd.active().bits(), 1);
run_intercept(b"\x1b[<5u", &mut state, &mut pending, &policy, &palette);
assert_eq!(state.kitty_kbd.depth(), 0);
assert_eq!(state.kitty_kbd.active().bits(), 0);
}
#[test]
fn intercept_osc7_decodes_simple_path() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
run_intercept(
b"\x1b]7;file:///tmp\x1b\\",
&mut state,
&mut pending,
&policy,
&palette,
);
let (path, _ts) = state.reported_cwd.as_ref().expect("OSC 7 not captured");
assert_eq!(path.as_path(), std::path::Path::new("/tmp"));
}
#[test]
fn intercept_osc7_decodes_percent_escapes_and_host() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
run_intercept(
b"\x1b]7;file://hostname/home/u%20ser/pkg\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
let (path, _ts) = state.reported_cwd.as_ref().unwrap();
assert_eq!(path.as_path(), std::path::Path::new("/home/u ser/pkg"));
}
#[test]
fn intercept_osc8_does_not_consume_or_inject() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
let reply = run_intercept(
b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(reply.is_empty(), "OSC 8 must not produce a writer reply");
assert!(pending.is_empty(), "OSC 8 must not enqueue anything");
assert!(state.reported_cwd.is_none());
}
#[test]
fn intercept_osc11_query_returns_theme_bg() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let mut palette = ThemePalette::default();
palette.bg = Some(Rgb::new(0x1e, 0x1e, 0x2e));
let reply = run_intercept(
b"\x1b]11;?\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert_eq!(reply, b"\x1b]11;rgb:1e1e/1e1e/2e2e\x07");
}
#[test]
fn intercept_osc11_query_passes_through_when_no_theme() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
let reply = run_intercept(
b"\x1b]11;?\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(reply.is_empty(), "no theme → no multiplexer-side reply");
}
#[test]
fn intercept_osc4_palette_query() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let mut palette = ThemePalette::default();
palette.palette[42] = Some(Rgb::new(0x12, 0x34, 0x56));
let reply = run_intercept(
b"\x1b]4;42;?\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert_eq!(reply, b"\x1b]4;42;rgb:1212/3434/5656\x07");
}
#[test]
fn intercept_osc4_unknown_index_silent() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let mut palette = ThemePalette::default();
palette.fg = Some(Rgb::new(0xff, 0xff, 0xff));
let reply = run_intercept(
b"\x1b]4;99;?\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(reply.is_empty());
}
#[test]
fn intercept_chunk_boundary_split_osc52() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy {
set: Osc52SetPolicy::Allow,
..ClipboardPolicy::default()
};
let palette = ThemePalette::default();
let mut writer = VecWriter(Vec::new());
let mut osc_carry = Vec::new();
let mut csi_carry = Vec::new();
{
let mut ctx = InterceptCtx {
state: &mut state,
osc52_pending: &mut pending,
osc_carry: &mut osc_carry,
csi_carry: &mut csi_carry,
writer: &mut writer,
policy: &policy,
palette: &palette,
};
intercept_chunk(&mut ctx, b"\x1b]52;c;");
assert!(ctx.osc52_pending.is_empty());
intercept_chunk(&mut ctx, b"aGVsbG8=\x07");
}
assert_eq!(pending.len(), 1);
assert_eq!(&pending[0], b"\x1b]52;c;aGVsbG8=\x07");
}
#[test]
fn intercept_osc52_set_allow_pushes_envelope() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy {
set: Osc52SetPolicy::Allow,
..ClipboardPolicy::default()
};
let palette = ThemePalette::default();
run_intercept(
b"\x1b]52;c;aGVsbG8=\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert_eq!(pending.len(), 1);
assert!(state.osc52_pending_confirm.is_empty());
}
#[test]
fn intercept_osc52_set_deny_drops_silently() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy {
set: Osc52SetPolicy::Deny,
..ClipboardPolicy::default()
};
let palette = ThemePalette::default();
run_intercept(
b"\x1b]52;c;aGVsbG8=\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(pending.is_empty());
assert!(state.osc52_pending_confirm.is_empty());
}
#[test]
fn intercept_osc52_set_confirm_parks_payload() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default(); let palette = ThemePalette::default();
run_intercept(
b"\x1b]52;c;aGVsbG8=\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(pending.is_empty(), "confirm policy must not auto-forward");
assert_eq!(state.osc52_pending_confirm.len(), 1);
}
#[test]
fn intercept_osc52_set_oversized_dropped() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy {
set: Osc52SetPolicy::Allow,
max_bytes: 16,
..ClipboardPolicy::default()
};
let palette = ThemePalette::default();
let blob = vec![b'A'; 32];
let mut seq = b"\x1b]52;c;".to_vec();
seq.extend_from_slice(&blob);
seq.push(0x07);
run_intercept(&seq, &mut state, &mut pending, &policy, &palette);
assert!(pending.is_empty(), "oversized OSC 52 must be dropped");
}
#[test]
fn intercept_osc52_get_default_denies() {
let mut state = PaneTerminalState::new();
let mut pending = Vec::new();
let policy = ClipboardPolicy::default();
let palette = ThemePalette::default();
run_intercept(
b"\x1b]52;c;?\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(pending.is_empty(), "OSC 52 read must be denied by default");
}
#[test]
fn intercept_osc52_per_pane_decision_overrides_confirm() {
let mut state = PaneTerminalState::new();
state.osc52_decision = Osc52Decision::Allowed;
let mut pending = Vec::new();
let policy = ClipboardPolicy::default(); let palette = ThemePalette::default();
run_intercept(
b"\x1b]52;c;aGVsbG8=\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert_eq!(pending.len(), 1);
assert!(state.osc52_pending_confirm.is_empty());
}
#[test]
fn intercept_osc52_per_pane_decision_denied_blocks() {
let mut state = PaneTerminalState::new();
state.osc52_decision = Osc52Decision::Denied;
let mut pending = Vec::new();
let policy = ClipboardPolicy {
set: Osc52SetPolicy::Allow,
..ClipboardPolicy::default()
};
let palette = ThemePalette::default();
run_intercept(
b"\x1b]52;c;aGVsbG8=\x07",
&mut state,
&mut pending,
&policy,
&palette,
);
assert!(pending.is_empty());
}
}