Add App core struct with event-handling and initialization logic for TUI.

This commit is contained in:
2025-09-27 05:41:46 +02:00
commit 5bc0e02cd3
32 changed files with 7205 additions and 0 deletions

370
crates/owlen-tui/src/ui.rs Normal file
View 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)
}