pub mod types;
use std::collections::HashMap;
use std::io::{Read, Write};
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use tokio::sync::mpsc;
pub use types::ShellEvent;
const MAX_WEB_SESSIONS: usize = 10;
struct ShellSession {
parser: vt100::Parser,
writer: Box<dyn Write + Send>,
child: Box<dyn portable_pty::Child + Send>,
master: Box<dyn portable_pty::MasterPty + Send>,
buffer_id: String,
label: String,
}
pub struct ShellManager {
sessions: HashMap<String, ShellSession>,
web_sessions: HashMap<String, ShellSession>,
event_tx: mpsc::Sender<ShellEvent>,
next_id: u32,
}
impl ShellManager {
pub fn new() -> (Self, mpsc::Receiver<ShellEvent>) {
let (event_tx, event_rx) = mpsc::channel(256);
(
Self {
sessions: HashMap::new(),
web_sessions: HashMap::new(),
event_tx,
next_id: 0,
},
event_rx,
)
}
pub fn open(
&mut self,
cols: u16,
rows: u16,
command: Option<&str>,
buffer_id: &str,
) -> Result<(String, String), String> {
let id = format!("shell-{}", self.next_id);
self.next_id += 1;
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {e}"))?;
let full_command = command
.map(String::from)
.or_else(|| std::env::var("SHELL").ok())
.unwrap_or_else(|| "/bin/sh".to_string());
let mut parts = full_command.split_whitespace();
let program = parts.next().unwrap_or("/bin/sh");
let label = std::path::Path::new(program)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("shell")
.to_string();
let mut cmd = CommandBuilder::new(program);
for arg in parts {
cmd.arg(arg);
}
cmd.env("TERM", "xterm-256color");
cmd.cwd(std::env::current_dir().unwrap_or_else(|_| "/".into()));
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
drop(pair.slave);
let writer = pair
.master
.take_writer()
.map_err(|e| format!("Failed to get PTY writer: {e}"))?;
let parser = vt100::Parser::new(rows, cols, 1000);
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let tx = self.event_tx.clone();
let reader_id: std::sync::Arc<str> = id.as_str().into();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => {
let _ = tx.blocking_send(ShellEvent::Exited {
id: reader_id,
status: None,
});
break;
}
Ok(n) => {
if tx
.blocking_send(ShellEvent::Output {
id: std::sync::Arc::clone(&reader_id),
bytes: buf[..n].to_vec(),
})
.is_err()
{
break;
}
}
}
}
});
let label_out = label.clone();
self.sessions.insert(
id.clone(),
ShellSession {
parser,
writer,
child,
master: pair.master,
buffer_id: buffer_id.to_string(),
label,
},
);
Ok((id, label_out))
}
pub fn close(&mut self, id: &str) {
if let Some(mut session) = self.sessions.remove(id) {
let _ = session.child.kill();
}
}
pub fn write(&mut self, id: &str, data: &[u8]) {
if let Some(session) = self.sessions.get_mut(id) {
let _ = session.writer.write_all(data);
let _ = session.writer.flush();
}
}
pub fn resize(&mut self, id: &str, cols: u16, rows: u16) {
if let Some(session) = self.sessions.get_mut(id) {
session.parser.screen_mut().set_size(rows, cols);
let _ = session.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
pub fn process_output(&mut self, id: &str, bytes: &[u8]) {
let Some(session) = self.sessions.get_mut(id) else {
return;
};
if !bytes.contains(&b'f') {
session.parser.process(bytes);
return;
}
let patched = rewrite_hvp_to_cup(bytes);
session.parser.process(&patched);
}
pub fn screen(&self, id: &str) -> Option<&vt100::Screen> {
self.sessions.get(id).map(|s| s.parser.screen())
}
pub fn screen_cols(&self, id: &str) -> u16 {
self.sessions
.get(id)
.map_or(80, |s| s.parser.screen().size().1)
}
pub fn screen_to_web(
&self,
id: &str,
) -> Option<(Vec<crate::web::protocol::ShellScreenRow>, u16, u16, bool)> {
let screen = self.sessions.get(id)?.parser.screen();
Some(Self::serialize_screen(screen))
}
#[expect(
clippy::similar_names,
reason = "fg/bg are standard terminal color abbreviations"
)]
fn serialize_screen(
screen: &vt100::Screen,
) -> (Vec<crate::web::protocol::ShellScreenRow>, u16, u16, bool) {
let (screen_rows, screen_cols) = screen.size();
let (cursor_row, cursor_col) = screen.cursor_position();
let cursor_visible = !screen.hide_cursor();
let mut rows = Vec::with_capacity(screen_rows as usize);
for row in 0..screen_rows {
let mut spans: Vec<crate::web::protocol::ShellSpan> = Vec::new();
let mut cur_text = String::new();
let mut cur_fg_raw = vt100::Color::Default;
let mut cur_bg_raw = vt100::Color::Default;
let mut cur_fg = String::new();
let mut cur_bg = String::new();
let mut cur_bold = false;
let mut cur_italic = false;
let mut cur_underline = false;
let mut cur_inverse = false;
for col in 0..screen_cols {
let Some(cell) = screen.cell(row, col) else {
continue;
};
let ch = cell.contents();
let fg_raw = cell.fgcolor();
let bg_raw = cell.bgcolor();
let bold = cell.bold();
let italic = cell.italic();
let underline = cell.underline();
let inverse = cell.inverse();
let fg_changed = fg_raw != cur_fg_raw;
let bg_changed = bg_raw != cur_bg_raw;
if !cur_text.is_empty()
&& (fg_changed
|| bg_changed
|| bold != cur_bold
|| italic != cur_italic
|| underline != cur_underline
|| inverse != cur_inverse)
{
spans.push(crate::web::protocol::ShellSpan {
text: std::mem::take(&mut cur_text),
fg: std::mem::take(&mut cur_fg),
bg: std::mem::take(&mut cur_bg),
bold: cur_bold,
italic: cur_italic,
underline: cur_underline,
inverse: cur_inverse,
});
cur_bold = bold;
cur_italic = italic;
cur_underline = underline;
cur_inverse = inverse;
cur_fg = vt100_color_to_css(fg_raw);
cur_bg = vt100_color_to_css(bg_raw);
cur_fg_raw = fg_raw;
cur_bg_raw = bg_raw;
} else {
if fg_changed {
cur_fg_raw = fg_raw;
cur_fg = vt100_color_to_css(fg_raw);
}
if bg_changed {
cur_bg_raw = bg_raw;
cur_bg = vt100_color_to_css(bg_raw);
}
}
if cur_text.is_empty() {
cur_bold = bold;
cur_italic = italic;
cur_underline = underline;
cur_inverse = inverse;
}
if ch.is_empty() {
cur_text.push(' ');
} else {
cur_text.push_str(ch);
}
}
let has_styling = !cur_bg.is_empty()
|| !cur_fg.is_empty()
|| cur_bold
|| cur_italic
|| cur_underline
|| cur_inverse;
let text = if has_styling {
cur_text
} else {
cur_text.trim_end().to_string()
};
if !text.is_empty() {
spans.push(crate::web::protocol::ShellSpan {
text,
fg: cur_fg,
bg: cur_bg,
bold: cur_bold,
italic: cur_italic,
underline: cur_underline,
inverse: cur_inverse,
});
}
rows.push(crate::web::protocol::ShellScreenRow { spans });
}
(rows, cursor_row, cursor_col, cursor_visible)
}
pub fn buffer_id(&self, id: &str) -> Option<&str> {
self.sessions.get(id).map(|s| s.buffer_id.as_str())
}
pub fn label(&self, id: &str) -> Option<&str> {
self.sessions.get(id).map(|s| s.label.as_str())
}
pub fn session_id_for_buffer(&self, buffer_id: &str) -> Option<&str> {
self.sessions
.iter()
.find(|(_, s)| s.buffer_id == buffer_id)
.map(|(id, _)| id.as_str())
}
pub fn session_count(&self) -> usize {
self.sessions.len()
}
pub fn list_sessions(&self) -> Vec<(&str, &str, &str)> {
self.sessions
.iter()
.map(|(id, s)| (id.as_str(), s.buffer_id.as_str(), s.label.as_str()))
.collect()
}
pub fn kill_all(&mut self) {
for session in self.sessions.values_mut() {
let _ = session.child.kill();
}
self.sessions.clear();
for session in self.web_sessions.values_mut() {
let _ = session.child.kill();
}
self.web_sessions.clear();
}
pub fn open_web(
&mut self,
web_session_id: &str,
cols: u16,
rows: u16,
) -> Result<String, String> {
let id = format!("web-{web_session_id}");
if self.web_sessions.contains_key(&id) {
return Ok(id);
}
if self.web_sessions.len() >= MAX_WEB_SESSIONS {
return Err(format!(
"maximum web shell sessions ({MAX_WEB_SESSIONS}) reached"
));
}
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open web PTY: {e}"))?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let program = std::path::Path::new(&shell)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("sh");
let label = program.to_string();
let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", "xterm-256color");
cmd.cwd(std::env::current_dir().unwrap_or_else(|_| "/".into()));
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn web shell: {e}"))?;
drop(pair.slave);
let writer = pair
.master
.take_writer()
.map_err(|e| format!("Failed to get web PTY writer: {e}"))?;
let parser = vt100::Parser::new(rows, cols, 1000);
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone web PTY reader: {e}"))?;
let tx = self.event_tx.clone();
let reader_id: std::sync::Arc<str> = id.as_str().into();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => {
let _ = tx.blocking_send(ShellEvent::Exited {
id: reader_id,
status: None,
});
break;
}
Ok(n) => {
if tx
.blocking_send(ShellEvent::Output {
id: std::sync::Arc::clone(&reader_id),
bytes: buf[..n].to_vec(),
})
.is_err()
{
break;
}
}
}
}
});
self.web_sessions.insert(
id.clone(),
ShellSession {
parser,
writer,
child,
master: pair.master,
buffer_id: String::new(),
label,
},
);
Ok(id)
}
pub fn close_web(&mut self, id: &str) {
if let Some(mut session) = self.web_sessions.remove(id) {
let _ = session.child.kill();
}
}
pub fn close_web_by_session(&mut self, web_session_id: &str) {
let key = format!("web-{web_session_id}");
self.close_web(&key);
}
pub fn write_web(&mut self, id: &str, data: &[u8]) {
if let Some(session) = self.web_sessions.get_mut(id) {
let _ = session.writer.write_all(data);
let _ = session.writer.flush();
}
}
pub fn resize_web(&mut self, id: &str, cols: u16, rows: u16) {
if let Some(session) = self.web_sessions.get_mut(id) {
session.parser.screen_mut().set_size(rows, cols);
let _ = session.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
pub fn process_output_web(&mut self, id: &str, bytes: &[u8]) {
let Some(session) = self.web_sessions.get_mut(id) else {
return;
};
if !bytes.contains(&b'f') {
session.parser.process(bytes);
return;
}
let patched = rewrite_hvp_to_cup(bytes);
session.parser.process(&patched);
}
pub fn screen_to_web_session(
&self,
id: &str,
) -> Option<(Vec<crate::web::protocol::ShellScreenRow>, u16, u16, bool)> {
let session = self.web_sessions.get(id)?;
let screen = session.parser.screen();
Some(Self::serialize_screen(screen))
}
pub fn screen_cols_web(&self, id: &str) -> u16 {
self.web_sessions
.get(id)
.map_or(80, |s| s.parser.screen().size().1)
}
#[expect(
clippy::unused_self,
reason = "method semantically belongs on ShellManager — may use self in the future"
)]
pub fn is_web_session(&self, id: &str) -> bool {
id.starts_with("web-")
}
pub fn has_web_session(&self, id: &str) -> bool {
self.web_sessions.contains_key(id)
}
}
fn vt100_color_to_css(color: vt100::Color) -> String {
match color {
vt100::Color::Default => String::new(),
vt100::Color::Idx(n) => format!("ansi({n})"),
vt100::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
}
}
fn rewrite_hvp_to_cup(input: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == 0x1b && i + 1 < input.len() && input[i + 1] == b'[' {
output.push(0x1b);
output.push(b'[');
i += 2;
while i < input.len() && (0x20..=0x3F).contains(&input[i]) {
output.push(input[i]);
i += 1;
}
if i < input.len() && (0x40..=0x7E).contains(&input[i]) {
if input[i] == b'f' {
output.push(b'H'); } else {
output.push(input[i]);
}
i += 1;
}
} else {
output.push(input[i]);
i += 1;
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_manager_new_has_no_sessions() {
let (mgr, _rx) = ShellManager::new();
assert_eq!(mgr.session_count(), 0);
}
#[test]
fn shell_manager_open_creates_session() {
let (mut mgr, _rx) = ShellManager::new();
let result = mgr.open(80, 24, Some("/bin/sh"), "shell/sh");
assert!(result.is_ok(), "open failed: {:?}", result.err());
assert_eq!(mgr.session_count(), 1);
mgr.kill_all();
}
#[test]
fn shell_manager_close_removes_session() {
let (mut mgr, _rx) = ShellManager::new();
let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
assert_eq!(mgr.session_count(), 1);
mgr.close(&id);
assert_eq!(mgr.session_count(), 0);
}
#[test]
fn shell_manager_session_id_for_buffer() {
let (mut mgr, _rx) = ShellManager::new();
let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
assert_eq!(mgr.session_id_for_buffer("shell/sh"), Some(id.as_str()));
assert_eq!(mgr.session_id_for_buffer("nonexistent"), None);
mgr.kill_all();
}
#[test]
fn shell_manager_buffer_id_and_label() {
let (mut mgr, _rx) = ShellManager::new();
let (id, label) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
assert_eq!(mgr.buffer_id(&id), Some("shell/sh"));
assert_eq!(label, "sh");
assert_eq!(mgr.label(&id), Some("sh"));
mgr.kill_all();
}
#[test]
fn shell_manager_list_sessions() {
let (mut mgr, _rx) = ShellManager::new();
let _ = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
let sessions = mgr.list_sessions();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].1, "shell/sh");
assert_eq!(sessions[0].2, "sh");
mgr.kill_all();
}
#[test]
fn shell_manager_kill_all_clears_sessions() {
let (mut mgr, _rx) = ShellManager::new();
let _ = mgr.open(80, 24, Some("/bin/sh"), "shell/sh1").unwrap();
let _ = mgr.open(80, 24, Some("/bin/sh"), "shell/sh2").unwrap();
assert_eq!(mgr.session_count(), 2);
mgr.kill_all();
assert_eq!(mgr.session_count(), 0);
}
#[test]
fn shell_manager_process_output_updates_screen() {
let (mut mgr, _rx) = ShellManager::new();
let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
mgr.process_output(&id, b"hello world");
let screen = mgr.screen(&id).unwrap();
let contents = screen.contents();
assert!(
contents.contains("hello world"),
"screen should contain 'hello world', got: {contents:?}"
);
mgr.kill_all();
}
#[test]
fn shell_manager_resize_updates_parser() {
let (mut mgr, _rx) = ShellManager::new();
let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
let screen = mgr.screen(&id).unwrap();
assert_eq!(screen.size(), (24, 80));
mgr.resize(&id, 120, 40);
let screen = mgr.screen(&id).unwrap();
assert_eq!(screen.size(), (40, 120));
mgr.kill_all();
}
#[test]
fn shell_manager_screen_returns_none_for_unknown_id() {
let (mgr, _rx) = ShellManager::new();
assert!(mgr.screen("nonexistent").is_none());
}
#[test]
fn shell_manager_open_returns_label_from_command() {
let (mut mgr, _rx) = ShellManager::new();
let (_, label) = mgr.open(80, 24, Some("/usr/bin/env"), "shell/env").unwrap();
assert_eq!(label, "env");
mgr.kill_all();
}
#[test]
fn shell_manager_multiple_opens_get_unique_ids() {
let (mut mgr, _rx) = ShellManager::new();
let (id1, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh1").unwrap();
let (id2, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh2").unwrap();
assert_ne!(id1, id2);
assert_eq!(mgr.session_count(), 2);
mgr.kill_all();
}
#[test]
fn hvp_rewrite_converts_csi_f_to_csi_h() {
let input = b"\x1b[5;10f";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"\x1b[5;10H");
}
#[test]
fn hvp_rewrite_preserves_plain_text_with_f() {
let input = b"hello from foo";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"hello from foo");
}
#[test]
fn hvp_rewrite_preserves_other_csi_sequences() {
let input = b"\x1b[2J";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"\x1b[2J");
}
#[test]
fn hvp_rewrite_handles_mixed_content() {
let input = b"abc\x1b[1;1fxyz\x1b[0m";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"abc\x1b[1;1Hxyz\x1b[0m");
}
#[test]
fn hvp_rewrite_handles_bare_csi_f() {
let input = b"\x1b[f";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"\x1b[H");
}
#[test]
fn hvp_rewrite_multiple_hvp_in_sequence() {
let input = b"\x1b[1;1f\x1b[2;5f\x1b[10;20f";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"\x1b[1;1H\x1b[2;5H\x1b[10;20H");
}
#[test]
fn hvp_rewrite_does_not_touch_sgr_with_f_in_params() {
let input = b"\x1b[38;2;255;0;0m";
let output = rewrite_hvp_to_cup(input);
assert_eq!(output, b"\x1b[38;2;255;0;0m");
}
#[test]
fn hvp_process_output_applies_cursor_position() {
let (mut mgr, _rx) = ShellManager::new();
let (id, _) = mgr.open(80, 24, Some("/bin/sh"), "shell/sh").unwrap();
mgr.process_output(&id, b"\x1b[5;10f");
let screen = mgr.screen(&id).unwrap();
assert_eq!(screen.cursor_position(), (4, 9));
mgr.kill_all();
}
}