//! Terminal UI module for Baobab Actor //! //! This module contains all the TUI logic, structures, and rendering functions //! for the actor monitoring and job dispatch interface. use anyhow::Result; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, execute, }; use hero_job::{Job, JobStatus, ScriptType}; use log::error; 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; // Public exports for the cmd binary #[derive(Debug, Clone, PartialEq)] pub enum AppState { Main, JobCreation, JobDetails, ExampleSelection, Confirmation, } #[derive(Debug, Clone, PartialEq)] pub enum TabState { Dashboard, Jobs, NewJob, Examples, } #[derive(Debug, Clone, PartialEq)] pub enum JobFormField { CallerId, ContextId, Timeout, ScriptContent, } #[derive(Debug, Clone)] pub struct JobCreationForm { pub caller_id: String, pub context_id: String, pub timeout: String, pub script_content: String, pub script_type: ScriptType, pub current_field: JobFormField, } impl Default for JobCreationForm { fn default() -> Self { Self { caller_id: String::new(), context_id: String::new(), timeout: "300".to_string(), script_content: String::new(), script_type: ScriptType::OSIS, current_field: JobFormField::CallerId, } } } #[derive(Debug, Clone)] pub struct ExampleScript { pub name: String, pub path: PathBuf, } #[derive(Debug, Clone)] pub enum ExampleTreeNode { File { name: String, path: PathBuf, }, Folder { name: String, path: PathBuf, children: Vec, expanded: bool, }, } impl ExampleTreeNode { pub fn name(&self) -> &str { match self { ExampleTreeNode::File { name, .. } => name, ExampleTreeNode::Folder { name, .. } => name, } } pub fn path(&self) -> &PathBuf { match self { ExampleTreeNode::File { path, .. } => path, ExampleTreeNode::Folder { path, .. } => path, } } pub fn is_file(&self) -> bool { matches!(self, ExampleTreeNode::File { .. }) } pub fn is_folder(&self) -> bool { matches!(self, ExampleTreeNode::Folder { .. }) } pub fn is_expanded(&self) -> bool { match self { ExampleTreeNode::File { .. } => false, ExampleTreeNode::Folder { expanded, .. } => *expanded, } } pub fn toggle_expanded(&mut self) { if let ExampleTreeNode::Folder { expanded, .. } = self { *expanded = !*expanded; } } pub fn set_expanded(&mut self, expand: bool) { if let ExampleTreeNode::Folder { expanded, .. } = self { *expanded = expand; } } } #[derive(Debug, Clone)] pub struct ExampleTreeItem { pub node: ExampleTreeNode, pub depth: usize, pub index: usize, } #[derive(Debug, Clone)] pub struct JobInfo { pub job: Job, pub status: JobStatus, pub output: Option, pub error: Option, pub created_at: Instant, } #[derive(Debug)] pub struct App { pub actor_id: String, pub actor_path: PathBuf, pub redis_client: redis::Client, pub current_tab: TabState, pub app_state: AppState, pub should_quit: bool, pub jobs: Vec, pub job_list_state: ListState, pub job_form: JobCreationForm, pub examples: Vec, pub example_list_state: ListState, pub selected_example: Option, pub status_message: Option, pub last_executed_job: Option, pub last_refresh: Instant, // New hierarchical examples system pub example_tree: Vec, pub example_tree_items: Vec, pub example_tree_state: ListState, pub selected_tree_item: Option, } pub struct ActorTui; impl ActorTui { pub fn new() -> Self { Self } } impl App { pub 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(), job_form: JobCreationForm::default(), examples: Vec::new(), example_list_state: ListState::default(), selected_example: None, status_message: None, last_executed_job: None, last_refresh: Instant::now(), // Initialize new hierarchical examples system example_tree: Vec::new(), example_tree_items: Vec::new(), example_tree_state: ListState::default(), selected_tree_item: None, }; if let Some(dir) = example_dir { if let Err(e) = app.load_examples_tree(dir) { error!("Failed to load examples tree: {}", e); } } Ok(app) } pub fn load_examples_tree(&mut self, dir: PathBuf) -> Result<()> { if !dir.exists() { return Ok(()); } // Load hierarchical tree structure self.example_tree = self.load_example_tree(&dir)?; self.rebuild_tree_items(); if !self.example_tree_items.is_empty() { self.example_tree_state.select(Some(0)); self.selected_tree_item = Some(self.example_tree_items[0].clone()); // Update selected_example for backward compatibility if let Some(first_file) = self.find_first_file_in_tree() { self.selected_example = Some(ExampleScript { name: first_file.name().to_string(), path: first_file.path().clone(), }); } } Ok(()) } fn load_example_tree(&self, dir: &PathBuf) -> Result> { let mut nodes = Vec::new(); let mut entries: Vec<_> = fs::read_dir(dir)? .collect::, _>>()?; // Sort entries: directories first, then files, both alphabetically entries.sort_by(|a, b| { let a_is_dir = a.path().is_dir(); let b_is_dir = b.path().is_dir(); match (a_is_dir, b_is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.file_name().cmp(&b.file_name()), } }); for entry in entries { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); if path.is_dir() { let children = self.load_example_tree(&path)?; nodes.push(ExampleTreeNode::Folder { name, path, children, expanded: false, // Folders collapsed by default }); } else if path.extension().map_or(false, |ext| ext == "rhai") { nodes.push(ExampleTreeNode::File { name: path.file_stem() .and_then(|s| s.to_str()) .unwrap_or(&name) .to_string(), path, }); } } Ok(nodes) } fn load_flat_examples(&mut self, dir: &PathBuf) -> Result<()> { let mut examples = Vec::new(); self.collect_all_rhai_files(&mut examples, dir)?; self.examples = examples; if !self.examples.is_empty() { self.example_list_state.select(Some(0)); if self.selected_example.is_none() { self.selected_example = Some(self.examples[0].clone()); } } Ok(()) } fn collect_all_rhai_files(&self, examples: &mut Vec, dir: &PathBuf) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { self.collect_all_rhai_files(examples, &path)?; } else if path.extension().map_or(false, |ext| ext == "rhai") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { examples.push(ExampleScript { name: name.to_string(), path, }); } } } Ok(()) } fn rebuild_tree_items(&mut self) { self.example_tree_items.clear(); let mut index = 0; let tree_clone = self.example_tree.clone(); for node in &tree_clone { self.add_tree_items_recursive(node, 0, &mut index); } } fn add_tree_items_recursive(&mut self, node: &ExampleTreeNode, depth: usize, index: &mut usize) { // Always add the current node to the flattened list self.example_tree_items.push(ExampleTreeItem { node: node.clone(), depth, index: *index, }); *index += 1; // For folders, add children only if the folder is expanded if let ExampleTreeNode::Folder { children, expanded, .. } = node { if *expanded { for child in children { self.add_tree_items_recursive(child, depth + 1, index); } } } } fn find_first_file_in_tree(&self) -> Option<&ExampleTreeNode> { for item in &self.example_tree_items { if item.node.is_file() { return Some(&item.node); } } None } pub 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 { 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") { "Dispatched" => JobStatus::Dispatched, "WaitingForPrerequisites" => JobStatus::WaitingForPrerequisites, "Started" => JobStatus::Started, "Error" => JobStatus::Error, "Finished" => JobStatus::Finished, _ => JobStatus::Dispatched, }; let _output = data.get("output").cloned(); let _error = data.get("error").cloned(); 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, }; 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) } pub async fn create_and_dispatch_job(&mut self) -> Result<()> { let job_id = Uuid::new_v4().to_string(); let timeout_secs: u64 = self.job_form.timeout.parse().unwrap_or(300); let job = Job { id: job_id.clone(), 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: self.job_form.script_type.clone(), timeout: Duration::from_secs(timeout_secs), 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(), }; // Store job info for tracking 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); // Store job in Redis using the proper Job::store_in_redis method let mut conn = self.redis_client.get_multiplexed_async_connection().await?; job.store_in_redis(&mut conn).await?; // Add to work queue let queue_name = format!("hero:job:actor_queue:{}", self.actor_id.to_lowercase()); let _: () = conn.lpush(&queue_name, &job_id).await?; self.status_message = Some(format!("Job {} dispatched successfully", job_id)); // Refresh jobs to show the new job self.refresh_jobs().await?; Ok(()) } pub async fn dispatch_example(&mut self, example: &ExampleScript) -> Result<()> { let script_content = fs::read_to_string(&example.path)?; self.job_form.script_content = script_content; self.create_and_dispatch_job().await } pub fn load_example_to_new_job(&mut self, example: &ExampleScript) -> Result<()> { let script_content = fs::read_to_string(&example.path)?; self.job_form.script_content = script_content; Ok(()) } pub fn handle_char_input(&mut self, c: char) { match self.job_form.current_field { JobFormField::CallerId => { self.job_form.caller_id.push(c); } JobFormField::ContextId => { self.job_form.context_id.push(c); } JobFormField::Timeout => { if c.is_ascii_digit() { self.job_form.timeout.push(c); } } JobFormField::ScriptContent => { self.job_form.script_content.push(c); } } } pub fn handle_backspace(&mut self) { match self.job_form.current_field { JobFormField::CallerId => { self.job_form.caller_id.pop(); } JobFormField::ContextId => { self.job_form.context_id.pop(); } JobFormField::Timeout => { self.job_form.timeout.pop(); } JobFormField::ScriptContent => { self.job_form.script_content.pop(); } } } pub fn next_job(&mut self) { if self.jobs.is_empty() { return; } let selected = 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(selected)); } pub fn previous_job(&mut self) { if self.jobs.is_empty() { return; } let selected = 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(selected)); } pub fn next_example(&mut self) { if self.example_tree_items.is_empty() { return; } let i = match self.example_tree_state.selected() { Some(i) => { if i >= self.example_tree_items.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.example_tree_state.select(Some(i)); self.selected_tree_item = Some(self.example_tree_items[i].clone()); // Update selected_example for backward compatibility if self.example_tree_items[i].node.is_file() { self.selected_example = Some(ExampleScript { name: self.example_tree_items[i].node.name().to_string(), path: self.example_tree_items[i].node.path().clone(), }); } } pub fn previous_example(&mut self) { if self.example_tree_items.is_empty() { return; } let i = match self.example_tree_state.selected() { Some(i) => { if i == 0 { self.example_tree_items.len() - 1 } else { i - 1 } } None => 0, }; self.example_tree_state.select(Some(i)); self.selected_tree_item = Some(self.example_tree_items[i].clone()); // Update selected_example for backward compatibility if self.example_tree_items[i].node.is_file() { self.selected_example = Some(ExampleScript { name: self.example_tree_items[i].node.name().to_string(), path: self.example_tree_items[i].node.path().clone(), }); } } pub fn expand_selected_folder(&mut self) { if let Some(selected_index) = self.example_tree_state.selected() { if selected_index < self.example_tree_items.len() { let item = &self.example_tree_items[selected_index]; if item.node.is_folder() { let path = item.node.path().clone(); self.expand_folder_by_path(&path); } } } } pub fn collapse_selected_folder(&mut self) { if let Some(selected_index) = self.example_tree_state.selected() { if selected_index < self.example_tree_items.len() { let item = &self.example_tree_items[selected_index]; if item.node.is_folder() { let path = item.node.path().clone(); self.collapse_folder_by_path(&path); } } } } pub fn toggle_selected_folder(&mut self) { if let Some(selected_index) = self.example_tree_state.selected() { if selected_index < self.example_tree_items.len() { let item = &self.example_tree_items[selected_index]; if item.node.is_folder() { let path = item.node.path().clone(); let is_expanded = item.node.is_expanded(); if is_expanded { self.collapse_folder_by_path(&path); } else { self.expand_folder_by_path(&path); } } } } } fn expand_folder_by_path(&mut self, target_path: &PathBuf) { Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, true); self.rebuild_tree_items(); } fn collapse_folder_by_path(&mut self, target_path: &PathBuf) { Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, false); self.rebuild_tree_items(); } fn set_folder_expanded_by_path_static(nodes: &mut Vec, target_path: &PathBuf, expanded: bool) { for node in nodes { match node { ExampleTreeNode::Folder { path, children, expanded: node_expanded, .. } => { if path == target_path { *node_expanded = expanded; return; } Self::set_folder_expanded_by_path_static(children, target_path, expanded); } _ => {} } } } pub fn next_field(&mut self) { self.job_form.current_field = match self.job_form.current_field { JobFormField::CallerId => JobFormField::ContextId, JobFormField::ContextId => JobFormField::Timeout, JobFormField::Timeout => JobFormField::ScriptContent, JobFormField::ScriptContent => JobFormField::CallerId, }; } pub fn previous_field(&mut self) { self.job_form.current_field = match self.job_form.current_field { JobFormField::CallerId => JobFormField::ScriptContent, JobFormField::ContextId => JobFormField::CallerId, JobFormField::Timeout => JobFormField::ContextId, JobFormField::ScriptContent => JobFormField::Timeout, }; } } /// Setup terminal and run the TUI application pub async fn setup_and_run_tui(mut app: App) -> Result<()> { // 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)?; 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, )?; terminal.show_cursor()?; if let Err(err) = res { println!("{:?}", err); } 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 Esc to quit, [ ] to switch tabs, Tab for fields, 'r' to refresh | 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 dispatched = app.jobs.len(); // Placeholder - all jobs shown as dispatched let job_status = vec![ Line::from(vec![Span::raw(format!("Dispatched: {}", dispatched))]), Line::from(vec![Span::raw("Waiting: 0")]), Line::from(vec![Span::raw("Started: 0")]), Line::from(vec![Span::raw("Finished: 0")]), Line::from(vec![Span::raw("Error: 0")]), ]; let status_block = Paragraph::new(job_status) .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 main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(area); // Job list (left side) let jobs: Vec = app.jobs .iter() .map(|job_info| { // Safely truncate job ID to avoid out-of-bounds panic let job_id_display = if job_info.job.id.len() >= 8 { &job_info.job.id[..8] } else { &job_info.job.id }; let content = vec![ Line::from(vec![ Span::styled(format!("[{}] ", job_id_display), Style::default().fg(Color::Yellow)), Span::raw(format!("Caller: {} | Context: {}", job_info.job.caller_id, job_info.job.context_id)), ]), ]; ListItem::new(content) }) .collect(); let jobs_list = List::new(jobs) .block(Block::default().borders(Borders::ALL).title("Jobs")) .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); f.render_stateful_widget(jobs_list, main_chunks[0], &mut app.job_list_state); // Right side: split into job details (top) and results (bottom) let right_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) .split(main_chunks[1]); // Job details (top right) if let Some(selected) = app.job_list_state.selected() { if let Some(job_info) = app.jobs.get(selected) { let 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!("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!("Script Type: {:?}", job_info.job.script_type))]), Line::from(vec![Span::raw(format!("Timeout: {}s", job_info.job.timeout.as_secs()))]), Line::from(vec![Span::raw(format!("Retries: {}", job_info.job.retries))]), Line::from(""), Line::from(vec![Span::styled("Script:", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))]), Line::from(vec![Span::raw(job_info.job.script.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, right_chunks[0]); // Job results (bottom right) let result_content = if let Some(ref output) = job_info.output { format!("Output:\n{}", output) } else if let Some(ref error) = job_info.error { format!("Error:\n{}", error) } else { match job_info.status { JobStatus::Dispatched => "Job dispatched, waiting for execution...".to_string(), JobStatus::Started => "Job is currently running...".to_string(), JobStatus::WaitingForPrerequisites => "Waiting for prerequisites to complete...".to_string(), _ => "No output available yet.".to_string(), } }; let result_color = match job_info.status { JobStatus::Finished => Color::Green, JobStatus::Error => Color::Red, JobStatus::Started => Color::Yellow, _ => Color::Gray, }; let result_block = Paragraph::new(result_content) .block(Block::default().borders(Borders::ALL).title("Job Results")) .style(Style::default().fg(result_color)) .wrap(Wrap { trim: true }); f.render_widget(result_block, right_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, right_chunks[0]); let result_placeholder = Paragraph::new("Select a job to view results") .block(Block::default().borders(Borders::ALL).title("Job Results")) .alignment(Alignment::Center); f.render_widget(result_placeholder, right_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 == JobFormField::CallerId { 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 == JobFormField::ContextId { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let timeout_style = if app.job_form.current_field == JobFormField::Timeout { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let script_style = if app.job_form.current_field == JobFormField::ScriptContent { 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 last_job) = app.last_executed_job { // Find the latest version of this job from the jobs list for real-time updates let current_job_info = app.jobs .iter() .find(|job| job.job.id == last_job.job.id) .unwrap_or(last_job); // Fallback to last_executed_job if not found in list // 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: {}", current_job_info.job.id))]), Line::from(vec![Span::raw(format!("Status: {:?}", current_job_info.status))]), Line::from(vec![Span::raw(format!("Caller ID: {}", current_job_info.job.caller_id))]), Line::from(vec![Span::raw(format!("Context ID: {}", current_job_info.job.context_id))]), Line::from(vec![Span::raw(format!("Created: {:.1}s ago", current_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 - use current job info for real-time updates let mut output_lines = Vec::new(); if let Some(ref output) = current_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) = current_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() { // Show status-appropriate message based on current job status let status_message = match current_job_info.status { JobStatus::Dispatched => "Job dispatched, waiting for execution...", JobStatus::Started => "Job is currently running...", JobStatus::WaitingForPrerequisites => "Waiting for prerequisites to complete...", JobStatus::Finished => "Job completed successfully (no output)", JobStatus::Error => "Job failed (check error details above)", }; output_lines.push(Line::from(vec![Span::styled(status_message, 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); // Hierarchical example tree list let tree_items: Vec = app.example_tree_items .iter() .map(|item| { let indent = " ".repeat(item.depth); let (icon, name) = match &item.node { ExampleTreeNode::File { name, .. } => ("📄", name.as_str()), ExampleTreeNode::Folder { name, expanded, .. } => { if *expanded { ("📂", name.as_str()) } else { ("📁", name.as_str()) } } }; let display_text = format!("{}{} {}", indent, icon, name); ListItem::new(Line::from(vec![Span::raw(display_text)])) }) .collect(); let examples_list = List::new(tree_items) .block(Block::default().borders(Borders::ALL).title("Example Scripts (→ expand, ← collapse)")) .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); f.render_stateful_widget(examples_list, chunks[0], &mut app.example_tree_state); // Example content if let Some(ref example) = app.selected_example { if let Ok(content) = fs::read_to_string(&example.path) { let lines: Vec = content.lines().map(|line| Line::from(vec![Span::raw(line)])).collect(); let example_content = Paragraph::new(lines) .block(Block::default().borders(Borders::ALL).title(format!("Script: {}", example.name))) .wrap(Wrap { trim: true }); f.render_widget(example_content, chunks[1]); } else { let error_msg = Paragraph::new("Failed to read example file") .block(Block::default().borders(Borders::ALL).title("Script Content")) .alignment(Alignment::Center); f.render_widget(error_msg, chunks[1]); } } else { let placeholder = Paragraph::new("Select an example to view its content") .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 instructions_text = vec![ Line::from("Job Creation Mode"), 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"), Line::from(""), Line::from("Press Enter to create and dispatch the job."), Line::from("Press Esc to return to main view."), ]; let instructions = Paragraph::new(instructions_text) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL).title("Instructions")); f.render_widget(instructions, area); } 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::Esc => { 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::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::Left => { if app.current_tab == TabState::Examples { app.collapse_selected_folder(); } } KeyCode::Right => { if app.current_tab == TabState::Examples { app.expand_selected_folder(); } } KeyCode::Enter => { if app.current_tab == TabState::Examples { // Handle hierarchical tree navigation if let Some(selected_index) = app.example_tree_state.selected() { if selected_index < app.example_tree_items.len() { let item = app.example_tree_items[selected_index].clone(); match &item.node { ExampleTreeNode::Folder { .. } => { // Toggle folder expand/collapse app.toggle_selected_folder(); } ExampleTreeNode::File { path, name, .. } => { // Load file into New Job tab let example = ExampleScript { name: name.clone(), path: path.clone(), }; if let Err(e) = app.load_example_to_new_job(&example) { app.status_message = Some(format!("Error loading example: {}", e)); } else { app.current_tab = TabState::NewJob; app.status_message = Some(format!("Loaded example '{}' into New Job tab", name)); } } } } } } 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)); } } } KeyCode::Char(c) => { // Handle character input in New Job tab if app.current_tab == TabState::NewJob { app.handle_char_input(c); } } KeyCode::Backspace => { // Handle backspace in New Job tab if app.current_tab == TabState::NewJob { app.handle_backspace(); } } _ => {} } } 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 { last_tick = Instant::now(); } if app.should_quit { break; } } Ok(()) }