mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
388 lines
12 KiB
Rust
388 lines
12 KiB
Rust
use std::io;
|
|
|
|
use crossterm::event::{
|
|
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
|
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
|
};
|
|
use crossterm::execute;
|
|
use tracing::info;
|
|
|
|
pub(crate) const HERDR_ENV_VAR: &str = "HERDR_ENV";
|
|
pub(crate) const HERDR_ENV_VALUE: &str = "1";
|
|
const NESTED_HERDR_MESSAGES: [&str; 6] = [
|
|
"inception detected. we need to go deeper... said no one ever.",
|
|
"recursion is a pathway to many abilities some consider to be... unnatural.",
|
|
"you were so preoccupied with whether you could, you didn't stop to think if you should. — dr. malcolm",
|
|
"recursive herdring is disabled. somewhere, a call stack breathes a sigh of relief.",
|
|
"recursive descent denied. there is, in fact, such a thing as too much herdr.",
|
|
"recursion detected. base case not found. aborting.",
|
|
];
|
|
|
|
mod api;
|
|
mod app;
|
|
mod cli;
|
|
mod config;
|
|
mod detect;
|
|
mod events;
|
|
mod input;
|
|
mod layout;
|
|
mod pane;
|
|
mod persist;
|
|
mod platform;
|
|
mod pty_callbacks;
|
|
mod raw_input;
|
|
mod selection;
|
|
mod sound;
|
|
mod ui;
|
|
mod update;
|
|
mod workspace;
|
|
|
|
fn init_logging() {
|
|
use std::fs::{self, OpenOptions};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
let log_dir = if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
|
|
std::path::PathBuf::from(dir).join("herdr")
|
|
} else if let Ok(home) = std::env::var("HOME") {
|
|
std::path::PathBuf::from(home).join(".config/herdr")
|
|
} else {
|
|
std::path::PathBuf::from("/tmp/herdr")
|
|
};
|
|
let _ = fs::create_dir_all(&log_dir);
|
|
let log_path = log_dir.join("herdr.log");
|
|
|
|
// Rotate: truncate if over 5MB
|
|
if let Ok(meta) = fs::metadata(&log_path) {
|
|
if meta.len() > 5 * 1024 * 1024 {
|
|
let _ = fs::remove_file(&log_path);
|
|
}
|
|
}
|
|
|
|
let file = match OpenOptions::new().create(true).append(true).open(&log_path) {
|
|
Ok(f) => f,
|
|
Err(_) => return, // can't open log file, proceed without logging
|
|
};
|
|
|
|
let filter =
|
|
EnvFilter::try_from_env("HERDR_LOG").unwrap_or_else(|_| EnvFilter::new("herdr=info"));
|
|
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_writer(file)
|
|
.with_ansi(false)
|
|
.with_target(false)
|
|
.init();
|
|
}
|
|
|
|
const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|
# Place this file at ~/.config/herdr/config.toml
|
|
|
|
# Show first-run notification setup on startup.
|
|
# Missing also shows onboarding; set false after you've chosen.
|
|
# onboarding = true
|
|
|
|
[theme]
|
|
# Built-in themes: catppuccin, tokyo-night, dracula, nord, gruvbox,
|
|
# one-dark, solarized, kanagawa, rose-pine
|
|
# name = "catppuccin"
|
|
|
|
# Override individual color tokens on top of the base theme.
|
|
# Accepts: hex (#rrggbb), named colors, or rgb(r,g,b)
|
|
# [theme.custom]
|
|
# accent = "#f5c2e7"
|
|
# red = "#ff6188"
|
|
# green = "#a6e3a1"
|
|
|
|
[keys]
|
|
# Prefix key to enter navigate mode (default: "ctrl+b")
|
|
# Examples: "ctrl+b", "f12", "esc", "-"
|
|
# Accepted syntax: plain keys, ctrl/shift/alt modifiers, and special keys like enter/tab/esc
|
|
# Most reliable bindings are plain keys, ctrl+letter, esc/tab/enter, and function keys.
|
|
# alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup.
|
|
# prefix = "ctrl+b"
|
|
|
|
# Navigate-mode actions
|
|
# new_workspace = "n"
|
|
# rename_workspace = "shift+n"
|
|
# close_workspace = "d"
|
|
# split_vertical = "v"
|
|
# split_horizontal = "-"
|
|
# close_pane = "x"
|
|
# fullscreen = "f"
|
|
# resize_mode = "r"
|
|
# toggle_sidebar = "b"
|
|
|
|
[ui]
|
|
# Sidebar width (auto-scaled based on workspace names, this sets the default)
|
|
# sidebar_width = 26
|
|
|
|
# Ask for confirmation before closing a workspace
|
|
# confirm_close = true
|
|
|
|
# Accent color for highlights, borders, and navigation UI.
|
|
# Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b)
|
|
# accent = "cyan"
|
|
|
|
# Optional visual toast notifications for background workspace events
|
|
[ui.toast]
|
|
# enabled = false
|
|
|
|
# Play sounds when agents change state in background workspaces
|
|
[ui.sound]
|
|
# enabled = true
|
|
|
|
# Per-agent overrides: default | on | off
|
|
# By default, droid is muted.
|
|
# [ui.sound.agents]
|
|
# droid = "off"
|
|
|
|
[advanced]
|
|
# Allow launching herdr from inside a herdr-managed pane.
|
|
# allow_nested = false
|
|
"##;
|
|
|
|
fn should_block_nested(config: &config::Config) -> bool {
|
|
should_block_nested_for_env(config, std::env::var(HERDR_ENV_VAR).ok().as_deref())
|
|
}
|
|
|
|
fn should_block_nested_for_env(config: &config::Config, herdr_env: Option<&str>) -> bool {
|
|
!config.advanced.allow_nested && herdr_env == Some(HERDR_ENV_VALUE)
|
|
}
|
|
|
|
fn random_nested_message() -> &'static str {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.subsec_nanos() as usize)
|
|
.unwrap_or(0);
|
|
let index = (nanos ^ (std::process::id() as usize)) % NESTED_HERDR_MESSAGES.len();
|
|
NESTED_HERDR_MESSAGES[index]
|
|
}
|
|
|
|
fn main() -> io::Result<()> {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? {
|
|
std::process::exit(code);
|
|
}
|
|
|
|
// Subcommands and flags (no TUI, no logging needed)
|
|
if args.get(1).map(|s| s.as_str()) == Some("update") {
|
|
match update::self_update() {
|
|
Ok(_) => return Ok(()),
|
|
Err(e) => {
|
|
eprintln!("update failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if args.iter().any(|a| a == "--help" || a == "-h") {
|
|
println!("herdr — terminal workspace manager for AI coding agents");
|
|
println!();
|
|
println!("Usage: herdr [options]");
|
|
println!(" herdr update");
|
|
println!(" herdr workspace <subcommand> ...");
|
|
println!(" herdr pane <subcommand> ...");
|
|
println!(" herdr wait <subcommand> ...");
|
|
println!();
|
|
println!("Commands:");
|
|
println!(" update Download and install the latest version");
|
|
println!(" workspace workspace helpers over the socket api");
|
|
println!(" pane pane control helpers over the socket api");
|
|
println!(" wait blocking wait helpers over the socket api");
|
|
println!();
|
|
println!("Options:");
|
|
println!(" --no-session Don't restore or save sessions");
|
|
println!(" --default-config Print default configuration and exit");
|
|
println!(" --version, -V Print version and exit");
|
|
println!(" --help, -h Show this help");
|
|
println!();
|
|
println!("Config: ~/.config/herdr/config.toml");
|
|
println!("Logs: ~/.config/herdr/herdr.log");
|
|
println!("Home: https://herdr.dev");
|
|
return Ok(());
|
|
}
|
|
|
|
if args.iter().any(|a| a == "--version" || a == "-V") {
|
|
println!("herdr {}", env!("CARGO_PKG_VERSION"));
|
|
return Ok(());
|
|
}
|
|
|
|
if args.iter().any(|a| a == "--default-config") {
|
|
print!("{DEFAULT_CONFIG}");
|
|
return Ok(());
|
|
}
|
|
|
|
// Reject unknown flags
|
|
let known_flags = [
|
|
"--no-session",
|
|
"--version",
|
|
"-V",
|
|
"--default-config",
|
|
"--help",
|
|
"-h",
|
|
];
|
|
for arg in &args[1..] {
|
|
if arg.starts_with('-') && !known_flags.contains(&arg.as_str()) {
|
|
eprintln!("unknown option: {arg}");
|
|
eprintln!("run 'herdr --help' for usage");
|
|
std::process::exit(1);
|
|
}
|
|
if !arg.starts_with('-') && !["update", "workspace", "pane", "wait"].contains(&arg.as_str())
|
|
{
|
|
eprintln!("unknown command: {arg}");
|
|
eprintln!("run 'herdr --help' for usage");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
let loaded_config = config::Config::load();
|
|
if should_block_nested(&loaded_config.config) {
|
|
eprintln!("\x1b[1merror:\x1b[0m nested herdr is disabled by default.");
|
|
eprintln!("see configuration if you want to enable it.");
|
|
eprintln!();
|
|
eprintln!("\x1b[2m\"{}\"\x1b[0m", random_nested_message());
|
|
std::process::exit(1);
|
|
}
|
|
|
|
init_logging();
|
|
|
|
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let event_hub = api::EventHub::default();
|
|
let _api_server = api::start_server(api_tx, event_hub.clone())?;
|
|
|
|
let no_session = std::env::args().any(|a| a == "--no-session");
|
|
let in_tmux = std::env::var("TMUX").is_ok();
|
|
|
|
let original_hook = std::panic::take_hook();
|
|
let panic_in_tmux = in_tmux;
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
tracing::error!("PANIC: {info}");
|
|
if panic_in_tmux {
|
|
let _ = std::io::Write::write_all(&mut io::stdout(), b"\x1b[>4;0m");
|
|
}
|
|
let _ = execute!(
|
|
io::stdout(),
|
|
PopKeyboardEnhancementFlags,
|
|
DisableBracketedPaste,
|
|
DisableMouseCapture
|
|
);
|
|
ratatui::restore();
|
|
original_hook(info);
|
|
}));
|
|
|
|
let config = &loaded_config.config;
|
|
let config_diagnostic = if loaded_config.diagnostics.is_empty() {
|
|
None
|
|
} else if loaded_config.diagnostics.len() == 1 {
|
|
Some(loaded_config.diagnostics[0].clone())
|
|
} else {
|
|
Some(format!(
|
|
"{} (and {} more)",
|
|
loaded_config.diagnostics[0],
|
|
loaded_config.diagnostics.len() - 1
|
|
))
|
|
};
|
|
info!("herdr starting, pid={}", std::process::id());
|
|
|
|
// Background auto-update (non-blocking, best-effort)
|
|
// Downloads and installs new version silently, notifies TUI when done.
|
|
// Skipped in --no-session mode (testing).
|
|
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("failed to create tokio runtime");
|
|
|
|
let result = rt.block_on(async {
|
|
let mut terminal = ratatui::init();
|
|
execute!(
|
|
io::stdout(),
|
|
EnableMouseCapture,
|
|
EnableBracketedPaste,
|
|
PushKeyboardEnhancementFlags(
|
|
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
|
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
|
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
|
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
|
|
)
|
|
)?;
|
|
|
|
// tmux doesn't understand kitty keyboard protocol push (\e[>1u).
|
|
// It uses modifyOtherKeys mode to send CSI u sequences for modified keys.
|
|
// Enable modifyOtherKeys mode 2 so tmux sends Shift+Enter as \e[13;2u etc.
|
|
if in_tmux {
|
|
use std::io::Write;
|
|
std::io::stdout().write_all(b"\x1b[>4;2m")?;
|
|
std::io::stdout().flush()?;
|
|
}
|
|
|
|
let mut app = app::App::new(config, no_session, config_diagnostic, api_rx, event_hub);
|
|
let result = app.run(&mut terminal).await;
|
|
|
|
// Reset modifyOtherKeys if we enabled it
|
|
if in_tmux {
|
|
use std::io::Write;
|
|
std::io::stdout().write_all(b"\x1b[>4;0m")?;
|
|
std::io::stdout().flush()?;
|
|
}
|
|
|
|
execute!(
|
|
io::stdout(),
|
|
PopKeyboardEnhancementFlags,
|
|
DisableBracketedPaste,
|
|
DisableMouseCapture
|
|
)?;
|
|
ratatui::restore();
|
|
|
|
// Drop app (and all workspaces/panes) before runtime shuts down
|
|
drop(app);
|
|
|
|
result
|
|
});
|
|
|
|
// Shut down runtime immediately — kills lingering PTY reader/writer tasks
|
|
rt.shutdown_timeout(std::time::Duration::from_millis(100));
|
|
|
|
info!("herdr exiting");
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn nested_herdr_blocks_when_env_is_set() {
|
|
let config = config::Config::default();
|
|
assert!(should_block_nested_for_env(&config, Some(HERDR_ENV_VALUE)));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_herdr_does_not_block_when_allowed() {
|
|
let config: config::Config = toml::from_str("[advanced]\nallow_nested = true\n").unwrap();
|
|
assert!(!should_block_nested_for_env(&config, Some(HERDR_ENV_VALUE)));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_herdr_does_not_block_without_env() {
|
|
let config = config::Config::default();
|
|
assert!(!should_block_nested_for_env(&config, None));
|
|
}
|
|
|
|
#[test]
|
|
fn random_nested_message_comes_from_known_set() {
|
|
let message = random_nested_message();
|
|
assert!(NESTED_HERDR_MESSAGES.contains(&message));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_message_strings_no_longer_repeat_herdr_prefix() {
|
|
assert!(NESTED_HERDR_MESSAGES
|
|
.iter()
|
|
.all(|message| !message.starts_with("herdr:")));
|
|
}
|
|
}
|