use crate::client::tui::app::{App, ConfirmAction, Focus, Modal}; use crate::client::tui::components::*; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use unicode_width::UnicodeWidthStr; pub fn render_ui(frame: &mut Frame, app: &App) { let area = frame.area(); let input_height = app.input.lines().count().clamp(1, 6) as u16 + 2; let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(3), Constraint::Length(input_height), Constraint::Length(1), ]) .split(area); title_bar::render(frame, rows[0], app); if area.width >= 80 { let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(28), Constraint::Min(30)]) .split(rows[1]); session_list::render(frame, columns[0], app); chat_history::render(frame, columns[1], app); } else { chat_history::render(frame, rows[1], app); } input_area::render(frame, rows[2], app); render_footer(frame, rows[3], app); if app.show_command_menu && !app.get_filtered_commands().is_empty() { let height = (app.get_filtered_commands().len().min(6) + 2) as u16; let menu_area = Rect::new( rows[2].x.saturating_add(1), rows[2].y.saturating_sub(height), rows[2].width.saturating_sub(2), height, ); command_menu::render(frame, menu_area, app); } if app.show_help { help_popup::render(frame, centered_rect(72, 26, area)); } if let Some(modal) = &app.modal { render_modal(frame, centered_rect(64, 7, area), modal); } } fn render_footer(frame: &mut Frame, area: Rect, app: &App) { let focus = match app.focus { Focus::Input => "输入", Focus::Sessions => "会话", }; let status = app.status_message .as_deref() .unwrap_or(if app.connected { "就绪" } else { "已断开" }); let line = Line::from(vec![ Span::styled( format!(" {focus} "), Style::default().fg(Color::Black).bg(Color::Cyan), ), Span::raw(format!(" {status}")), Span::styled( " F1 帮助 Tab 切换焦点 Ctrl+N 新会话 ", Style::default().fg(Color::DarkGray), ), ]); frame.render_widget(Paragraph::new(line), area); } fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) { frame.render_widget(Clear, area); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)); match modal { Modal::Rename { input, cursor } => { frame.render_widget( Paragraph::new(vec![ Line::from(Span::styled( "重命名会话", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(input.as_str()), Line::from(Span::styled( "Enter 保存 · Esc 取消", Style::default().fg(Color::DarkGray), )), ]) .block(block) .wrap(Wrap { trim: false }), area, ); let cursor_x = area.x + 1 + UnicodeWidthStr::width(&input[..*cursor]) .min(area.width.saturating_sub(3) as usize) as u16; let cursor_y = area.y.saturating_add(3); if cursor_x < area.right() && cursor_y < area.bottom() { frame.set_cursor_position((cursor_x, cursor_y)); } } Modal::AttachPath { input, cursor } => { frame.render_widget( Paragraph::new(vec![ Line::from(Span::styled( "添加附件路径", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(input.as_str()), Line::from(Span::styled( "Enter 上传 · Esc 取消", Style::default().fg(Color::DarkGray), )), ]) .block(block) .wrap(Wrap { trim: false }), area, ); let cursor_x = area.x + 1 + UnicodeWidthStr::width(&input[..*cursor]) .min(area.width.saturating_sub(3) as usize) as u16; let cursor_y = area.y.saturating_add(3); if cursor_x < area.right() && cursor_y < area.bottom() { frame.set_cursor_position((cursor_x, cursor_y)); } } Modal::Confirm(action) => { let prompt = match action { ConfirmAction::Archive => "归档所选会话?", ConfirmAction::Delete => "永久删除所选会话?此操作不可撤销。", ConfirmAction::ClearHistory => "清空所选会话的全部历史?", }; frame.render_widget( Paragraph::new(vec![ Line::from(""), Line::from(prompt), Line::from(""), Line::from(Span::styled( "Y / Enter 确认 · N / Esc 取消", Style::default().fg(Color::DarkGray), )), ]) .block(block) .wrap(Wrap { trim: false }), area, ); } } } fn centered_rect(max_width: u16, max_height: u16, area: Rect) -> Rect { let width = max_width.min(area.width.saturating_sub(2)).max(1); let height = max_height.min(area.height.saturating_sub(2)).max(1); Rect::new( area.x + area.width.saturating_sub(width) / 2, area.y + area.height.saturating_sub(height) / 2, width, height, ) } #[cfg(test)] mod tests { use super::*; use ratatui::{Terminal, backend::TestBackend}; #[test] fn narrow_terminal_renders_without_sidebar_or_panic() { let backend = TestBackend::new(48, 14); let mut terminal = Terminal::new(backend).unwrap(); let mut app = App::new(); app.input_insert_str("中文 input"); app.add_message( crate::client::tui::app::MessageRole::Assistant, "一条很长的响应,用于验证窄终端换行。".repeat(4), ); terminal.draw(|frame| render_ui(frame, &app)).unwrap(); } #[test] fn active_reasoning_text_and_tool_snapshot_renders_without_panic() { let backend = TestBackend::new(96, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut app = App::new(); app.set_current_session(Some("session".into())); let (controller, emitter, _) = crate::session::TurnController::start("session", "message"); emitter .emit(crate::agent::TurnEvent::ReasoningDelta { iteration: 0, delta: "先检查状态".into(), }) .unwrap(); emitter .emit(crate::agent::TurnEvent::TextDelta { iteration: 0, delta: "正在处理".into(), }) .unwrap(); emitter .emit(crate::agent::TurnEvent::ToolStarted { iteration: 0, call: crate::providers::ToolCall { id: "call".into(), name: "bash".into(), arguments: serde_json::json!({"cmd": "pwd"}), }, }) .unwrap(); app.apply_turn_snapshot((*controller.snapshot()).clone()); terminal.draw(|frame| render_ui(frame, &app)).unwrap(); } }