use std::io::{Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use super::theme::Theme;
pub struct TermPanel {
pub title: String,
pub rows: u16,
pub cols: u16,
pub exited: Arc<AtomicBool>,
dirty: Arc<AtomicBool>,
parser: Arc<Mutex<vt100::Parser>>,
writer: Box<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
}
impl TermPanel {
pub fn spawn(
title: &str,
program: Option<&str>,
args: &[String],
cwd: &Path,
rows: u16,
cols: u16,
) -> anyhow::Result<TermPanel> {
let pty = native_pty_system();
let pair = pty.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = match program {
Some(p) => CommandBuilder::new(p),
None => CommandBuilder::new_default_prog(),
};
for a in args {
cmd.arg(a);
}
cmd.cwd(cwd);
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd)?;
drop(pair.slave);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 5_000)));
let exited = Arc::new(AtomicBool::new(false));
let dirty = Arc::new(AtomicBool::new(true));
let mut reader = pair.master.try_clone_reader()?;
let writer = pair.master.take_writer()?;
{
let parser = parser.clone();
let exited = exited.clone();
let dirty = dirty.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if let Ok(mut p) = parser.lock() {
p.process(&buf[..n]);
}
dirty.store(true, Ordering::Relaxed);
}
}
}
exited.store(true, Ordering::Relaxed);
dirty.store(true, Ordering::Relaxed);
});
}
Ok(TermPanel {
title: title.to_string(),
rows,
cols,
exited,
dirty,
parser,
writer,
master: pair.master,
child,
})
}
pub fn take_dirty(&self) -> bool {
self.dirty.swap(false, Ordering::Relaxed)
}
pub fn has_exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
pub fn resize(&mut self, rows: u16, cols: u16) {
if (rows, cols) == (self.rows, self.cols) || rows == 0 || cols == 0 {
return;
}
self.rows = rows;
self.cols = cols;
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
if let Ok(mut p) = self.parser.lock() {
p.screen_mut().set_size(rows, cols);
}
self.dirty.store(true, Ordering::Relaxed);
}
pub fn write(&mut self, bytes: &[u8]) {
let _ = self.writer.write_all(bytes);
let _ = self.writer.flush();
}
fn application_cursor(&self) -> bool {
self.parser
.lock()
.map(|p| p.screen().application_cursor())
.unwrap_or(false)
}
pub fn kill(&mut self) {
let _ = self.child.kill();
}
}
pub fn encode_key(panel: &TermPanel, k: ratatui::crossterm::event::KeyEvent) -> Vec<u8> {
use ratatui::crossterm::event::{KeyCode::*, KeyModifiers};
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let alt = k.modifiers.contains(KeyModifiers::ALT);
let app = panel.application_cursor();
let arrow = |c: char| {
if app {
format!("\x1bO{c}").into_bytes()
} else {
format!("\x1b[{c}").into_bytes()
}
};
let mut out = match k.code {
Char(c) if ctrl => {
let b = c.to_ascii_lowercase() as u8;
match b {
b'a'..=b'z' => vec![b - b'a' + 1],
b'[' => vec![0x1b],
b'\\' => vec![0x1c],
b']' => vec![0x1d],
b'^' => vec![0x1e],
b'_' | b'/' => vec![0x1f],
b' ' | b'@' => vec![0x00],
_ => c.to_string().into_bytes(),
}
}
Char(c) => c.to_string().into_bytes(),
Enter => vec![b'\r'],
Backspace => vec![0x7f],
Tab => vec![b'\t'],
BackTab => b"\x1b[Z".to_vec(),
Esc => vec![0x1b],
Up => arrow('A'),
Down => arrow('B'),
Right => arrow('C'),
Left => arrow('D'),
Home => b"\x1b[H".to_vec(),
End => b"\x1b[F".to_vec(),
PageUp => b"\x1b[5~".to_vec(),
PageDown => b"\x1b[6~".to_vec(),
Delete => b"\x1b[3~".to_vec(),
Insert => b"\x1b[2~".to_vec(),
F(n) => match n {
1 => b"\x1bOP".to_vec(),
2 => b"\x1bOQ".to_vec(),
3 => b"\x1bOR".to_vec(),
4 => b"\x1bOS".to_vec(),
5..=15 => {
const CODES: [u8; 11] = [15, 17, 18, 19, 20, 21, 23, 24, 25, 26, 28];
format!("\x1b[{}~", CODES[(n - 5) as usize]).into_bytes()
}
_ => Vec::new(),
},
_ => Vec::new(),
};
if alt && !out.is_empty() && out[0] != 0x1b {
let mut esc = vec![0x1b];
esc.append(&mut out);
return esc;
}
out
}
fn conv(c: vt100::Color, fallback: Color) -> Color {
match c {
vt100::Color::Default => fallback,
vt100::Color::Idx(i) => Color::Indexed(i),
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
pub fn render(f: &mut Frame, area: Rect, panel: &mut TermPanel, theme: &Theme, focused: bool) {
let head_style = Style::default()
.fg(if focused { theme.on_accent } else { theme.dim })
.bg(if focused {
theme.accent
} else {
theme.elevated
});
let status = if panel.has_exited() { " (exited)" } else { "" };
let hint = if focused {
"^Alt+T back to chat · ^Alt+W close"
} else {
"^Alt+T focus"
};
let title = format!(" ▸ {}{}", panel.title, status);
let pad =
(area.width as usize).saturating_sub(title.chars().count() + hint.chars().count() + 2);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(title, head_style.add_modifier(Modifier::BOLD)),
Span::styled(" ".repeat(pad), head_style),
Span::styled(format!("{hint} "), head_style),
])),
Rect { height: 1, ..area },
);
let body = Rect {
y: area.y + 1,
height: area.height.saturating_sub(1),
..area
};
if body.height == 0 || body.width == 0 {
return;
}
panel.resize(body.height, body.width);
f.render_widget(
Block::default().style(Style::default().bg(theme.base)),
body,
);
let Ok(parser) = panel.parser.lock() else {
return;
};
let screen = parser.screen();
let mut lines: Vec<Line> = Vec::with_capacity(body.height as usize);
for row in 0..body.height {
let mut spans: Vec<Span> = Vec::new();
let mut run = String::new();
let mut run_style: Option<Style> = None;
for col in 0..body.width {
let (text, style) = match screen.cell(row, col) {
Some(cell) => {
let mut st = Style::default()
.fg(conv(cell.fgcolor(), theme.assistant))
.bg(conv(cell.bgcolor(), theme.base));
if cell.bold() {
st = st.add_modifier(Modifier::BOLD);
}
if cell.italic() {
st = st.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
st = st.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
st = st.add_modifier(Modifier::REVERSED);
}
let t = if cell.has_contents() {
cell.contents().to_string()
} else {
" ".to_string()
};
(t, st)
}
None => (" ".to_string(), Style::default().bg(theme.base)),
};
match run_style {
Some(s) if s == style => run.push_str(&text),
Some(s) => {
spans.push(Span::styled(std::mem::take(&mut run), s));
run.push_str(&text);
run_style = Some(style);
}
None => {
run.push_str(&text);
run_style = Some(style);
}
}
}
if let Some(s) = run_style {
spans.push(Span::styled(run, s));
}
lines.push(Line::from(spans));
}
f.render_widget(Paragraph::new(lines), body);
if focused && !screen.hide_cursor() {
let (cy, cx) = screen.cursor_position();
if cy < body.height && cx < body.width {
f.set_cursor_position((body.x + cx, body.y + cy));
}
}
}
#[derive(Default)]
pub struct Terminals {
pub panels: Vec<TermPanel>,
pub active: usize,
pub visible: bool,
pub focused: bool,
pub height_pct: u16,
}
impl Terminals {
pub fn open(
&mut self,
title: &str,
program: Option<&str>,
args: &[String],
cwd: &Path,
) -> Result<(), String> {
if self.panels.len() >= 4 {
return Err("terminal limit reached (4) — close one with ^Alt+W".into());
}
match TermPanel::spawn(title, program, args, cwd, 24, 80) {
Ok(p) => {
self.panels.push(p);
self.active = self.panels.len() - 1;
self.visible = true;
self.focused = true;
if self.height_pct == 0 {
self.height_pct = 45;
}
Ok(())
}
Err(e) => Err(format!("could not start terminal: {e}")),
}
}
pub fn close_active(&mut self) {
if self.panels.is_empty() {
return;
}
let i = self.active.min(self.panels.len() - 1);
self.panels[i].kill();
self.panels.remove(i);
self.active = self.active.min(self.panels.len().saturating_sub(1));
if self.panels.is_empty() {
self.visible = false;
self.focused = false;
}
}
pub fn active_panel(&mut self) -> Option<&mut TermPanel> {
let i = self.active;
self.panels.get_mut(i)
}
pub fn next(&mut self) {
if !self.panels.is_empty() {
self.active = (self.active + 1) % self.panels.len();
}
}
pub fn any_dirty(&self) -> bool {
self.panels.iter().any(|p| p.take_dirty())
}
pub fn grow(&mut self, delta: i16) {
let next = self.height_pct as i16 + delta;
self.height_pct = next.clamp(15, 80) as u16;
}
pub fn kill_all(&mut self) {
for p in &mut self.panels {
p.kill();
}
self.panels.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn panel() -> TermPanel {
let prog = if cfg!(windows) { "cmd.exe" } else { "sh" };
let args = if cfg!(windows) {
vec!["/C".to_string(), "exit".to_string()]
} else {
vec!["-c".to_string(), "exit".to_string()]
};
TermPanel::spawn("test", Some(prog), &args, Path::new("."), 10, 40).unwrap()
}
#[test]
fn spawns_a_pty_and_reports_its_size() {
let p = panel();
assert_eq!((p.rows, p.cols), (10, 40));
}
#[test]
fn encodes_control_and_arrow_keys() {
let p = panel();
let key = |code, m| KeyEvent::new(code, m);
assert_eq!(
encode_key(&p, key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
vec![3]
);
assert_eq!(
encode_key(&p, key(KeyCode::Char('a'), KeyModifiers::NONE)),
b"a".to_vec()
);
assert_eq!(
encode_key(&p, key(KeyCode::Enter, KeyModifiers::NONE)),
b"\r".to_vec()
);
assert_eq!(
encode_key(&p, key(KeyCode::Backspace, KeyModifiers::NONE)),
vec![0x7f]
);
assert_eq!(
encode_key(&p, key(KeyCode::Up, KeyModifiers::NONE)),
b"\x1b[A".to_vec()
);
assert_eq!(
encode_key(&p, key(KeyCode::Char('b'), KeyModifiers::ALT)),
vec![0x1b, b'b']
);
}
#[test]
fn terminals_open_close_and_clamp_height() {
let mut t = Terminals {
height_pct: 45,
..Default::default()
};
t.grow(100);
assert_eq!(t.height_pct, 80);
t.grow(-200);
assert_eq!(t.height_pct, 15);
assert!(t.panels.is_empty());
t.close_active(); assert!(!t.visible);
}
}