diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 129fca8..803daf6 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -2,10 +2,11 @@ use std::fs; use std::path::Path; -use std::process::{Command, Stdio}; +use std::sync::Arc; use std::thread; use std::time::Duration; +use super::pty_manager::pty_manager; use super::traits::CodingAgent; use super::{ agents_log_dir, build_issue_prompt, create_worktree, get_diff_stats, new_session_id, @@ -33,8 +34,8 @@ impl CodingAgent for ClaudeCodeAgent { /// Dispatch an issue to a coding agent for processing. /// -/// This creates a git worktree, launches the agent in an interactive -/// tmux session, and returns immediately with a session handle. +/// This creates a git worktree, launches the agent in a PTY session, +/// and returns immediately with a session handle. pub async fn dispatch_to_agent( issue: &IssueDetail, local_path: &Path, @@ -61,11 +62,11 @@ pub async fn dispatch_to_agent( // Build the prompt with optional additional instructions let prompt = build_issue_prompt(issue, additional_instructions); - // Get tmux session name - let tmux_name = tmux_session_name(project, issue.number); + // Get PTY session name + let pty_name = pty_session_name(project, issue.number); - // Launch agent in tmux using trait method - launch_agent_tmux(&*agent, &worktree_path, &prompt, &tmux_name)?; + // Launch agent in PTY + launch_agent_pty(&*agent, &worktree_path, &prompt, &pty_name)?; // Create session with agent type let session = AgentSession::new( @@ -73,7 +74,7 @@ pub async fn dispatch_to_agent( issue.number, issue.title.clone(), project.to_string(), - 0, // No direct PID, we use tmux session name + 0, // No direct PID, we use PTY session name log_file.clone(), worktree_path.clone(), branch_name, @@ -85,8 +86,8 @@ pub async fn dispatch_to_agent( manager.add(session.clone()); manager.save()?; - // Start monitoring thread for tmux session with agent type - start_tmux_monitoring(session_id, tmux_name, worktree_path, agent_type.clone()); + // Start monitoring thread for PTY session + start_pty_monitoring(session_id, pty_name, worktree_path, agent_type.clone()); Ok(session) } @@ -101,44 +102,35 @@ pub async fn dispatch_to_claude( dispatch_to_agent(issue, local_path, project, &CodingAgentType::Claude, base_branch, None).await } -/// Launch a coding agent in an interactive tmux session. -fn launch_agent_tmux( +/// Launch a coding agent in a PTY session. +pub fn launch_agent_pty( agent: &dyn CodingAgent, worktree_path: &Path, prompt: &str, session_name: &str, ) -> Result<(), AgentError> { + // Get terminal size + let (cols, rows) = crossterm::terminal::size().unwrap_or((200, 50)); + // Build the command using the agent's trait method let cmd = agent.build_launch_command(worktree_path, prompt); - // Create tmux session in detached mode - let status = Command::new("tmux") - .args([ - "new-session", - "-d", - "-s", + // Spawn PTY session with bash -c to run the command + pty_manager() + .spawn_session( session_name, - "-x", - "200", - "-y", - "50", "bash", - "-c", - &cmd, - ]) - .status() - .map_err(|e| AgentError::ProcessError(format!("Failed to launch tmux: {}", e)))?; - - if !status.success() { - return Err(AgentError::ProcessError( - "Failed to create tmux session".to_string(), - )); - } + &["-c", &cmd], + worktree_path, + rows, + cols, + ) + .map_err(|e| AgentError::ProcessError(format!("Failed to launch PTY: {}", e)))?; Ok(()) } -/// Launch a coding agent interactively in a tmux session. +/// Launch a coding agent interactively in a PTY session. /// /// If `initial_prompt` is provided, the agent will be launched with that prompt. /// Otherwise, it starts in interactive mode without initial context. @@ -159,159 +151,71 @@ pub fn launch_agent_interactive( } // Check if session already exists - if is_tmux_session_running(session_name) { + if is_pty_session_running(session_name) { return Err(AgentError::ProcessError(format!( - "Tmux session '{}' already exists", + "PTY session '{}' already exists", session_name ))); } let agent = get_agent(agent_type); + let (cols, rows) = crossterm::terminal::size().unwrap_or((200, 50)); - // If we have a prompt, use build_launch_command like launch_agent_tmux if let Some(prompt) = initial_prompt { + // With prompt - use build_launch_command let cmd = agent.build_launch_command(worktree_path, prompt); - let status = Command::new("tmux") - .args([ - "new-session", - "-d", - "-s", + pty_manager() + .spawn_session( session_name, - "-x", - "200", - "-y", - "50", "bash", - "-c", - &cmd, - ]) - .status() - .map_err(|e| AgentError::ProcessError(format!("Failed to launch tmux: {}", e)))?; - - if !status.success() { - return Err(AgentError::ProcessError( - "Failed to create tmux session".to_string(), - )); - } + &["-c", &cmd], + worktree_path, + rows, + cols, + ) + .map_err(|e| AgentError::ProcessError(format!("Failed to launch PTY: {}", e)))?; } else { - // No prompt - start in interactive mode - let output = Command::new("tmux") - .args([ - "new-session", - "-d", - "-s", - session_name, - "-c", - worktree_path.to_str().unwrap_or("."), - "-x", - "200", - "-y", - "50", - ]) - .output() - .map_err(|e| AgentError::ProcessError(format!("Failed to launch tmux: {}", e)))?; - - if !output.status.success() { - return Err(AgentError::ProcessError(format!( - "Failed to create tmux session: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - - // Send the agent command to the tmux session + // No prompt - start agent in interactive mode let agent_cmd = agent.cli_command(); - let _ = Command::new("tmux") - .args(["send-keys", "-t", session_name, agent_cmd, "Enter"]) - .output(); + + pty_manager() + .spawn_session(session_name, agent_cmd, &[], worktree_path, rows, cols) + .map_err(|e| AgentError::ProcessError(format!("Failed to launch PTY: {}", e)))?; } Ok(()) } -/// Get the tmux session name for an issue. -pub fn tmux_session_name(project: &str, issue_number: u64) -> String { +/// Get the PTY session name for an issue. +pub fn pty_session_name(project: &str, issue_number: u64) -> String { format!("{}-issue-{}", project, issue_number) } -/// Check if a tmux session exists and is running. -pub fn is_tmux_session_running(session_name: &str) -> bool { - Command::new("tmux") - .args(["has-session", "-t", session_name]) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) +/// Check if a PTY session exists and is running. +pub fn is_pty_session_running(session_name: &str) -> bool { + pty_manager().is_session_running(session_name) } -/// List all tmux sessions for the assistant (issue-based only). -pub fn list_tmux_sessions() -> Vec { - let output = Command::new("tmux") - .args(["list-sessions", "-F", "#{session_name}"]) - .output(); - - match output { - Ok(out) if out.status.success() => { - String::from_utf8_lossy(&out.stdout) - .lines() - .filter(|s| s.contains("-issue-")) - .map(|s| s.to_string()) - .collect() - } - _ => vec![], - } +/// List all PTY sessions for the assistant (issue-based only). +pub fn list_pty_sessions() -> Vec { + pty_manager() + .list_running_sessions() + .into_iter() + .filter(|s| s.contains("-issue-")) + .collect() } -/// List all tmux sessions (no filtering). -pub fn list_all_tmux_sessions() -> Vec { - let output = Command::new("tmux") - .args(["list-sessions", "-F", "#{session_name}"]) - .output(); - - match output { - Ok(out) if out.status.success() => { - String::from_utf8_lossy(&out.stdout) - .lines() - .map(|s| s.to_string()) - .collect() - } - _ => vec![], - } +/// List all PTY sessions (no filtering). +pub fn list_all_pty_sessions() -> Vec { + pty_manager().list_running_sessions() } -/// Attach to a tmux session (returns the command to run). -pub fn attach_tmux_command(session_name: &str) -> String { - format!("tmux attach -t {}", session_name) -} - -/// Kill a tmux session. -pub fn kill_tmux_session(session_name: &str) -> Result<(), AgentError> { - let status = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .status() - .map_err(|e| AgentError::ProcessError(format!("Failed to kill tmux session: {}", e)))?; - - if !status.success() { - return Err(AgentError::ProcessError( - "Failed to kill tmux session".to_string(), - )); - } - - Ok(()) -} - -/// Capture the content of a tmux pane. -fn capture_tmux_pane(session_name: &str) -> Option { - let output = Command::new("tmux") - .args(["capture-pane", "-t", session_name, "-p", "-S", "-50"]) - .output() - .ok()?; - - if output.status.success() { - Some(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - None - } +/// Get recent output from a PTY session (for idle detection). +fn get_pty_output(session_name: &str) -> Option { + pty_manager() + .get_session(session_name) + .map(|session| session.get_recent_output()) } /// Check if Claude Code is idle (waiting for input). @@ -338,13 +242,11 @@ fn is_claude_idle(pane_content: &str) -> bool { } // Claude Code shows selection dialog when asking a question - // Pattern: "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" if trimmed.contains("Enter to select") { return true; } // Claude Code shows authorization prompt - // Pattern: "Esc to cancel" at the end of permission dialogs if trimmed == "Esc to cancel" { return true; } @@ -353,11 +255,10 @@ fn is_claude_idle(pane_content: &str) -> bool { false } -/// Start a monitoring thread for the tmux session. -/// If `already_awaiting` is true, skip the first idle notification (used when resuming). -fn start_tmux_monitoring_with_state( +/// Start a monitoring thread for the PTY session. +fn start_pty_monitoring_with_state( session_id: String, - tmux_name: String, + pty_name: String, worktree_path: std::path::PathBuf, already_awaiting: bool, agent_type: CodingAgentType, @@ -376,13 +277,12 @@ fn start_tmux_monitoring_with_state( let (lines_added, lines_deleted, files_changed) = get_diff_stats(&worktree_path); let stats = AgentStats { - lines_output: 0, // We don't track output lines with tmux + lines_output: 0, lines_added, lines_deleted, files_changed, }; - // Keep a copy for notification message let stats_copy = stats.clone(); // Update session @@ -390,8 +290,8 @@ fn start_tmux_monitoring_with_state( manager.update_stats(&session_id, stats); let _ = manager.save(); - // Check if tmux session is still running - if !is_tmux_session_running(&tmux_name) { + // Check if PTY session is still running + if !is_pty_session_running(&pty_name) { // Session ended - mark as completed let new_status = AgentStatus::Completed { exit_code: 0 }; @@ -406,12 +306,15 @@ fn start_tmux_monitoring_with_state( send_notification(title, &message); } + // Clean up the session from the manager + pty_manager().remove_session(&pty_name); + break; } // Check if agent is idle (waiting for user input) - if let Some(pane_content) = capture_tmux_pane(&tmux_name) { - let is_idle = agent.is_idle(&pane_content); + if let Some(output) = get_pty_output(&pty_name) { + let is_idle = agent.is_idle(&output); if is_idle && !was_idle { // Agent just became idle - update status to Awaiting @@ -438,7 +341,6 @@ fn start_tmux_monitoring_with_state( let mut manager = SessionManager::load(); manager.update_status(&session_id, AgentStatus::Running); let _ = manager.save(); - // Reset notification flag so we can notify again when idle idle_notified = false; } @@ -448,14 +350,14 @@ fn start_tmux_monitoring_with_state( }); } -/// Start a monitoring thread for a new tmux session. -fn start_tmux_monitoring( +/// Start a monitoring thread for a new PTY session. +fn start_pty_monitoring( session_id: String, - tmux_name: String, + pty_name: String, worktree_path: std::path::PathBuf, agent_type: CodingAgentType, ) { - start_tmux_monitoring_with_state(session_id, tmux_name, worktree_path, false, agent_type); + start_pty_monitoring_with_state(session_id, pty_name, worktree_path, false, agent_type); } /// Resume monitoring threads for all running sessions. @@ -466,15 +368,14 @@ pub fn resume_monitoring_for_running_sessions() { let manager = SessionManager::load(); for session in manager.running() { - let tmux_name = tmux_session_name(&session.project, session.issue_number); + let pty_name = pty_session_name(&session.project, session.issue_number); - // Only start monitoring if tmux session is actually running - if is_tmux_session_running(&tmux_name) { - // Pass the current awaiting state to avoid duplicate notifications + // Only start monitoring if PTY session is actually running + if is_pty_session_running(&pty_name) { let already_awaiting = session.is_awaiting(); - start_tmux_monitoring_with_state( + start_pty_monitoring_with_state( session.id.clone(), - tmux_name, + pty_name, session.worktree_path.clone(), already_awaiting, session.agent_type.clone(), @@ -483,16 +384,17 @@ pub fn resume_monitoring_for_running_sessions() { } } -/// Kill an agent by session ID (kills the tmux session). +/// Kill an agent by session ID. pub fn kill_agent(session_id: &str) -> Result<(), AgentError> { let manager = SessionManager::load(); if let Some(session) = manager.get(session_id) && session.is_running() { - // Build tmux session name and kill it - let tmux_name = tmux_session_name(&session.project, session.issue_number); - let _ = kill_tmux_session(&tmux_name); + // Build PTY session name and kill it + let pty_name = pty_session_name(&session.project, session.issue_number); + pty_manager().kill_session(&pty_name); + pty_manager().remove_session(&pty_name); // Update status let mut manager = SessionManager::load(); @@ -510,7 +412,8 @@ pub fn kill_agent(session_id: &str) -> Result<(), AgentError> { /// Create a PR from a completed session. pub fn create_pr(session: &AgentSession, base_branch: Option<&str>) -> Result { - // Build base args + use std::process::Command; + let mut args = vec![ "pr".to_string(), "create".to_string(), @@ -523,7 +426,6 @@ pub fn create_pr(session: &AgentSession, base_branch: Option<&str>) -> Result) -> Result Option> { + pty_manager().get_session(session_name) +} + #[cfg(test)] mod tests { use super::*; @@ -641,14 +548,12 @@ mod tests { comments: vec![], }; - // Empty/whitespace-only instructions should not be added let prompt = build_issue_prompt(&issue, Some(" ")); assert!(!prompt.contains("Additional instructions:")); } #[test] fn idle_detection_simple_prompt() { - // Simple prompt on its own line assert!(is_claude_idle("Some output\n>\n")); assert!(is_claude_idle("Some output\n> \n")); assert!(is_claude_idle("Some output\n>")); @@ -656,42 +561,35 @@ mod tests { #[test] fn idle_detection_with_empty_lines() { - // Prompt followed by empty lines (common in tmux capture) assert!(is_claude_idle("Some output\n>\n\n\n")); assert!(is_claude_idle("Some output\n> \n\n")); } #[test] fn idle_detection_with_leading_space() { - // Prompt with leading whitespace assert!(is_claude_idle("Some output\n >\n")); assert!(is_claude_idle("Some output\n\t> \n")); } #[test] fn idle_detection_not_idle() { - // Working output (no prompt) assert!(!is_claude_idle("Processing files...\nDone")); assert!(!is_claude_idle("Some output without prompt")); } #[test] fn idle_detection_prompt_in_output() { - // Prompt character in middle of text should still trigger - // because we check last non-empty lines assert!(is_claude_idle("Some > text\n>\n")); } #[test] fn idle_detection_question_dialog() { - // Claude Code selection dialog let content = "Quel type de fichier?\n1. JSON\n2. YAML\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel\n"; assert!(is_claude_idle(content)); } #[test] fn idle_detection_authorization_prompt() { - // Claude Code authorization/permission dialog let content = "Bash command\nuv run python --version\nDo you want to proceed?\n1. Yes\n2. Yes, and don't ask again\nEsc to cancel\n"; assert!(is_claude_idle(content)); } diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 2438563..e4d6ebb 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -2,16 +2,20 @@ mod claude; mod opencode; +pub mod pty_manager; +pub mod pty_session; mod session; mod traits; mod worktree; pub use claude::{ - attach_tmux_command, create_pr, dispatch_to_agent, dispatch_to_claude, is_tmux_session_running, - kill_agent, kill_tmux_session, launch_agent_interactive, list_all_tmux_sessions, - list_tmux_sessions, resume_monitoring_for_running_sessions, tmux_session_name, ClaudeCodeAgent, + create_pr, dispatch_to_agent, dispatch_to_claude, get_pty_session, is_pty_session_running, + kill_agent, launch_agent_interactive, launch_agent_pty, list_all_pty_sessions, + list_pty_sessions, pty_session_name, resume_monitoring_for_running_sessions, ClaudeCodeAgent, }; pub use opencode::OpencodeAgent; +pub use pty_manager::{pty_manager, PtyManager}; +pub use pty_session::PtySession; pub use session::{AgentSession, AgentStats, AgentStatus, SessionManager}; pub use traits::{get_agent, CodingAgent}; pub use worktree::{ diff --git a/src/agents/pty_manager.rs b/src/agents/pty_manager.rs new file mode 100644 index 0000000..aee026b --- /dev/null +++ b/src/agents/pty_manager.rs @@ -0,0 +1,159 @@ +//! PTY Manager for handling multiple PTY sessions. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, RwLock}; + +use super::pty_session::PtySession; + +/// Global PTY manager singleton. +static PTY_MANAGER: std::sync::LazyLock = + std::sync::LazyLock::new(PtyManager::new); + +/// Get the global PTY manager instance. +pub fn pty_manager() -> &'static PtyManager { + &PTY_MANAGER +} + +/// Manager for multiple PTY sessions. +pub struct PtyManager { + sessions: Arc>>>, +} + +impl PtyManager { + /// Create a new PTY manager. + pub fn new() -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Spawn a new PTY session. + /// + /// # Arguments + /// * `id` - Unique session identifier (e.g., "project-issue-42") + /// * `program` - The program to run (e.g., "claude" or "opencode") + /// * `args` - Arguments to pass to the program + /// * `working_dir` - Working directory for the command + /// * `rows` - Terminal height in rows + /// * `cols` - Terminal width in columns + pub fn spawn_session( + &self, + id: &str, + program: &str, + args: &[&str], + working_dir: &Path, + rows: u16, + cols: u16, + ) -> Result, String> { + // Check if session already exists + { + let sessions = self.sessions.read().unwrap(); + if let Some(existing) = sessions.get(id) { + if existing.is_running() { + return Err(format!("Session '{}' already exists and is running", id)); + } + } + } + + // Spawn the new session + let session = PtySession::spawn(id, program, args, working_dir, rows, cols)?; + let session = Arc::new(session); + + // Store in map + { + let mut sessions = self.sessions.write().unwrap(); + sessions.insert(id.to_string(), Arc::clone(&session)); + } + + Ok(session) + } + + /// Get a session by ID. + pub fn get_session(&self, id: &str) -> Option> { + let sessions = self.sessions.read().unwrap(); + sessions.get(id).cloned() + } + + /// List all session IDs. + pub fn list_sessions(&self) -> Vec { + let sessions = self.sessions.read().unwrap(); + sessions.keys().cloned().collect() + } + + /// List all running session IDs. + pub fn list_running_sessions(&self) -> Vec { + let sessions = self.sessions.read().unwrap(); + sessions + .iter() + .filter(|(_, session)| session.is_running()) + .map(|(id, _)| id.clone()) + .collect() + } + + /// Check if a session exists and is running. + pub fn is_session_running(&self, id: &str) -> bool { + let sessions = self.sessions.read().unwrap(); + sessions + .get(id) + .is_some_and(|session| session.is_running()) + } + + /// Kill a session by ID. + pub fn kill_session(&self, id: &str) -> bool { + let sessions = self.sessions.read().unwrap(); + if let Some(session) = sessions.get(id) { + session.kill(); + true + } else { + false + } + } + + /// Remove a session from the manager (cleanup). + pub fn remove_session(&self, id: &str) -> Option> { + let mut sessions = self.sessions.write().unwrap(); + sessions.remove(id) + } + + /// Cleanup dead sessions from the manager. + pub fn cleanup_dead_sessions(&self) { + let mut sessions = self.sessions.write().unwrap(); + sessions.retain(|_, session| session.is_running()); + } + + /// Get the number of active sessions. + pub fn session_count(&self) -> usize { + let sessions = self.sessions.read().unwrap(); + sessions.len() + } + + /// Get the number of running sessions. + pub fn running_session_count(&self) -> usize { + let sessions = self.sessions.read().unwrap(); + sessions.values().filter(|s| s.is_running()).count() + } +} + +impl Default for PtyManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manager_creation() { + let manager = PtyManager::new(); + assert_eq!(manager.session_count(), 0); + } + + #[test] + fn test_list_sessions_empty() { + let manager = PtyManager::new(); + assert!(manager.list_sessions().is_empty()); + } +} diff --git a/src/agents/pty_session.rs b/src/agents/pty_session.rs new file mode 100644 index 0000000..bdb62a7 --- /dev/null +++ b/src/agents/pty_session.rs @@ -0,0 +1,352 @@ +//! PTY session for running agents directly without tmux. + +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; +use std::collections::VecDeque; +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; +use vt100::Parser; + +use crate::embedded_term::StyledCell; + +/// Size of the circular buffer for recent output (for idle detection). +const OUTPUT_BUFFER_SIZE: usize = 4096; + +/// A PTY session running an agent directly. +pub struct PtySession { + /// Unique session identifier (e.g., "project-issue-42") + pub id: String, + /// Terminal parser for screen state + parser: Arc>, + /// Channel to send input to the PTY + input_tx: Sender>, + /// Circular buffer for recent output (for idle detection) + output_buffer: Arc>>, + /// Whether the session is still running + running: Arc>, + /// Child process handle + _child: Arc>>, + /// Master PTY (kept for resize operations) + master: Arc>>, +} + +impl PtySession { + /// Spawn a new PTY session running a command. + /// + /// # Arguments + /// * `id` - Unique session identifier + /// * `program` - The program to run (e.g., "claude" or "opencode") + /// * `args` - Arguments to pass to the program + /// * `working_dir` - Working directory for the command + /// * `rows` - Terminal height in rows + /// * `cols` - Terminal width in columns + pub fn spawn( + id: &str, + program: &str, + args: &[&str], + working_dir: &Path, + rows: u16, + cols: u16, + ) -> Result { + let pty_system = native_pty_system(); + + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("Failed to open PTY: {}", e))?; + + // Build the command + let mut cmd = CommandBuilder::new(program); + cmd.args(args); + cmd.cwd(working_dir); + + let child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn command: {}", e))?; + + // Create the terminal parser + let parser = Arc::new(Mutex::new(Parser::new(rows, cols, 1000))); // 1000 lines scrollback + let parser_clone = Arc::clone(&parser); + + // Create circular output buffer + let output_buffer = Arc::new(RwLock::new(VecDeque::with_capacity(OUTPUT_BUFFER_SIZE))); + let output_buffer_clone = Arc::clone(&output_buffer); + + // Create input channel + let (input_tx, input_rx): (Sender>, Receiver>) = mpsc::channel(); + + // Running flag + let running = Arc::new(Mutex::new(true)); + let running_clone = Arc::clone(&running); + + // Get reader and writer from PTY + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone reader: {}", e))?; + + let mut writer = pair + .master + .take_writer() + .map_err(|e| format!("Failed to take writer: {}", e))?; + + // Spawn reader thread + let running_reader = Arc::clone(&running); + thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => { + // EOF - PTY closed + *running_reader.lock().unwrap() = false; + break; + } + Ok(n) => { + let data = &buf[..n]; + + // Update vt100 parser + if let Ok(mut parser) = parser_clone.lock() { + parser.process(data); + } + + // Append to circular buffer + if let Ok(mut buffer) = output_buffer_clone.write() { + for byte in data { + if buffer.len() >= OUTPUT_BUFFER_SIZE { + buffer.pop_front(); + } + buffer.push_back(*byte); + } + } + } + Err(_) => { + *running_reader.lock().unwrap() = false; + break; + } + } + } + }); + + // Spawn writer thread + thread::spawn(move || { + while let Ok(data) = input_rx.recv() { + if writer.write_all(&data).is_err() { + break; + } + let _ = writer.flush(); + } + }); + + Ok(Self { + id: id.to_string(), + parser, + input_tx, + output_buffer, + running: running_clone, + _child: Arc::new(Mutex::new(child)), + master: Arc::new(Mutex::new(pair.master)), + }) + } + + /// Check if the session is still running. + pub fn is_running(&self) -> bool { + *self.running.lock().unwrap() + } + + /// Send input bytes to the terminal. + pub fn send_input(&self, data: &[u8]) { + let _ = self.input_tx.send(data.to_vec()); + } + + /// Send a key to the terminal. + pub fn send_key(&self, key: crossterm::event::KeyCode) { + use crossterm::event::KeyCode; + + let bytes: Vec = match key { + KeyCode::Char(c) => vec![c as u8], + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![127], + KeyCode::Tab => vec![b'\t'], + KeyCode::BackTab => b"\x1b[Z".to_vec(), + KeyCode::Up => b"\x1b[A".to_vec(), + KeyCode::Down => b"\x1b[B".to_vec(), + KeyCode::Right => b"\x1b[C".to_vec(), + KeyCode::Left => b"\x1b[D".to_vec(), + KeyCode::Home => b"\x1b[H".to_vec(), + KeyCode::End => b"\x1b[F".to_vec(), + KeyCode::PageUp => b"\x1b[5~".to_vec(), + KeyCode::PageDown => b"\x1b[6~".to_vec(), + KeyCode::Delete => b"\x1b[3~".to_vec(), + KeyCode::Esc => vec![0x1b], + KeyCode::F(n) => match n { + 1 => b"\x1bOP".to_vec(), + 2 => b"\x1bOQ".to_vec(), + 3 => b"\x1bOR".to_vec(), + 4 => b"\x1bOS".to_vec(), + 5 => b"\x1b[15~".to_vec(), + 6 => b"\x1b[17~".to_vec(), + 7 => b"\x1b[18~".to_vec(), + 8 => b"\x1b[19~".to_vec(), + 9 => b"\x1b[20~".to_vec(), + 10 => b"\x1b[21~".to_vec(), + 11 => b"\x1b[23~".to_vec(), + 12 => b"\x1b[24~".to_vec(), + _ => vec![], + }, + _ => vec![], + }; + + if !bytes.is_empty() { + self.send_input(&bytes); + } + } + + /// Send a key with modifiers. + pub fn send_key_with_modifiers( + &self, + key: crossterm::event::KeyCode, + modifiers: crossterm::event::KeyModifiers, + ) { + use crossterm::event::{KeyCode, KeyModifiers}; + + // Handle Ctrl+key combinations + if modifiers.contains(KeyModifiers::CONTROL) { + if let KeyCode::Char(c) = key { + let ctrl_code = (c.to_ascii_lowercase() as u8).wrapping_sub(b'a').wrapping_add(1); + if ctrl_code <= 26 { + self.send_input(&[ctrl_code]); + return; + } + } + } + + // Handle Alt+key combinations (ESC prefix) + if modifiers.contains(KeyModifiers::ALT) { + match key { + KeyCode::Backspace => { + self.send_input(&[0x1b, 127]); + return; + } + KeyCode::Char(c) => { + self.send_input(&[0x1b, c as u8]); + return; + } + _ => {} + } + } + + // Handle Super (CMD on macOS) combinations + if modifiers.contains(KeyModifiers::SUPER) { + if let KeyCode::Backspace = key { + self.send_input(&[21]); // Ctrl+U + return; + } + } + + // Default: send the key without modifiers + self.send_key(key); + } + + /// Get the current screen contents as styled cells. + pub fn get_screen(&self) -> Vec> { + let parser = self.parser.lock().unwrap(); + let screen = parser.screen(); + let mut lines = Vec::new(); + + for row in 0..screen.size().0 { + let mut line = Vec::new(); + for col in 0..screen.size().1 { + let cell = screen.cell(row, col).unwrap(); + line.push(StyledCell { + content: cell.contents().to_string(), + fg: convert_color(cell.fgcolor()), + bg: convert_color(cell.bgcolor()), + bold: cell.bold(), + underline: cell.underline(), + inverse: cell.inverse(), + }); + } + lines.push(line); + } + + lines + } + + /// Get recent output as a string (for idle detection). + pub fn get_recent_output(&self) -> String { + let buffer = self.output_buffer.read().unwrap(); + let bytes: Vec = buffer.iter().copied().collect(); + String::from_utf8_lossy(&bytes).to_string() + } + + /// Resize the terminal. + pub fn resize(&self, rows: u16, cols: u16) { + // Resize the vt100 parser + if let Ok(mut parser) = self.parser.lock() { + parser.screen_mut().set_size(rows, cols); + } + + // Resize the actual PTY + if let Ok(master) = self.master.lock() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + } + + /// Kill the session. + pub fn kill(&self) { + *self.running.lock().unwrap() = false; + // The child process will be killed when dropped + } +} + +/// Convert vt100 color to ratatui color. +fn convert_color(color: vt100::Color) -> ratatui::style::Color { + use ratatui::style::Color; + + match color { + vt100::Color::Default => Color::Reset, + vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), + vt100::Color::Idx(n) => match n { + 0 => Color::Black, + 1 => Color::Red, + 2 => Color::Green, + 3 => Color::Yellow, + 4 => Color::Blue, + 5 => Color::Magenta, + 6 => Color::Cyan, + 7 => Color::Gray, + 8 => Color::DarkGray, + 9 => Color::LightRed, + 10 => Color::LightGreen, + 11 => Color::LightYellow, + 12 => Color::LightBlue, + 13 => Color::LightMagenta, + 14 => Color::LightCyan, + 15 => Color::White, + _ => Color::Indexed(n), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_output_buffer_circular() { + let buffer: VecDeque = VecDeque::with_capacity(10); + assert_eq!(buffer.capacity(), 10); + } +} diff --git a/src/agents/session.rs b/src/agents/session.rs index 6ec9ad0..e2dd07c 100644 --- a/src/agents/session.rs +++ b/src/agents/session.rs @@ -4,8 +4,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use super::pty_manager::pty_manager; use super::{cache_dir, sessions_file}; use crate::config::CodingAgentType; @@ -95,7 +95,7 @@ impl AgentSession { } /// Check if the session is still running (based on stored status only) - /// Note: Awaiting is also considered "running" since tmux session is active + /// Note: Awaiting is also considered "running" since PTY session is active pub fn is_running(&self) -> bool { matches!(self.status, AgentStatus::Running | AgentStatus::Awaiting) } @@ -105,8 +105,8 @@ impl AgentSession { matches!(self.status, AgentStatus::Awaiting) } - /// Get the tmux session name for this session - pub fn tmux_session_name(&self) -> String { + /// Get the PTY session name for this session + pub fn pty_session_name(&self) -> String { format!("{}-issue-{}", self.project, self.issue_number) } @@ -149,21 +149,16 @@ impl SessionManager { Self { sessions } } - /// Sync session statuses with actual tmux state. - /// Marks sessions as completed if their tmux session no longer exists. - pub fn sync_with_tmux(&mut self) -> bool { + /// Sync session statuses with actual PTY state. + /// Marks sessions as completed if their PTY session no longer exists. + pub fn sync_with_pty(&mut self) -> bool { let mut changed = false; for session in &mut self.sessions { if session.is_running() { - let tmux_name = session.tmux_session_name(); - let tmux_exists = Command::new("tmux") - .args(["has-session", "-t", &tmux_name]) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - - if !tmux_exists { + let pty_name = session.pty_session_name(); + let pty_exists = pty_manager().is_session_running(&pty_name); + + if !pty_exists { session.status = AgentStatus::Completed { exit_code: 0 }; changed = true; } diff --git a/src/agents/traits.rs b/src/agents/traits.rs index 00109f8..f8756c9 100644 --- a/src/agents/traits.rs +++ b/src/agents/traits.rs @@ -15,7 +15,7 @@ pub trait CodingAgent: Send + Sync { /// Returns the CLI command to invoke this agent (e.g., "claude", "opencode") fn cli_command(&self) -> &'static str; - /// Check if the agent is idle (waiting for user input) based on tmux pane content. + /// Check if the agent is idle (waiting for user input) based on PTY output. fn is_idle(&self, pane_content: &str) -> bool; /// Build the shell command to launch the agent with a prompt. diff --git a/src/agents/worktree.rs b/src/agents/worktree.rs index 3b4cdb1..64a1552 100644 --- a/src/agents/worktree.rs +++ b/src/agents/worktree.rs @@ -379,8 +379,8 @@ pub struct WorktreeInfo { pub issue_number: Option, /// Whether this worktree has an active session pub has_session: bool, - /// Whether there's a running tmux session for this worktree - pub has_tmux: bool, + /// Whether there's a running PTY session for this worktree + pub has_pty: bool, } /// List all worktrees in the cache directory with their status. @@ -406,7 +406,7 @@ pub fn list_worktrees() -> Vec { project, issue_number, has_session: false, // Will be filled in by caller - has_tmux: false, // Will be filled in by caller + has_pty: false, // Will be filled in by caller }); } } diff --git a/src/commands/render.rs b/src/commands/render.rs index 55508b6..32cd0a7 100644 --- a/src/commands/render.rs +++ b/src/commands/render.rs @@ -79,7 +79,7 @@ pub fn generate_full_help() -> Vec> { // Embedded Terminal section lines.push(section_header("EMBEDDED TERMINAL")); lines.push(Line::from("")); - lines.extend(help_lines_for_context(CommandContext::EmbeddedTmux)); + lines.extend(help_lines_for_context(CommandContext::EmbeddedPty)); lines } @@ -142,7 +142,7 @@ pub fn status_bar_shortcuts(context: CommandContext) -> &'static [Shortcut] { Shortcut::MergePR, Shortcut::GoBack, ], - CommandContext::EmbeddedTmux => &[ + CommandContext::EmbeddedPty => &[ Shortcut::ExitTerminal, Shortcut::PrevSession, Shortcut::NextSession, diff --git a/src/commands/shortcuts.rs b/src/commands/shortcuts.rs index 527b8f4..3fa09d5 100644 --- a/src/commands/shortcuts.rs +++ b/src/commands/shortcuts.rs @@ -175,8 +175,8 @@ impl Shortcut { Self::AssignUser => "Assign user", Self::DispatchAgent => "Dispatch agent", Self::StartAgent => "Start agent", - Self::OpenTmux => "Open tmux session", - Self::OpenAnyTmux => "Open any tmux session", + Self::OpenTmux => "Open session", + Self::OpenAnyTmux => "Open any session", Self::ViewLogs => "View agent logs", Self::CreatePR => "Create pull request", Self::KillAgent => "Kill agent", @@ -202,7 +202,7 @@ impl Shortcut { Self::StartAgent => "agent", Self::OpenIDE => "ide", Self::CreatePR => "pr", - Self::OpenTmux => "tmux", + Self::OpenTmux => "term", Self::Refresh => "refresh", Self::OpenCommandPalette => "cmd", Self::OpenHelp => "help", @@ -262,7 +262,7 @@ impl Shortcut { | Self::OpenAnyTmux | Self::ExitTerminal | Self::PrevSession - | Self::NextSession => CommandCategory::Tmux, + | Self::NextSession => CommandCategory::Terminal, Self::SwitchToPRs | Self::SwitchToIssues @@ -371,7 +371,7 @@ impl Shortcut { // Embedded terminal Self::ExitTerminal | Self::PrevSession | Self::NextSession => { - &[CommandContext::EmbeddedTmux] + &[CommandContext::EmbeddedPty] } } } diff --git a/src/commands/types.rs b/src/commands/types.rs index 201954d..42ce82f 100644 --- a/src/commands/types.rs +++ b/src/commands/types.rs @@ -15,8 +15,8 @@ pub enum CommandContext { PullRequestList, /// Pull request detail view PullRequestDetail, - /// Embedded tmux terminal - EmbeddedTmux, + /// Embedded PTY terminal + EmbeddedPty, } /// Category for grouping shortcuts in help display. @@ -25,7 +25,7 @@ pub enum CommandCategory { Navigation, Issues, Agent, - Tmux, + Terminal, PullRequests, Worktrees, Other, @@ -38,7 +38,7 @@ impl CommandCategory { Self::Navigation => "NAVIGATION", Self::Issues => "ISSUES", Self::Agent => "AGENT / WORKTREE", - Self::Tmux => "TMUX", + Self::Terminal => "TERMINAL", Self::PullRequests => "PULL REQUESTS", Self::Worktrees => "WORKTREES", Self::Other => "OTHER", @@ -51,7 +51,7 @@ impl CommandCategory { Self::Navigation => 0, Self::Issues => 1, Self::Agent => 2, - Self::Tmux => 3, + Self::Terminal => 3, Self::PullRequests => 4, Self::Worktrees => 5, Self::Other => 6, diff --git a/src/embedded_term.rs b/src/embedded_term.rs index ea749f7..4ed814e 100644 --- a/src/embedded_term.rs +++ b/src/embedded_term.rs @@ -1,161 +1,78 @@ -//! Embedded terminal for running tmux sessions within the TUI. +//! Embedded terminal for displaying PTY sessions in the TUI. -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use std::io::{Read, Write}; -use std::sync::mpsc::{self, Receiver, Sender}; -use std::sync::{Arc, Mutex}; -use std::thread; -use vt100::Parser; +use std::sync::Arc; -/// Embedded terminal that wraps a tmux session. +use ratatui::style::Color; + +use crate::agents::pty_manager::pty_manager; +use crate::agents::pty_session::PtySession; + +/// A styled cell from the terminal. +#[derive(Clone)] +pub struct StyledCell { + pub content: String, + pub fg: Color, + pub bg: Color, + pub bold: bool, + pub underline: bool, + pub inverse: bool, +} + +/// Embedded terminal that wraps a PTY session for display in the TUI. pub struct EmbeddedTerminal { - /// Terminal parser that maintains the screen buffer - parser: Arc>, - /// Channel to send input to the PTY - input_tx: Sender>, - /// Whether the terminal is still running - running: Arc>, - /// Current tmux session name + /// Reference to the PTY session. + session: Arc, + /// Current terminal height (for resize). + rows: u16, + /// Current terminal width (for resize). + cols: u16, + /// Session name for reference. pub session_name: String, } impl EmbeddedTerminal { - /// Create a new embedded terminal attached to a tmux session. - pub fn new(session_name: &str, rows: u16, cols: u16) -> Result { - let pty_system = native_pty_system(); - - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| format!("Failed to open PTY: {}", e))?; - - // Build the tmux attach command - let mut cmd = CommandBuilder::new("tmux"); - cmd.args(["attach", "-t", session_name]); - - let _child = pair - .slave - .spawn_command(cmd) - .map_err(|e| format!("Failed to spawn tmux: {}", e))?; + /// Attach to an existing PTY session. + /// + /// This is the primary constructor - it attaches to a session managed by PtyManager. + pub fn attach(session_name: &str, rows: u16, cols: u16) -> Result { + let session = pty_manager() + .get_session(session_name) + .ok_or_else(|| format!("Session '{}' not found", session_name))?; - // Create the terminal parser - let parser = Arc::new(Mutex::new(Parser::new(rows, cols, 0))); - let parser_clone = Arc::clone(&parser); - - // Create input channel - let (input_tx, input_rx): (Sender>, Receiver>) = mpsc::channel(); - - // Running flag - let running = Arc::new(Mutex::new(true)); - let running_clone = Arc::clone(&running); - - // Get reader and writer from PTY - let mut reader = pair - .master - .try_clone_reader() - .map_err(|e| format!("Failed to clone reader: {}", e))?; - - let mut writer = pair - .master - .take_writer() - .map_err(|e| format!("Failed to take writer: {}", e))?; - - // Spawn reader thread - let running_reader = Arc::clone(&running); - thread::spawn(move || { - let mut buf = [0u8; 4096]; - loop { - match reader.read(&mut buf) { - Ok(0) => { - // EOF - PTY closed - *running_reader.lock().unwrap() = false; - break; - } - Ok(n) => { - if let Ok(mut parser) = parser_clone.lock() { - parser.process(&buf[..n]); - } - } - Err(_) => { - *running_reader.lock().unwrap() = false; - break; - } - } - } - }); - - // Spawn writer thread - thread::spawn(move || { - while let Ok(data) = input_rx.recv() { - if writer.write_all(&data).is_err() { - break; - } - let _ = writer.flush(); - } - }); + // Resize the session to match our terminal size + session.resize(rows, cols); Ok(Self { - parser, - input_tx, - running: running_clone, + session, + rows, + cols, session_name: session_name.to_string(), }) } - /// Check if the terminal is still running. + /// Create a new embedded terminal (alias for attach for backward compatibility). + pub fn new(session_name: &str, rows: u16, cols: u16) -> Result { + Self::attach(session_name, rows, cols) + } + + /// Get the current screen as styled cells for rendering. + pub fn get_screen(&self) -> Vec> { + self.session.get_screen() + } + + /// Check if the session is still running. pub fn is_running(&self) -> bool { - *self.running.lock().unwrap() + self.session.is_running() } /// Send input bytes to the terminal. pub fn send_input(&self, data: &[u8]) { - let _ = self.input_tx.send(data.to_vec()); + self.session.send_input(data); } /// Send a key to the terminal. pub fn send_key(&self, key: crossterm::event::KeyCode) { - use crossterm::event::KeyCode; - - let bytes: Vec = match key { - KeyCode::Char(c) => vec![c as u8], - KeyCode::Enter => vec![b'\r'], - KeyCode::Backspace => vec![127], - KeyCode::Tab => vec![b'\t'], - KeyCode::BackTab => b"\x1b[Z".to_vec(), // Shift+Tab - KeyCode::Up => b"\x1b[A".to_vec(), - KeyCode::Down => b"\x1b[B".to_vec(), - KeyCode::Right => b"\x1b[C".to_vec(), - KeyCode::Left => b"\x1b[D".to_vec(), - KeyCode::Home => b"\x1b[H".to_vec(), - KeyCode::End => b"\x1b[F".to_vec(), - KeyCode::PageUp => b"\x1b[5~".to_vec(), - KeyCode::PageDown => b"\x1b[6~".to_vec(), - KeyCode::Delete => b"\x1b[3~".to_vec(), - KeyCode::F(n) => match n { - 1 => b"\x1bOP".to_vec(), - 2 => b"\x1bOQ".to_vec(), - 3 => b"\x1bOR".to_vec(), - 4 => b"\x1bOS".to_vec(), - 5 => b"\x1b[15~".to_vec(), - 6 => b"\x1b[17~".to_vec(), - 7 => b"\x1b[18~".to_vec(), - 8 => b"\x1b[19~".to_vec(), - 9 => b"\x1b[20~".to_vec(), - 10 => b"\x1b[21~".to_vec(), - 11 => b"\x1b[23~".to_vec(), - 12 => b"\x1b[24~".to_vec(), - _ => vec![], - }, - _ => vec![], - }; - - if !bytes.is_empty() { - self.send_input(&bytes); - } + self.session.send_key(key); } /// Send a key with modifiers. @@ -164,136 +81,40 @@ impl EmbeddedTerminal { key: crossterm::event::KeyCode, modifiers: crossterm::event::KeyModifiers, ) { - use crossterm::event::{KeyCode, KeyModifiers}; - - // Handle Ctrl+key combinations - if modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char(c) = key { - // Ctrl+A = 1, Ctrl+B = 2, etc. - let ctrl_code = (c.to_ascii_lowercase() as u8).wrapping_sub(b'a').wrapping_add(1); - if ctrl_code <= 26 { - self.send_input(&[ctrl_code]); - return; - } - } - } - - // Handle Alt+key combinations (ESC prefix) - if modifiers.contains(KeyModifiers::ALT) { - match key { - KeyCode::Backspace => { - // Alt+Backspace: delete word (ESC + DEL) - self.send_input(&[0x1b, 127]); - return; - } - KeyCode::Char(c) => { - // Alt+char: ESC followed by the character - self.send_input(&[0x1b, c as u8]); - return; - } - _ => {} - } - } - - // Handle Meta (CMD on macOS) combinations - if modifiers.contains(KeyModifiers::SUPER) { - match key { - KeyCode::Backspace => { - // CMD+Backspace: delete entire line (send Ctrl+U) - self.send_input(&[21]); // Ctrl+U = 21 - return; - } - _ => {} - } - } - - // Default: send the key without modifiers - self.send_key(key); - } - - /// Get the current screen contents as lines of styled spans. - pub fn get_screen(&self) -> Vec> { - let parser = self.parser.lock().unwrap(); - let screen = parser.screen(); - let mut lines = Vec::new(); - - for row in 0..screen.size().0 { - let mut line = Vec::new(); - for col in 0..screen.size().1 { - let cell = screen.cell(row, col).unwrap(); - line.push(StyledCell { - content: cell.contents().to_string(), - fg: convert_color(cell.fgcolor()), - bg: convert_color(cell.bgcolor()), - bold: cell.bold(), - underline: cell.underline(), - inverse: cell.inverse(), - }); - } - lines.push(line); - } - - lines + self.session.send_key_with_modifiers(key, modifiers); } /// Resize the terminal. - pub fn resize(&self, rows: u16, cols: u16) { - if let Ok(mut parser) = self.parser.lock() { - parser.screen_mut().set_size(rows, cols); + pub fn resize(&mut self, rows: u16, cols: u16) { + if self.rows != rows || self.cols != cols { + self.rows = rows; + self.cols = cols; + self.session.resize(rows, cols); } } -} - -/// A styled cell from the terminal. -#[derive(Clone)] -pub struct StyledCell { - pub content: String, - pub fg: ratatui::style::Color, - pub bg: ratatui::style::Color, - pub bold: bool, - pub underline: bool, - pub inverse: bool, -} -/// Convert vt100 color to ratatui color. -fn convert_color(color: vt100::Color) -> ratatui::style::Color { - use ratatui::style::Color; - - match color { - vt100::Color::Default => Color::Reset, - vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), - vt100::Color::Idx(n) => match n { - 0 => Color::Black, - 1 => Color::Red, - 2 => Color::Green, - 3 => Color::Yellow, - 4 => Color::Blue, - 5 => Color::Magenta, - 6 => Color::Cyan, - 7 => Color::Gray, - 8 => Color::DarkGray, - 9 => Color::LightRed, - 10 => Color::LightGreen, - 11 => Color::LightYellow, - 12 => Color::LightBlue, - 13 => Color::LightMagenta, - 14 => Color::LightCyan, - 15 => Color::White, - _ => Color::Indexed(n), - }, + /// Get the PTY session ID. + pub fn session_id(&self) -> &str { + &self.session.id } } #[cfg(test)] mod tests { - use super::*; - #[test] - fn color_conversion() { + fn test_styled_cell_clone() { + use super::StyledCell; use ratatui::style::Color; - assert_eq!(convert_color(vt100::Color::Default), Color::Reset); - assert_eq!(convert_color(vt100::Color::Idx(1)), Color::Red); - assert_eq!(convert_color(vt100::Color::Rgb(255, 0, 0)), Color::Rgb(255, 0, 0)); + let cell = StyledCell { + content: "x".to_string(), + fg: Color::White, + bg: Color::Black, + bold: false, + underline: false, + inverse: false, + }; + let cloned = cell.clone(); + assert_eq!(cloned.content, "x"); } } diff --git a/src/lib.rs b/src/lib.rs index b842fc0..be60a9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,3 +18,4 @@ pub mod tui_events; pub mod tui_image; pub mod tui_types; pub mod tui_utils; +pub mod widgets; diff --git a/src/tui.rs b/src/tui.rs index 968908e..b12618f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -68,7 +68,7 @@ pub struct IssueBrowser { pub selected_issues: std::collections::HashSet, // Session cache for dispatch status display pub session_cache: std::collections::HashMap, - // Embedded terminal for tmux sessions + // Embedded terminal for PTY sessions pub embedded_term: Option, // Last session cache refresh time pub last_session_refresh: std::time::Instant, @@ -227,11 +227,11 @@ impl IssueBrowser { for wt in &mut worktrees { wt.has_session = session_worktrees.contains(&wt.path); if let Some(issue_num) = wt.issue_number { - let tmux_name = crate::agents::tmux_session_name(&wt.project, issue_num); - wt.has_tmux = crate::agents::is_tmux_session_running(&tmux_name); + let session_name = crate::agents::pty_session_name(&wt.project, issue_num); + wt.has_pty = crate::agents::is_pty_session_running(&session_name); } else { // Standalone worktree: session name is the worktree name - wt.has_tmux = crate::agents::is_tmux_session_running(&wt.name); + wt.has_pty = crate::agents::is_pty_session_running(&wt.name); } } worktrees @@ -379,7 +379,7 @@ impl IssueBrowser { /// Refresh session cache for the current project pub fn refresh_sessions(&mut self, project: &str) { let mut manager = crate::agents::SessionManager::load(); - if manager.sync_with_tmux() { + if manager.sync_with_pty() { let _ = manager.save(); } self.session_cache.clear(); @@ -413,7 +413,7 @@ impl IssueBrowser { } let _ = manager.save(); - if manager.sync_with_tmux() { + if manager.sync_with_pty() { let _ = manager.save(); } @@ -848,7 +848,7 @@ impl IssueBrowser { ) -> Result { let session_name = format!("pr-review-{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap_or("unknown")); - // Launch agent interactively with tmux + // Launch agent interactively with PTY crate::agents::launch_agent_interactive( worktree_path, &session_name, @@ -962,7 +962,7 @@ pub async fn run_issue_browser_with_pagination( if event::poll(std::time::Duration::from_millis(100))? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { - let in_embedded_terminal = matches!(browser.view, TuiView::EmbeddedTmux { .. }); + let in_embedded_terminal = matches!(browser.view, TuiView::EmbeddedPty { .. }); let is_cmd_v = key.code == KeyCode::Char('v') && key.modifiers.contains(KeyModifiers::SUPER); let is_ctrl_v = key.code == KeyCode::Char('v') @@ -986,7 +986,7 @@ pub async fn run_issue_browser_with_pagination( } } Event::Paste(content) => { - if matches!(browser.view, TuiView::EmbeddedTmux { .. }) { + if matches!(browser.view, TuiView::EmbeddedPty { .. }) { // In embedded terminal: send paste content directly if let Some(ref term) = browser.embedded_term { term.send_input(content.as_bytes()); @@ -996,7 +996,7 @@ pub async fn run_issue_browser_with_pagination( } } Event::Mouse(mouse_event) => { - if matches!(browser.view, TuiView::EmbeddedTmux { .. }) { + if matches!(browser.view, TuiView::EmbeddedPty { .. }) { // Forward mouse events to embedded terminal (SGR mouse format) if let Some(ref term) = browser.embedded_term { let col = mouse_event.column + 1; diff --git a/src/tui_draw.rs b/src/tui_draw.rs index 7420514..0257a35 100644 --- a/src/tui_draw.rs +++ b/src/tui_draw.rs @@ -10,6 +10,12 @@ use crate::issues::IssueContent; use crate::markdown::{parse_markdown_content, render_markdown_line}; use crate::tui_types::{CommandSuggestion, CreateStage, IssueFilterFocus, IssueStatus, PrFilterFocus, PrStatus, TuiView}; use crate::tui_utils::{format_date, truncate_str}; +use crate::widgets::{ + build_issue_item, build_issue_item_dimmed, build_issue_list_title, + render_command_palette, render_filter_popup, + ConfirmDialog, CommandPaletteConfig, FilterFocus, FilterOption, FilterPopupConfig, + IssueListTitleConfig, Popup, StatusBar, +}; use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, @@ -159,12 +165,12 @@ pub fn draw_ui(f: &mut Frame, browser: &mut IssueBrowser) { } => { draw_agent_logs(f, session_id, content, *scroll); } - TuiView::EmbeddedTmux { + TuiView::EmbeddedPty { available_sessions, current_index, .. } => { - draw_embedded_tmux(f, browser, available_sessions, *current_index); + draw_embedded_pty(f, browser, available_sessions, *current_index); } TuiView::ProjectSelect { projects, selected } => { draw_project_select_inline(f, projects, *selected); @@ -351,107 +357,29 @@ pub fn draw_list_view(f: &mut Frame, browser: &mut IssueBrowser) { draw_list_view_in_area(f, browser, area); } -/// Draw list view in a specific area +/// Draw list view in a specific area using the IssueList widget pub fn draw_list_view_in_area(f: &mut Frame, browser: &mut IssueBrowser, area: Rect) { - use crate::agents::AgentStatus; - let items: Vec = browser .issues .iter() .map(|issue| { let is_selected = browser.selected_issues.contains(&issue.number); - let select_marker = if is_selected { "[x] " } else { "[ ] " }; - - let session_info = browser.session_cache.get(&issue.number); - let (session_icon, session_color, session_stats) = match session_info { - Some(session) => { - let (icon, color) = match &session.status { - AgentStatus::Running => ("▶", Color::Yellow), - AgentStatus::Awaiting => ("⏸", Color::Cyan), - AgentStatus::Completed { .. } | AgentStatus::Failed { .. } => { - ("●", Color::Blue) - } - }; - let stats = if session.stats.lines_added > 0 || session.stats.lines_deleted > 0 - { - format!(" +{} -{}", session.stats.lines_added, session.stats.lines_deleted) - } else { - String::new() - }; - (Some(icon), color, stats) - } - None => (None, Color::DarkGray, String::new()), - }; - - let labels_str = if issue.labels.is_empty() { - String::new() - } else { - format!(" [{}]", issue.labels.join(", ")) - }; - let assignees_str = if issue.assignees.is_empty() { - String::new() - } else { - format!(" @{}", issue.assignees.join(", @")) - }; - let is_closed = issue.state == "Closed"; - let line = if is_closed { - Line::from(vec![ - Span::styled( - select_marker, - if is_selected { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - }, - ), - Span::styled( - format!("#{:<5}", issue.number), - Style::default().fg(Color::DarkGray), - ), - Span::styled(" ✓ ", Style::default().fg(Color::Green)), - Span::styled( - &issue.title, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::CROSSED_OUT), - ), - Span::styled(labels_str, Style::default().fg(Color::DarkGray)), - Span::styled(assignees_str, Style::default().fg(Color::DarkGray)), - ]) - } else { - let session_span = if let Some(icon) = session_icon { - Span::styled( - format!("{}{} ", icon, session_stats), - Style::default().fg(session_color), - ) - } else { - Span::raw(" ") - }; - - Line::from(vec![ - Span::styled( - select_marker, - if is_selected { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - }, - ), - Span::styled( - format!("#{:<5}", issue.number), - Style::default().fg(Color::Cyan), - ), - session_span, - Span::raw(&issue.title), - Span::styled(labels_str, Style::default().fg(Color::DarkGray)), - Span::styled(assignees_str, Style::default().fg(Color::Magenta)), - ]) - }; - ListItem::new(line) + let session = browser.session_cache.get(&issue.number); + build_issue_item(issue, is_selected, session) }) .collect(); - let title = build_list_title(browser); + let title_config = IssueListTitleConfig { + search_query: browser.search_query.clone(), + total_count: browser.all_issues.len(), + has_next_page: browser.has_next_page, + is_loading: browser.is_loading, + selected_count: browser.selected_issues.len(), + }; + let title = format_status_bar( + CommandContext::IssueList, + &build_issue_list_title(&title_config, "Issues"), + ); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) @@ -465,62 +393,23 @@ pub fn draw_list_view_in_area(f: &mut Frame, browser: &mut IssueBrowser, area: R f.render_stateful_widget(list, area, &mut browser.list_state); } -/// Build the title string for the list view -fn build_list_title(browser: &IssueBrowser) -> String { - let mut parts = Vec::new(); - parts.push("Issues".to_string()); - - if let Some(ref query) = browser.search_query { - parts.push(format!("(filtered: '{}')", query)); - } - - if browser.has_next_page { - parts.push(format!( - "[{} loaded, more available]", - browser.all_issues.len() - )); - } else if browser.all_issues.len() > 20 { - parts.push(format!("[{} total]", browser.all_issues.len())); - } - - if browser.is_loading { - parts.push("[Loading...]".to_string()); - } - - if !browser.selected_issues.is_empty() { - parts.push(format!("[{} selected]", browser.selected_issues.len())); - } - - format_status_bar(CommandContext::IssueList, &parts.join(" ")) -} - -/// Draw centered search popup +/// Draw centered search popup using composable widgets pub fn draw_search_popup(f: &mut Frame, input: &str) { let area = f.area(); - // Calculate centered popup area (50 chars wide, 3 lines tall) - let popup_width = 50.min(area.width.saturating_sub(4)); - let popup_height = 3; - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; + // Use Popup widget for the container + let popup = Popup::new(" Search GitHub ") + .percent(50, 10) + .min_size(50, 3) + .border_color(Color::Yellow); - let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - - // Clear the background behind the popup - let clear = Block::default().style(Style::default().bg(Color::Black)); - f.render_widget(clear, popup_area); - - let block = Block::default() - .borders(Borders::ALL) - .title(" Search GitHub ") - .border_style(Style::default().fg(Color::Yellow)); + let inner = popup.inner(area); + f.render_widget(popup, area); + // Render the input text with cursor let text = format!("{}_", input); - let paragraph = Paragraph::new(text) - .block(block) - .style(Style::default().fg(Color::White)); - - f.render_widget(paragraph, popup_area); + let paragraph = Paragraph::new(text).style(Style::default().fg(Color::White)); + f.render_widget(paragraph, inner); } /// Draw issue detail view @@ -653,45 +542,14 @@ pub fn draw_comment_input(f: &mut Frame, area: Rect, input: &str, status: Option f.render_widget(paragraph, area); } -/// Draw confirmation dialog +/// Draw confirmation dialog using the ConfirmDialog widget pub fn draw_confirmation(f: &mut Frame, area: Rect, message: &str) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)); - - let paragraph = Paragraph::new(message) - .block(block) - .style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center); - - f.render_widget(paragraph, area); + f.render_widget(ConfirmDialog::new(message), area); } -/// Draw status bar +/// Draw status bar using the StatusBar widget pub fn draw_status_bar(f: &mut Frame, area: Rect, message: &str) { - let color = if message.contains("Failed") - || message.contains("No ") - || message.contains("error") - { - Color::Red - } else { - Color::Green - }; - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(color)); - - let paragraph = Paragraph::new(message) - .block(block) - .style(Style::default().fg(color)) - .alignment(Alignment::Center); - - f.render_widget(paragraph, area); + f.render_widget(StatusBar::new(message), area); } /// Draw assignee picker @@ -794,8 +652,8 @@ pub fn draw_agent_logs(f: &mut Frame, session_id: &str, content: &str, scroll: u f.render_widget(paragraph, f.area()); } -/// Draw embedded tmux terminal view -pub fn draw_embedded_tmux( +/// Draw embedded PTY terminal view +pub fn draw_embedded_pty( f: &mut Frame, browser: &IssueBrowser, available_sessions: &[String], @@ -804,7 +662,7 @@ pub fn draw_embedded_tmux( let area = f.area(); let header_text = if available_sessions.is_empty() { - " No tmux session │ ESC ESC to exit ".to_string() + " No session │ ESC ESC to exit ".to_string() } else { let session_name = &available_sessions[current_index]; format!( @@ -952,7 +810,7 @@ pub fn draw_agent_select( f.render_widget(help, chunks[1]); } -/// Draw command palette +/// Draw command palette using the CommandPalette widget pub fn draw_command_palette( f: &mut Frame, browser: &IssueBrowser, @@ -961,7 +819,6 @@ pub fn draw_command_palette( selected: usize, ) { let area = f.area(); - let chunks = Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area); @@ -969,88 +826,16 @@ pub fn draw_command_palette( draw_list_view_in_area_dimmed(f, browser, chunks[0]); // Command palette panel - let cmd_area = chunks[1]; - let title = if suggestions.is_empty() { - " Commands (no match) │ Esc cancel " - } else { - " Commands │ ↑↓ navigate │ Enter execute │ Esc cancel " - }; - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(Color::Yellow)); - - let inner = block.inner(cmd_area); - f.render_widget(block, cmd_area); - - let inner_chunks = - Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(1)]) - .split(inner); - - // Input field with cursor - let input_text = format!("/{}_", input); - let input_para = Paragraph::new(input_text) - .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); - f.render_widget(input_para, inner_chunks[0]); - - // Separator - let sep = Paragraph::new("─".repeat(inner_chunks[1].width as usize)) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(sep, inner_chunks[1]); - - // Suggestions list - let items: Vec = suggestions - .iter() - .enumerate() - .map(|(i, cmd)| { - let is_selected = i == selected; - let prefix = if is_selected { "▸ " } else { " " }; - let style = if is_selected { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - - let line = Line::from(vec![ - Span::raw(prefix), - Span::styled( - format!("/{}", cmd.name), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(&cmd.description, Style::default().fg(Color::White)), - ]); - ListItem::new(line).style(style) - }) - .collect(); - - let list = List::new(items); - f.render_widget(list, inner_chunks[2]); + let config = CommandPaletteConfig::new(input, suggestions, selected); + render_command_palette(f, chunks[1], &config); } -/// Draw list view but dimmed (for overlay effect) +/// Draw list view but dimmed (for overlay effect) using the IssueList widget fn draw_list_view_in_area_dimmed(f: &mut Frame, browser: &IssueBrowser, area: Rect) { let items: Vec = browser .issues .iter() - .map(|issue| { - let labels_str = if issue.labels.is_empty() { - String::new() - } else { - format!(" [{}]", issue.labels.join(", ")) - }; - let line = Line::from(vec![ - Span::styled( - format!("#{:<5}", issue.number), - Style::default().fg(Color::DarkGray), - ), - Span::styled(&issue.title, Style::default().fg(Color::DarkGray)), - Span::styled(labels_str, Style::default().fg(Color::DarkGray)), - ]); - ListItem::new(line) - }) + .map(|issue| build_issue_item_dimmed(issue)) .collect(); let list = List::new(items).block( @@ -1351,7 +1136,7 @@ pub fn draw_worktree_list( let is_selected = idx == selected; // Status indicators - let status_icon = if wt.has_tmux { + let status_icon = if wt.has_pty { Span::styled("▶ ", Style::default().fg(Color::Yellow)) } else if wt.has_session { Span::styled("● ", Style::default().fg(Color::Green)) @@ -1364,7 +1149,7 @@ pub fn draw_worktree_list( .map(|n| format!("#{:<5}", n)) .unwrap_or_else(|| " ".to_string()); - let has_agent = wt.has_session || wt.has_tmux; + let has_agent = wt.has_session || wt.has_pty; let name_style = if is_selected { Style::default() .fg(Color::White) @@ -2096,7 +1881,7 @@ fn draw_pr_review_popup(f: &mut Frame, pr: &PullRequestDetail, input: &str) { f.render_widget(hint, chunks[2]); } -/// Draw PR filters popup +/// Draw PR filters popup using the FilterPopup widget fn draw_pr_filters_popup( f: &mut Frame, status_filter: &HashSet, @@ -2108,116 +1893,28 @@ fn draw_pr_filters_popup( author_input: &str, author_suggestions: &[String], ) { - let area = centered_rect(50, 60, f.area()); - - let block = Block::default() - .borders(Borders::ALL) - .title(" PR Filters ") - .style(Style::default().bg(Color::Black)); - - let inner = block.inner(area); - f.render_widget(ratatui::widgets::Clear, area); - f.render_widget(block, area); - - let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(6), - Constraint::Length(1), - Constraint::Length(1), // Author input field - Constraint::Min(5), // Author suggestions/selected - Constraint::Length(1), - ]) - .split(inner); - - // Status section header - let status_header_style = if *focus == PrFilterFocus::Status { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; - let status_header = Paragraph::new("Status:") - .style(status_header_style); - f.render_widget(status_header, chunks[0]); - - // Status options - let status_items: Vec = PrStatus::all() + let status_options: Vec = PrStatus::all() .iter() - .enumerate() - .map(|(i, status)| { - let checked = if status_filter.contains(status) { "[x]" } else { "[ ]" }; - let style = if *focus == PrFilterFocus::Status && i == selected_status { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - ListItem::new(format!("{} {}", checked, status.label())).style(style) + .map(|s| FilterOption { + label: s.label().to_string(), + checked: status_filter.contains(s), }) .collect(); - let status_list = List::new(status_items); - f.render_widget(status_list, chunks[1]); - - // Author section header - let author_header_style = if *focus == PrFilterFocus::Author { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) + let filter_focus = match focus { + PrFilterFocus::Status => FilterFocus::Status, + PrFilterFocus::Author => FilterFocus::Author, }; - let author_header = Paragraph::new("Author:") - .style(author_header_style); - f.render_widget(author_header, chunks[2]); - // Author input field - let input_style = if *focus == PrFilterFocus::Author { - Style::default().fg(Color::White) - } else { - Style::default().fg(Color::DarkGray) - }; - let input_text = if author_input.is_empty() && *focus == PrFilterFocus::Author { - "Type to search or add author...".to_string() - } else if author_input.is_empty() { - "".to_string() - } else { - format!("@{}_", author_input) - }; - let input_paragraph = Paragraph::new(input_text) - .style(if author_input.is_empty() && *focus == PrFilterFocus::Author { - Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC) - } else { - input_style - }); - f.render_widget(input_paragraph, chunks[3]); + let config = FilterPopupConfig::new("PR Filters", author_suggestions, author_filter, author_input) + .status_options(status_options) + .focus(filter_focus, selected_status, selected_author) + .status_height(6); - // Author list: always show all available authors with checkmarks - let author_items: Vec = if author_suggestions.is_empty() { - vec![ListItem::new("No authors available").style(Style::default().fg(Color::DarkGray))] - } else { - author_suggestions - .iter() - .enumerate() - .map(|(i, author)| { - let checked = if author_filter.contains(author) { "[x]" } else { "[ ]" }; - let style = if *focus == PrFilterFocus::Author && i == selected_author { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - ListItem::new(format!("{} @{}", checked, author)).style(style) - }) - .collect() - }; - - let author_list = List::new(author_items); - f.render_widget(author_list, chunks[4]); - - // Hint - let hint = Paragraph::new("Tab: switch │ Space: toggle │ Enter: add/apply │ Esc: cancel") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - f.render_widget(hint, chunks[5]); + render_filter_popup(f, &config); } -/// Draw issue filters popup +/// Draw issue filters popup using the FilterPopup widget fn draw_issue_filters_popup( f: &mut Frame, status_filter: &HashSet, @@ -2229,111 +1926,23 @@ fn draw_issue_filters_popup( author_input: &str, author_suggestions: &[String], ) { - let area = centered_rect(50, 60, f.area()); - - let block = Block::default() - .borders(Borders::ALL) - .title(" Issue Filters ") - .style(Style::default().bg(Color::Black)); - - let inner = block.inner(area); - f.render_widget(ratatui::widgets::Clear, area); - f.render_widget(block, area); - - let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(4), // Status has only 2 options - Constraint::Length(1), - Constraint::Length(1), // Author input field - Constraint::Min(5), // Author suggestions/selected - Constraint::Length(1), - ]) - .split(inner); - - // Status section header - let status_header_style = if *focus == IssueFilterFocus::Status { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; - let status_header = Paragraph::new("Status:") - .style(status_header_style); - f.render_widget(status_header, chunks[0]); - - // Status options - let status_items: Vec = IssueStatus::all() + let status_options: Vec = IssueStatus::all() .iter() - .enumerate() - .map(|(i, status)| { - let checked = if status_filter.contains(status) { "[x]" } else { "[ ]" }; - let style = if *focus == IssueFilterFocus::Status && i == selected_status { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - ListItem::new(format!("{} {}", checked, status.label())).style(style) + .map(|s| FilterOption { + label: s.label().to_string(), + checked: status_filter.contains(s), }) .collect(); - let status_list = List::new(status_items); - f.render_widget(status_list, chunks[1]); - - // Author section header - let author_header_style = if *focus == IssueFilterFocus::Author { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; - let author_header = Paragraph::new("Author:") - .style(author_header_style); - f.render_widget(author_header, chunks[2]); - - // Author input field - let input_style = if *focus == IssueFilterFocus::Author { - Style::default().fg(Color::White) - } else { - Style::default().fg(Color::DarkGray) - }; - let input_text = if author_input.is_empty() && *focus == IssueFilterFocus::Author { - "Type to search or add author...".to_string() - } else if author_input.is_empty() { - "".to_string() - } else { - format!("@{}_", author_input) - }; - let input_paragraph = Paragraph::new(input_text) - .style(if author_input.is_empty() && *focus == IssueFilterFocus::Author { - Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC) - } else { - input_style - }); - f.render_widget(input_paragraph, chunks[3]); - - // Author list: always show all available authors with checkmarks - let author_items: Vec = if author_suggestions.is_empty() { - vec![ListItem::new("No authors available").style(Style::default().fg(Color::DarkGray))] - } else { - author_suggestions - .iter() - .enumerate() - .map(|(i, author)| { - let checked = if author_filter.contains(author) { "[x]" } else { "[ ]" }; - let style = if *focus == IssueFilterFocus::Author && i == selected_author { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - ListItem::new(format!("{} @{}", checked, author)).style(style) - }) - .collect() + let filter_focus = match focus { + IssueFilterFocus::Status => FilterFocus::Status, + IssueFilterFocus::Author => FilterFocus::Author, }; - let author_list = List::new(author_items); - f.render_widget(author_list, chunks[4]); + let config = FilterPopupConfig::new("Issue Filters", author_suggestions, author_filter, author_input) + .status_options(status_options) + .focus(filter_focus, selected_status, selected_author) + .status_height(4); // Issues have only 2 status options - // Hint - let hint = Paragraph::new("Tab: switch │ Space: toggle │ Enter: add/apply │ Esc: cancel") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - f.render_widget(hint, chunks[5]); + render_filter_popup(f, &config); } diff --git a/src/tui_events/detail.rs b/src/tui_events/detail.rs index 01113c9..397e304 100644 --- a/src/tui_events/detail.rs +++ b/src/tui_events/detail.rs @@ -84,10 +84,10 @@ pub async fn handle_detail_key(browser: &mut IssueBrowser, key: KeyCode, issue: } KeyCode::Char('d') => { if let Some(project) = browser.project_name.clone() { - let tmux_name = crate::agents::tmux_session_name(&project, issue.number); - if crate::agents::is_tmux_session_running(&tmux_name) { + let session_name = crate::agents::pty_session_name(&project, issue.number); + if crate::agents::is_pty_session_running(&session_name) { browser.status_message = Some(format!( - "Session already running for #{}. Use 't' to open tmux or 'K' to kill it.", + "Session already running for #{}. Use 't' to open terminal or 'K' to kill it.", issue.number )); } else { @@ -371,10 +371,10 @@ pub async fn handle_confirm_dispatch_key( if let (Some(project), Some(local_path)) = (&browser.project_name, &browser.local_path) { - let tmux_name = crate::agents::tmux_session_name(project, number); - if crate::agents::is_tmux_session_running(&tmux_name) { + let session_name = crate::agents::pty_session_name(project, number); + if crate::agents::is_pty_session_running(&session_name) { browser.status_message = Some(format!( - "Session already running for #{}. Use 't' to open tmux or 'K' to kill it.", + "Session already running for #{}. Use 't' to open terminal or 'K' to kill it.", number )); } else { diff --git a/src/tui_events/dispatch.rs b/src/tui_events/dispatch.rs index e884f06..994377b 100644 --- a/src/tui_events/dispatch.rs +++ b/src/tui_events/dispatch.rs @@ -102,8 +102,8 @@ pub async fn handle_worktree_agent_instructions_key( initial_prompt, ) { Ok(_) => { - // Enter the tmux session directly - let sessions = crate::agents::list_tmux_sessions(); + // Enter the PTY session directly + let sessions = crate::agents::list_pty_sessions(); let mut all_sessions = sessions; if !all_sessions.contains(&session_name) { all_sessions.push(session_name.clone()); @@ -113,14 +113,14 @@ pub async fn handle_worktree_agent_instructions_key( .position(|s| s == &session_name) .unwrap_or(0); let area = crossterm::terminal::size().unwrap_or((80, 24)); - match crate::embedded_term::EmbeddedTerminal::new( + match crate::embedded_term::EmbeddedTerminal::attach( &session_name, area.1.saturating_sub(1), area.0, ) { Ok(term) => { browser.embedded_term = Some(term); - browser.view = TuiView::EmbeddedTmux { + browser.view = TuiView::EmbeddedPty { available_sessions: all_sessions, current_index, return_to_worktrees: true, diff --git a/src/tui_events/embedded.rs b/src/tui_events/embedded.rs index e46d630..66ac971 100644 --- a/src/tui_events/embedded.rs +++ b/src/tui_events/embedded.rs @@ -1,10 +1,10 @@ -//! Embedded terminal (tmux) event handling. +//! Embedded terminal (PTY) event handling. use crate::tui::IssueBrowser; use crate::tui_types::TuiView; use crossterm::event::{KeyCode, KeyModifiers}; -pub fn handle_embedded_tmux_key( +pub fn handle_embedded_pty_key( browser: &mut IssueBrowser, key: KeyCode, modifiers: KeyModifiers, @@ -33,7 +33,7 @@ pub fn handle_embedded_tmux_key( browser.refresh_sessions_with_fresh_stats(&project); } } else if key == KeyCode::Esc { - // Single ESC passes through to tmux + // Single ESC passes through to the PTY session if let Some(ref term) = browser.embedded_term { term.send_input(&[0x1b]); // ESC byte } @@ -43,7 +43,7 @@ pub fn handle_embedded_tmux_key( *current_index -= 1; let session_name = &available_sessions[*current_index]; let area = crossterm::terminal::size().unwrap_or((80, 24)); - if let Ok(term) = crate::embedded_term::EmbeddedTerminal::new( + if let Ok(term) = crate::embedded_term::EmbeddedTerminal::attach( session_name, area.1.saturating_sub(1), area.0, @@ -57,7 +57,7 @@ pub fn handle_embedded_tmux_key( *current_index += 1; let session_name = &available_sessions[*current_index]; let area = crossterm::terminal::size().unwrap_or((80, 24)); - if let Ok(term) = crate::embedded_term::EmbeddedTerminal::new( + if let Ok(term) = crate::embedded_term::EmbeddedTerminal::attach( session_name, area.1.saturating_sub(1), area.0, diff --git a/src/tui_events/list.rs b/src/tui_events/list.rs index 8b3c8b2..7a597c7 100644 --- a/src/tui_events/list.rs +++ b/src/tui_events/list.rs @@ -88,10 +88,10 @@ pub async fn handle_list_key(browser: &mut IssueBrowser, key: KeyCode) { handle_dispatch(browser).await; } KeyCode::Char('t') => { - handle_open_tmux(browser); + handle_open_pty(browser); } KeyCode::Char('T') => { - handle_open_any_tmux(browser); + handle_open_any_pty(browser); } KeyCode::Char('l') => { if let Some(issue) = browser.selected_issue() { @@ -177,10 +177,10 @@ async fn handle_dispatch(browser: &mut IssueBrowser) { let issue_number = issue.number; let project_name = browser.project_name.clone().unwrap_or_default(); - let tmux_name = crate::agents::tmux_session_name(&project_name, issue_number); - if crate::agents::is_tmux_session_running(&tmux_name) { + let session_name = crate::agents::pty_session_name(&project_name, issue_number); + if crate::agents::is_pty_session_running(&session_name) { browser.status_message = Some(format!( - "Session already running for #{}. Use 't' to open tmux or 'K' to kill it.", + "Session already running for #{}. Use 't' to open terminal or 'K' to kill it.", issue_number )); } else if let Ok(detail) = browser.github.get_issue(issue_number).await { @@ -200,8 +200,8 @@ async fn handle_dispatch(browser: &mut IssueBrowser) { let agent = crate::agents::get_agent(&browser.coding_agent); for issue_number in browser.selected_issues.iter() { - let tmux_name = crate::agents::tmux_session_name(&project_name, *issue_number); - if crate::agents::is_tmux_session_running(&tmux_name) { + let session_name = crate::agents::pty_session_name(&project_name, *issue_number); + if crate::agents::is_pty_session_running(&session_name) { skipped += 1; continue; } @@ -237,27 +237,27 @@ async fn handle_dispatch(browser: &mut IssueBrowser) { } } -fn handle_open_tmux(browser: &mut IssueBrowser) { +fn handle_open_pty(browser: &mut IssueBrowser) { if let Some(issue) = browser.selected_issue() { let issue_number = issue.number; if let Some(project) = browser.project_name.clone() { - let tmux_name = crate::agents::tmux_session_name(&project, issue_number); - if crate::agents::is_tmux_session_running(&tmux_name) { - let all_sessions = crate::agents::list_tmux_sessions(); + let session_name = crate::agents::pty_session_name(&project, issue_number); + if crate::agents::is_pty_session_running(&session_name) { + let all_sessions = crate::agents::list_pty_sessions(); let current_idx = all_sessions .iter() - .position(|s| s == &tmux_name) + .position(|s| s == &session_name) .unwrap_or(0); let area = crossterm::terminal::size().unwrap_or((80, 24)); - match crate::embedded_term::EmbeddedTerminal::new( - &tmux_name, + match crate::embedded_term::EmbeddedTerminal::attach( + &session_name, area.1.saturating_sub(1), area.0, ) { Ok(term) => { browser.embedded_term = Some(term); - browser.view = TuiView::EmbeddedTmux { + browser.view = TuiView::EmbeddedPty { available_sessions: all_sessions, current_index: current_idx, return_to_worktrees: false, @@ -276,18 +276,18 @@ fn handle_open_tmux(browser: &mut IssueBrowser) { } } -fn handle_open_any_tmux(browser: &mut IssueBrowser) { - let all_sessions = crate::agents::list_tmux_sessions(); +fn handle_open_any_pty(browser: &mut IssueBrowser) { + let all_sessions = crate::agents::list_pty_sessions(); if !all_sessions.is_empty() { let area = crossterm::terminal::size().unwrap_or((80, 24)); - match crate::embedded_term::EmbeddedTerminal::new( + match crate::embedded_term::EmbeddedTerminal::attach( &all_sessions[0], area.1.saturating_sub(1), area.0, ) { Ok(term) => { browser.embedded_term = Some(term); - browser.view = TuiView::EmbeddedTmux { + browser.view = TuiView::EmbeddedPty { available_sessions: all_sessions, current_index: 0, return_to_worktrees: false, @@ -298,7 +298,7 @@ fn handle_open_any_tmux(browser: &mut IssueBrowser) { } } } else { - browser.status_message = Some("No tmux sessions available".to_string()); + browser.status_message = Some("No sessions available".to_string()); } } diff --git a/src/tui_events/mod.rs b/src/tui_events/mod.rs index 4de4237..41a578e 100644 --- a/src/tui_events/mod.rs +++ b/src/tui_events/mod.rs @@ -12,7 +12,7 @@ //! - `filters`: Filter dialogs //! - `agents`: Agent logs and selection //! - `project`: Project selection -//! - `embedded`: Embedded tmux terminal +//! - `embedded`: Embedded PTY terminal //! - `help`: Help view //! - `common`: Shared utilities @@ -326,7 +326,7 @@ pub async fn handle_key_event(browser: &mut IssueBrowser, key: KeyCode, modifier worktree::handle_confirm_delete_worktree_key(browser, key, &worktree, return_index); } - TuiView::EmbeddedTmux { + TuiView::EmbeddedPty { available_sessions, current_index, return_to_worktrees, @@ -334,7 +334,7 @@ pub async fn handle_key_event(browser: &mut IssueBrowser, key: KeyCode, modifier let mut available_sessions = available_sessions.clone(); let mut current_index = *current_index; let return_to_worktrees = *return_to_worktrees; - embedded::handle_embedded_tmux_key( + embedded::handle_embedded_pty_key( browser, key, modifiers, @@ -342,7 +342,7 @@ pub async fn handle_key_event(browser: &mut IssueBrowser, key: KeyCode, modifier &mut current_index, return_to_worktrees, ); - if let TuiView::EmbeddedTmux { + if let TuiView::EmbeddedPty { available_sessions: ref mut a, current_index: ref mut c, .. diff --git a/src/tui_events/pr.rs b/src/tui_events/pr.rs index b9418a8..3724ef1 100644 --- a/src/tui_events/pr.rs +++ b/src/tui_events/pr.rs @@ -234,7 +234,7 @@ pub async fn handle_dispatch_pr_review_key( browser.refresh_sessions(&project_name); // Open embedded terminal to show the agent - let sessions = crate::agents::list_tmux_sessions(); + let sessions = crate::agents::list_pty_sessions(); let mut all_sessions = sessions; if !all_sessions.contains(&session_name) { all_sessions.push(session_name.clone()); @@ -244,14 +244,14 @@ pub async fn handle_dispatch_pr_review_key( .position(|s| s == &session_name) .unwrap_or(0); let area = crossterm::terminal::size().unwrap_or((80, 24)); - match crate::embedded_term::EmbeddedTerminal::new( + match crate::embedded_term::EmbeddedTerminal::attach( &session_name, area.1.saturating_sub(1), area.0, ) { Ok(term) => { browser.embedded_term = Some(term); - browser.view = TuiView::EmbeddedTmux { + browser.view = TuiView::EmbeddedPty { available_sessions: all_sessions, current_index, return_to_worktrees: false, diff --git a/src/tui_events/worktree.rs b/src/tui_events/worktree.rs index b10ad1f..70b0522 100644 --- a/src/tui_events/worktree.rs +++ b/src/tui_events/worktree.rs @@ -72,9 +72,9 @@ pub fn handle_worktree_list_key( // Show confirmation before deleting let selected_idx = *selected; if let Some(wt) = worktrees.get(selected_idx).cloned() { - if wt.has_tmux { + if wt.has_pty { browser.status_message = - Some("Tmux session still running. Kill it first (K).".to_string()); + Some("Session still running. Kill it first (K).".to_string()); } else { browser.view = TuiView::ConfirmDeleteWorktree { worktree: wt, @@ -84,32 +84,34 @@ pub fn handle_worktree_list_key( } } KeyCode::Char('K') => { - // Kill tmux session for selected worktree + // Kill PTY session for selected worktree let selected_idx = *selected; if let Some(wt) = worktrees.get(selected_idx).cloned() { - // Determine tmux session name based on worktree type - let tmux_name = if let Some(issue_num) = wt.issue_number { - crate::agents::tmux_session_name(&wt.project, issue_num) + // Determine PTY session name based on worktree type + let session_name = if let Some(issue_num) = wt.issue_number { + crate::agents::pty_session_name(&wt.project, issue_num) } else { // Standalone worktree: session name matches worktree name wt.name.clone() }; - if crate::agents::is_tmux_session_running(&tmux_name) { + if crate::agents::is_pty_session_running(&session_name) { if let Some(issue_num) = wt.issue_number { let manager = crate::agents::SessionManager::load(); if let Some(session) = manager.get_by_issue(&wt.project, issue_num) { // Session exists in manager, use kill_agent to update status let _ = crate::agents::kill_agent(&session.id); } else { - // Orphaned: no session but tmux running, kill directly - let _ = crate::agents::kill_tmux_session(&tmux_name); + // Orphaned: no session but PTY running, kill directly + crate::agents::pty_manager().kill_session(&session_name); + crate::agents::pty_manager().remove_session(&session_name); } } else { - // Standalone worktree without issue: kill tmux directly - let _ = crate::agents::kill_tmux_session(&tmux_name); + // Standalone worktree without issue: kill PTY directly + crate::agents::pty_manager().kill_session(&session_name); + crate::agents::pty_manager().remove_session(&session_name); } - browser.status_message = Some(format!("Killed tmux session: {}", tmux_name)); + browser.status_message = Some(format!("Killed session: {}", session_name)); // Refresh session cache to update issue list indicators browser.refresh_sessions(&wt.project); // Refresh the list @@ -120,7 +122,7 @@ pub fn handle_worktree_list_key( selected: new_selected, }; } else { - browser.status_message = Some("No tmux session running".to_string()); + browser.status_message = Some("No session running".to_string()); } } } @@ -136,32 +138,32 @@ pub fn handle_worktree_list_key( } } KeyCode::Char('t') => { - // Open tmux session for selected worktree + // Open PTY session for selected worktree if let Some(wt) = worktrees.get(*selected) { - // Determine tmux session name based on worktree type + // Determine PTY session name based on worktree type let session_name = if let Some(issue_num) = wt.issue_number { - crate::agents::tmux_session_name(&wt.project, issue_num) + crate::agents::pty_session_name(&wt.project, issue_num) } else { // Standalone worktree: session name matches worktree name wt.name.clone() }; - if crate::agents::is_tmux_session_running(&session_name) { - let all_sessions = crate::agents::list_all_tmux_sessions(); + if crate::agents::is_pty_session_running(&session_name) { + let all_sessions = crate::agents::list_all_pty_sessions(); let current_index = all_sessions .iter() .position(|s| s == &session_name) .unwrap_or(0); - // Create embedded terminal to attach to tmux session + // Create embedded terminal to attach to PTY session let area = crossterm::terminal::size().unwrap_or((80, 24)); - match crate::embedded_term::EmbeddedTerminal::new( + match crate::embedded_term::EmbeddedTerminal::attach( &session_name, area.1.saturating_sub(1), area.0, ) { Ok(term) => { browser.embedded_term = Some(term); - browser.view = TuiView::EmbeddedTmux { + browser.view = TuiView::EmbeddedPty { available_sessions: all_sessions, current_index, return_to_worktrees: true, @@ -174,23 +176,23 @@ pub fn handle_worktree_list_key( } } else { browser.status_message = - Some("No tmux session running for this worktree".to_string()); + Some("No session running for this worktree".to_string()); } } } KeyCode::Char('a') => { // Start agent on selected worktree if let Some(wt) = worktrees.get(*selected) { - // Check if tmux session is already running + // Check if PTY session is already running let session_name = if let Some(issue_num) = wt.issue_number { - crate::agents::tmux_session_name(&wt.project, issue_num) + crate::agents::pty_session_name(&wt.project, issue_num) } else { wt.name.clone() }; - if crate::agents::is_tmux_session_running(&session_name) { + if crate::agents::is_pty_session_running(&session_name) { browser.status_message = - Some("Tmux session already running. Open it with 't'.".to_string()); + Some("Session already running. Open it with 't'.".to_string()); } else { // Extract branch name from worktree name let branch_name = wt.name.clone(); diff --git a/src/tui_types.rs b/src/tui_types.rs index 399bcc8..d444c94 100644 --- a/src/tui_types.rs +++ b/src/tui_types.rs @@ -29,8 +29,8 @@ pub enum TuiView { content: String, scroll: u16, }, - EmbeddedTmux { - /// Available tmux sessions for switching + EmbeddedPty { + /// Available PTY sessions for switching available_sessions: Vec, /// Current session index current_index: usize, diff --git a/src/tui_utils.rs b/src/tui_utils.rs index 9bf932a..4a0e447 100644 --- a/src/tui_utils.rs +++ b/src/tui_utils.rs @@ -1,11 +1,5 @@ //! TUI utility functions. -use std::io; -use crossterm::{ - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; - /// Format a date string to just the date part (YYYY-MM-DD) pub fn format_date(date_str: &str) -> String { if date_str.len() >= 10 { @@ -35,31 +29,6 @@ pub fn truncate_str(s: &str, max_chars: usize) -> String { } } -/// Attach to a tmux session, temporarily exiting the TUI -#[allow(dead_code)] -pub fn attach_to_tmux_session(session_name: &str) -> io::Result<()> { - // Exit raw mode and alternate screen - disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen)?; - - // Run tmux attach interactively - let status = std::process::Command::new("tmux") - .args(["attach", "-t", session_name]) - .status()?; - - // Re-enter alternate screen and raw mode - execute!(io::stdout(), EnterAlternateScreen)?; - enable_raw_mode()?; - - if !status.success() { - return Err(io::Error::other( - "tmux attach failed", - )); - } - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/widgets/command_palette.rs b/src/widgets/command_palette.rs new file mode 100644 index 0000000..941ac91 --- /dev/null +++ b/src/widgets/command_palette.rs @@ -0,0 +1,163 @@ +//! Command palette widget for slash commands. + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Widget}, + Frame, +}; + +use crate::tui_types::CommandSuggestion; + +/// A command suggestion item for the palette. +#[allow(dead_code)] +pub struct CommandItem<'a> { + pub name: &'a str, + pub description: &'a str, + pub is_selected: bool, +} + +#[allow(dead_code)] +impl<'a> CommandItem<'a> { + pub fn new(name: &'a str, description: &'a str) -> Self { + Self { + name, + description, + is_selected: false, + } + } + + pub fn selected(mut self, selected: bool) -> Self { + self.is_selected = selected; + self + } +} + +/// Build a ListItem for a command suggestion. +pub fn build_command_item(cmd: &CommandSuggestion, is_selected: bool) -> ListItem<'_> { + let prefix = if is_selected { "▸ " } else { " " }; + let style = if is_selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + let line = Line::from(vec![ + Span::raw(prefix), + Span::styled( + format!("/{}", cmd.name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(&cmd.description, Style::default().fg(Color::White)), + ]); + + ListItem::new(line).style(style) +} + +/// Configuration for the command palette. +pub struct CommandPaletteConfig<'a> { + pub input: &'a str, + pub suggestions: &'a [CommandSuggestion], + pub selected: usize, +} + +impl<'a> CommandPaletteConfig<'a> { + pub fn new(input: &'a str, suggestions: &'a [CommandSuggestion], selected: usize) -> Self { + Self { + input, + suggestions, + selected, + } + } +} + +/// Render the command palette panel (without background). +/// Returns the area used for the palette. +pub fn render_command_palette(f: &mut Frame, area: Rect, config: &CommandPaletteConfig) { + let title = if config.suggestions.is_empty() { + " Commands (no match) │ Esc cancel " + } else { + " Commands │ ↑↓ navigate │ Enter execute │ Esc cancel " + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(Color::Yellow)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let chunks = Layout::vertical([ + Constraint::Length(1), // Input + Constraint::Length(1), // Separator + Constraint::Min(1), // Suggestions + ]) + .split(inner); + + // Input field with cursor + let input_text = format!("/{}_", config.input); + let input_para = Paragraph::new(input_text) + .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)); + f.render_widget(input_para, chunks[0]); + + // Separator + let sep = Paragraph::new("─".repeat(chunks[1].width as usize)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(sep, chunks[1]); + + // Suggestions list + let items: Vec = config + .suggestions + .iter() + .enumerate() + .map(|(i, cmd)| build_command_item(cmd, i == config.selected)) + .collect(); + + f.render_widget(List::new(items), chunks[2]); +} + +/// A simple separator line widget. +pub struct Separator { + char: char, + color: Color, +} + +impl Separator { + pub fn new() -> Self { + Self { + char: '─', + color: Color::DarkGray, + } + } + + #[allow(dead_code)] + pub fn char(mut self, c: char) -> Self { + self.char = c; + self + } + + #[allow(dead_code)] + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } +} + +impl Default for Separator { + fn default() -> Self { + Self::new() + } +} + +impl Widget for Separator { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let line = self.char.to_string().repeat(area.width as usize); + let paragraph = Paragraph::new(line).style(Style::default().fg(self.color)); + paragraph.render(area, buf); + } +} diff --git a/src/widgets/confirm_dialog.rs b/src/widgets/confirm_dialog.rs new file mode 100644 index 0000000..10a9bf6 --- /dev/null +++ b/src/widgets/confirm_dialog.rs @@ -0,0 +1,186 @@ +//! Confirmation dialog widget. + +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +/// A confirmation dialog widget with customizable message and hint. +/// +/// # Example +/// ``` +/// use assistant::widgets::ConfirmDialog; +/// +/// let dialog = ConfirmDialog::new("Close issue #42?") +/// .hint("y/n"); +/// ``` +#[derive(Debug, Clone)] +pub struct ConfirmDialog<'a> { + message: &'a str, + hint: Option<&'a str>, + border_color: Color, +} + +impl<'a> ConfirmDialog<'a> { + /// Create a new confirmation dialog with the given message. + pub fn new(message: &'a str) -> Self { + Self { + message, + hint: Some("y/n"), + border_color: Color::Yellow, + } + } + + /// Set the hint text shown after the message. + pub fn hint(mut self, hint: &'a str) -> Self { + self.hint = Some(hint); + self + } + + /// Remove the hint text. + pub fn no_hint(mut self) -> Self { + self.hint = None; + self + } + + /// Set the border color. + pub fn border_color(mut self, color: Color) -> Self { + self.border_color = color; + self + } +} + +impl Widget for ConfirmDialog<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(self.border_color)); + + let text = if let Some(hint) = self.hint { + format!("{} ({})", self.message, hint) + } else { + self.message.to_string() + }; + + let paragraph = Paragraph::new(text) + .block(block) + .style( + Style::default() + .fg(self.border_color) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center); + + paragraph.render(area, buf); + } +} + +/// A more detailed confirmation dialog with title, body, and action hints. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DetailedConfirmDialog<'a> { + title: &'a str, + body_lines: Vec<&'a str>, + confirm_key: &'a str, + confirm_label: &'a str, + cancel_key: &'a str, + cancel_label: &'a str, +} + +#[allow(dead_code)] +impl<'a> DetailedConfirmDialog<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + body_lines: Vec::new(), + confirm_key: "y", + confirm_label: "Yes", + cancel_key: "n", + cancel_label: "No", + } + } + + pub fn body(mut self, lines: Vec<&'a str>) -> Self { + self.body_lines = lines; + self + } + + pub fn confirm(mut self, key: &'a str, label: &'a str) -> Self { + self.confirm_key = key; + self.confirm_label = label; + self + } + + pub fn cancel(mut self, key: &'a str, label: &'a str) -> Self { + self.cancel_key = key; + self.cancel_label = label; + self + } +} + +impl Widget for DetailedConfirmDialog<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + use ratatui::text::{Line, Span}; + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", self.title)) + .style(Style::default().bg(Color::Black)); + + let inner = block.inner(area); + block.render(area, buf); + + let mut lines = vec![Line::from("")]; + + for body_line in &self.body_lines { + lines.push(Line::from(*body_line)); + } + + lines.push(Line::from("")); + lines.push(Line::from("")); + + // Action hints + lines.push(Line::from(vec![ + Span::styled( + self.confirm_key, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(": {} │ ", self.confirm_label)), + Span::styled( + self.cancel_key, + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(": {}", self.cancel_label)), + ])); + + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + paragraph.render(inner, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confirm_dialog_creation() { + let dialog = ConfirmDialog::new("Delete?").hint("y/n"); + assert_eq!(dialog.message, "Delete?"); + assert_eq!(dialog.hint, Some("y/n")); + } + + #[test] + fn test_detailed_dialog() { + let dialog = DetailedConfirmDialog::new("Confirm") + .body(vec!["Line 1", "Line 2"]) + .confirm("y", "Yes, do it") + .cancel("n", "Cancel"); + assert_eq!(dialog.title, "Confirm"); + assert_eq!(dialog.body_lines.len(), 2); + } +} diff --git a/src/widgets/filter_popup.rs b/src/widgets/filter_popup.rs new file mode 100644 index 0000000..a76b427 --- /dev/null +++ b/src/widgets/filter_popup.rs @@ -0,0 +1,203 @@ +//! Filter popup widget for issues and pull requests. + +use std::collections::HashSet; + +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + Frame, +}; + +/// Focus area in filter popup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterFocus { + Status, + Author, +} + +/// A filter option with a label and checked state. +pub struct FilterOption { + pub label: String, + pub checked: bool, +} + +/// Configuration for the filter popup. +pub struct FilterPopupConfig<'a> { + pub title: &'a str, + pub status_options: Vec, + pub author_suggestions: &'a [String], + pub author_filter: &'a HashSet, + pub author_input: &'a str, + pub focus: FilterFocus, + pub selected_status: usize, + pub selected_author: usize, + pub status_height: u16, +} + +impl<'a> FilterPopupConfig<'a> { + pub fn new( + title: &'a str, + author_suggestions: &'a [String], + author_filter: &'a HashSet, + author_input: &'a str, + ) -> Self { + Self { + title, + status_options: Vec::new(), + author_suggestions, + author_filter, + author_input, + focus: FilterFocus::Status, + selected_status: 0, + selected_author: 0, + status_height: 6, + } + } + + pub fn status_options(mut self, options: Vec) -> Self { + self.status_options = options; + self + } + + pub fn focus(mut self, focus: FilterFocus, selected_status: usize, selected_author: usize) -> Self { + self.focus = focus; + self.selected_status = selected_status; + self.selected_author = selected_author; + self + } + + pub fn status_height(mut self, height: u16) -> Self { + self.status_height = height; + self + } +} + +/// Render a filter popup with the given configuration. +pub fn render_filter_popup(f: &mut Frame, config: &FilterPopupConfig) { + let area = centered_rect(50, 60, f.area()); + + // Render popup container + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", config.title)) + .style(Style::default().bg(Color::Black)); + + let inner = block.inner(area); + f.render_widget(Clear, area); + f.render_widget(block, area); + + // Layout sections + let chunks = Layout::vertical([ + Constraint::Length(1), // Status header + Constraint::Length(config.status_height), // Status options + Constraint::Length(1), // Author header + Constraint::Length(1), // Author input + Constraint::Min(5), // Author list + Constraint::Length(1), // Hint + ]) + .split(inner); + + // Status section header + let status_header_style = if config.focus == FilterFocus::Status { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + f.render_widget( + Paragraph::new("Status:").style(status_header_style), + chunks[0], + ); + + // Status options + let status_items: Vec = config + .status_options + .iter() + .enumerate() + .map(|(i, opt)| { + let checked = if opt.checked { "[x]" } else { "[ ]" }; + let style = if config.focus == FilterFocus::Status && i == config.selected_status { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + ListItem::new(format!("{} {}", checked, opt.label)).style(style) + }) + .collect(); + f.render_widget(List::new(status_items), chunks[1]); + + // Author section header + let author_header_style = if config.focus == FilterFocus::Author { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + f.render_widget( + Paragraph::new("Author:").style(author_header_style), + chunks[2], + ); + + // Author input field + let input_text = if config.author_input.is_empty() && config.focus == FilterFocus::Author { + "Type to search or add author...".to_string() + } else if config.author_input.is_empty() { + String::new() + } else { + format!("@{}_", config.author_input) + }; + + let input_style = if config.author_input.is_empty() && config.focus == FilterFocus::Author { + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC) + } else if config.focus == FilterFocus::Author { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + f.render_widget(Paragraph::new(input_text).style(input_style), chunks[3]); + + // Author list + let author_items: Vec = if config.author_suggestions.is_empty() { + vec![ListItem::new("No authors available").style(Style::default().fg(Color::DarkGray))] + } else { + config + .author_suggestions + .iter() + .enumerate() + .map(|(i, author)| { + let checked = if config.author_filter.contains(author) { + "[x]" + } else { + "[ ]" + }; + let style = if config.focus == FilterFocus::Author && i == config.selected_author { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + ListItem::new(format!("{} @{}", checked, author)).style(style) + }) + .collect() + }; + f.render_widget(List::new(author_items), chunks[4]); + + // Hint + f.render_widget( + Paragraph::new("Tab: switch │ Space: toggle │ Enter: add/apply │ Esc: cancel") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center), + chunks[5], + ); +} + +/// Calculate centered rectangle for popups. +fn centered_rect(percent_x: u16, percent_y: u16, outer: Rect) -> Rect { + let popup_width = (outer.width * percent_x / 100) + .max(20) + .min(outer.width.saturating_sub(4)); + let popup_height = (outer.height * percent_y / 100) + .max(5) + .min(outer.height.saturating_sub(4)); + let popup_x = (outer.width.saturating_sub(popup_width)) / 2; + let popup_y = (outer.height.saturating_sub(popup_height)) / 2; + Rect::new(popup_x, popup_y, popup_width, popup_height) +} diff --git a/src/widgets/input_field.rs b/src/widgets/input_field.rs new file mode 100644 index 0000000..0eeac77 --- /dev/null +++ b/src/widgets/input_field.rs @@ -0,0 +1,200 @@ +//! Input field widget for text entry. + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph, Widget, Wrap}, +}; + +/// A text input field with placeholder support and cursor display. +/// +/// # Example +/// ``` +/// use assistant::widgets::InputField; +/// +/// let input = InputField::new("search_term") +/// .placeholder("Type to search...") +/// .title(" Search "); +/// ``` +#[derive(Debug, Clone)] +pub struct InputField<'a> { + value: &'a str, + placeholder: Option<&'a str>, + title: Option<&'a str>, + border_color: Color, + show_cursor: bool, + focused: bool, + prefix: Option<&'a str>, +} + +impl<'a> InputField<'a> { + /// Create a new input field with the current value. + pub fn new(value: &'a str) -> Self { + Self { + value, + placeholder: None, + title: None, + border_color: Color::Yellow, + show_cursor: true, + focused: true, + prefix: None, + } + } + + /// Set the placeholder text shown when the field is empty. + pub fn placeholder(mut self, text: &'a str) -> Self { + self.placeholder = Some(text); + self + } + + /// Set the title displayed in the border. + pub fn title(mut self, title: &'a str) -> Self { + self.title = Some(title); + self + } + + /// Set the border color. + pub fn border_color(mut self, color: Color) -> Self { + self.border_color = color; + self + } + + /// Set whether to show the cursor. + pub fn show_cursor(mut self, show: bool) -> Self { + self.show_cursor = show; + self + } + + /// Set whether the field is focused. + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + /// Set a prefix to display before the input (e.g., "/" for commands, "@" for usernames). + pub fn prefix(mut self, prefix: &'a str) -> Self { + self.prefix = Some(prefix); + self + } +} + +impl Widget for InputField<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let border_style = if self.focused { + Style::default().fg(self.border_color) + } else { + Style::default().fg(Color::DarkGray) + }; + + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(border_style); + + if let Some(title) = self.title { + block = block.title(title); + } + + let (display_text, text_style) = if self.value.is_empty() { + let text = self.placeholder.unwrap_or(""); + (text.to_string(), Style::default().fg(Color::DarkGray)) + } else { + let prefix = self.prefix.unwrap_or(""); + let cursor = if self.show_cursor && self.focused { + "_" + } else { + "" + }; + ( + format!("{}{}{}", prefix, self.value, cursor), + Style::default().fg(Color::White), + ) + }; + + let paragraph = Paragraph::new(display_text) + .block(block) + .style(text_style) + .wrap(Wrap { trim: false }); + + paragraph.render(area, buf); + } +} + +/// A simpler inline input (no borders) for use inside other widgets. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct InlineInput<'a> { + value: &'a str, + placeholder: Option<&'a str>, + prefix: Option<&'a str>, + show_cursor: bool, +} + +#[allow(dead_code)] +impl<'a> InlineInput<'a> { + pub fn new(value: &'a str) -> Self { + Self { + value, + placeholder: None, + prefix: None, + show_cursor: true, + } + } + + pub fn placeholder(mut self, text: &'a str) -> Self { + self.placeholder = Some(text); + self + } + + pub fn prefix(mut self, prefix: &'a str) -> Self { + self.prefix = Some(prefix); + self + } + + pub fn show_cursor(mut self, show: bool) -> Self { + self.show_cursor = show; + self + } +} + +impl Widget for InlineInput<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let (text, style) = if self.value.is_empty() { + ( + self.placeholder.unwrap_or("").to_string(), + Style::default().fg(Color::DarkGray), + ) + } else { + let prefix = self.prefix.unwrap_or(""); + let cursor = if self.show_cursor { "_" } else { "" }; + ( + format!("{}{}{}", prefix, self.value, cursor), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + }; + + let paragraph = Paragraph::new(text).style(style); + paragraph.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_input_field_creation() { + let input = InputField::new("test") + .placeholder("Enter text") + .title(" Input "); + assert_eq!(input.value, "test"); + assert_eq!(input.placeholder, Some("Enter text")); + } + + #[test] + fn test_input_with_prefix() { + let input = InputField::new("search").prefix("/"); + assert_eq!(input.prefix, Some("/")); + } +} diff --git a/src/widgets/issue_list.rs b/src/widgets/issue_list.rs new file mode 100644 index 0000000..2df242e --- /dev/null +++ b/src/widgets/issue_list.rs @@ -0,0 +1,176 @@ +//! Issue list widget for displaying GitHub issues. + +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::ListItem, +}; + +use crate::agents::{AgentSession, AgentStatus}; +use crate::github::IssueSummary; + +/// Represents the display state of an issue in the list. +pub struct IssueItemState<'a> { + pub issue: &'a IssueSummary, + pub is_selected: bool, + pub session: Option<&'a AgentSession>, +} + +/// Build a ListItem for an issue with session status and selection state. +pub fn build_issue_item<'a>( + issue: &'a IssueSummary, + is_selected: bool, + session: Option<&'a AgentSession>, +) -> ListItem<'a> { + let state = IssueItemState { + issue, + is_selected, + session, + }; + build_issue_item_from_state(&state) +} + +/// Build a ListItem from an IssueItemState. +pub fn build_issue_item_from_state<'a>(state: &IssueItemState<'a>) -> ListItem<'a> { + let issue = state.issue; + let is_selected = state.is_selected; + + // Selection marker + let select_marker = if is_selected { "[x] " } else { "[ ] " }; + let select_style = if is_selected { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + + // Session status + let (session_icon, session_color, session_stats) = match state.session { + Some(session) => { + let (icon, color) = match &session.status { + AgentStatus::Running => ("▶", Color::Yellow), + AgentStatus::Awaiting => ("⏸", Color::Cyan), + AgentStatus::Completed { .. } | AgentStatus::Failed { .. } => ("●", Color::Blue), + }; + let stats = if session.stats.lines_added > 0 || session.stats.lines_deleted > 0 { + format!(" +{} -{}", session.stats.lines_added, session.stats.lines_deleted) + } else { + String::new() + }; + (Some(icon), color, stats) + } + None => (None, Color::DarkGray, String::new()), + }; + + // Labels and assignees + let labels_str = if issue.labels.is_empty() { + String::new() + } else { + format!(" [{}]", issue.labels.join(", ")) + }; + + let assignees_str = if issue.assignees.is_empty() { + String::new() + } else { + format!(" @{}", issue.assignees.join(", @")) + }; + + let is_closed = issue.state == "Closed"; + + let line = if is_closed { + // Closed issue style + Line::from(vec![ + Span::styled(select_marker, select_style), + Span::styled( + format!("#{:<5}", issue.number), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" ✓ ", Style::default().fg(Color::Green)), + Span::styled( + issue.title.clone(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::CROSSED_OUT), + ), + Span::styled(labels_str, Style::default().fg(Color::DarkGray)), + Span::styled(assignees_str, Style::default().fg(Color::DarkGray)), + ]) + } else { + // Open issue style + let session_span = if let Some(icon) = session_icon { + Span::styled( + format!("{}{} ", icon, session_stats), + Style::default().fg(session_color), + ) + } else { + Span::raw(" ") + }; + + Line::from(vec![ + Span::styled(select_marker, select_style), + Span::styled( + format!("#{:<5}", issue.number), + Style::default().fg(Color::Cyan), + ), + session_span, + Span::raw(issue.title.clone()), + Span::styled(labels_str, Style::default().fg(Color::DarkGray)), + Span::styled(assignees_str, Style::default().fg(Color::Magenta)), + ]) + }; + + ListItem::new(line) +} + +/// Build a dimmed ListItem for an issue (used in overlay backgrounds). +pub fn build_issue_item_dimmed(issue: &IssueSummary) -> ListItem<'_> { + let labels_str = if issue.labels.is_empty() { + String::new() + } else { + format!(" [{}]", issue.labels.join(", ")) + }; + + let line = Line::from(vec![ + Span::styled( + format!("#{:<5}", issue.number), + Style::default().fg(Color::DarkGray), + ), + Span::styled(issue.title.clone(), Style::default().fg(Color::DarkGray)), + Span::styled(labels_str, Style::default().fg(Color::DarkGray)), + ]); + + ListItem::new(line) +} + +/// Configuration for the issue list title. +pub struct IssueListTitleConfig { + pub search_query: Option, + pub total_count: usize, + pub has_next_page: bool, + pub is_loading: bool, + pub selected_count: usize, +} + +/// Build the title string for the issue list. +pub fn build_issue_list_title(config: &IssueListTitleConfig, base_title: &str) -> String { + let mut parts = vec![base_title.to_string()]; + + if let Some(ref query) = config.search_query { + parts.push(format!("(filtered: '{}')", query)); + } + + if config.has_next_page { + parts.push(format!("[{} loaded, more available]", config.total_count)); + } else if config.total_count > 20 { + parts.push(format!("[{} total]", config.total_count)); + } + + if config.is_loading { + parts.push("[Loading...]".to_string()); + } + + if config.selected_count > 0 { + parts.push(format!("[{} selected]", config.selected_count)); + } + + parts.join(" ") +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..4d4abf6 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,23 @@ +//! Composable widgets for the TUI. +//! +//! This module provides reusable widget components that encapsulate +//! both state and rendering logic, making the UI code more modular +//! and easier to maintain. + +mod command_palette; +mod confirm_dialog; +mod filter_popup; +mod input_field; +pub mod issue_list; +mod popup; +mod status_bar; + +pub use command_palette::{build_command_item, render_command_palette, CommandPaletteConfig, Separator}; +pub use confirm_dialog::ConfirmDialog; +pub use filter_popup::{render_filter_popup, FilterFocus, FilterOption, FilterPopupConfig}; +#[allow(unused_imports)] +pub use input_field::InlineInput; +pub use input_field::InputField; +pub use issue_list::{build_issue_item, build_issue_item_dimmed, build_issue_list_title, IssueListTitleConfig}; +pub use popup::Popup; +pub use status_bar::StatusBar; diff --git a/src/widgets/popup.rs b/src/widgets/popup.rs new file mode 100644 index 0000000..aeca38a --- /dev/null +++ b/src/widgets/popup.rs @@ -0,0 +1,162 @@ +//! Popup widget for centered modal dialogs. + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Clear, Widget}, +}; + +/// A centered popup container that clears the background and draws a bordered box. +/// +/// # Example +/// ``` +/// use assistant::widgets::Popup; +/// use ratatui::style::Color; +/// +/// let popup = Popup::new(" Search ") +/// .percent(50, 30) +/// .border_color(Color::Yellow); +/// ``` +#[derive(Debug, Clone)] +pub struct Popup<'a> { + title: &'a str, + percent_x: u16, + percent_y: u16, + border_color: Color, + min_width: u16, + min_height: u16, +} + +impl<'a> Popup<'a> { + /// Create a new popup with the given title. + pub fn new(title: &'a str) -> Self { + Self { + title, + percent_x: 50, + percent_y: 30, + border_color: Color::Cyan, + min_width: 20, + min_height: 5, + } + } + + /// Set the popup size as a percentage of the parent area. + pub fn percent(mut self, x: u16, y: u16) -> Self { + self.percent_x = x; + self.percent_y = y; + self + } + + /// Set the border color. + pub fn border_color(mut self, color: Color) -> Self { + self.border_color = color; + self + } + + /// Set minimum dimensions. + pub fn min_size(mut self, width: u16, height: u16) -> Self { + self.min_width = width; + self.min_height = height; + self + } + + /// Calculate the centered popup area within the given outer area. + pub fn area(&self, outer: Rect) -> Rect { + let popup_width = (outer.width * self.percent_x / 100) + .max(self.min_width) + .min(outer.width.saturating_sub(4)); + let popup_height = (outer.height * self.percent_y / 100) + .max(self.min_height) + .min(outer.height.saturating_sub(4)); + let popup_x = (outer.width.saturating_sub(popup_width)) / 2; + let popup_y = (outer.height.saturating_sub(popup_height)) / 2; + + Rect::new(popup_x, popup_y, popup_width, popup_height) + } + + /// Get the inner area (inside the borders) for content rendering. + pub fn inner(&self, outer: Rect) -> Rect { + let popup_area = self.area(outer); + let block = Block::default().borders(Borders::ALL); + block.inner(popup_area) + } +} + +impl Widget for Popup<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let popup_area = self.area(area); + + // Clear the background + Clear.render(popup_area, buf); + + // Draw the bordered box + let block = Block::default() + .borders(Borders::ALL) + .title(self.title) + .border_style(Style::default().fg(self.border_color)) + .style(Style::default().bg(Color::Black)); + + block.render(popup_area, buf); + } +} + +/// Helper to render a popup and return its inner area for content. +#[allow(dead_code)] +pub fn render_popup( + f: &mut ratatui::Frame, + title: &str, + percent_x: u16, + percent_y: u16, +) -> Rect { + let popup = Popup::new(title).percent(percent_x, percent_y); + let area = f.area(); + let inner = popup.inner(area); + f.render_widget(popup, area); + inner +} + +/// Render a popup with custom border color and return its inner area. +#[allow(dead_code)] +pub fn render_popup_colored( + f: &mut ratatui::Frame, + title: &str, + percent_x: u16, + percent_y: u16, + border_color: Color, +) -> Rect { + let popup = Popup::new(title) + .percent(percent_x, percent_y) + .border_color(border_color); + let area = f.area(); + let inner = popup.inner(area); + f.render_widget(popup, area); + inner +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_popup_area_calculation() { + let popup = Popup::new("Test").percent(50, 50); + let outer = Rect::new(0, 0, 100, 50); + let area = popup.area(outer); + + assert_eq!(area.width, 50); + assert_eq!(area.height, 25); + assert_eq!(area.x, 25); + assert_eq!(area.y, 12); + } + + #[test] + fn test_popup_min_size() { + let popup = Popup::new("Test").percent(10, 10).min_size(30, 10); + let outer = Rect::new(0, 0, 100, 50); + let area = popup.area(outer); + + // Should use min_size since 10% is smaller + assert_eq!(area.width, 30); + assert_eq!(area.height, 10); + } +} diff --git a/src/widgets/status_bar.rs b/src/widgets/status_bar.rs new file mode 100644 index 0000000..2af7648 --- /dev/null +++ b/src/widgets/status_bar.rs @@ -0,0 +1,103 @@ +//! Status bar widget for displaying messages at the bottom of the screen. + +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +/// A status bar widget that displays messages with automatic color coding. +/// +/// # Example +/// ``` +/// use assistant::widgets::StatusBar; +/// +/// let status = StatusBar::new("Operation successful"); +/// // Renders as a green bordered box with centered text +/// +/// let error = StatusBar::new("Failed to connect"); +/// // Automatically renders as red because message contains "Failed" +/// ``` +#[derive(Debug, Clone)] +pub struct StatusBar<'a> { + message: &'a str, + style: Option