use std::collections::HashMap;
use std::time::Instant;
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::error::RslphError;
use crate::subprocess::StreamEvent;
use crate::tui::conversation::{render_conversation, ConversationBuffer, ConversationItem};
use crate::tui::event::EventHandler;
use crate::tui::terminal::{init_terminal, restore_terminal};
#[derive(Debug, Clone)]
pub enum PlanStatus {
StackDetection,
Planning,
GeneratingName,
Complete,
Failed(String),
}
#[derive(Debug)]
pub struct PlanTuiState {
pub conversation: ConversationBuffer,
pub scroll_offset: usize,
pub plan_preview: String,
pub status: PlanStatus,
pub start_time: Instant,
pub should_quit: bool,
}
impl Default for PlanTuiState {
fn default() -> Self {
Self::new()
}
}
impl PlanTuiState {
pub fn new() -> Self {
Self {
conversation: ConversationBuffer::new(500),
scroll_offset: 0,
plan_preview: String::new(),
status: PlanStatus::StackDetection,
start_time: Instant::now(),
should_quit: false,
}
}
pub fn update(&mut self, event: &StreamEvent) {
for item in event.extract_conversation_items() {
if let ConversationItem::Text(ref text) = item {
self.plan_preview.push_str(text);
self.plan_preview.push('\n');
}
self.conversation.push(item);
}
self.scroll_offset = self.conversation.len().saturating_sub(15);
if matches!(self.status, PlanStatus::StackDetection) {
self.status = PlanStatus::Planning;
}
}
pub fn set_failed(&mut self, error: String) {
self.status = PlanStatus::Failed(error);
}
pub fn set_complete(&mut self) {
self.status = PlanStatus::Complete;
}
}
pub fn render_plan_tui(frame: &mut Frame, state: &PlanTuiState) {
let area = frame.area();
let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(5),
])
.areas(area);
render_header(frame, header_area, state);
let empty_collapsed: HashMap<usize, bool> = HashMap::new();
render_conversation(
frame,
main_area,
state.conversation.items(),
state.scroll_offset,
&empty_collapsed,
);
render_footer(frame, footer_area, state);
}
fn render_header(frame: &mut Frame, area: Rect, state: &PlanTuiState) {
let elapsed = state.start_time.elapsed().as_secs();
let status_text = match &state.status {
PlanStatus::StackDetection => "Detecting project stack...",
PlanStatus::Planning => "Generating plan...",
PlanStatus::GeneratingName => "Generating project name...",
PlanStatus::Complete => "Complete!",
PlanStatus::Failed(e) => e.as_str(),
};
let status_color = match &state.status {
PlanStatus::Complete => Color::Green,
PlanStatus::Failed(_) => Color::Red,
_ => Color::Yellow,
};
let header = Paragraph::new(vec![Line::from(vec![
Span::styled("Plan ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format!("| {} ", status_text),
Style::default().fg(status_color),
),
Span::styled(format!("| {}s", elapsed), Style::default().fg(Color::Cyan)),
])])
.block(Block::default().borders(Borders::BOTTOM));
frame.render_widget(header, area);
}
fn render_footer(frame: &mut Frame, area: Rect, state: &PlanTuiState) {
let preview_lines: Vec<Line> = state
.plan_preview
.lines()
.rev()
.take(3)
.map(|l| Line::from(l.to_string()))
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let footer = Paragraph::new(preview_lines).block(
Block::default()
.borders(Borders::TOP)
.title("Plan Preview (q to quit, PgUp/PgDn to scroll)"),
);
frame.render_widget(footer, area);
}
pub async fn run_plan_tui(
event_rx: mpsc::UnboundedReceiver<StreamEvent>,
cancel_token: CancellationToken,
) -> Result<PlanTuiState, RslphError> {
let mut terminal = init_terminal()
.map_err(|e| RslphError::Subprocess(format!("Terminal init failed: {}", e)))?;
let mut state = PlanTuiState::new();
let mut event_rx = event_rx;
let (mut kbd_handler, _subprocess_tx) = EventHandler::new(30);
loop {
terminal
.draw(|frame| render_plan_tui(frame, &state))
.map_err(|e| RslphError::Subprocess(format!("Render failed: {}", e)))?;
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
state.set_failed("Cancelled".to_string());
break;
}
stream_event = event_rx.recv() => {
match stream_event {
Some(event) => {
state.update(&event);
}
None => {
state.set_complete();
break;
}
}
}
kbd_event = kbd_handler.next() => {
if let Some(app_event) = kbd_event {
match app_event {
crate::tui::AppEvent::Quit => {
state.should_quit = true;
cancel_token.cancel();
break;
}
crate::tui::AppEvent::ScrollUp => {
state.scroll_offset = state.scroll_offset.saturating_sub(1);
}
crate::tui::AppEvent::ScrollDown => {
state.scroll_offset = (state.scroll_offset + 1)
.min(state.conversation.len().saturating_sub(1));
}
_ => {}
}
}
}
}
}
restore_terminal()
.map_err(|e| RslphError::Subprocess(format!("Terminal restore failed: {}", e)))?;
Ok(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plan_tui_state_new() {
let state = PlanTuiState::new();
assert!(state.conversation.items().is_empty());
assert_eq!(state.scroll_offset, 0);
assert!(state.plan_preview.is_empty());
assert!(matches!(state.status, PlanStatus::StackDetection));
assert!(!state.should_quit);
}
#[test]
fn test_plan_tui_state_default() {
let state = PlanTuiState::default();
assert!(matches!(state.status, PlanStatus::StackDetection));
}
#[test]
fn test_plan_status_variants() {
let _ = PlanStatus::StackDetection;
let _ = PlanStatus::Planning;
let _ = PlanStatus::GeneratingName;
let _ = PlanStatus::Complete;
let _ = PlanStatus::Failed("error".to_string());
}
#[test]
fn test_set_complete() {
let mut state = PlanTuiState::new();
state.set_complete();
assert!(matches!(state.status, PlanStatus::Complete));
}
#[test]
fn test_set_failed() {
let mut state = PlanTuiState::new();
state.set_failed("test error".to_string());
assert!(matches!(state.status, PlanStatus::Failed(ref e) if e == "test error"));
}
}