use std::io::Write;
const SEPARATOR: &str = " — ";
const ESCAPE: u8 = 0x1b;
const BELL: u8 = 0x07;
const MAX_SEQUENCE: usize = 8 * 1024;
#[derive(Clone, Copy, Eq, PartialEq)]
enum State {
Text,
Escape,
Command,
CommandEscape,
}
pub struct TitleFilter {
label: String,
state: State,
pending: Vec<u8>,
}
impl TitleFilter {
pub fn new(profile: &str) -> Self {
Self {
label: format!("ditto:{profile}"),
state: State::Text,
pending: Vec::new(),
}
}
pub fn initial_title(&self) -> Vec<u8> {
let mut sequence = Vec::new();
write_command(&mut sequence, b"0", Some(self.label.as_bytes()), BELL);
sequence
}
pub fn push(&mut self, input: &[u8], output: &mut Vec<u8>) {
for &byte in input {
match self.state {
State::Text => {
if byte == ESCAPE {
self.state = State::Escape;
} else {
output.push(byte);
}
}
State::Escape => match byte {
b']' => {
self.state = State::Command;
self.pending.clear();
}
ESCAPE => output.push(ESCAPE),
_ => {
output.push(ESCAPE);
output.push(byte);
self.state = State::Text;
}
},
State::Command => match byte {
BELL => self.finish(output, BELL),
ESCAPE => self.state = State::CommandEscape,
_ => {
self.pending.push(byte);
if self.pending.len() > MAX_SEQUENCE {
self.release(output);
}
}
},
State::CommandEscape => {
if byte == b'\\' {
self.finish(output, ESCAPE);
} else {
self.pending.push(ESCAPE);
self.pending.push(byte);
self.state = State::Command;
}
}
}
}
}
fn finish(&mut self, output: &mut Vec<u8>, terminator: u8) {
let (kind, text) = split_command(&self.pending);
match (names_the_title(kind), text.map(std::str::from_utf8)) {
(true, Some(Ok(text))) => {
let rewritten = self.rewrite(text);
write_command(output, kind, Some(rewritten.as_bytes()), terminator);
}
(true, None) => {
let rewritten = self.rewrite("");
write_command(output, kind, Some(rewritten.as_bytes()), terminator);
}
_ => write_command(output, kind, text, terminator),
}
self.pending.clear();
self.state = State::Text;
}
fn rewrite(&self, title: &str) -> String {
if title == self.label || title.starts_with(&format!("{}{SEPARATOR}", self.label)) {
return title.to_owned();
}
if title.is_empty() {
return self.label.clone();
}
format!("{}{SEPARATOR}{title}", self.label)
}
fn release(&mut self, output: &mut Vec<u8>) {
output.push(ESCAPE);
output.push(b']');
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = State::Text;
}
}
fn split_command(command: &[u8]) -> (&[u8], Option<&[u8]>) {
match command.iter().position(|&byte| byte == b';') {
Some(separator) => (&command[..separator], Some(&command[separator + 1..])),
None => (command, None),
}
}
fn names_the_title(kind: &[u8]) -> bool {
matches!(kind, b"0" | b"1" | b"2")
}
fn write_command(output: &mut Vec<u8>, kind: &[u8], text: Option<&[u8]>, terminator: u8) {
output.push(ESCAPE);
output.push(b']');
output.extend_from_slice(kind);
if let Some(text) = text {
output.push(b';');
output.extend_from_slice(text);
}
if terminator == ESCAPE {
output.push(ESCAPE);
output.push(b'\\');
} else {
output.push(BELL);
}
}
pub fn announce(writer: &mut impl Write, filter: &TitleFilter) {
let _ = writer.write_all(&filter.initial_title());
let _ = writer.flush();
}
#[cfg(unix)]
pub use unix::{exit_code, run};
#[cfg(unix)]
mod unix {
use std::{
ffi::{CString, OsString},
fs::File,
io::{self, ErrorKind, Read, Write},
os::{
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
unix::process::{CommandExt, ExitStatusExt},
},
process::ExitStatus,
ptr,
sync::atomic::{AtomicBool, Ordering},
thread,
};
use anyhow::{Context, Result, bail};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size};
use super::{TitleFilter, announce};
use crate::{launch, profile::Profile};
static RESIZED: AtomicBool = AtomicBool::new(false);
extern "C" fn on_resize(_signal: libc::c_int) {
RESIZED.store(true, Ordering::Relaxed);
}
struct RawMode;
impl RawMode {
fn enter() -> Result<Self> {
enable_raw_mode().context("could not put the terminal into raw mode")?;
Ok(Self)
}
}
impl Drop for RawMode {
fn drop(&mut self) {
let _ = disable_raw_mode();
}
}
pub fn run(tool: launch::Tool, profile: &Profile, args: &[OsString]) -> Result<ExitStatus> {
let (controller, device) = open_pair()?;
let mut command = launch::build_command(tool, profile, args);
command
.stdin(device.try_clone().context("could not attach the input")?)
.stdout(device.try_clone().context("could not attach the output")?)
.stderr(
device
.try_clone()
.context("could not attach the error output")?,
);
unsafe {
command.pre_exec(|| {
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut child = command
.spawn()
.with_context(|| format!("could not launch {}", tool.label()))?;
drop(device);
let started = (|| {
let raw = RawMode::enter()?;
install_resize_handler();
resize(controller.as_raw_fd());
let status = forward(&controller, &profile.name, &mut child);
drop(raw);
status
})();
if started.is_err() {
let _ = child.kill();
let _ = child.wait();
}
started
}
fn forward(
controller: &OwnedFd,
profile: &str,
child: &mut std::process::Child,
) -> Result<ExitStatus> {
let mut reader = File::from(
controller
.try_clone()
.context("could not read from the tool")?,
);
let mut writer = File::from(
controller
.try_clone()
.context("could not write to the tool")?,
);
thread::spawn(move || {
let mut keyboard = io::stdin();
let mut buffer = [0u8; 4096];
loop {
match keyboard.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
if writer.write_all(&buffer[..read]).is_err() {
break;
}
}
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
let mut filter = TitleFilter::new(profile);
let mut screen = io::stdout();
announce(&mut screen, &filter);
let mut buffer = [0u8; 8192];
let mut rewritten = Vec::with_capacity(buffer.len());
loop {
if RESIZED.swap(false, Ordering::Relaxed) {
resize(controller.as_raw_fd());
}
match reader.read(&mut buffer) {
Ok(0) => break,
Err(error) if error.raw_os_error() == Some(libc::EIO) => break,
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(error) => return Err(error).context("could not read from the tool"),
Ok(read) => {
rewritten.clear();
filter.push(&buffer[..read], &mut rewritten);
screen
.write_all(&rewritten)
.and_then(|()| screen.flush())
.context("could not write to the terminal")?;
}
}
}
child.wait().context("could not wait for the tool to exit")
}
fn open_pair() -> Result<(OwnedFd, OwnedFd)> {
unsafe {
let controller = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY);
if controller < 0 {
bail!(
"could not open a pseudoterminal: {}",
io::Error::last_os_error()
);
}
let controller = OwnedFd::from_raw_fd(controller);
if libc::grantpt(controller.as_raw_fd()) < 0
|| libc::unlockpt(controller.as_raw_fd()) < 0
{
bail!(
"could not prepare a pseudoterminal: {}",
io::Error::last_os_error()
);
}
let name = libc::ptsname(controller.as_raw_fd());
if name.is_null() {
bail!("could not name the pseudoterminal");
}
let name = CString::from(std::ffi::CStr::from_ptr(name));
let device = libc::open(name.as_ptr(), libc::O_RDWR | libc::O_NOCTTY);
if device < 0 {
bail!(
"could not open the pseudoterminal: {}",
io::Error::last_os_error()
);
}
Ok((controller, OwnedFd::from_raw_fd(device)))
}
}
fn install_resize_handler() {
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_resize as *const () as libc::sighandler_t;
libc::sigemptyset(&mut action.sa_mask);
action.sa_flags = 0;
libc::sigaction(libc::SIGWINCH, &action, ptr::null_mut());
}
}
fn resize(controller: RawFd) {
let Ok((columns, rows)) = size() else {
return;
};
let size = libc::winsize {
ws_row: rows,
ws_col: columns,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
libc::ioctl(controller, libc::TIOCSWINSZ as _, &size);
}
}
pub fn exit_code(status: ExitStatus) -> i32 {
status
.code()
.unwrap_or_else(|| status.signal().map_or(1, |signal| 128 + signal))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn filtered(chunks: &[&[u8]]) -> Vec<u8> {
let mut filter = TitleFilter::new("work");
let mut output = Vec::new();
for chunk in chunks {
filter.push(chunk, &mut output);
}
output
}
fn text(chunks: &[&[u8]]) -> String {
String::from_utf8(filtered(chunks)).unwrap()
}
#[test]
fn names_the_profile_in_a_title_the_tool_sets() {
assert_eq!(
text(&[b"\x1b]0;Codex\x07"]),
"\x1b]0;ditto:work — Codex\x07"
);
assert_eq!(
text(&[b"\x1b]2;Codex\x07"]),
"\x1b]2;ditto:work — Codex\x07"
);
assert_eq!(text(&[b"\x1b]1;omp\x07"]), "\x1b]1;ditto:work — omp\x07");
}
#[test]
fn forwards_a_command_that_carries_no_argument_as_it_stands() {
assert_eq!(filtered(&[b"\x1b]112\x07"]), b"\x1b]112\x07");
assert_eq!(filtered(&[b"\x1b]104\x1b\\"]), b"\x1b]104\x1b\\");
}
#[test]
fn keeps_the_terminator_the_tool_used() {
assert_eq!(
text(&[b"\x1b]0;Codex\x1b\\"]),
"\x1b]0;ditto:work — Codex\x1b\\"
);
}
#[test]
fn rebuilds_a_title_split_across_reads() {
assert_eq!(
text(&[b"\x1b]0;Co", b"dex \xe2\x80\x94 rep", b"o\x07"]),
"\x1b]0;ditto:work — Codex — repo\x07"
);
assert_eq!(
text(&[b"\x1b", b"]0;omp\x07"]),
"\x1b]0;ditto:work — omp\x07"
);
assert_eq!(
text(&[b"\x1b]0;omp\x1b", b"\\"]),
"\x1b]0;ditto:work — omp\x1b\\"
);
}
#[test]
fn leaves_a_title_that_already_names_the_profile() {
let once = text(&[b"\x1b]0;Codex\x07"]);
let twice = text(&[once.as_bytes()]);
assert_eq!(twice, once);
assert_eq!(text(&[b"\x1b]0;ditto:work\x07"]), "\x1b]0;ditto:work\x07");
}
#[test]
fn names_the_profile_alone_when_the_tool_clears_the_title() {
assert_eq!(text(&[b"\x1b]0;\x07"]), "\x1b]0;ditto:work\x07");
}
#[test]
fn forwards_everything_that_is_not_a_title() {
for sequence in [
"\x1b]8;;https://example.com\x07link\x1b]8;;\x07",
"\x1b]11;rgb:0000/0000/0000\x07",
"\x1b]52;c;aGk=\x07",
"\x1b]133;A\x07",
] {
assert_eq!(text(&[sequence.as_bytes()]), sequence, "{sequence:?}");
}
}
#[test]
fn forwards_drawing_untouched() {
let screen = "\x1b[2J\x1b[H\x1b[38;2;1;2;3mhello\x1b[0m\r\n\x1b[?1049h";
assert_eq!(text(&[screen.as_bytes()]), screen);
assert_eq!(text(&[b"plain output"]), "plain output");
assert_eq!(filtered(&[b"\x1b\x1bZ"]), b"\x1b\x1bZ");
}
#[test]
fn gives_up_on_a_sequence_that_never_ends() {
let mut runaway = b"\x1b]0;".to_vec();
runaway.extend(std::iter::repeat_n(b'x', MAX_SEQUENCE + 1));
let output = filtered(&[&runaway]);
assert!(output.starts_with(b"\x1b]0;"));
assert_eq!(output.len(), runaway.len());
}
#[test]
fn starts_by_naming_the_profile() {
let filter = TitleFilter::new("work");
assert_eq!(filter.initial_title(), b"\x1b]0;ditto:work\x07");
}
}