mod agent;
mod app;
mod cli;
mod config;
mod detect;
mod event;
mod git;
mod i18n;
mod ids;
mod integration;
mod ipc;
mod layout;
mod module;
mod persist;
mod platform;
mod terminal;
mod ui;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use ratatui::crossterm::event::{
read as read_event, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste,
EnableMouseCapture, Event,
};
use ratatui::crossterm::execute;
use ratatui::DefaultTerminal;
use crate::app::App;
use crate::event::AppEvent;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("--version") | Some("-V") => {
println!("bohay {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
Some("--help") | Some("-h") => {
let help = [args[0].clone(), "help".to_string()];
std::process::exit(cli::run(&help)?);
}
Some("server") if args.get(2).map(String::as_str) == Some("stop") => return server_stop(),
Some("server") => return ipc::server::run(),
Some("client") => return ipc::client::run(&persist::client_socket_path()),
Some("remote-client-bridge") => return remote_client_bridge(),
Some("--remote") => return remote_attach(&args),
Some("attach") => return attach_cmd(&args),
Some("integration") => std::process::exit(integration::run(&args)?),
Some("--local") => return run_local(),
Some(_) if cli::is_cli(&args) => {
let code = cli::run(&args)?;
std::process::exit(code);
}
_ => {}
}
autodetect_and_attach()
}
pub(crate) fn install_tui_panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(
std::io::stdout(),
DisableMouseCapture,
DisableBracketedPaste
);
prev(info);
}));
}
pub(crate) fn emit_notification(msg: &str) {
use std::io::Write;
let safe: String = msg.chars().filter(|c| !c.is_control()).take(120).collect();
let mut out = std::io::stdout().lock();
let _ = write!(out, "\x1b]9;{safe}\x1b\\");
let _ = out.flush();
}
fn run_local() -> Result<()> {
let mut terminal = ratatui::init();
let _ = execute!(std::io::stdout(), EnableBracketedPaste, EnableMouseCapture);
install_tui_panic_hook();
let result = run(&mut terminal);
let _ = execute!(
std::io::stdout(),
DisableMouseCapture,
DisableBracketedPaste
);
ratatui::restore();
result
}
fn autodetect_and_attach() -> Result<()> {
let sock = persist::client_socket_path();
if !server_running(&sock) {
spawn_server()?;
wait_for_socket(&sock)?;
}
ipc::client::run(&sock)
}
fn remote_client_bridge() -> Result<()> {
let sock = persist::client_socket_path();
if !server_running(&sock) {
spawn_server()?;
wait_for_socket(&sock)?;
}
ipc::client::remote_bridge(&sock)
}
fn attach_cmd(args: &[String]) -> Result<()> {
let sock = persist::client_socket_path();
if !server_running(&sock) {
spawn_server()?;
wait_for_socket(&sock)?;
}
if let Some(id) = args.get(2).filter(|s| s.parse::<u32>().is_ok()) {
let _ = cli::request_attach(id); }
ipc::client::run(&sock)
}
fn remote_attach(args: &[String]) -> Result<()> {
let host = args
.get(2)
.ok_or_else(|| anyhow!("usage: bohay --remote <host> [ssh args]"))?;
let mut cmd = Command::new("ssh");
cmd.arg("-T")
.arg("-o")
.arg("ServerAliveInterval=15")
.arg("-o")
.arg("ServerAliveCountMax=3");
for extra in args.iter().skip(3) {
cmd.arg(extra);
}
cmd.arg(host)
.arg("bohay")
.arg("remote-client-bridge")
.stdin(Stdio::piped())
.stdout(Stdio::piped()); let mut child = cmd
.spawn()
.map_err(|e| anyhow!("failed to launch ssh: {e}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("no ssh stdout"))?;
let stdin = child.stdin.take().ok_or_else(|| anyhow!("no ssh stdin"))?;
let result = ipc::client::attach(stdout, stdin);
let _ = child.kill();
let _ = child.wait();
result
}
fn server_running(sock: &Path) -> bool {
ipc::transport::connect(sock).is_ok()
}
fn spawn_server() -> Result<()> {
let exe = std::env::current_exe()?;
let mut cmd = Command::new(exe);
cmd.arg("server")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0000_0008 | 0x0000_0200);
}
cmd.spawn()?;
Ok(())
}
fn wait_for_socket(sock: &Path) -> Result<()> {
for _ in 0..100 {
if server_running(sock) {
return Ok(());
}
thread::sleep(Duration::from_millis(50));
}
Err(anyhow!("bohay server did not start in time"))
}
fn server_stop() -> Result<()> {
match ipc::transport::connect(&persist::socket_path()) {
Ok(mut s) => {
writeln!(s, r#"{{"id":"1","method":"server.stop","params":{{}}}}"#)?;
let mut line = String::new();
BufReader::new(s).read_line(&mut line)?;
print!("{line}");
Ok(())
}
Err(_) => {
println!("no bohay server running");
Ok(())
}
}
}
fn run(terminal: &mut DefaultTerminal) -> Result<()> {
let (tx, rx) = mpsc::channel::<AppEvent>();
{
let tx = tx.clone();
thread::spawn(move || input_loop(tx));
}
let size = terminal.size()?;
let cols = size.width.saturating_sub(34).max(20);
let rows = size.height.saturating_sub(4).max(4);
let sock = persist::socket_path();
ipc::api::set_socket_path(sock.clone());
let mut app = App::restore_or_new(cols, rows, tx.clone())?;
app.set_color_mode(ipc::protocol::truecolor_supported());
let (api_tx, api_rx) = mpsc::channel::<ipc::api::ApiRequest>();
ipc::api::start_server(sock, api_tx, app.events.clone());
terminal.draw(|f| ui::render(f, &mut app))?;
let mut last_draw = Instant::now();
let mut last_save = Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(ev) => app.handle_event(ev),
Err(RecvTimeoutError::Timeout) => app.spinner = app.spinner.wrapping_add(1),
Err(RecvTimeoutError::Disconnected) => break,
}
while let Ok(ev) = rx.try_recv() {
app.handle_event(ev);
}
while let Ok(req) = api_rx.try_recv() {
let resp = app.handle_api(&req);
let _ = req.reply.send(resp);
}
if app.should_quit || app.detach_requested {
break;
}
if app.session_dirty && last_save.elapsed() > Duration::from_secs(2) {
persist::save(&app);
app.session_dirty = false;
last_save = Instant::now();
}
let since = last_draw.elapsed();
if since < Duration::from_millis(16) {
thread::sleep(Duration::from_millis(16) - since);
}
app.detect_tick(Instant::now());
for msg in app.pending_notify.drain(..) {
emit_notification(&msg);
}
terminal.draw(|f| ui::render(f, &mut app))?;
last_draw = Instant::now();
}
persist::save(&app);
Ok(())
}
fn input_loop(tx: Sender<AppEvent>) {
loop {
let sent = match read_event() {
Ok(Event::Key(k)) => tx.send(AppEvent::Key(k)),
Ok(Event::Mouse(m)) => tx.send(AppEvent::Mouse(m)),
Ok(Event::Resize(w, h)) => tx.send(AppEvent::Resize(w, h)),
Ok(Event::Paste(s)) => tx.send(AppEvent::Paste(s)),
Ok(_) => Ok(()),
Err(_) => break,
};
if sent.is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
#[test]
fn renders_chrome() {
let (tx, _rx) = mpsc::channel::<AppEvent>();
let mut app = App::new(80, 24, tx).expect("spawn pane");
thread::sleep(Duration::from_millis(150));
let backend = TestBackend::new(110, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let mut text = String::new();
for cell in buf.content() {
text.push_str(cell.symbol());
}
assert!(text.contains("bohay"), "brand missing");
assert!(text.contains("NODES"), "nodes header missing");
assert!(text.contains("AGENTS"), "agents header missing");
assert!(text.contains("tab"), "tab status missing");
assert!(text.contains("NORMAL"), "status mode missing");
}
#[test]
fn renders_pane_with_tab() {
use crate::terminal::vt::VtEngine;
let (tx, _rx) = mpsc::channel::<AppEvent>();
let mut app = App::new(80, 24, tx).expect("spawn pane");
let id = app.layout().focus;
app.panes
.get(&id)
.unwrap()
.engine
.lock()
.unwrap()
.advance(b"\tmodified:\tsrc/main.rs\r\n");
let backend = TestBackend::new(110, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
}
#[test]
fn api_serves_requests() {
use std::io::{BufRead, BufReader, Write};
let (tx, _rx) = mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let (api_tx, api_rx) = mpsc::channel::<ipc::api::ApiRequest>();
let path = std::env::temp_dir().join(format!("bohay-test-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&path);
ipc::api::start_server(path.clone(), api_tx, app.events.clone());
thread::spawn(move || {
while let Ok(req) = api_rx.recv() {
let resp = app.handle_api(&req);
let _ = req.reply.send(resp);
}
});
let send = |req: &str| -> String {
let mut s = ipc::transport::connect(&path).unwrap();
writeln!(s, "{req}").unwrap();
let mut line = String::new();
BufReader::new(s).read_line(&mut line).unwrap();
line
};
assert!(send(r#"{"id":"1","method":"ping","params":{}}"#).contains("pong"));
let list = send(r#"{"id":"2","method":"pane.list","params":{}}"#);
assert!(list.contains("pane_list"), "got: {list}");
let split = send(r#"{"id":"3","method":"pane.split","params":{}}"#);
assert!(split.contains("\"pane\""), "got: {split}");
let _ = std::fs::remove_file(&path);
}
#[test]
#[ignore]
fn generate_preview() {
use crate::ui::theme::State;
use ratatui::style::Modifier;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let key = |c, m| AppEvent::Key(KeyEvent::new(KeyCode::Char(c), m));
let (tx, _rx) = mpsc::channel::<AppEvent>();
let mut app = App::new(78, 30, tx).expect("spawn pane");
let left = app.layout().focus;
app.handle_event(key(' ', KeyModifiers::CONTROL)); app.handle_event(key('v', KeyModifiers::NONE)); if let Some(p) = app.panes.get_mut(&left) {
p.command = "claude".to_string();
}
let payload: &[u8] = b"\x1b[2J\x1b[H\r\n\
\x1b[38;5;213m \xe2\x9c\xbb Claude Code\x1b[0m \x1b[38;5;245mopus-4.8\x1b[0m\r\n\r\n\
\x1b[38;5;245m \xe2\x94\x82\x1b[0m \x1b[38;5;252mrefactor the auth module to use the new token store\x1b[0m\r\n\r\n\
\x1b[38;5;114m \xe2\x97\x8f\x1b[0m \x1b[38;5;252mRead\x1b[0m \x1b[38;5;111msrc/auth/mod.rs\x1b[0m \x1b[38;5;245m(214 lines)\x1b[0m\r\n\
\x1b[38;5;114m \xe2\x97\x8f\x1b[0m \x1b[38;5;252mEdit\x1b[0m \x1b[38;5;111msrc/auth/token.rs\x1b[0m \x1b[38;5;114m+42\x1b[0m \x1b[38;5;210m-17\x1b[0m\r\n\
\x1b[38;5;114m \xe2\x97\x8f\x1b[0m \x1b[38;5;252mEdit\x1b[0m \x1b[38;5;111msrc/auth/session.rs\x1b[0m \x1b[38;5;114m+8\x1b[0m \x1b[38;5;210m-3\x1b[0m\r\n\r\n\
\x1b[38;5;221m \xe2\x97\x8f\x1b[0m \x1b[38;5;252mRunning\x1b[0m \x1b[38;5;245mcargo test auth\x1b[0m\r\n\
\x1b[38;5;245m test auth::token::roundtrip ... \x1b[0m\x1b[38;5;114mok\x1b[0m\r\n\
\x1b[38;5;245m test auth::session::expiry ... \x1b[0m\x1b[38;5;114mok\x1b[0m\r\n\r\n\
\x1b[38;5;245m \xe2\x94\x94\xe2\x94\x80\x1b[0m \x1b[38;5;252mAll tests passing. Ready for review.\x1b[0m\r\n\r\n\
\x1b[38;5;240m \xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\x1b[0m\r\n\
\x1b[38;5;245m >\x1b[0m \x1b[7m \x1b[0m\r\n";
if let Some(p) = app.panes.get(&left) {
if let Ok(mut e) = p.engine.lock() {
e.advance(payload);
}
}
let right = app.layout().focus;
let prompt: &[u8] = b"\x1b[2J\x1b[H\r\n \x1b[38;5;108mbohay\x1b[0m \x1b[38;5;245m~/skyrizz/bohay\x1b[0m\r\n \x1b[38;5;215m\xe2\x9d\xaf\x1b[0m \x1b[7m \x1b[0m\x1b[0m";
if let Some(p) = app.panes.get(&right) {
if let Ok(mut e) = p.engine.lock() {
e.advance(prompt);
}
}
if let Some(s) = app.status.get_mut(&left) {
s.state = State::Working;
s.agent = "claude".to_string();
}
if let Some(s) = app.status.get_mut(&right) {
s.state = State::Idle;
s.agent = "zsh".to_string(); }
app.workspaces[0].branch = Some("main".to_string());
let backend = TestBackend::new(110, 34);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let (w, h) = (buf.area.width, buf.area.height);
let mut body = String::new();
for y in 0..h {
for x in 0..w {
let cell = &buf[(x, y)];
let rev = cell.modifier.contains(Modifier::REVERSED);
let mut fg = resolve(cell.fg, (0xcd, 0xd6, 0xf4));
let mut bg = resolve(cell.bg, (0x1e, 0x1e, 0x2e));
if rev {
std::mem::swap(&mut fg, &mut bg);
}
if cell.modifier.contains(Modifier::DIM) {
fg = dim(fg);
}
let mut style = format!(
"color:#{:02x}{:02x}{:02x};background:#{:02x}{:02x}{:02x}",
fg.0, fg.1, fg.2, bg.0, bg.1, bg.2
);
if cell.modifier.contains(Modifier::BOLD) {
style.push_str(";font-weight:700");
}
if cell.modifier.contains(Modifier::ITALIC) {
style.push_str(";font-style:italic");
}
let sym = match cell.symbol() {
"" => " ",
s => s,
};
let esc = sym
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
body.push_str(&format!("<span style=\"{style}\">{esc}</span>"));
}
body.push('\n');
}
let html = format!(
"<!doctype html><meta charset=utf-8><title>bohay preview</title>\
<style>body{{background:#11111b;margin:0;padding:40px;display:flex;justify-content:center}}\
pre{{font:14px/1.3 'SF Mono',Menlo,Consolas,monospace;background:#1e1e2e;padding:0;\
border-radius:12px;overflow:hidden;box-shadow:0 16px 50px rgba(0,0,0,.6)}}\
span{{white-space:pre}}</style><pre>{body}</pre>"
);
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/preview.html");
std::fs::write(path, html).unwrap();
eprintln!("wrote {path}");
let mut ans = String::new();
for y in 0..h {
for x in 0..w {
let cell = &buf[(x, y)];
let fg = resolve(cell.fg, (0xcd, 0xd6, 0xf4));
let bg = resolve(cell.bg, (0x1e, 0x1e, 0x2e));
ans.push_str(&format!(
"\x1b[38;2;{};{};{};48;2;{};{};{}m",
fg.0, fg.1, fg.2, bg.0, bg.1, bg.2
));
if cell.modifier.contains(Modifier::BOLD) {
ans.push_str("\x1b[1m");
}
ans.push_str(match cell.symbol() {
"" => " ",
s => s,
});
ans.push_str("\x1b[0m");
}
ans.push('\n');
}
let apath = concat!(env!("CARGO_MANIFEST_DIR"), "/preview.ans");
std::fs::write(apath, ans).unwrap();
eprintln!("wrote {apath}");
}
fn resolve(c: ratatui::style::Color, reset: (u8, u8, u8)) -> (u8, u8, u8) {
use ratatui::style::Color::*;
match c {
Reset => reset,
Rgb(r, g, b) => (r, g, b),
Indexed(i) => xterm(i),
Black => xterm(0),
Red => xterm(1),
Green => xterm(2),
Yellow => xterm(3),
Blue => xterm(4),
Magenta => xterm(5),
Cyan => xterm(6),
Gray => xterm(7),
DarkGray => xterm(8),
LightRed => xterm(9),
LightGreen => xterm(10),
LightYellow => xterm(11),
LightBlue => xterm(12),
LightMagenta => xterm(13),
LightCyan => xterm(14),
White => xterm(15),
}
}
fn dim(c: (u8, u8, u8)) -> (u8, u8, u8) {
let f = |v: u8| (v as f32 * 0.6) as u8;
(f(c.0), f(c.1), f(c.2))
}
fn xterm(i: u8) -> (u8, u8, u8) {
const ANSI: [(u8, u8, u8); 16] = [
(0x45, 0x47, 0x5a),
(0xf3, 0x8b, 0xa8),
(0xa6, 0xe3, 0xa1),
(0xf9, 0xe2, 0xaf),
(0x89, 0xb4, 0xfa),
(0xf5, 0xc2, 0xe7),
(0x94, 0xe2, 0xd5),
(0xba, 0xc2, 0xde),
(0x58, 0x5b, 0x70),
(0xf3, 0x8b, 0xa8),
(0xa6, 0xe3, 0xa1),
(0xf9, 0xe2, 0xaf),
(0x89, 0xb4, 0xfa),
(0xf5, 0xc2, 0xe7),
(0x94, 0xe2, 0xd5),
(0xa6, 0xad, 0xc8),
];
if i < 16 {
ANSI[i as usize]
} else if i < 232 {
let i = i - 16;
let c = |v: u8| if v == 0 { 0 } else { 55 + 40 * v };
(c(i / 36), c((i / 6) % 6), c(i % 6))
} else {
let v = 8 + 10 * (i - 232);
(v, v, v)
}
}
}