use std::io::Read;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use clap::{CommandFactory, Parser};
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
mod app;
mod ui;
mod widgets;
use app::{App, ExecutionState};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(long)]
cmd: Option<String>,
#[arg(long)]
spec_file: Option<PathBuf>,
#[arg(long)]
usage: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
spec_cmd: Vec<String>,
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let args = Args::parse();
if args.usage {
let mut cmd = Args::command();
let bin_name = std::env::args()
.next()
.unwrap_or_else(|| "tuisage".to_string());
let mut buf = Vec::new();
clap_usage::generate(&mut cmd, bin_name, &mut buf);
print!("{}", String::from_utf8_lossy(&buf));
return Ok(());
}
let has_spec_cmd = !args.spec_cmd.is_empty();
let has_spec_file = args.spec_file.is_some();
if has_spec_cmd && has_spec_file {
return Err(color_eyre::eyre::eyre!(
"Cannot specify both a spec command and --spec-file. Use --help for usage information."
));
}
if !has_spec_cmd && !has_spec_file {
return Err(color_eyre::eyre::eyre!(
"Must specify either a spec command or --spec-file. Use --help for usage information."
));
}
let mut spec = if has_spec_cmd {
let spec_cmd = args.spec_cmd.join(" ");
let output = run_spec_command(&spec_cmd)?;
output.parse::<usage::Spec>().map_err(|e| {
color_eyre::eyre::eyre!(
"Failed to parse usage spec from command '{}': {}",
spec_cmd,
e
)
})?
} else if let Some(ref spec_file) = args.spec_file {
usage::Spec::parse_file(spec_file).map_err(|e| {
color_eyre::eyre::eyre!(
"Failed to parse usage spec '{}': {}",
spec_file.display(),
e
)
})?
} else {
unreachable!()
};
if let Some(ref cmd) = args.cmd {
spec.bin = cmd.clone();
}
crossterm::execute!(std::io::stderr(), crossterm::event::EnableMouseCapture)?;
let mut terminal = ratatui::init();
let mut app = App::new(spec);
let result = run_event_loop(&mut terminal, &mut app);
ratatui::restore();
crossterm::execute!(std::io::stderr(), crossterm::event::DisableMouseCapture)?;
result
}
fn run_spec_command(cmd: &str) -> color_eyre::Result<String> {
let output = if cfg!(target_os = "windows") {
ProcessCommand::new("cmd")
.args(["/C", cmd])
.output()
.map_err(|e| color_eyre::eyre::eyre!("Failed to run spec command '{}': {}", cmd, e))?
} else {
ProcessCommand::new("sh")
.args(["-c", cmd])
.output()
.map_err(|e| color_eyre::eyre::eyre!("Failed to run spec command '{}': {}", cmd, e))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(color_eyre::eyre::eyre!(
"Spec command '{}' failed with status {}{}",
cmd,
output.status,
if stderr.is_empty() {
String::new()
} else {
format!(": {}", stderr.trim())
}
));
}
String::from_utf8(output.stdout).map_err(|e| {
color_eyre::eyre::eyre!(
"Spec command '{}' produced invalid UTF-8 output: {}",
cmd,
e
)
})
}
fn spawn_command(app: &mut App, terminal_size: ratatui::layout::Size) -> color_eyre::Result<()> {
let parts = app.build_command_parts();
if parts.is_empty() {
return Err(color_eyre::eyre::eyre!("No command to execute"));
}
let command_display = app.build_command();
let mut cmd = CommandBuilder::new(&parts[0]);
for arg in &parts[1..] {
cmd.arg(arg);
}
if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd);
}
let pty_rows = terminal_size.height.saturating_sub(4).max(4);
let pty_cols = terminal_size.width.max(20);
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: pty_rows,
cols: pty_cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| color_eyre::eyre::eyre!("Failed to open PTY: {}", e))?;
let parser = Arc::new(RwLock::new(vt100::Parser::new(pty_rows, pty_cols, 0)));
let exited = Arc::new(AtomicBool::new(false));
let exit_status: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let child_result = pair.slave.spawn_command(cmd);
drop(pair.slave);
let mut child = child_result
.map_err(|e| color_eyre::eyre::eyre!("Failed to spawn command '{}': {}", parts[0], e))?;
{
let exited = exited.clone();
let exit_status = exit_status.clone();
std::thread::spawn(move || {
match child.wait() {
Ok(status) => {
if let Ok(mut s) = exit_status.lock() {
*s = Some(format!("{}", status));
}
}
Err(e) => {
if let Ok(mut s) = exit_status.lock() {
*s = Some(format!("error: {}", e));
}
}
}
exited.store(true, Ordering::Relaxed);
});
}
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| color_eyre::eyre::eyre!("Failed to clone PTY reader: {}", e))?;
{
let parser = parser.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break, Ok(size) => {
if let Ok(mut p) = parser.write() {
p.process(&buf[..size]);
}
}
Err(_) => break,
}
}
});
}
let writer = pair
.master
.take_writer()
.map_err(|e| color_eyre::eyre::eyre!("Failed to take PTY writer: {}", e))?;
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(Some(writer)));
let pty_master: Arc<Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>> =
Arc::new(Mutex::new(Some(pair.master)));
{
let exited = exited.clone();
let pty_writer = pty_writer.clone();
let pty_master = pty_master.clone();
std::thread::spawn(move || {
while !exited.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(50));
}
std::thread::sleep(Duration::from_millis(100));
if let Ok(mut w) = pty_writer.lock() {
*w = None;
}
if let Ok(mut m) = pty_master.lock() {
*m = None;
}
});
}
let state = ExecutionState {
command_display,
parser,
pty_writer,
pty_master,
exited,
exit_status,
};
app.start_execution(state);
Ok(())
}
fn run_event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
) -> color_eyre::Result<()> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
loop {
terminal.draw(|frame| ui::render(frame, app))?;
if app.is_executing() {
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
app.handle_key(key);
}
Event::Resize(width, height) => {
let pty_rows = height.saturating_sub(4).max(4);
let pty_cols = width.max(20);
app.resize_pty(pty_rows, pty_cols);
}
_ => {}
}
}
continue;
}
match event::read()? {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(());
}
match app.handle_key(key) {
app::Action::None => {}
app::Action::Quit => return Ok(()),
app::Action::Execute => {
let size = terminal.size()?;
let term_size = ratatui::layout::Size {
width: size.width,
height: size.height,
};
if let Err(e) = spawn_command(app, term_size) {
eprintln!("Failed to execute command: {}", e);
}
}
}
}
Event::Mouse(mouse) => match app.handle_mouse(mouse) {
app::Action::None => {}
app::Action::Quit => return Ok(()),
app::Action::Execute => {
let size = terminal.size()?;
let term_size = ratatui::layout::Size {
width: size.width,
height: size.height,
};
if let Err(e) = spawn_command(app, term_size) {
eprintln!("Failed to execute command: {}", e);
}
}
},
Event::Resize(_, _) => {
}
_ => {}
}
}
}