Add App core struct with event-handling and initialization logic for TUI.
This commit is contained in:
370
crates/owlen-tui/src/ui.rs
Normal file
370
crates/owlen-tui/src/ui.rs
Normal file
@@ -0,0 +1,370 @@
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::chat_app::{ChatApp, InputMode};
|
||||
use owlen_core::types::Role;
|
||||
|
||||
pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
render_messages(frame, layout[0], app);
|
||||
render_input(frame, layout[1], app);
|
||||
render_status(frame, layout[2], app);
|
||||
|
||||
match app.mode() {
|
||||
InputMode::ProviderSelection => render_provider_selector(frame, app),
|
||||
InputMode::ModelSelection => render_model_selector(frame, app),
|
||||
InputMode::Help => render_help(frame),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
let conversation = app.conversation();
|
||||
let formatter = app.formatter();
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for message in &conversation.messages {
|
||||
let color = role_color(message.role.clone());
|
||||
let mut formatted = formatter.format_message(message);
|
||||
let is_streaming = message
|
||||
.metadata
|
||||
.get("streaming")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(first) = formatted.first_mut() {
|
||||
if let Some((label, rest)) = first.split_once(':') {
|
||||
let mut spans = Vec::new();
|
||||
spans.push(Span::styled(
|
||||
format!("{label}:"),
|
||||
color.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if !rest.trim().is_empty() {
|
||||
spans.push(Span::raw(format!(" {}", rest.trim_start())));
|
||||
}
|
||||
if is_streaming {
|
||||
spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta)));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
} else {
|
||||
let mut spans = vec![Span::raw(first.clone())];
|
||||
if is_streaming {
|
||||
spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta)));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
for line in formatted.into_iter().skip(1) {
|
||||
let mut spans = vec![Span::raw(line)];
|
||||
if is_streaming {
|
||||
spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta)));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from("No messages yet. Press 'i' to start typing."));
|
||||
}
|
||||
|
||||
let mut paragraph = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
"Conversation",
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||
)
|
||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||
|
||||
let scroll = app.scroll().min(u16::MAX as usize) as u16;
|
||||
paragraph = paragraph.scroll((scroll, 0));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
let title = match app.mode() {
|
||||
InputMode::Editing => "Input (Enter=send · Shift+Enter/Ctrl+J=newline)",
|
||||
_ => "Input",
|
||||
};
|
||||
|
||||
let input_block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
|
||||
|
||||
let input_text = app.input_buffer().text().to_string();
|
||||
let paragraph = Paragraph::new(input_text.clone())
|
||||
.block(input_block)
|
||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
if matches!(app.mode(), InputMode::Editing) {
|
||||
let cursor_index = app.input_buffer().cursor();
|
||||
let (cursor_line, cursor_col) = cursor_position(&input_text, cursor_index);
|
||||
let x = area.x + 1 + cursor_col as u16;
|
||||
let y = area.y + 1 + cursor_line as u16;
|
||||
frame.set_cursor_position((
|
||||
x.min(area.right().saturating_sub(1)),
|
||||
y.min(area.bottom().saturating_sub(1)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
let mut spans = Vec::new();
|
||||
spans.push(Span::styled(
|
||||
" OWLEN ",
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!("Model {} ({})", app.selected_model(), app.selected_provider),
|
||||
Style::default().fg(Color::LightMagenta),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!("Mode {}", app.mode()),
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!("Msgs {}", app.message_count()),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
|
||||
if app.streaming_count() > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!("⟳ {}", app.streaming_count()),
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
app.status_message(),
|
||||
Style::default().fg(Color::LightBlue),
|
||||
));
|
||||
|
||||
if let Some(error) = app.error_message() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
error,
|
||||
Style::default()
|
||||
.fg(Color::LightRed)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans))
|
||||
.alignment(Alignment::Left)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||
);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let area = centered_rect(60, 60, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.available_providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
ListItem::new(Span::styled(
|
||||
provider.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
"Select Provider",
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(app.selected_provider_index));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let area = centered_rect(60, 60, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.models()
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let label = if model.name.is_empty() {
|
||||
model.id.clone()
|
||||
} else {
|
||||
format!("{} — {}", model.id, model.name)
|
||||
};
|
||||
ListItem::new(Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
format!("Select Model ({})", app.selected_provider),
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(app.selected_model_index());
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn render_help(frame: &mut Frame<'_>) {
|
||||
let area = centered_rect(70, 60, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
let help_text = vec![
|
||||
Line::from("Controls:"),
|
||||
Line::from(" i / Enter → start typing"),
|
||||
Line::from(" Enter → send message"),
|
||||
Line::from(" Shift+Enter → newline"),
|
||||
Line::from(" Ctrl+J → newline"),
|
||||
Line::from(" m → select model"),
|
||||
Line::from(" n → new conversation"),
|
||||
Line::from(" c → clear conversation"),
|
||||
Line::from(" q → quit"),
|
||||
Line::from(""),
|
||||
Line::from("Press Esc to close this help."),
|
||||
];
|
||||
|
||||
let paragraph = Paragraph::new(help_text).block(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
"Help",
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||
);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
fn role_color(role: Role) -> Style {
|
||||
match role {
|
||||
Role::User => Style::default().fg(Color::LightBlue),
|
||||
Role::Assistant => Style::default().fg(Color::LightMagenta),
|
||||
Role::System => Style::default().fg(Color::Cyan),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_position(text: &str, cursor: usize) -> (usize, usize) {
|
||||
let mut line = 0;
|
||||
let mut col = 0;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
if idx >= cursor {
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
col = 0;
|
||||
} else {
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
(line, col)
|
||||
}
|
||||
Reference in New Issue
Block a user