use std::fs;
use std::io::{self, Write};
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use embers_client::host_terminal::{MAX_HOST_REPLY_BYTES, ends_with_device_attributes};
use embers_client::{
ConfigManager, ConfiguredClient, HOST_QUERIES, HostCapabilities, HostEnvironment, HostReplies,
KeyCode, KeyEvent, LayoutOptions, Modifiers, MouseButton, MouseEvent, MouseEventKind,
MouseModifiers, MuxClient, PanePlacement, PendingAttachQueries, RenderGrid, SocketTransport,
detect_host_capabilities, spawn_pty_size_with,
};
use embers_core::{CellSize, CursorShape, MuxError, PtySize, Result, SessionId, Size};
use embers_protocol::{BufferRequest, ClientMessage, ServerEvent, ServerResponse, SessionRequest};
use tokio::sync::mpsc;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const DEFAULT_SESSION_NAME: &str = "main";
const KEY_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(15);
const KEY_SEQUENCE_CONTINUATION_TIMEOUT: Duration = Duration::from_millis(2);
const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(20);
const CONFIG_WATCH_POLL_INTERVAL: Duration = Duration::from_millis(250);
const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
const TERMINAL_ENTER_BASE_SEQUENCE: &str =
"\x1b[?1049h\x1b[?1004h\x1b[?2004h\x1b[?25l\x1b[2J\x1b[H";
const TERMINAL_ENABLE_MOUSE_SEQUENCE: &str = "\x1b[?1002h\x1b[?1006h";
const TERMINAL_DISABLE_MOUSE_SEQUENCE: &str = "\x1b[?1006l\x1b[?1002l";
pub async fn run(
socket_path: PathBuf,
target: Option<String>,
config_path: Option<PathBuf>,
) -> Result<()> {
let transport = SocketTransport::connect(&socket_path).await?;
let server_shutdown = transport.server_shutdown_flag();
let result = run_attached(MuxClient::new(transport), socket_path, target, config_path).await;
if server_shutdown.load(std::sync::atomic::Ordering::Acquire) {
println!("[server exited]");
return Ok(());
}
result
}
async fn run_attached(
client: MuxClient<SocketTransport>,
socket_path: PathBuf,
target: Option<String>,
config_path: Option<PathBuf>,
) -> Result<()> {
let attached_client_id = client.current_client().await?.id;
client.subscribe(None).await?;
let requested_target = target;
let config = ConfigManager::from_process(config_path)
.map_err(|error| MuxError::invalid_input(error.to_string()))?;
let mut configured = ConfiguredClient::new(client, config);
let initial_viewport = terminal_size(io::stdout().as_raw_fd())
.ok()
.map(|size| configured.content_viewport(size));
let layout = configured.layout_options();
let initial_session_id = ensure_session_ready(
configured.client_mut(),
requested_target.as_deref(),
initial_viewport,
layout,
)
.await?;
configured
.client_mut()
.switch_current_session(initial_session_id)
.await?;
let mut session_id = Some(initial_session_id);
let watched_config_path = configured.config().active_source().path.clone();
configured.set_socket_path(socket_path.clone());
configured.set_command_runner(std::sync::Arc::new(crate::PromptCommandRunner::new(
socket_path.clone(),
)));
if let Some(session_id) = session_id {
configured.emit_terminal_title(session_id);
}
let (mut terminal, host_replies) = TerminalGuard::enter(mouse_capture_enabled(&configured))?;
configured.set_host_capabilities(detect_host_capabilities(
&host_replies,
&HostEnvironment::from_process(),
));
configured.set_pending_attach_queries(PendingAttachQueries::after(
&host_replies,
std::time::Instant::now() + LATE_HOST_REPLY_WINDOW,
));
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
let _input_thread = spawn_input_thread(input_tx.clone())?;
let _config_thread = spawn_config_thread(watched_config_path, input_tx)?;
let mut terminal_size = terminal.size()?;
let mut cell_size = host_cell_size(io::stdout().as_raw_fd());
let mut dirty = true;
loop {
if dirty {
terminal.sync_mouse_capture(mouse_capture_enabled(&configured))?;
terminal_size = terminal.size()?;
let viewport = configured.content_viewport(terminal_size);
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
if let Some(active_session_id) = session_id
&& !configured
.client()
.state()
.sessions
.contains_key(&active_session_id)
{
session_id = None;
}
if let Some(active_session_id) = session_id {
cell_size = host_cell_size(io::stdout().as_raw_fd());
configured.set_host_cell_size(cell_size);
configured
.sync_pty_sizes(active_session_id, viewport)
.await?;
configured.sync_host_graphics().await;
let grid = configured
.render_session(active_session_id, viewport)
.await?;
let status = configured.status_line(active_session_id, &socket_path);
terminal.render(
&grid,
terminal_size,
Some(&status),
configured.status_cursor(),
configured.host_capabilities(),
&configured.drain_frame_output(),
)?;
} else {
let grid =
RenderGrid::new(terminal_size.width, terminal_size.height.saturating_sub(1));
terminal.render(
&grid,
terminal_size,
Some("detached"),
None,
configured.host_capabilities(),
&[],
)?;
}
dirty = false;
}
loop {
match input_rx.try_recv() {
Ok(TerminalEvent::Key(key)) if is_quit_key(&key) => return Ok(()),
Ok(TerminalEvent::Key(key)) => {
if let Some(active_session_id) = session_id {
let viewport = configured.content_viewport(terminal_size);
configured
.handle_key(active_session_id, viewport, key)
.await?;
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
}
Ok(TerminalEvent::Paste(bytes)) => {
if let Some(active_session_id) = session_id {
let viewport = configured.content_viewport(terminal_size);
configured
.handle_paste(active_session_id, viewport, bytes)
.await?;
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
}
Ok(TerminalEvent::Focus(focused)) => {
if let Some(active_session_id) = session_id {
let viewport = configured.content_viewport(terminal_size);
configured
.handle_focus_event(active_session_id, viewport, focused)
.await?;
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
}
Ok(TerminalEvent::HostReply(bytes)) => {
if let Some(active_session_id) = session_id {
let viewport = configured.content_viewport(terminal_size);
configured
.handle_host_reply(active_session_id, viewport, bytes)
.await?;
dirty = true;
}
}
Ok(TerminalEvent::Mouse(mouse)) => {
if let Some(active_session_id) = session_id {
let viewport = configured.content_viewport(terminal_size);
configured
.handle_mouse(active_session_id, viewport, mouse)
.await?;
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
}
Ok(TerminalEvent::ConfigChanged) => match configured.reload_config_if_changed() {
Ok(true) => {
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
Ok(false) => {}
Err(_) => {
dirty = true;
}
},
Ok(TerminalEvent::Ignored) => {}
Ok(TerminalEvent::InputClosed) => return Ok(()),
Ok(TerminalEvent::InputError(message)) => {
return Err(MuxError::transport(message));
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()),
}
if let Some(active_session_id) = moved_to_session(&configured, session_id) {
ensure_shown_root_window(&mut configured, active_session_id, terminal_size).await?;
session_id = Some(active_session_id);
dirty = true;
}
}
let next_size = terminal.size()?;
let next_cell_size = host_cell_size(io::stdout().as_raw_fd());
if next_size != terminal_size || next_cell_size != cell_size {
terminal_size = next_size;
cell_size = next_cell_size;
dirty = true;
continue;
}
let events = configured
.process_ready_events_timeout(EVENT_POLL_INTERVAL)
.await?;
if events.is_empty() {
if configured.drain_background_notifications() {
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
if configured.expire_status_message() {
dirty = true;
}
continue;
}
for event in &events {
match switched_session_id(event, attached_client_id) {
SwitchedSession::Switched(next_session_id)
if configured
.client()
.state()
.sessions
.contains_key(&next_session_id) =>
{
ensure_shown_root_window(&mut configured, next_session_id, terminal_size)
.await?;
session_id = Some(next_session_id);
}
SwitchedSession::Detached => {
session_id = None;
}
SwitchedSession::Switched(_) | SwitchedSession::Ignore => {}
}
}
if configured.should_exit() {
return Ok(());
}
if let Some(active_session_id) = moved_to_session(&configured, session_id) {
ensure_shown_root_window(&mut configured, active_session_id, terminal_size).await?;
session_id = Some(active_session_id);
}
configured.drain_background_notifications();
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
}
fn moved_to_session(
configured: &ConfiguredClient<SocketTransport>,
shown: Option<SessionId>,
) -> Option<SessionId> {
configured.active_session_id().filter(|session_id| {
Some(*session_id) != shown
&& configured
.client()
.state()
.sessions
.contains_key(session_id)
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SwitchedSession {
Ignore,
Detached,
Switched(SessionId),
}
fn switched_session_id(event: &ServerEvent, attached_client_id: u64) -> SwitchedSession {
match event {
ServerEvent::ClientChanged(event) if event.client.id == attached_client_id => {
match event.client.current_session_id {
Some(session_id) => SwitchedSession::Switched(session_id),
None => SwitchedSession::Detached,
}
}
_ => SwitchedSession::Ignore,
}
}
#[cfg(test)]
mod client_switch_tests {
use super::{SwitchedSession, switched_session_id};
use embers_core::SessionId;
use embers_protocol::{ClientChangedEvent, ClientRecord, ServerEvent};
#[test]
fn switched_session_id_uses_attached_client_only() {
let event = ServerEvent::ClientChanged(ClientChangedEvent {
client: ClientRecord {
id: 7,
current_session_id: Some(SessionId(3)),
subscribed_all_sessions: true,
subscribed_session_ids: vec![],
},
previous_session_id: Some(SessionId(2)),
});
assert_eq!(
switched_session_id(&event, 7),
SwitchedSession::Switched(SessionId(3))
);
assert_eq!(switched_session_id(&event, 9), SwitchedSession::Ignore);
}
#[test]
fn switched_session_id_reports_detach_for_attached_client() {
let event = ServerEvent::ClientChanged(ClientChangedEvent {
client: ClientRecord {
id: 7,
current_session_id: None,
subscribed_all_sessions: true,
subscribed_session_ids: vec![],
},
previous_session_id: Some(SessionId(2)),
});
assert_eq!(switched_session_id(&event, 7), SwitchedSession::Detached);
}
}
async fn ensure_session_ready(
client: &mut MuxClient<SocketTransport>,
target: Option<&str>,
viewport: Option<Size>,
layout: LayoutOptions,
) -> Result<SessionId> {
client.resync_all_sessions().await?;
let session_id = match select_session_id(client, target)? {
Some(session_id) => session_id,
None => create_session(client, target.unwrap_or(DEFAULT_SESSION_NAME)).await?,
};
ensure_root_window(client, session_id, viewport, layout).await?;
client.resync_session(session_id).await?;
Ok(session_id)
}
fn select_session_id(
client: &MuxClient<SocketTransport>,
target: Option<&str>,
) -> Result<Option<SessionId>> {
if client.state().sessions.is_empty() {
return Ok(None);
}
if let Some(target) = target {
return client
.state()
.sessions
.values()
.find(|session| session.name == target)
.map(|session| Some(session.id))
.ok_or_else(|| MuxError::not_found(format!("session '{target}' was not found")));
}
Ok(client
.state()
.sessions
.values()
.max_by_key(|session| session.id.0)
.map(|session| session.id))
}
async fn create_session(client: &mut MuxClient<SocketTransport>, name: &str) -> Result<SessionId> {
let response = client
.request_message(ClientMessage::Session(SessionRequest::Create {
request_id: client.next_request_id(),
name: name.to_owned(),
}))
.await?;
match response {
ServerResponse::SessionSnapshot(response) => {
let session_id = response.snapshot.session.id;
client.state_mut().apply_session_snapshot(response.snapshot);
Ok(session_id)
}
other => Err(MuxError::protocol(format!(
"expected session snapshot response, got {other:?}"
))),
}
}
async fn ensure_shown_root_window(
configured: &mut ConfiguredClient<SocketTransport>,
session_id: SessionId,
terminal: Size,
) -> Result<()> {
let viewport = configured.content_viewport(terminal);
let layout = configured.layout_options();
ensure_root_window(configured.client_mut(), session_id, Some(viewport), layout).await
}
async fn ensure_root_window(
client: &mut MuxClient<SocketTransport>,
session_id: SessionId,
viewport: Option<Size>,
layout: LayoutOptions,
) -> Result<()> {
client.resync_session(session_id).await?;
if session_has_root_window(client, session_id)? {
return Ok(());
}
let command = default_shell_command();
let title = default_title(&command, "shell");
let size = viewport.and_then(|viewport| {
spawn_pty_size_with(
client.state(),
session_id,
viewport,
layout,
PanePlacement::RootTab,
)
});
let size = size.map(|size| size.with_cell_size(host_cell_size(io::stdout().as_raw_fd())));
let buffer_id = create_buffer(client, &command, &title, size, session_id).await?;
let response = client
.request_message(ClientMessage::Session(SessionRequest::AddRootTab {
request_id: client.next_request_id(),
session_id,
title,
buffer_id: Some(buffer_id),
child_node_id: None,
}))
.await?;
match response {
ServerResponse::SessionSnapshot(response) => {
client.state_mut().apply_session_snapshot(response.snapshot);
Ok(())
}
other => Err(MuxError::protocol(format!(
"expected session snapshot response, got {other:?}"
))),
}
}
fn session_has_root_window(
client: &MuxClient<SocketTransport>,
session_id: SessionId,
) -> Result<bool> {
let session = client
.state()
.sessions
.get(&session_id)
.ok_or_else(|| MuxError::not_found(format!("session {session_id} is not cached")))?;
let root = client
.state()
.nodes
.get(&session.root_node_id)
.ok_or_else(|| {
MuxError::not_found(format!("node {} is not cached", session.root_node_id))
})?;
let tabs = root.tabs.as_ref();
Ok(tabs.is_none_or(|tabs| !tabs.tabs.is_empty()))
}
async fn create_buffer(
client: &mut MuxClient<SocketTransport>,
command: &[String],
title: &str,
size: Option<PtySize>,
session_id: SessionId,
) -> Result<embers_core::BufferId> {
let response = client
.request_message(ClientMessage::Buffer(BufferRequest::Create {
request_id: client.next_request_id(),
title: Some(title.to_owned()),
command: command.to_vec(),
cwd: None,
env: Default::default(),
size,
session_id: Some(session_id),
}))
.await?;
match response {
ServerResponse::Buffer(response) => Ok(response.buffer.id),
other => Err(MuxError::protocol(format!(
"expected buffer response, got {other:?}"
))),
}
}
fn default_shell_command() -> Vec<String> {
vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())]
}
fn default_title(command: &[String], fallback: &str) -> String {
command
.first()
.and_then(|value| {
Path::new(value)
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
})
.unwrap_or_else(|| fallback.to_owned())
}
fn drain_terminal_output(configured: &mut ConfiguredClient<SocketTransport>) -> Vec<u8> {
configured
.drain_terminal_output()
.into_iter()
.flatten()
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum TerminalEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(Vec<u8>),
Focus(bool),
HostReply(Vec<u8>),
Ignored,
ConfigChanged,
InputClosed,
InputError(String),
}
fn spawn_input_thread(
tx: mpsc::UnboundedSender<TerminalEvent>,
) -> Result<std::thread::JoinHandle<()>> {
thread::Builder::new()
.name("embers-input".to_owned())
.spawn(move || {
let stdin = io::stdin();
let stdin_lock = stdin.lock();
let fd = stdin_lock.as_raw_fd();
let _stdin_lock = stdin_lock;
loop {
match read_terminal_event(fd) {
Ok(Some(event)) => {
if tx.send(event).is_err() {
break;
}
}
Ok(None) => {
let _ = tx.send(TerminalEvent::InputClosed);
break;
}
Err(error) => {
let _ = tx.send(TerminalEvent::InputError(error.to_string()));
break;
}
}
}
})
.map_err(|error| MuxError::internal(format!("failed to spawn input thread: {error}")))
}
fn spawn_config_thread(
config_path: Option<PathBuf>,
tx: mpsc::UnboundedSender<TerminalEvent>,
) -> Result<Option<std::thread::JoinHandle<()>>> {
let Some(config_path) = config_path else {
return Ok(None);
};
let handle = thread::Builder::new()
.name("embers-config".to_owned())
.spawn(move || {
let mut last_modified = config_modified(&config_path);
let mut pending_change = None;
let mut missing_polls = 0usize;
loop {
thread::sleep(CONFIG_WATCH_POLL_INTERVAL);
let observed_modified = config_modified(&config_path);
if observed_modified.is_none() && last_modified.is_some() {
missing_polls = missing_polls.saturating_add(1);
if missing_polls < 2 {
continue;
}
} else {
missing_polls = 0;
}
if observed_modified == last_modified {
pending_change = None;
continue;
}
if pending_change == Some(observed_modified) {
last_modified = observed_modified;
pending_change = None;
if tx.send(TerminalEvent::ConfigChanged).is_err() {
break;
}
} else {
pending_change = Some(observed_modified);
}
}
})
.map_err(|error| MuxError::internal(format!("failed to spawn config thread: {error}")))?;
Ok(Some(handle))
}
fn config_modified(path: &Path) -> Option<std::time::SystemTime> {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
}
fn read_terminal_event(fd: libc::c_int) -> Result<Option<TerminalEvent>> {
let Some(first) = read_byte(fd)? else {
return Ok(None);
};
let event = match first {
b'\r' | b'\n' => TerminalEvent::Key(KeyEvent::Enter),
b'\t' => TerminalEvent::Key(KeyEvent::Tab),
0x7f => TerminalEvent::Key(KeyEvent::Backspace),
0x1b => read_escape_event(fd)?,
0x00 | 0x01..=0x1a | 0x1c..=0x1f => TerminalEvent::Key(KeyEvent::Ctrl(
char::from(first | 0x40).to_ascii_lowercase(),
)),
0x20..=0x7e => TerminalEvent::Key(KeyEvent::Char(char::from(first))),
other => TerminalEvent::Key(decode_utf8_key(fd, other)?),
};
Ok(Some(event))
}
fn read_escape_event(fd: libc::c_int) -> Result<TerminalEvent> {
let Some(next) = read_optional_byte(fd, KEY_SEQUENCE_TIMEOUT)? else {
return Ok(TerminalEvent::Key(KeyEvent::Escape));
};
match next {
b'[' => read_csi_event(fd),
b'O' => read_ss3_event(fd),
b'P' | b'_' | b']' => read_string_event(fd, next),
byte if byte.is_ascii() => Ok(TerminalEvent::Key(KeyEvent::Alt(char::from(byte)))),
other => {
let mut bytes = vec![0x1b, other];
while let Some(extra) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? {
bytes.push(extra);
}
Ok(TerminalEvent::Key(KeyEvent::Bytes(bytes)))
}
}
}
fn is_quit_key(key: &KeyEvent) -> bool {
match key {
KeyEvent::Ctrl('q') => true,
KeyEvent::Key {
code: KeyCode::Char('q'),
mods,
} => {
*mods
== Modifiers {
ctrl: true,
..Modifiers::NONE
}
}
_ => false,
}
}
fn read_csi_event(fd: libc::c_int) -> Result<TerminalEvent> {
let bytes = read_control_sequence(fd, b'[')?;
if bytes == b"\x1b[200~" {
return Ok(TerminalEvent::Paste(read_bracketed_paste(fd)?));
}
Ok(parse_csi_event(&bytes).unwrap_or_else(|| {
if is_csi_report(&bytes) {
TerminalEvent::HostReply(bytes)
} else {
TerminalEvent::Key(KeyEvent::Bytes(bytes))
}
}))
}
fn is_csi_report(bytes: &[u8]) -> bool {
matches!(bytes.get(2), Some(b'?' | b'>' | b'=')) || (bytes.len() > 4 && bytes.ends_with(b"$y"))
}
const MAX_HOST_STRING_LEN: usize = 64 * 1024;
fn read_string_event(fd: libc::c_int, introducer: u8) -> Result<TerminalEvent> {
let Some(first) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? else {
return Ok(TerminalEvent::Key(KeyEvent::Alt(char::from(introducer))));
};
let mut bytes = vec![0x1b, introducer, first];
while bytes.len() < MAX_HOST_STRING_LEN && !ends_control_string(&bytes, introducer) {
let Some(next) = read_optional_byte(fd, KEY_SEQUENCE_TIMEOUT)? else {
break;
};
bytes.push(next);
}
Ok(TerminalEvent::HostReply(bytes))
}
fn ends_control_string(bytes: &[u8], introducer: u8) -> bool {
(bytes.len() >= 4 && bytes.ends_with(b"\x1b\\"))
|| (introducer == b']' && bytes.ends_with(b"\x07"))
}
fn read_ss3_event(fd: libc::c_int) -> Result<TerminalEvent> {
let mut bytes = vec![0x1b, b'O'];
let Some(final_byte) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? else {
return Ok(TerminalEvent::Key(KeyEvent::Bytes(bytes)));
};
bytes.push(final_byte);
let key = match final_byte {
b'A' => Some(KeyEvent::Up),
b'B' => Some(KeyEvent::Down),
b'C' => Some(KeyEvent::Right),
b'D' => Some(KeyEvent::Left),
b'P'..=b'S' => Some(KeyEvent::Key {
code: KeyCode::Function(final_byte - b'P' + 1),
mods: Modifiers::NONE,
}),
_ => None,
};
Ok(match key {
Some(key) => TerminalEvent::Key(key),
None => TerminalEvent::Key(KeyEvent::Bytes(bytes)),
})
}
fn read_control_sequence(fd: libc::c_int, introducer: u8) -> Result<Vec<u8>> {
let mut bytes = vec![0x1b, introducer];
while let Some(next) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? {
bytes.push(next);
if is_csi_final_byte(next) {
break;
}
}
Ok(bytes)
}
fn is_csi_final_byte(byte: u8) -> bool {
(0x40..=0x7e).contains(&byte)
}
fn parse_csi_event(bytes: &[u8]) -> Option<TerminalEvent> {
match bytes {
b"\x1b[A" => Some(TerminalEvent::Key(KeyEvent::Up)),
b"\x1b[B" => Some(TerminalEvent::Key(KeyEvent::Down)),
b"\x1b[C" => Some(TerminalEvent::Key(KeyEvent::Right)),
b"\x1b[D" => Some(TerminalEvent::Key(KeyEvent::Left)),
b"\x1b[1~" | b"\x1b[H" => Some(TerminalEvent::Key(KeyEvent::Home)),
b"\x1b[2~" => Some(TerminalEvent::Key(KeyEvent::Insert)),
b"\x1b[3~" => Some(TerminalEvent::Key(KeyEvent::Delete)),
b"\x1b[4~" | b"\x1b[F" => Some(TerminalEvent::Key(KeyEvent::End)),
b"\x1b[5~" => Some(TerminalEvent::Key(KeyEvent::PageUp)),
b"\x1b[6~" => Some(TerminalEvent::Key(KeyEvent::PageDown)),
b"\x1b[Z" => Some(TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Tab,
mods: Modifiers {
shift: true,
..Modifiers::NONE
},
})),
b"\x1b[I" => Some(TerminalEvent::Focus(true)),
b"\x1b[O" => Some(TerminalEvent::Focus(false)),
_ => match parse_extended_key(bytes) {
Some(ExtendedKey::Event(key)) => Some(TerminalEvent::Key(key)),
Some(ExtendedKey::Consumed) => Some(TerminalEvent::Ignored),
None => parse_sgr_mouse(bytes)
.map(TerminalEvent::Mouse)
.or_else(|| is_sgr_mouse_report(bytes).then_some(TerminalEvent::Ignored)),
},
}
}
enum ExtendedKey {
Event(KeyEvent),
Consumed,
}
fn decode_kitty_mods(value: u32) -> Option<Modifiers> {
let mask = value.checked_sub(1)? & !(64 | 128);
if mask & !0b1111 != 0 {
return None;
}
Some(Modifiers {
shift: mask & 1 != 0,
alt: mask & 2 != 0,
ctrl: mask & 4 != 0,
super_: mask & 8 != 0,
})
}
fn modify_other_keys_as_kitty(bytes: &[u8]) -> Option<Vec<u8>> {
let params = bytes.strip_prefix(b"\x1b[27;")?.strip_suffix(b"~")?;
let text = std::str::from_utf8(params).ok()?;
let (mods, code) = text.split_once(';')?;
if mods.is_empty()
|| code.is_empty()
|| !mods.bytes().all(|byte| byte.is_ascii_digit())
|| !code.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
Some(format!("\x1b[{code};{mods}u").into_bytes())
}
fn parse_extended_key(bytes: &[u8]) -> Option<ExtendedKey> {
if let Some(kitty) = modify_other_keys_as_kitty(bytes) {
return parse_extended_key(&kitty);
}
let body = bytes.strip_prefix(b"\x1b[")?;
let (&final_byte, params_bytes) = body.split_last()?;
let params = std::str::from_utf8(params_bytes).ok()?;
let mut parts = params.split(';');
let first: u32 = parts.next()?.parse().ok()?;
let (modifier_value, event_type) = match parts.next() {
Some(modifier) => {
let mut sub = modifier.split(':');
let value: u32 = sub.next()?.parse().ok()?;
let event_type = sub.next();
if sub.next().is_some() {
return None;
}
(value, event_type)
}
None => (1, None),
};
if parts.next().is_some() {
return None;
}
let code = match final_byte {
b'u' => match first {
9 => KeyCode::Tab,
13 => KeyCode::Enter,
27 => KeyCode::Escape,
127 => KeyCode::Backspace,
other => {
if (57344..=63743).contains(&other) {
return None;
}
KeyCode::Char(char::from_u32(other)?)
}
},
b'A' if first == 1 => KeyCode::Up,
b'B' if first == 1 => KeyCode::Down,
b'C' if first == 1 => KeyCode::Right,
b'D' if first == 1 => KeyCode::Left,
b'H' if first == 1 => KeyCode::Home,
b'F' if first == 1 => KeyCode::End,
b'P' if first == 1 => KeyCode::Function(1),
b'Q' if first == 1 => KeyCode::Function(2),
b'S' if first == 1 => KeyCode::Function(4),
b'~' => match first {
2 => KeyCode::Insert,
3 => KeyCode::Delete,
5 => KeyCode::PageUp,
6 => KeyCode::PageDown,
13 => KeyCode::Function(3),
15 => KeyCode::Function(5),
17 => KeyCode::Function(6),
18 => KeyCode::Function(7),
19 => KeyCode::Function(8),
20 => KeyCode::Function(9),
21 => KeyCode::Function(10),
23 => KeyCode::Function(11),
24 => KeyCode::Function(12),
_ => return None,
},
_ => return None,
};
match event_type {
None | Some("1") | Some("2") => {}
Some("3") => return Some(ExtendedKey::Consumed),
Some(_) => return None,
}
let mods = decode_kitty_mods(modifier_value)?;
Some(ExtendedKey::Event(KeyEvent::Key { code, mods }))
}
fn is_sgr_mouse_report(bytes: &[u8]) -> bool {
bytes
.strip_prefix(b"\x1b[<")
.and_then(|body| body.strip_suffix(b"M").or_else(|| body.strip_suffix(b"m")))
.is_some_and(|params| {
params.split(|&byte| byte == b';').count() == 3
&& params
.iter()
.all(|&byte| byte.is_ascii_digit() || byte == b';')
})
}
fn parse_sgr_mouse(bytes: &[u8]) -> Option<MouseEvent> {
let text = std::str::from_utf8(bytes).ok()?;
let body = text.strip_prefix("\x1b[<")?;
let (payload, suffix) = body.split_at(body.len().checked_sub(1)?);
if suffix != "M" && suffix != "m" {
return None;
}
let mut parts = payload.split(';');
let code = parts.next()?.parse::<u16>().ok()?;
let column = parts.next()?.parse::<u16>().ok()?.saturating_sub(1);
let row = parts.next()?.parse::<u16>().ok()?.saturating_sub(1);
if parts.next().is_some() {
return None;
}
let modifiers = MouseModifiers {
shift: (code & 0b00100) != 0,
alt: (code & 0b01000) != 0,
ctrl: (code & 0b10000) != 0,
};
let button_code = code & 0b11;
let kind = if (code & 0b1_000000) != 0 {
match button_code {
0 => MouseEventKind::WheelUp,
1 => MouseEventKind::WheelDown,
_ => return None,
}
} else if (code & 0b100000) != 0 {
MouseEventKind::Drag(mouse_button(button_code)?)
} else if suffix == "m" {
MouseEventKind::Release(mouse_button(button_code))
} else {
MouseEventKind::Press(mouse_button(button_code)?)
};
Some(MouseEvent {
row,
column,
modifiers,
kind,
})
}
fn mouse_button(code: u16) -> Option<MouseButton> {
match code {
0 => Some(MouseButton::Left),
1 => Some(MouseButton::Middle),
2 => Some(MouseButton::Right),
_ => None,
}
}
fn read_bracketed_paste(fd: libc::c_int) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
while let Some(next) = read_byte(fd)? {
bytes.push(next);
if bytes.ends_with(BRACKETED_PASTE_END) {
let new_len = bytes.len() - BRACKETED_PASTE_END.len();
bytes.truncate(new_len);
break;
}
}
Ok(bytes)
}
fn decode_utf8_key(fd: libc::c_int, first: u8) -> Result<KeyEvent> {
let width = utf8_width(first);
if width <= 1 {
return Ok(KeyEvent::Bytes(vec![first]));
}
let mut bytes = vec![first];
for _ in 1..width {
let Some(next) = read_byte(fd)? else {
return Ok(KeyEvent::Bytes(bytes));
};
bytes.push(next);
}
match std::str::from_utf8(&bytes)
.ok()
.and_then(|text| text.chars().next())
{
Some(ch) => Ok(KeyEvent::Char(ch)),
None => Ok(KeyEvent::Bytes(bytes)),
}
}
fn utf8_width(first: u8) -> usize {
match first {
0x00..=0x7f => 1,
0xc0..=0xdf => 2,
0xe0..=0xef => 3,
0xf0..=0xf7 => 4,
_ => 0,
}
}
fn read_optional_byte(fd: libc::c_int, timeout: Duration) -> Result<Option<u8>> {
if poll_fd(fd, timeout)? {
read_byte(fd)
} else {
Ok(None)
}
}
fn poll_fd(fd: libc::c_int, timeout: Duration) -> Result<bool> {
let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32;
let mut poll_fd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
loop {
let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
if result == 0 {
return Ok(false);
}
if result > 0 {
return Ok((poll_fd.revents & libc::POLLIN) != 0);
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error.into());
}
}
fn read_byte(fd: libc::c_int) -> Result<Option<u8>> {
let mut byte = 0_u8;
loop {
let result = unsafe { libc::read(fd, (&mut byte as *mut u8).cast(), 1) };
if result == 0 {
return Ok(None);
}
if result > 0 {
return Ok(Some(byte));
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error.into());
}
}
struct TerminalGuard {
input_fd: libc::c_int,
original_mode: libc::termios,
mouse_capture_enabled: bool,
}
const HOST_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
const LATE_HOST_REPLY_WINDOW: Duration = Duration::from_secs(10);
fn read_host_replies(input_fd: libc::c_int) -> Result<HostReplies> {
let deadline = std::time::Instant::now() + HOST_REPLY_TIMEOUT;
let mut bytes = Vec::new();
while bytes.len() < MAX_HOST_REPLY_BYTES && !ends_with_device_attributes(&bytes) {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
match read_optional_byte(input_fd, remaining)? {
Some(byte) => bytes.push(byte),
None => break,
}
}
Ok(HostReplies::parse(&bytes))
}
impl TerminalGuard {
fn enter(mouse_capture_enabled: bool) -> Result<(Self, HostReplies)> {
let input_fd = io::stdin().as_raw_fd();
let output_fd = io::stdout().as_raw_fd();
if !is_tty(input_fd) || !is_tty(output_fd) {
return Err(MuxError::invalid_input(
"interactive embers client requires a TTY on stdin/stdout",
));
}
let original_mode = terminal_mode(input_fd)?;
let mut raw_mode = original_mode;
raw_mode.c_iflag &= !(libc::BRKINT | libc::ICRNL | libc::INPCK | libc::ISTRIP | libc::IXON);
raw_mode.c_oflag &= !libc::OPOST;
raw_mode.c_cflag |= libc::CS8;
raw_mode.c_lflag &= !(libc::ECHO | libc::ICANON | libc::IEXTEN | libc::ISIG);
raw_mode.c_cc[libc::VMIN] = 1;
raw_mode.c_cc[libc::VTIME] = 0;
set_terminal_mode(input_fd, &raw_mode)?;
let mut stdout = io::stdout();
match write!(
stdout,
"{}{HOST_QUERIES}",
terminal_enter_sequence(mouse_capture_enabled)
)
.and_then(|()| stdout.flush())
{
Ok(()) => {}
Err(error) => {
let _ = set_terminal_mode(input_fd, &original_mode);
return Err(error.into());
}
}
let guard = Self {
input_fd,
original_mode,
mouse_capture_enabled,
};
let replies = read_host_replies(input_fd)?;
Ok((guard, replies))
}
fn size(&self) -> Result<Size> {
terminal_size(io::stdout().as_raw_fd())
}
fn write_bytes(&self, bytes: &[u8]) -> Result<()> {
if bytes.is_empty() {
return Ok(());
}
let mut stdout = io::stdout();
stdout.write_all(bytes)?;
stdout.flush()?;
Ok(())
}
fn sync_mouse_capture(&mut self, enabled: bool) -> Result<()> {
if self.mouse_capture_enabled == enabled {
return Ok(());
}
let mut stdout = io::stdout();
write!(
stdout,
"{}",
if enabled {
TERMINAL_ENABLE_MOUSE_SEQUENCE
} else {
TERMINAL_DISABLE_MOUSE_SEQUENCE
}
)?;
stdout.flush()?;
self.mouse_capture_enabled = enabled;
Ok(())
}
fn render(
&self,
grid: &RenderGrid,
terminal_size: Size,
status: Option<&str>,
status_cursor: Option<u16>,
host: HostCapabilities,
after_frame: &[u8],
) -> Result<()> {
let frame = frame_bytes(
grid,
terminal_size,
status,
status_cursor,
host,
after_frame,
);
let mut stdout = io::stdout();
stdout.write_all(&frame)?;
stdout.flush()?;
Ok(())
}
}
fn frame_bytes(
grid: &RenderGrid,
terminal_size: Size,
status: Option<&str>,
status_cursor: Option<u16>,
host: HostCapabilities,
after_frame: &[u8],
) -> Vec<u8> {
let mut frame = Vec::new();
frame.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[H");
for (index, line) in grid.ansi_lines_for(host).iter().enumerate() {
if index > 0 {
frame.extend_from_slice(b"\r\n");
}
frame.extend_from_slice(line.as_bytes());
frame.extend_from_slice(b"\x1b[K");
}
if terminal_size.height > grid.height() {
let status = fit_width(status.unwrap_or_default(), terminal_size.width);
if grid.height() > 0 {
frame.extend_from_slice(b"\r\n");
}
frame.extend_from_slice(format!("\x1b[7m{status}\x1b[0m\x1b[K").as_bytes());
}
frame.extend_from_slice(b"\x1b[J");
frame.extend_from_slice(after_frame);
frame.extend_from_slice(cursor_bytes(grid, terminal_size, status_cursor).as_bytes());
frame.extend_from_slice(b"\x1b[?2026l");
frame
}
fn cursor_bytes(grid: &RenderGrid, terminal_size: Size, status_cursor: Option<u16>) -> String {
if let Some(column) = status_cursor
&& terminal_size.height > grid.height()
{
format!(
"\x1b[{};{}H\x1b[{} q\x1b[?25h",
terminal_size.height,
column
.min(terminal_size.width.saturating_sub(1))
.saturating_add(1),
cursor_shape_code(CursorShape::Beam),
)
} else if let Some(cursor) = grid.cursor() {
format!(
"\x1b[{};{}H\x1b[{} q\x1b[?25h",
cursor.y.saturating_add(1),
cursor.x.saturating_add(1),
cursor_shape_code(cursor.shape),
)
} else {
"\x1b[?25l".to_owned()
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = set_terminal_mode(self.input_fd, &self.original_mode);
let mut stdout = io::stdout();
let _ = write!(
stdout,
"{}",
terminal_exit_sequence(self.mouse_capture_enabled)
);
let _ = stdout.flush();
}
}
fn mouse_capture_enabled(configured: &ConfiguredClient<SocketTransport>) -> bool {
configured
.config()
.active_script()
.loaded_config()
.mouse
.capture_enabled()
}
fn terminal_enter_sequence(mouse_capture_enabled: bool) -> String {
let mut sequence = TERMINAL_ENTER_BASE_SEQUENCE.to_owned();
if mouse_capture_enabled {
sequence.push_str(TERMINAL_ENABLE_MOUSE_SEQUENCE);
}
sequence.push_str("\x1b[>1u");
sequence
}
fn terminal_exit_sequence(mouse_capture_enabled: bool) -> String {
let mut sequence = String::from("\x1b[<u\x1b[0m\x1b[2 q\x1b[?25h\x1b[?2004l");
if mouse_capture_enabled {
sequence.push_str(TERMINAL_DISABLE_MOUSE_SEQUENCE);
}
sequence.push_str("\x1b[?1004l\x1b[?1049l");
sequence
}
fn is_tty(fd: libc::c_int) -> bool {
unsafe { libc::isatty(fd) == 1 }
}
fn terminal_mode(fd: libc::c_int) -> Result<libc::termios> {
let mut mode = unsafe { std::mem::zeroed::<libc::termios>() };
if unsafe { libc::tcgetattr(fd, &mut mode) } == -1 {
return Err(io::Error::last_os_error().into());
}
Ok(mode)
}
fn set_terminal_mode(fd: libc::c_int, mode: &libc::termios) -> Result<()> {
if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, mode) } == -1 {
return Err(io::Error::last_os_error().into());
}
Ok(())
}
fn terminal_size(fd: libc::c_int) -> Result<Size> {
let mut winsize = unsafe { std::mem::zeroed::<libc::winsize>() };
if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) } == -1 {
return Err(io::Error::last_os_error().into());
}
let width = if winsize.ws_col == 0 {
80
} else {
winsize.ws_col
};
let height = if winsize.ws_row == 0 {
24
} else {
winsize.ws_row
};
Ok(Size { width, height })
}
fn host_cell_size(fd: libc::c_int) -> Option<CellSize> {
let mut winsize = unsafe { std::mem::zeroed::<libc::winsize>() };
if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) } == -1 {
return None;
}
CellSize::of_text_area(
winsize.ws_col,
winsize.ws_row,
winsize.ws_xpixel,
winsize.ws_ypixel,
)
}
fn fit_width(text: &str, width: u16) -> String {
if width == 0 {
return String::new();
}
let width = usize::from(width);
let mut fitted = String::new();
let mut used = 0;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(1);
if used + ch_width > width {
break;
}
fitted.push(ch);
used += ch_width;
}
let current = UnicodeWidthStr::width(fitted.as_str());
if current < width {
fitted.push_str(&" ".repeat(width - current));
}
fitted
}
fn cursor_shape_code(shape: CursorShape) -> u8 {
match shape {
CursorShape::Block => 2,
CursorShape::Underline => 4,
CursorShape::Beam => 6,
}
}
#[cfg(test)]
mod tests {
use super::{
TERMINAL_DISABLE_MOUSE_SEQUENCE, TERMINAL_ENABLE_MOUSE_SEQUENCE, TerminalEvent,
frame_bytes, is_quit_key, read_terminal_event, terminal_enter_sequence,
terminal_exit_sequence,
};
use embers_client::{
GridCursor, HostCapabilities, KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent,
MouseEventKind, MouseModifiers, RenderGrid,
};
use embers_core::{CursorShape, Size};
#[test]
fn a_frame_is_one_synchronized_update_with_the_cursor_hidden_until_placed() {
let mut grid = RenderGrid::new(10, 3);
grid.set_cursor(Some(GridCursor {
x: 4,
y: 1,
shape: CursorShape::Block,
}));
let frame = frame_bytes(
&grid,
Size {
width: 10,
height: 4,
},
Some("status"),
None,
HostCapabilities::ALL,
b"after",
);
let frame = String::from_utf8(frame).expect("frame is text");
assert!(frame.starts_with("\x1b[?2026h\x1b[?25l"), "{frame:?}");
assert!(frame.ends_with("\x1b[?2026l"), "{frame:?}");
let show = frame.find("\x1b[?25h").expect("cursor shown");
let place = frame.rfind("\x1b[2;5H").expect("cursor placed");
assert!(place < show, "{frame:?}");
assert!(frame.find("after").expect("after-frame bytes") < place);
assert_eq!(frame.matches("\x1b[?25h").count(), 1);
}
#[test]
fn parses_csi_u_and_modified_legacy_keys() {
let key = |bytes: &[u8]| {
with_pipe(bytes, read_terminal_event)
.expect("read succeeds")
.expect("event produced")
};
assert_eq!(
key(b"\x1b[105;5u"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Char('i'),
mods: Modifiers {
ctrl: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[9;6u"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Tab,
mods: Modifiers {
shift: true,
ctrl: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[1;2D"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Left,
mods: Modifiers {
shift: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[15;3~"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Function(5),
mods: Modifiers {
alt: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[99z"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[99z".to_vec()))
);
assert_eq!(
key(b"\x1b[\xc3\xa9"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[\xc3\xa9".to_vec()))
);
assert_eq!(key(b"\x1b[97;5:3u"), TerminalEvent::Ignored);
assert_eq!(
key(b"\x1b[97;5:1u"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Char('a'),
mods: Modifiers {
ctrl: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[97;5:4u"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;5:4u".to_vec()))
);
assert_eq!(
key(b"\x1b[97;5:1:2u"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;5:1:2u".to_vec()))
);
assert_eq!(
key(b"\x1b[97;5:3z"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;5:3z".to_vec()))
);
assert_eq!(
key(b"\x1b[97;17u"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;17u".to_vec()))
);
assert_eq!(
key(b"\x1b[97;0u"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;0u".to_vec()))
);
assert_eq!(
key(b"\x1b[5A"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[5A".to_vec()))
);
assert_eq!(
key(b"\x1b[1;2A"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Up,
mods: Modifiers {
shift: true,
..Modifiers::NONE
},
})
);
}
#[test]
fn parses_legacy_forms_of_extended_keys() {
let key = |bytes: &[u8]| {
with_pipe(bytes, read_terminal_event)
.expect("read succeeds")
.expect("event produced")
};
assert_eq!(
key(b"\x1bOP"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Function(1),
mods: Modifiers::NONE,
})
);
assert_eq!(
key(b"\x1b[Z"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Tab,
mods: Modifiers {
shift: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[13;2~"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Function(3),
mods: Modifiers {
shift: true,
..Modifiers::NONE
},
})
);
assert_eq!(
key(b"\x1b[1;2R"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[1;2R".to_vec()))
);
}
#[test]
fn unmapped_kitty_reports_degrade_gracefully() {
let key = |bytes: &[u8]| {
with_pipe(bytes, read_terminal_event)
.expect("read succeeds")
.expect("event produced")
};
assert_eq!(
key(b"\x1b[57414u"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[57414u".to_vec()))
);
assert_eq!(
key(b"\x1b[57414;5u"),
TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[57414;5u".to_vec()))
);
assert_eq!(
key(b"\x1b[104;133u"),
TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Char('h'),
mods: Modifiers {
ctrl: true,
..Modifiers::NONE
},
})
);
}
#[test]
fn quit_chord_matches_both_encodings() {
assert!(is_quit_key(&KeyEvent::Ctrl('q')));
assert!(is_quit_key(&KeyEvent::Key {
code: KeyCode::Char('q'),
mods: Modifiers {
ctrl: true,
..Modifiers::NONE
},
}));
assert!(!is_quit_key(&KeyEvent::Key {
code: KeyCode::Char('q'),
mods: Modifiers {
ctrl: true,
shift: true,
..Modifiers::NONE
},
}));
}
fn with_pipe<T>(bytes: &[u8], test: impl FnOnce(libc::c_int) -> T) -> T {
let mut fds = [0; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
let read_fd = fds[0];
let write_fd = fds[1];
let written = unsafe { libc::write(write_fd, bytes.as_ptr().cast(), bytes.len()) };
assert_eq!(written, bytes.len() as isize);
unsafe {
libc::close(write_fd);
}
let result = test(read_fd);
unsafe {
libc::close(read_fd);
}
result
}
#[test]
fn host_string_replies_are_read_whole_as_replies() {
let replies: [&[u8]; 3] = [
b"\x1b_Gi=31;OK\x1b\\",
b"\x1bP1+r536d756c78=5c455b343a25703125646d\x1b\\",
b"\x1b]11;rgb:0000/0000/0000\x07",
];
with_pipe(&replies.concat(), |fd| {
for reply in replies {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::HostReply(reply.to_vec()))
);
}
});
}
#[test]
fn private_csi_reports_are_replies_not_keys() {
let replies: [&[u8]; 4] = [
b"\x1b[?62;22c",
b"\x1b[>1;4000;29c",
b"\x1b[?1u",
b"\x1b[?2026;2$y",
];
with_pipe(&replies.concat(), |fd| {
for reply in replies {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::HostReply(reply.to_vec()))
);
}
});
with_pipe(b"\x1b[99z", |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[99z".to_vec())))
);
});
}
#[test]
fn alt_p_alone_stays_a_key() {
with_pipe(b"\x1bP", |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Alt('P')))
);
});
}
#[test]
fn parses_page_up_and_page_down_keys() {
with_pipe(b"\x1b[1~\x1b[2~\x1b[3~\x1b[4~\x1b[5~\x1b[6~", |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Home))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Insert))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Delete))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::End))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::PageUp))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::PageDown))
);
});
}
#[test]
fn parses_extended_ctrl_chords() {
with_pipe(&[0x00, 0x1c, 0x1d, 0x1e, 0x1f], |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Ctrl('@')))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Ctrl('\\')))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Ctrl(']')))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Ctrl('^')))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Ctrl('_')))
);
});
}
#[test]
fn decodes_backspace_and_ctrl_h_like_tmux() {
with_pipe(&[0x7f, 0x08], |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Backspace))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Ctrl('h')))
);
});
}
#[test]
fn parses_arrow_and_focus_events() {
with_pipe(b"\x1b[A\x1b[I\x1b[O", |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Up))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Focus(true))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Focus(false))
);
});
}
#[test]
fn unused_sgr_mouse_reports_are_dropped_not_typed() {
with_pipe(
b"\x1b[<66;30;20M\x1b[<67;30;20M\x1b[<35;31;20M\x1b[<71;30;20M",
|fd| {
for _ in 0..4 {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Ignored)
);
}
},
);
}
#[test]
fn parses_xterm_modify_other_keys_reports() {
with_pipe(b"\x1b[27;5;9~\x1b[27;6;9~\x1b[27;5;13~", |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Tab,
mods: Modifiers {
ctrl: true,
..Modifiers::NONE
},
}))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Tab,
mods: Modifiers {
ctrl: true,
shift: true,
..Modifiers::NONE
},
}))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Key(KeyEvent::Key {
code: KeyCode::Enter,
mods: Modifiers {
ctrl: true,
..Modifiers::NONE
},
}))
);
});
}
#[test]
fn parses_sgr_mouse_events() {
with_pipe(
b"\x1b[<0;12;7M\x1b[<64;3;5M\x1b[<32;10;4M\x1b[<3;10;4m",
|fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Mouse(MouseEvent {
row: 6,
column: 11,
modifiers: MouseModifiers::default(),
kind: MouseEventKind::Press(MouseButton::Left),
}))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Mouse(MouseEvent {
row: 4,
column: 2,
modifiers: MouseModifiers::default(),
kind: MouseEventKind::WheelUp,
}))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Mouse(MouseEvent {
row: 3,
column: 9,
modifiers: MouseModifiers::default(),
kind: MouseEventKind::Drag(MouseButton::Left),
}))
);
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Mouse(MouseEvent {
row: 3,
column: 9,
modifiers: MouseModifiers::default(),
kind: MouseEventKind::Release(None),
}))
);
},
);
}
#[test]
fn parses_bracketed_paste_payloads() {
with_pipe(b"\x1b[200~hello\nworld\x1b[201~", |fd| {
assert_eq!(
read_terminal_event(fd).unwrap(),
Some(TerminalEvent::Paste(b"hello\nworld".to_vec()))
);
});
}
#[test]
fn terminal_guard_sequences_toggle_mouse_capture_with_config() {
let with_mouse_enter = terminal_enter_sequence(true);
let without_mouse_enter = terminal_enter_sequence(false);
let with_mouse_exit = terminal_exit_sequence(true);
let without_mouse_exit = terminal_exit_sequence(false);
assert!(with_mouse_enter.contains("\x1b[?1004h"));
assert!(with_mouse_enter.contains("\x1b[?2004h"));
assert!(with_mouse_enter.contains(TERMINAL_ENABLE_MOUSE_SEQUENCE));
assert!(with_mouse_exit.contains("\x1b[?1004l"));
assert!(with_mouse_exit.contains("\x1b[?2004l"));
assert!(with_mouse_exit.contains(TERMINAL_DISABLE_MOUSE_SEQUENCE));
assert!(without_mouse_enter.contains("\x1b[?1004h"));
assert!(without_mouse_enter.contains("\x1b[?2004h"));
assert!(!without_mouse_enter.contains(TERMINAL_ENABLE_MOUSE_SEQUENCE));
assert!(without_mouse_exit.contains("\x1b[?1004l"));
assert!(without_mouse_exit.contains("\x1b[?2004l"));
assert!(!without_mouse_exit.contains(TERMINAL_DISABLE_MOUSE_SEQUENCE));
}
}