重构: 添加 TUI 组件以支持聊天界面和输入处理

This commit is contained in:
xiaoxixi 2026-04-26 21:00:17 +08:00
parent 86dea0f874
commit cf6d57c568
13 changed files with 683 additions and 184 deletions

View File

@ -28,3 +28,7 @@ base64 = "0.22"
tempfile = "3"
meval = "0.2"
rusqlite = { version = "0.32", features = ["bundled"] }
ratatui = "0.27"
crossterm = { version = "0.28", features = ["event-stream"] }
termimad = "0.34"
textwrap = "0.16"

View File

@ -1,214 +1,118 @@
pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound};
mod channel;
mod input;
mod tui;
use futures_util::{SinkExt, StreamExt};
use crate::client::tui::app::{App, MessageRole};
use crate::client::tui::event::handle_key_event;
use crate::client::tui::ui::render_ui;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::StreamExt;
use ratatui::{prelude::CrosstermBackend, Terminal};
use std::io;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use input::{InputCommand, InputEvent, InputHandler};
fn format_session_list(sessions: &[crate::protocol::SessionSummary], current_session_id: Option<&str>) -> String {
if sessions.is_empty() {
return "No sessions found.".to_string();
}
let mut lines = Vec::with_capacity(sessions.len() + 1);
lines.push("Sessions:".to_string());
for session in sessions {
let marker = if current_session_id == Some(session.session_id.as_str()) {
"*"
} else {
"-"
};
let archived = if session.archived_at.is_some() {
" [archived]"
} else {
""
};
lines.push(format!(
"{} {} | {} | {} messages{}",
marker,
session.session_id,
session.title,
session.message_count,
archived,
));
}
lines.join("\n")
}
fn parse_message(raw: &str) -> Result<WsOutbound, serde_json::Error> {
serde_json::from_str(raw)
}
pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let (ws_stream, _) = connect_async(gateway_url).await?;
tracing::info!(url = %gateway_url, "Connected to gateway");
let (mut sender, mut receiver) = ws_stream.split();
let (ws_sender, ws_receiver) = ws_stream.split();
let mut input = InputHandler::new();
let mut current_session_id: Option<String> = None;
input.write_output("picobot CLI - Commands: /new [title], /reset, /sessions, /use <session>, /rename <title>, /archive, /delete, /clear, /quit\n").await?;
let mut app = App::new(gateway_url.to_string());
app.ws_sender = Some(ws_sender);
app.ws_receiver = Some(ws_receiver);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal, app).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut app: App,
) -> Result<(), Box<dyn std::error::Error>> {
let mut ws_receiver = app.ws_receiver.take().unwrap();
let mut event_reader = event::EventStream::new();
// Main loop: poll both stdin and WebSocket
loop {
terminal.draw(|f| render_ui(f, &app))?;
tokio::select! {
// Handle WebSocket messages
msg = receiver.next() => {
msg = ws_receiver.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let text = text.to_string();
if let Ok(outbound) = parse_message(&text) {
match outbound {
WsOutbound::AssistantResponse { content, .. } => {
input.write_response(&content).await?;
}
WsOutbound::Error { message, .. } => {
input.write_output(&format!("Error: {}", message)).await?;
}
WsOutbound::SessionEstablished { session_id } => {
current_session_id = Some(session_id.clone());
#[cfg(debug_assertions)]
tracing::debug!(session_id = %session_id, "Session established");
input.write_output(&format!("Session: {}\n", session_id)).await?;
}
WsOutbound::SessionCreated { session_id, title } => {
current_session_id = Some(session_id.clone());
input.write_output(&format!("Created session: {} ({})\n", session_id, title)).await?;
}
WsOutbound::SessionList { sessions, current_session_id: listed_current } => {
let display = format_session_list(&sessions, listed_current.as_deref());
input.write_output(&format!("{}\n", display)).await?;
}
WsOutbound::SessionLoaded { session_id, title, message_count } => {
current_session_id = Some(session_id.clone());
input.write_output(&format!("Loaded session: {} ({}, {} messages)\n", session_id, title, message_count)).await?;
}
WsOutbound::SessionRenamed { session_id, title } => {
input.write_output(&format!("Renamed session: {} -> {}\n", session_id, title)).await?;
}
WsOutbound::SessionArchived { session_id } => {
input.write_output(&format!("Archived session: {}\n", session_id)).await?;
}
WsOutbound::SessionDeleted { session_id } => {
if current_session_id.as_deref() == Some(session_id.as_str()) {
current_session_id = None;
}
input.write_output(&format!("Deleted session: {}\n", session_id)).await?;
}
WsOutbound::HistoryCleared { session_id } => {
input.write_output(&format!("Cleared history for session: {}\n", session_id)).await?;
}
_ => {}
}
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
handle_ws_message(&mut app, outbound);
}
}
Some(Ok(Message::Close(_))) | None => {
tracing::info!("Gateway disconnected");
input.write_output("Gateway disconnected").await?;
break;
app.quit();
}
_ => {}
}
}
// Handle stdin input
result = input.read_input("> ") => {
match result {
Ok(Some(event)) => {
match event {
InputEvent::Command(InputCommand::Exit) => {
input.write_output("Goodbye!").await?;
break;
}
InputEvent::Command(InputCommand::Clear) => {
let inbound = WsInbound::ClearHistory {
chat_id: None,
session_id: current_session_id.clone(),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Command(InputCommand::New(title)) => {
let inbound = WsInbound::CreateSession { title };
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Command(InputCommand::Sessions) => {
let inbound = WsInbound::ListSessions {
include_archived: true,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Command(InputCommand::Use(session_id)) => {
let inbound = WsInbound::LoadSession { session_id };
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Command(InputCommand::Rename(title)) => {
let inbound = WsInbound::RenameSession {
session_id: current_session_id.clone(),
title,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Command(InputCommand::Archive) => {
let inbound = WsInbound::ArchiveSession {
session_id: current_session_id.clone(),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Command(InputCommand::Delete) => {
let inbound = WsInbound::DeleteSession {
session_id: current_session_id.clone(),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
continue;
}
InputEvent::Message(content) => {
let inbound = WsInbound::UserInput {
content,
channel: None,
chat_id: current_session_id.clone(),
sender_id: None,
};
if let Ok(text) = serialize_inbound(&inbound) {
if sender.send(Message::Text(text.into())).await.is_err() {
tracing::error!("Failed to send message to gateway");
break;
}
}
}
}
}
Ok(None) => break,
Err(e) => {
tracing::error!(error = %e, "Input error");
break;
}
event_result = event_reader.next() => {
if let Some(Ok(Event::Key(key))) = event_result {
handle_key_event(&mut app, key).await;
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
match outbound {
WsOutbound::AssistantResponse { content, .. } => {
app.add_message(MessageRole::Assistant, content);
}
WsOutbound::Error { message, .. } => {
app.add_message(MessageRole::System, format!("Error: {}", message));
}
WsOutbound::SessionEstablished { session_id } => {
app.set_current_session(Some(session_id));
}
WsOutbound::SessionCreated { session_id, .. } => {
app.set_current_session(Some(session_id));
}
WsOutbound::SessionList { sessions, current_session_id } => {
app.set_sessions(sessions);
if let Some(id) = current_session_id {
app.set_current_session(Some(id));
}
}
WsOutbound::SessionLoaded { session_id, .. } => {
app.set_current_session(Some(session_id));
}
WsOutbound::SessionRenamed { .. } => {}
WsOutbound::SessionArchived { .. } => {}
WsOutbound::SessionDeleted { session_id } => {
if app.current_session_id.as_ref() == Some(&session_id) {
app.set_current_session(None);
}
}
WsOutbound::HistoryCleared { .. } => {
app.messages.clear();
}
_ => {}
}
}

155
src/client/tui/app.rs Normal file
View File

@ -0,0 +1,155 @@
use crate::protocol::SessionSummary;
use std::collections::VecDeque;
use tokio_tungstenite::tungstenite::Message;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusArea {
TitleBar,
SessionList,
ChatHistory,
InputArea,
}
#[derive(Debug, Clone)]
pub enum MessageRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
}
pub struct App {
pub gateway_url: String,
pub ws_sender: Option<
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>,
>,
pub ws_receiver: Option<
futures_util::stream::SplitStream<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
>,
>,
pub current_session_id: Option<String>,
pub sessions: Vec<SessionSummary>,
pub messages: VecDeque<ChatMessage>,
pub focus: FocusArea,
pub input: String,
pub input_cursor_pos: usize,
pub show_help: bool,
pub chat_scroll_offset: u16,
pub session_scroll_offset: u16,
pub should_quit: bool,
}
impl App {
pub fn new(gateway_url: String) -> Self {
Self {
gateway_url,
ws_sender: None,
ws_receiver: None,
current_session_id: None,
sessions: Vec::new(),
messages: VecDeque::new(),
focus: FocusArea::InputArea,
input: String::new(),
input_cursor_pos: 0,
show_help: false,
chat_scroll_offset: 0,
session_scroll_offset: 0,
should_quit: false,
}
}
pub fn add_message(&mut self, role: MessageRole, content: String) {
self.messages.push_back(ChatMessage { role, content });
self.chat_scroll_offset = 0;
}
pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) {
self.sessions = sessions;
}
pub fn set_current_session(&mut self, session_id: Option<String>) {
self.current_session_id = session_id;
self.messages.clear();
}
pub fn scroll_chat_up(&mut self) {
self.chat_scroll_offset = self.chat_scroll_offset.saturating_add(1);
}
pub fn scroll_chat_down(&mut self) {
self.chat_scroll_offset = self.chat_scroll_offset.saturating_sub(1);
}
pub fn scroll_session_up(&mut self) {
self.session_scroll_offset = self.session_scroll_offset.saturating_add(1);
}
pub fn scroll_session_down(&mut self) {
self.session_scroll_offset = self.session_scroll_offset.saturating_sub(1);
}
pub fn input_insert_char(&mut self, c: char) {
self.input.insert(self.input_cursor_pos, c);
self.input_cursor_pos += 1;
}
pub fn input_delete_char(&mut self) {
if self.input_cursor_pos > 0 {
self.input.remove(self.input_cursor_pos - 1);
self.input_cursor_pos -= 1;
}
}
pub fn input_move_cursor_left(&mut self) {
self.input_cursor_pos = self.input_cursor_pos.saturating_sub(1);
}
pub fn input_move_cursor_right(&mut self) {
if self.input_cursor_pos < self.input.len() {
self.input_cursor_pos += 1;
}
}
pub fn input_move_cursor_to_start(&mut self) {
self.input_cursor_pos = 0;
}
pub fn input_move_cursor_to_end(&mut self) {
self.input_cursor_pos = self.input.len();
}
pub fn input_clear(&mut self) {
self.input.clear();
self.input_cursor_pos = 0;
}
pub fn take_input(&mut self) -> String {
let input = std::mem::take(&mut self.input);
self.input_cursor_pos = 0;
input
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn quit(&mut self) {
self.should_quit = true;
}
}

View File

@ -0,0 +1,37 @@
use crate::client::tui::app::{App, MessageRole};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, List, ListItem},
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app
.messages
.iter()
.map(|msg| {
let (prefix, color) = match msg.role {
MessageRole::User => ("[User] ", Color::Blue),
MessageRole::Assistant => ("[Assistant] ", Color::Green),
MessageRole::System => ("[System] ", Color::Red),
};
let content = vec![
Line::from(vec![ratatui::text::Span::styled(
prefix,
Style::default().fg(color).add_modifier(Modifier::BOLD),
)]),
Line::from(msg.content.as_str()),
Line::from(""),
];
ListItem::new(content)
})
.collect();
let list = List::new(items).block(Block::default().title("Conversation").borders(Borders::ALL));
f.render_widget(list, area);
}

View File

@ -0,0 +1,42 @@
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, List, ListItem},
Frame,
};
pub fn render(f: &mut Frame, area: Rect) {
f.render_widget(Clear, area);
let help_text = vec![
ListItem::new("Commands:"),
ListItem::new(" /new [title] - Create new session"),
ListItem::new(" /sessions - List all sessions"),
ListItem::new(" /use <id> - Load session"),
ListItem::new(" /rename <title> - Rename session"),
ListItem::new(" /archive - Archive session"),
ListItem::new(" /delete - Delete session"),
ListItem::new(" /clear - Clear history"),
ListItem::new(" /help, /? - Show this help"),
ListItem::new(" /quit, /q - Quit"),
ListItem::new(""),
ListItem::new("Keyboard:"),
ListItem::new(" Enter - Send message"),
ListItem::new(" Esc, q - Quit"),
ListItem::new(" ? - Show help"),
ListItem::new(" Arrow keys - Navigate"),
];
let list = List::new(help_text).block(
Block::default()
.title("Help")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL),
);
f.render_widget(list, area);
}

View File

@ -0,0 +1,21 @@
use crate::client::tui::app::App;
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let input = Paragraph::new(app.input.as_str())
.style(Style::default().fg(Color::White))
.block(Block::default().title("Input").borders(Borders::ALL));
f.render_widget(input, area);
let cursor_x = area.x + 1 + app.input_cursor_pos as u16;
let cursor_y = area.y + 1;
if cursor_x < area.right() && cursor_y < area.bottom() {
f.set_cursor(cursor_x, cursor_y);
}
}

View File

@ -0,0 +1,5 @@
pub mod chat_history;
pub mod help_popup;
pub mod input_area;
pub mod session_list;
pub mod title_bar;

View File

@ -0,0 +1,45 @@
use crate::client::tui::app::App;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem},
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app
.sessions
.iter()
.map(|session| {
let is_current = app
.current_session_id
.as_ref()
.map_or(false, |id| id == &session.session_id);
let archived = session.archived_at.is_some();
let mut content = if is_current {
format!("{}", session.title)
} else {
format!(" {}", session.title)
};
if archived {
content.push_str(" [archived]");
}
let style = if is_current {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL));
f.render_widget(list, area);
}

View File

@ -0,0 +1,25 @@
use crate::client::tui::app::App;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let title = if let Some(session_id) = &app.current_session_id {
format!("PicoBot | Session: {}", session_id)
} else {
"PicoBot".to_string()
};
let paragraph = Paragraph::new(title)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::ALL));
f.render_widget(paragraph, area);
}

196
src/client/tui/event.rs Normal file
View File

@ -0,0 +1,196 @@
use crate::client::tui::app::{App, MessageRole};
use crate::protocol::serialize_inbound;
use crate::protocol::WsInbound;
use crossterm::event::{KeyCode, KeyEvent};
use futures_util::SinkExt;
pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
if app.show_help {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.toggle_help();
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.quit();
}
KeyCode::Char('?') => {
app.toggle_help();
}
KeyCode::Char(c) => {
app.input_insert_char(c);
}
KeyCode::Backspace => {
app.input_delete_char();
}
KeyCode::Left => {
app.input_move_cursor_left();
}
KeyCode::Right => {
app.input_move_cursor_right();
}
KeyCode::Home => {
app.input_move_cursor_to_start();
}
KeyCode::End => {
app.input_move_cursor_to_end();
}
KeyCode::Up => {
app.scroll_chat_up();
}
KeyCode::Down => {
app.scroll_chat_down();
}
KeyCode::Enter => {
let input = app.take_input();
if !input.is_empty() {
process_input(app, input).await;
}
}
_ => {}
}
}
async fn process_input(app: &mut App, input: String) {
let trimmed = input.trim();
if let Some(cmd) = parse_command(trimmed) {
match cmd {
InputCommand::Quit => {
app.quit();
}
InputCommand::Help => {
app.toggle_help();
}
InputCommand::Clear => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::ClearHistory {
chat_id: None,
session_id: Some(session_id.clone()),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
InputCommand::New(title) => {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::CreateSession { title };
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
InputCommand::Sessions => {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::ListSessions {
include_archived: true,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
InputCommand::Use(session_id) => {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::LoadSession { session_id };
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
InputCommand::Rename(title) => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::RenameSession {
session_id: Some(session_id.clone()),
title,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
InputCommand::Archive => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::ArchiveSession {
session_id: Some(session_id.clone()),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
InputCommand::Delete => {
if let Some(session_id) = &app.current_session_id {
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::DeleteSession {
session_id: Some(session_id.clone()),
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
}
} else {
app.add_message(MessageRole::User, input.clone());
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::UserInput {
content: input,
chat_id: app.current_session_id.clone(),
channel: None,
sender_id: None,
};
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
}
}
}
}
enum InputCommand {
Quit,
Help,
Clear,
New(Option<String>),
Sessions,
Use(String),
Rename(String),
Archive,
Delete,
}
fn parse_command(input: &str) -> Option<InputCommand> {
if !input.starts_with('/') {
return None;
}
let parts: Vec<&str> = input.splitn(2, char::is_whitespace).collect();
let cmd = parts[0];
let arg = parts.get(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
match cmd {
"/quit" | "/exit" | "/q" => Some(InputCommand::Quit),
"/help" | "/?" => Some(InputCommand::Help),
"/clear" | "/reset" => Some(InputCommand::Clear),
"/new" => Some(InputCommand::New(arg)),
"/sessions" => Some(InputCommand::Sessions),
"/use" => arg.map(InputCommand::Use),
"/rename" => arg.map(InputCommand::Rename),
"/archive" => Some(InputCommand::Archive),
"/delete" => Some(InputCommand::Delete),
_ => None,
}
}

View File

@ -0,0 +1,3 @@
pub fn render_markdown(content: &str) -> String {
content.to_string()
}

5
src/client/tui/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod app;
pub mod components;
pub mod event;
pub mod markdown;
pub mod ui;

57
src/client/tui/ui.rs Normal file
View File

@ -0,0 +1,57 @@
use crate::client::tui::app::App;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use super::components::*;
pub fn render_ui(f: &mut Frame, app: &App) {
let size = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(5),
])
.split(size);
title_bar::render(f, chunks[0], app);
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(chunks[1]);
session_list::render(f, middle_chunks[0], app);
chat_history::render(f, middle_chunks[1], app);
input_area::render(f, chunks[2], app);
if app.show_help {
let area = centered_rect(60, 60, size);
help_popup::render(f, area);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}