//! Baobab Actor UI - Self-contained WASM UI with automatic Webdis installation //! //! This binary provides a complete actor monitoring and job dispatch interface //! with automatic Webdis installation and management. use anyhow::Result; use clap::Parser; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use hero_job::{Job, JobStatus, ScriptType}; use log::{error, info}; use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}, Frame, Terminal, }; use redis::AsyncCommands; use std::fs; use std::io; use std::path::PathBuf; use std::time::{Duration, Instant}; use uuid::Uuid; #[derive(Parser)] #[command(name = "baobab-actor-ui")] #[command(about = "Terminal UI for Baobab Actor - Monitor and dispatch jobs to a single actor")] struct Args { /// Actor ID to monitor #[arg(short, long)] id: String, /// Path to actor binary #[arg(short, long)] path: PathBuf, /// Directory containing example .rhai scripts #[arg(short, long)] example_dir: Option, /// Redis URL for job queue #[arg(short, long, default_value = "redis://localhost:6379")] redis_url: String, /// Enable verbose logging #[arg(short, long)] verbose: bool, } #[derive(Debug, Clone, PartialEq)] enum AppState { Main, JobCreation, JobDetails, ExampleSelection, Confirmation, } #[derive(Debug, Clone, PartialEq)] enum TabState { Dashboard, Jobs, NewJob, Examples, } #[derive(Debug, Clone)] struct JobCreationForm { caller_id: String, context_id: String, script_content: String, timeout: String, current_field: usize, // 0=caller_id, 1=context_id, 2=timeout, 3=script } impl Default for JobCreationForm { fn default() -> Self { Self { caller_id: "tui_user".to_string(), context_id: "default_context".to_string(), script_content: String::new(), timeout: "300".to_string(), current_field: 0, } } } #[derive(Debug, Clone)] struct ExampleScript { name: String, path: PathBuf, content: String, } #[derive(Debug, Clone)] struct JobInfo { job: Job, status: JobStatus, output: Option, error: Option, created_at: Instant, } #[derive(Debug)] struct App { // Core state actor_id: String, actor_path: PathBuf, redis_client: redis::Client, // UI state current_tab: TabState, app_state: AppState, should_quit: bool, // Jobs jobs: Vec, job_list_state: ListState, selected_job: Option, // Job creation job_form: JobCreationForm, last_executed_job: Option, // Examples examples: Vec, example_list_state: ListState, selected_example: Option, // Status status_message: Option, last_refresh: Instant, } pub struct ActorTui; impl ActorTui { pub fn new() -> Self { Self } } impl App { fn new(actor_id: String, actor_path: PathBuf, redis_url: String, example_dir: Option) -> Result { let redis_client = redis::Client::open(redis_url)?; let mut app = Self { actor_id, actor_path, redis_client, current_tab: TabState::Dashboard, app_state: AppState::Main, should_quit: false, jobs: Vec::new(), job_list_state: ListState::default(), selected_job: None, job_form: JobCreationForm::default(), last_executed_job: None, examples: Vec::new(), example_list_state: ListState::default(), selected_example: None, status_message: None, last_refresh: Instant::now(), }; // Load example scripts if directory provided if let Some(dir) = example_dir { app.load_examples(dir)?; } Ok(app) } fn load_examples(&mut self, dir: PathBuf) -> Result<()> { if !dir.exists() { return Ok(()); } for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("rhai") { let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let content = fs::read_to_string(&path) .unwrap_or_else(|_| "// Failed to read script".to_string()); self.examples.push(ExampleScript { name, path, content, }); } } Ok(()) } async fn refresh_jobs(&mut self) -> Result<()> { let mut conn = self.redis_client.get_multiplexed_async_connection().await?; // Get all job keys for this actor let pattern = format!("hero:job:*"); let keys: Vec = conn.keys(pattern).await?; self.jobs.clear(); for key in keys { if let Ok(job_data) = conn.hgetall::>(key).await { // Filter jobs by script_type field (e.g., "OSIS" for OSIS actor) if let Some(script_type) = job_data.get("script_type") { if script_type.to_uppercase() == self.actor_id.to_uppercase() { if let Ok(job) = self.parse_job_from_redis(&job_data) { let status = match job_data.get("status").map(|s| s.as_str()).unwrap_or("dispatched") { "started" => JobStatus::Started, "finished" => JobStatus::Finished, "error" => JobStatus::Error, "waiting_for_prerequisites" => JobStatus::WaitingForPrerequisites, _ => JobStatus::Dispatched, }; let output = job_data.get("output").cloned(); let error = job_data.get("error").cloned(); let job_info = JobInfo { job, status, output, error, created_at: Instant::now(), }; self.jobs.push(job_info); } } } } } // Sort jobs by creation time (newest first) self.jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); self.last_refresh = Instant::now(); Ok(()) } fn parse_job_from_redis(&self, data: &std::collections::HashMap) -> Result { // Use actual camelCase field names from Redis let id = data.get("jobId").unwrap_or(&String::new()).clone(); let caller_id = data.get("callerId").unwrap_or(&String::new()).clone(); let context_id = data.get("contextId").unwrap_or(&String::new()).clone(); let script = data.get("script").unwrap_or(&String::new()).clone(); let timeout_secs = data.get("timeout").unwrap_or(&"300".to_string()).parse().unwrap_or(300u64); let timeout = Duration::from_secs(timeout_secs); let retries = data.get("retries").map_or(0u8, |v| v.parse().unwrap_or(0)); let status = match data.get("status").map(|s| s.as_str()).unwrap_or("dispatched") { "started" => JobStatus::Started, "finished" => JobStatus::Finished, "error" => JobStatus::Error, "waiting_for_prerequisites" => JobStatus::WaitingForPrerequisites, _ => JobStatus::Dispatched, }; let output = data.get("output").cloned(); let error = data.get("error").cloned(); // Parse script_type from Redis data let script_type = match data.get("script_type").map(|s| s.as_str()).unwrap_or("OSIS") { "OSIS" => ScriptType::OSIS, "SAL" => ScriptType::SAL, "V" => ScriptType::V, "Python" => ScriptType::Python, _ => ScriptType::OSIS, // Default }; let job = Job { id, caller_id, context_id, script, script_type, timeout, retries, concurrent: false, log_path: None, env_vars: std::collections::HashMap::new(), prerequisites: Vec::new(), dependents: Vec::new(), created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; Ok(job) } async fn create_and_dispatch_job(&mut self) -> Result<()> { let job_id = format!("job_{}", chrono::Utc::now().timestamp_millis()); let timeout_secs = self.job_form.timeout.parse().unwrap_or(300u64); let timeout = Duration::from_secs(timeout_secs); let job = Job { id: Uuid::new_v4().to_string(), caller_id: self.job_form.caller_id.clone(), context_id: self.job_form.context_id.clone(), script: self.job_form.script_content.clone(), script_type: ScriptType::OSIS, // Use OSIS for this actor timeout, retries: 0, concurrent: false, log_path: None, env_vars: std::collections::HashMap::new(), prerequisites: Vec::new(), dependents: Vec::new(), created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; let mut conn = self.redis_client.get_multiplexed_async_connection().await?; // Store job in Redis let job_key = format!("hero:job:{}", job.id); let _: () = conn.hset_multiple(&job_key, &[ ("id", &job.id), ("caller_id", &job.caller_id), ("context_id", &job.context_id), ("script", &job.script), ("timeout", &job.timeout.as_secs().to_string()), ("retries", &job.retries.to_string()), ("status", &"dispatched".to_string()), ("actor_id", &self.actor_id), // Store actor_id separately for filtering ]).await?; // Add to work queue let queue_key = format!("hero:work_queue:{}", self.actor_id); let _: () = conn.lpush(&queue_key, &job_id).await?; // Track this job for display in the right pane let job_info = JobInfo { job: job.clone(), status: JobStatus::Dispatched, output: None, error: None, created_at: Instant::now(), }; self.last_executed_job = Some(job_info); self.status_message = Some(format!("Job {} dispatched successfully!", job_id)); self.refresh_jobs().await?; Ok(()) } async fn dispatch_example(&mut self, example: &ExampleScript) -> Result<()> { self.job_form.script_content = example.content.clone(); self.create_and_dispatch_job().await } fn next_job(&mut self) { if self.jobs.is_empty() { return; } let i = match self.job_list_state.selected() { Some(i) => { if i >= self.jobs.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.job_list_state.select(Some(i)); if let Some(job_info) = self.jobs.get(i) { self.selected_job = Some(job_info.job.clone()); } } fn previous_job(&mut self) { if self.jobs.is_empty() { return; } let i = match self.job_list_state.selected() { Some(i) => { if i == 0 { self.jobs.len() - 1 } else { i - 1 } } None => 0, }; self.job_list_state.select(Some(i)); if let Some(job_info) = self.jobs.get(i) { self.selected_job = Some(job_info.job.clone()); } } fn next_example(&mut self) { if self.examples.is_empty() { return; } let i = match self.example_list_state.selected() { Some(i) => { if i >= self.examples.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.example_list_state.select(Some(i)); if let Some(example) = self.examples.get(i) { self.selected_example = Some(example.clone()); } } fn previous_example(&mut self) { if self.examples.is_empty() { return; } let i = match self.example_list_state.selected() { Some(i) => { if i == 0 { self.examples.len() - 1 } else { i - 1 } } None => 0, }; self.example_list_state.select(Some(i)); if let Some(example) = self.examples.get(i) { self.selected_example = Some(example.clone()); } } fn handle_char_input(&mut self, c: char) { match self.job_form.current_field { 0 => self.job_form.caller_id.push(c), 1 => self.job_form.context_id.push(c), 2 => self.job_form.timeout.push(c), 3 => self.job_form.script_content.push(c), _ => {} } } fn handle_backspace(&mut self) { match self.job_form.current_field { 0 => { self.job_form.caller_id.pop(); } 1 => { self.job_form.context_id.pop(); } 2 => { self.job_form.timeout.pop(); } 3 => { self.job_form.script_content.pop(); } _ => {} } } fn next_field(&mut self) { self.job_form.current_field = (self.job_form.current_field + 1) % 4; } fn previous_field(&mut self) { self.job_form.current_field = if self.job_form.current_field == 0 { 3 } else { self.job_form.current_field - 1 }; } } async fn run_app(terminal: &mut Terminal, mut app: App) -> Result<()> { let mut last_tick = Instant::now(); let tick_rate = Duration::from_millis(250); loop { terminal.draw(|f| ui(f, &mut app))?; let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); if crossterm::event::poll(timeout)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { match app.app_state { AppState::Main => { match key.code { KeyCode::Char('q') => { app.should_quit = true; } KeyCode::Char('1') => app.current_tab = TabState::Dashboard, KeyCode::Char('2') => app.current_tab = TabState::Jobs, KeyCode::Char('3') => app.current_tab = TabState::NewJob, KeyCode::Char('4') => app.current_tab = TabState::Examples, KeyCode::Char('[') => { app.current_tab = match app.current_tab { TabState::Dashboard => TabState::Examples, TabState::Jobs => TabState::Dashboard, TabState::NewJob => TabState::Jobs, TabState::Examples => TabState::NewJob, }; } KeyCode::Char(']') => { app.current_tab = match app.current_tab { TabState::Dashboard => TabState::Jobs, TabState::Jobs => TabState::NewJob, TabState::NewJob => TabState::Examples, TabState::Examples => TabState::Dashboard, }; } KeyCode::Tab => { // Tab key now handles field navigation within tabs if app.current_tab == TabState::NewJob { app.next_field(); } } KeyCode::BackTab => { // Shift+Tab for reverse field navigation if app.current_tab == TabState::NewJob { app.previous_field(); } } KeyCode::Char('r') => { if let Err(e) = app.refresh_jobs().await { app.status_message = Some(format!("Error refreshing jobs: {}", e)); } } KeyCode::Char('n') => { app.app_state = AppState::JobCreation; } KeyCode::Down => { match app.current_tab { TabState::Jobs => app.next_job(), TabState::Examples => app.next_example(), _ => {} } } KeyCode::Up => { match app.current_tab { TabState::Jobs => app.previous_job(), TabState::Examples => app.previous_example(), _ => {} } } KeyCode::Enter => { if app.current_tab == TabState::Examples { if let Some(example) = app.selected_example.clone() { if let Err(e) = app.dispatch_example(&example).await { app.status_message = Some(format!("Error dispatching example: {}", e)); } } } else if app.current_tab == TabState::NewJob { if let Err(e) = app.create_and_dispatch_job().await { app.status_message = Some(format!("Error creating job: {}", e)); } } } _ => {} } } AppState::JobCreation => { match key.code { KeyCode::Esc => { app.app_state = AppState::Main; } KeyCode::Enter => { if let Err(e) = app.create_and_dispatch_job().await { app.status_message = Some(format!("Error creating job: {}", e)); } } KeyCode::Char(c) => { app.handle_char_input(c); } KeyCode::Backspace => { app.handle_backspace(); } _ => {} } } _ => { if key.code == KeyCode::Esc { app.app_state = AppState::Main; } } } } } } if last_tick.elapsed() >= tick_rate { // Auto-refresh jobs every few seconds if app.last_refresh.elapsed() >= Duration::from_secs(5) { if let Err(e) = app.refresh_jobs().await { app.status_message = Some(format!("Auto-refresh error: {}", e)); } } last_tick = Instant::now(); } if app.should_quit { break; } } Ok(()) } fn ui(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]) .split(f.area()); // Title let title = Paragraph::new(format!("Baobab Actor UI - Actor: {}", app.actor_id)) .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); f.render_widget(title, chunks[0]); // Main content area match app.app_state { AppState::Main => render_main_view(f, app, chunks[1]), AppState::JobCreation => render_job_creation(f, app, chunks[1]), _ => render_main_view(f, app, chunks[1]), } // Status bar let status_text = if let Some(ref msg) = app.status_message { msg.clone() } else { format!("Jobs: {} | Press 'q' to quit, Tab/1-4 to switch tabs, 'r' to refresh, 'n' for new job | Last refresh: {:.1}s ago", app.jobs.len(), app.last_refresh.elapsed().as_secs_f32()) }; let status = Paragraph::new(status_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left) .block(Block::default().borders(Borders::ALL)); f.render_widget(status, chunks[2]); } fn render_main_view(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)]) .split(area); // Tabs let tab_titles: Vec = vec!["Dashboard", "Jobs", "New Job", "Examples"] .iter() .cloned() .map(Line::from) .collect(); let selected_tab = match app.current_tab { TabState::Dashboard => 0, TabState::Jobs => 1, TabState::NewJob => 2, TabState::Examples => 3, }; let tabs = Tabs::new(tab_titles) .block(Block::default().borders(Borders::ALL).title("Tabs (Use [ ] to navigate, Tab for fields, q to quit)")) .style(Style::default().fg(Color::White)) .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) .select(selected_tab); f.render_widget(tabs, chunks[0]); // Tab content match app.current_tab { TabState::Dashboard => render_dashboard(f, app, chunks[1]), TabState::Jobs => render_jobs(f, app, chunks[1]), TabState::NewJob => render_new_job(f, app, chunks[1]), TabState::Examples => render_examples(f, app, chunks[1]), } } fn render_dashboard(f: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(area); // Actor info let actor_info = vec![ Line::from(vec![Span::raw(format!("Actor ID: {}", app.actor_id))]), Line::from(vec![Span::raw(format!("Actor Path: {}", app.actor_path.display()))]), Line::from(vec![Span::raw(format!("Total Jobs: {}", app.jobs.len()))]), Line::from(vec![Span::raw(format!("Examples Loaded: {}", app.examples.len()))]), ]; let actor_block = Paragraph::new(actor_info) .block(Block::default().borders(Borders::ALL).title("Actor Information")) .wrap(Wrap { trim: true }); f.render_widget(actor_block, chunks[0]); // Job status summary let mut dispatched = 0; let mut waiting = 0; let mut started = 0; let mut finished = 0; let mut error = 0; for job_info in &app.jobs { match job_info.status { JobStatus::Dispatched => dispatched += 1, JobStatus::WaitingForPrerequisites => waiting += 1, JobStatus::Started => started += 1, JobStatus::Finished => finished += 1, JobStatus::Error => error += 1, } } let status_info = vec![ Line::from(vec![Span::styled(format!("Dispatched: {}", dispatched), Style::default().fg(Color::Yellow))]), Line::from(vec![Span::styled(format!("Waiting: {}", waiting), Style::default().fg(Color::Cyan))]), Line::from(vec![Span::styled(format!("Started: {}", started), Style::default().fg(Color::Blue))]), Line::from(vec![Span::styled(format!("Finished: {}", finished), Style::default().fg(Color::Green))]), Line::from(vec![Span::styled(format!("Error: {}", error), Style::default().fg(Color::Red))]), ]; let status_block = Paragraph::new(status_info) .block(Block::default().borders(Borders::ALL).title("Job Status Summary")) .wrap(Wrap { trim: true }); f.render_widget(status_block, chunks[1]); } fn render_jobs(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(area); // Job list let jobs: Vec = app.jobs .iter() .map(|job_info| { let status_color = match job_info.status { JobStatus::Dispatched => Color::Yellow, JobStatus::WaitingForPrerequisites => Color::Cyan, JobStatus::Started => Color::Blue, JobStatus::Finished => Color::Green, JobStatus::Error => Color::Red, }; let content = format!("{} - {:?} - {:.1}s ago", job_info.job.id, job_info.status, job_info.created_at.elapsed().as_secs_f32()); ListItem::new(Line::from(Span::styled(content, Style::default().fg(status_color)))) }) .collect(); let jobs_list = List::new(jobs) .block(Block::default().borders(Borders::ALL).title("Jobs")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); f.render_stateful_widget(jobs_list, chunks[0], &mut app.job_list_state); // Job details if let Some(selected_idx) = app.job_list_state.selected() { if let Some(job_info) = app.jobs.get(selected_idx) { let mut details = vec![ Line::from(vec![Span::raw(format!("ID: {}", job_info.job.id))]), Line::from(vec![Span::raw(format!("Status: {:?}", job_info.status))]), Line::from(vec![Span::raw(format!("Timeout: {:?}", job_info.job.timeout))]), Line::from(vec![Span::raw("Script:")]), Line::from(vec![Span::raw(job_info.job.script.clone())]), ]; if let Some(ref output) = job_info.output { details.push(Line::from(vec![Span::raw("Output:")])); details.push(Line::from(vec![Span::raw(output.clone())])); } if let Some(ref error) = job_info.error { details.push(Line::from(vec![Span::raw("Error:")])); details.push(Line::from(vec![Span::raw(error.clone())])); } let details_block = Paragraph::new(details) .block(Block::default().borders(Borders::ALL).title("Job Details")) .wrap(Wrap { trim: true }); f.render_widget(details_block, chunks[1]); } } else { let placeholder = Paragraph::new("Select a job to view details") .block(Block::default().borders(Borders::ALL).title("Job Details")) .alignment(Alignment::Center); f.render_widget(placeholder, chunks[1]); } } fn render_new_job(f: &mut Frame, app: &App, area: Rect) { // Split into left (job config) and right (job output) panes let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(area); // Left pane: Job configuration form render_job_config_form(f, app, chunks[0]); // Right pane: Job execution results render_job_output(f, app, chunks[1]); } fn render_job_config_form(f: &mut Frame, app: &App, area: Rect) { // Split form into 4 sections for the 4 fields let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // caller_id Constraint::Length(3), // context_id Constraint::Length(3), // timeout Constraint::Min(4), // script (takes remaining space) ]) .split(area); // Field styles based on current selection let caller_id_style = if app.job_form.current_field == 0 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let context_id_style = if app.job_form.current_field == 1 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let timeout_style = if app.job_form.current_field == 2 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let script_style = if app.job_form.current_field == 3 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; // Render form fields let caller_id_input = Paragraph::new(app.job_form.caller_id.clone()) .block(Block::default().borders(Borders::ALL).title("Caller ID").border_style(caller_id_style)) .wrap(Wrap { trim: true }); f.render_widget(caller_id_input, chunks[0]); let context_id_input = Paragraph::new(app.job_form.context_id.clone()) .block(Block::default().borders(Borders::ALL).title("Context ID").border_style(context_id_style)) .wrap(Wrap { trim: true }); f.render_widget(context_id_input, chunks[1]); let timeout_input = Paragraph::new(app.job_form.timeout.clone()) .block(Block::default().borders(Borders::ALL).title("Timeout (seconds)").border_style(timeout_style)) .wrap(Wrap { trim: true }); f.render_widget(timeout_input, chunks[2]); let script_input = Paragraph::new(app.job_form.script_content.clone()) .block(Block::default().borders(Borders::ALL).title("Script Content (Press Enter to execute)").border_style(script_style)) .wrap(Wrap { trim: true }); f.render_widget(script_input, chunks[3]); } fn render_job_output(f: &mut Frame, app: &App, area: Rect) { if let Some(ref job_info) = app.last_executed_job { // Split into header and output sections let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(6), // Job header Constraint::Min(4), // Output content ]) .split(area); // Job header with ID, status, created_at let header_lines = vec![ Line::from(vec![Span::raw(format!("Job ID: {}", job_info.job.id))]), Line::from(vec![Span::raw(format!("Status: {:?}", job_info.status))]), Line::from(vec![Span::raw(format!("Caller ID: {}", job_info.job.caller_id))]), Line::from(vec![Span::raw(format!("Context ID: {}", job_info.job.context_id))]), Line::from(vec![Span::raw(format!("Created: {:.1}s ago", job_info.created_at.elapsed().as_secs_f32()))]), ]; let header_block = Paragraph::new(header_lines) .block(Block::default().borders(Borders::ALL).title("Job Details")) .wrap(Wrap { trim: true }); f.render_widget(header_block, chunks[0]); // Output content let mut output_lines = Vec::new(); if let Some(ref output) = job_info.output { output_lines.push(Line::from(vec![Span::styled("Output:", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))])); for line in output.lines() { output_lines.push(Line::from(vec![Span::raw(line)])); } } if let Some(ref error) = job_info.error { if !output_lines.is_empty() { output_lines.push(Line::from("")); } output_lines.push(Line::from(vec![Span::styled("Error:", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))])); for line in error.lines() { output_lines.push(Line::from(vec![Span::styled(line, Style::default().fg(Color::Red))])); } } if output_lines.is_empty() { output_lines.push(Line::from(vec![Span::styled("Job is running... No output yet.", Style::default().fg(Color::Yellow))])); } let output_block = Paragraph::new(output_lines) .block(Block::default().borders(Borders::ALL).title("Job Output")) .wrap(Wrap { trim: true }); f.render_widget(output_block, chunks[1]); } else { // No job executed yet let placeholder_text = vec![ Line::from("No job executed yet."), Line::from(""), Line::from("Configure a job in the left pane and press Enter to execute."), Line::from(""), Line::from("Use Tab to navigate between fields:"), Line::from("• Caller ID"), Line::from("• Context ID"), Line::from("• Timeout (seconds)"), Line::from("• Script Content"), ]; let placeholder_block = Paragraph::new(placeholder_text) .block(Block::default().borders(Borders::ALL).title("Job Output")) .alignment(Alignment::Center); f.render_widget(placeholder_block, area); } } fn render_examples(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(area); // Example list let examples: Vec = app.examples .iter() .map(|example| { ListItem::new(Line::from(Span::raw(&example.name))) }) .collect(); let examples_list = List::new(examples) .block(Block::default().borders(Borders::ALL).title("Example Scripts")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); f.render_stateful_widget(examples_list, chunks[0], &mut app.example_list_state); // Example content if let Some(ref example) = app.selected_example { let content = Paragraph::new(example.content.clone()) .block(Block::default().borders(Borders::ALL).title(format!("Script: {}", example.name))) .wrap(Wrap { trim: true }); f.render_widget(content, chunks[1]); } else { let placeholder = Paragraph::new("Select an example to view content\nPress Enter to dispatch selected example") .block(Block::default().borders(Borders::ALL).title("Script Content")) .alignment(Alignment::Center); f.render_widget(placeholder, chunks[1]); } } fn render_job_creation(f: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(10), Constraint::Min(0), Constraint::Length(3)]) .split(area); // Form fields let script_style = if app.job_form.current_field == 0 { Style::default().fg(Color::Yellow) } else { Style::default() }; let timeout_style = if app.job_form.current_field == 2 { Style::default().fg(Color::Yellow) } else { Style::default() }; let script_input = Paragraph::new(app.job_form.script_content.clone()) .style(script_style) .block(Block::default().borders(Borders::ALL).title("Script Content")) .wrap(Wrap { trim: true }); f.render_widget(script_input, chunks[0]); let timeout_input = Paragraph::new(app.job_form.timeout.clone()) .style(timeout_style) .block(Block::default().borders(Borders::ALL).title("Timeout (seconds)")); f.render_widget(timeout_input, chunks[2]); // Instructions let instructions = Paragraph::new("Tab: Next field | Enter: Create job | Esc: Cancel") .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL).title("Instructions")); f.render_widget(instructions, chunks[2]); } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); // Initialize logging if args.verbose { env_logger::Builder::from_default_env() .filter_level(log::LevelFilter::Debug) .init(); } else { env_logger::Builder::from_default_env() .filter_level(log::LevelFilter::Info) .init(); } info!("🚀 Starting Baobab Actor TUI..."); info!("Actor ID: {}", args.id); info!("Actor Path: {}", args.path.display()); info!("Redis URL: {}", args.redis_url); if let Some(ref example_dir) = args.example_dir { info!("Example Directory: {}", example_dir.display()); } // Create app let mut app = App::new(args.id, args.path, args.redis_url, args.example_dir)?; // Initial job refresh if let Err(e) = app.refresh_jobs().await { error!("Failed to refresh jobs: {}", e); } // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Run the app let res = run_app(&mut terminal, app).await; // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; if let Err(err) = res { println!("{:?}", err); } Ok(()) }