mod app;
mod open;
mod render;
use std::collections::HashMap;
use std::io::{self, Write};
use std::time::Duration;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::layout::Rect;
use ratatui::Terminal;
use app::{Action, App, EditTarget, Tab};
type Tui = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn launch() -> Result<(), String> {
let mut app = App::load()?;
install_panic_hook();
let mut terminal = setup().map_err(|e| format!("failed to start TUI: {e}"))?;
let result = run_loop(&mut terminal, &mut app).await;
let _ = teardown();
result
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = teardown();
original(info);
}));
}
fn setup() -> io::Result<Tui> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
Terminal::new(CrosstermBackend::new(stdout))
}
fn teardown() -> io::Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
async fn run_loop(terminal: &mut Tui, app: &mut App) -> Result<(), String> {
while !app.should_quit() {
terminal
.draw(|f| render::render(app, f))
.map_err(|e| e.to_string())?;
if event::poll(Duration::from_millis(200)).map_err(|e| e.to_string())? {
match event::read().map_err(|e| e.to_string())? {
Event::Key(key) if key.kind == KeyEventKind::Press => app.on_key(key),
Event::Mouse(m) if m.kind == MouseEventKind::Down(MouseButton::Left) => {
on_click(app, terminal.get_frame().area(), m.column, m.row);
}
_ => {}
}
}
if let Some(action) = app.take_action() {
handle_action(terminal, app, action).await;
app.refresh();
}
}
Ok(())
}
fn on_click(app: &mut App, area: Rect, column: u16, row: u16) {
let [tabs, _header, body, _footer] = render::layout(area);
if row == tabs.y {
if let Some(i) = tab_at_x(column) {
app.click_tab(i);
}
return;
}
let inner_top = body.y + 1;
let inner_bottom = body.bottom().saturating_sub(1);
if row >= inner_top && row < inner_bottom {
app.click_row((row - inner_top) as usize);
}
}
fn tab_at_x(x: u16) -> Option<usize> {
let mut start = 1u16; for (i, tab) in Tab::ALL.iter().enumerate() {
let width = tab.title().len() as u16 + 2;
if x >= start && x < start + width {
return Some(i);
}
start += width + 1; }
None
}
async fn handle_action(terminal: &mut Tui, app: &mut App, action: Action) {
let result = match action {
Action::OpenSpecialist(name) => run_specialist_interactive(terminal, &name).await,
Action::RunTool(name) => run_tool(terminal, &name),
Action::Edit(target) => edit(terminal, target),
Action::AddTool(name) => add_tool(terminal, &name),
};
if let Err(e) = result {
app.set_status(e);
}
}
fn suspended<T>(terminal: &mut Tui, f: impl FnOnce() -> T) -> io::Result<T> {
teardown()?;
let out = f();
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
terminal.clear()?;
Ok(out)
}
async fn run_specialist_interactive(terminal: &mut Tui, name: &str) -> Result<(), String> {
teardown().map_err(|e| e.to_string())?;
let prompt = read_line(&format!("prompt for '{name}'> "));
let outcome = match prompt {
Some(prompt) if !prompt.trim().is_empty() => {
crate::commands::run::run_specialist(name, prompt.trim(), None).await
}
_ => Ok(()),
};
if let Err(e) = &outcome {
eprintln!("error: {e}");
}
pause("\nPress Enter to return to the TUI…");
enable_raw_mode().map_err(|e| e.to_string())?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture).map_err(|e| e.to_string())?;
terminal.clear().map_err(|e| e.to_string())?;
outcome
}
fn run_tool(terminal: &mut Tui, name: &str) -> Result<(), String> {
let dir = spawningpool::store::tools_dir();
let tool = spawningpool::tools::resolve(&dir, name)?;
suspended(terminal, || {
println!("[tool {name}] running {}…\n", tool.script.display());
match spawningpool::run_script(&tool.script, &HashMap::new()) {
Ok(run) => {
print!("{}", run.output);
if !run.success {
let detail = match run.code {
Some(code) => format!("exited with status {code}"),
None => "was terminated by a signal".to_string(),
};
if run.output.is_empty() {
eprintln!("\n[tool {name}] {detail} (no output)");
} else {
eprintln!("\n[tool {name}] {detail} — see its output above");
}
}
}
Err(e) => eprintln!("[tool {name}] could not run: {e}"),
}
pause("\nPress Enter to return to the TUI…");
})
.map_err(|e| e.to_string())
}
fn edit(terminal: &mut Tui, target: EditTarget) -> Result<(), String> {
match target {
EditTarget::Tool(name) => {
let dir = spawningpool::store::tools_dir();
let tool = spawningpool::tools::resolve(&dir, &name)?;
let path = tool.script.to_string_lossy().to_string();
launch_editor(terminal, &path)
}
other => edit_registry_entity(terminal, other),
}
}
fn edit_registry_entity(terminal: &mut Tui, target: EditTarget) -> Result<(), String> {
let mut registry = spawningpool::store::load()?;
let (key, json) = entity_json(®istry, &target)?;
let path = std::env::temp_dir().join(format!(
"sp-edit-{}-{}.json",
key.replace(['/', ' '], "_"),
std::process::id()
));
std::fs::write(&path, json).map_err(|e| format!("failed to stage edit: {e}"))?;
let path_str = path.to_string_lossy().to_string();
let argv = open::inline_editor(&path_str, |k| std::env::var(k).ok());
suspended(terminal, || spawn_wait(&argv))
.map_err(|e| e.to_string())?
.map_err(|e| format!("editor failed: {e}"))?;
let edited = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
std::fs::remove_file(&path).ok();
apply_entity_json(&mut registry, &target, &key, &edited)?;
spawningpool::store::save(®istry)
}
fn entity_json(
registry: &spawningpool::Registry,
target: &EditTarget,
) -> Result<(String, String), String> {
match target {
EditTarget::Provider(name) => registry
.providers
.get(name)
.map(|d| (name.clone(), serde_json::to_string_pretty(d).unwrap()))
.ok_or_else(|| format!("no such provider '{name}'")),
EditTarget::Model(name) => registry
.models
.get(name)
.map(|d| (name.clone(), serde_json::to_string_pretty(d).unwrap()))
.ok_or_else(|| format!("no such model '{name}'")),
EditTarget::Specialist(name) => registry
.specialists
.get(name)
.map(|d| (name.clone(), serde_json::to_string_pretty(d).unwrap()))
.ok_or_else(|| format!("no such specialist '{name}'")),
EditTarget::Tool(_) => unreachable!("tools are edited as scripts"),
}
}
fn apply_entity_json(
registry: &mut spawningpool::Registry,
target: &EditTarget,
old_key: &str,
json: &str,
) -> Result<(), String> {
match target {
EditTarget::Provider(_) => {
let def: spawningpool::ProviderDef =
serde_json::from_str(json).map_err(|e| format!("invalid provider JSON: {e}"))?;
registry.providers.remove(old_key);
registry.providers.insert(def.name.clone(), def);
}
EditTarget::Model(_) => {
let def: spawningpool::ModelDef =
serde_json::from_str(json).map_err(|e| format!("invalid model JSON: {e}"))?;
registry.models.remove(old_key);
registry.models.insert(def.id.clone(), def);
}
EditTarget::Specialist(_) => {
let def: spawningpool::Specialist =
serde_json::from_str(json).map_err(|e| format!("invalid specialist JSON: {e}"))?;
def.validate()?;
registry.specialists.remove(old_key);
registry.specialists.insert(def.name.clone(), def);
}
EditTarget::Tool(_) => unreachable!("tools are edited as scripts"),
}
Ok(())
}
fn add_tool(terminal: &mut Tui, name: &str) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let dir = spawningpool::store::tools_dir();
std::fs::create_dir_all(&dir)
.map_err(|e| format!("failed to create {}: {e}", dir.display()))?;
let path = dir.join(name);
if path.exists() {
return Err(format!("a tool named '{name}' already exists"));
}
let template = format!(
"#!/bin/sh\n# desc: {name} — describe what this tool does\n# params: \n\n\
# Arguments arrive as environment variables named after each param.\n\
echo \"hello from {name}\"\n"
);
std::fs::write(&path, template)
.map_err(|e| format!("failed to write {}: {e}", path.display()))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| e.to_string())?;
launch_editor(terminal, &path.to_string_lossy())
}
fn launch_editor(terminal: &mut Tui, file: &str) -> Result<(), String> {
let launch = open::editor_launch(file, |k| std::env::var(k).ok());
if launch.inline {
suspended(terminal, || spawn_wait(&launch.argv))
.map_err(|e| e.to_string())?
.map_err(|e| format!("editor failed: {e}"))
} else {
spawn_wait(&launch.argv).map_err(|e| format!("couldn't open editor pane: {e}"))
}
}
fn spawn_wait(argv: &[String]) -> io::Result<()> {
let status = std::process::Command::new(&argv[0])
.args(&argv[1..])
.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"command exited with status {status}"
)))
}
}
fn read_line(prompt: &str) -> Option<String> {
print!("{prompt}");
io::stdout().flush().ok();
let mut line = String::new();
match io::stdin().read_line(&mut line) {
Ok(0) => None,
Ok(_) => Some(line),
Err(_) => None,
}
}
fn pause(message: &str) {
print!("{message}");
io::stdout().flush().ok();
let mut buf = String::new();
io::stdin().read_line(&mut buf).ok();
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;