refactor(core): remove provider module, migrate to LLMProvider, add client mode handling, improve serialization error handling, update workspace edition, and clean up conditionals and imports

This commit is contained in:
2025-10-12 12:38:55 +02:00
parent c2f5ccea3b
commit 7851af14a9
63 changed files with 2221 additions and 1236 deletions

View File

@@ -1,14 +1,14 @@
use ratatui::Frame;
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, Wrap};
use ratatui::Frame;
use serde_json;
use textwrap::{wrap, Options};
use textwrap::{Options, wrap};
use tui_textarea::TextArea;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, ModelSelectorItemKind, HELP_TAB_COUNT};
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, ModelSelectorItemKind};
use owlen_core::model::DetailedModelInfo;
use owlen_core::types::{ModelInfo, Role};
use owlen_core::ui::{FocusedPanel, InputMode};
@@ -22,10 +22,21 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Set terminal background color
let theme = app.theme().clone();
let background_block = Block::default().style(Style::default().bg(theme.background));
frame.render_widget(background_block, frame.area());
let full_area = frame.area();
frame.render_widget(background_block, full_area);
let (chat_area, code_area) = if app.should_show_code_view() {
let segments = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
.split(full_area);
(segments[0], Some(segments[1]))
} else {
(full_area, None)
};
// Calculate dynamic input height based on textarea content
let available_width = frame.area().width;
let available_width = chat_area.width;
let input_height = if matches!(app.mode(), InputMode::Editing) {
let visual_lines = calculate_wrapped_line_count(
app.textarea().lines().iter().map(|s| s.as_str()),
@@ -81,7 +92,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
.split(chat_area);
let mut idx = 0;
render_header(frame, layout[idx], app);
@@ -124,19 +135,22 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
}
if app.is_model_info_visible() {
let panel_width = frame
.area()
let panel_width = full_area
.width
.saturating_div(3)
.max(30)
.min(frame.area().width.saturating_sub(20).max(30));
let x = frame.area().x + frame.area().width.saturating_sub(panel_width);
let area = Rect::new(x, frame.area().y, panel_width, frame.area().height);
.min(full_area.width.saturating_sub(20).max(30));
let x = full_area.x + full_area.width.saturating_sub(panel_width);
let area = Rect::new(x, full_area.y, panel_width, full_area.height);
frame.render_widget(Clear, area);
let viewport_height = area.height.saturating_sub(2) as usize;
app.set_model_info_viewport_height(viewport_height);
app.model_info_panel_mut().render(frame, area, &theme);
}
if let Some(area) = code_area {
render_code_view(frame, area, app);
}
}
fn render_editable_textarea(
@@ -219,10 +233,10 @@ fn render_editable_textarea(
let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines);
if let Some(ref metrics) = metrics {
if metrics.scroll_top > 0 {
paragraph = paragraph.scroll((metrics.scroll_top, 0));
}
if let Some(ref metrics) = metrics
&& metrics.scroll_top > 0
{
paragraph = paragraph.scroll((metrics.scroll_top, 0));
}
if let Some(block) = block {
@@ -374,12 +388,10 @@ fn compute_cursor_metrics(
break;
}
if !cursor_found {
if let Some(last_segment) = segments.last() {
cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1);
cursor_col_width = UnicodeWidthStr::width(last_segment.as_str());
cursor_found = true;
}
if !cursor_found && let Some(last_segment) = segments.last() {
cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1);
cursor_col_width = UnicodeWidthStr::width(last_segment.as_str());
cursor_found = true;
}
}
@@ -469,9 +481,15 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
);
let provider_span = Span::styled(
app.current_provider().to_string(),
Style::default().fg(theme.text),
);
let model_span = Span::styled(
format!("Model: {}", app.selected_model()),
Style::default().fg(theme.user_message_role),
app.selected_model().to_string(),
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
);
let header_block = Block::default()
@@ -482,7 +500,17 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let inner_area = header_block.inner(area);
let header_text = vec![Line::from(""), Line::from(format!(" {model_span} "))];
let header_text = vec![
Line::default(),
Line::from(vec![
Span::raw(" "),
Span::styled("Provider: ", Style::default().fg(theme.placeholder)),
provider_span,
Span::raw(" "),
Span::styled("Model: ", Style::default().fg(theme.placeholder)),
model_span,
]),
];
let paragraph = Paragraph::new(header_text)
.style(Style::default().bg(theme.background).fg(theme.text))
@@ -776,11 +804,11 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
}
// Apply visual selection highlighting if in visual mode and Chat panel is focused
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat)
if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Chat)
&& let Some(selection) = app.visual_selection()
{
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection), &theme);
}
lines = apply_visual_selection(lines, Some(selection), &theme);
}
// Update AutoScroll state with accurate content length
@@ -864,10 +892,9 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Apply visual selection highlighting if in visual mode and Thinking panel is focused
if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Thinking)
&& let Some(selection) = app.visual_selection()
{
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection), &theme);
}
lines = apply_visual_selection(lines, Some(selection), &theme);
}
// Update AutoScroll state with accurate content length
@@ -1264,11 +1291,7 @@ where
total += wrapped.len().max(1);
}
if !seen {
1
} else {
total.max(1)
}
if !seen { 1 } else { total.max(1) }
}
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
@@ -1328,6 +1351,30 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", Style::default().fg(theme.text)));
spans.push(Span::styled(
"Provider: ",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
spans.push(Span::styled(
app.current_provider().to_string(),
Style::default().fg(theme.text),
));
spans.push(Span::styled(" ", Style::default().fg(theme.text)));
spans.push(Span::styled(
"Model: ",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
spans.push(Span::styled(
app.selected_model().to_string(),
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", Style::default().fg(theme.text)));
spans.push(Span::styled(help_text, Style::default().fg(theme.info)));
@@ -1344,6 +1391,76 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
frame.render_widget(paragraph, area);
}
fn render_code_view(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let path = match app.code_view_path() {
Some(p) => p.to_string(),
None => {
frame.render_widget(Clear, area);
return;
}
};
let theme = app.theme().clone();
frame.render_widget(Clear, area);
let viewport_height = area.height.saturating_sub(2) as usize;
app.set_code_view_viewport_height(viewport_height);
let mut lines: Vec<Line> = Vec::new();
if app.code_view_lines().is_empty() {
lines.push(Line::from(Span::styled(
"(empty file)",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
)));
} else {
for (idx, content) in app.code_view_lines().iter().enumerate() {
let number = format!("{:>4} ", idx + 1);
let spans = vec![
Span::styled(
number,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
),
Span::styled(content.clone(), Style::default().fg(theme.text)),
];
lines.push(Line::from(spans));
}
}
let scroll_state = app.code_view_scroll_mut();
scroll_state.content_len = lines.len();
scroll_state.on_viewport(viewport_height);
let scroll_position = scroll_state.scroll.min(u16::MAX as usize) as u16;
let border_color = if matches!(app.focused_panel(), FocusedPanel::Code) {
theme.focused_panel_border
} else {
theme.unfocused_panel_border
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text))
.title(Span::styled(
path,
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
));
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(block)
.scroll((scroll_position, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(60, 60, frame.area());
@@ -1510,10 +1627,9 @@ fn build_model_selector_label(
.parameter_size
.as_ref()
.or(detail.parameters.as_ref())
&& !parameters.trim().is_empty()
{
if !parameters.trim().is_empty() {
parts.push(parameters.trim().to_string());
}
parts.push(parameters.trim().to_string());
}
if let Some(size) = detail.size {
@@ -2032,8 +2148,17 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
)]),
Line::from(" :save [name] → save current session (with optional name)"),
Line::from(" :w [name] → alias for :save"),
Line::from(" :load, :o, :open → browse and load saved sessions"),
Line::from(" :load, :o → browse and load saved sessions"),
Line::from(" :sessions, :ls → browse saved sessions"),
Line::from(""),
Line::from(vec![Span::styled(
"CODE VIEW",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :open <path> → open file in code side panel"),
Line::from(" :close → close the code side panel"),
// New mode and tool commands added in phases 05
Line::from(" :code → switch to code mode (CLI: owlen --code)"),
Line::from(" :mode <chat|code> → change current mode explicitly"),
@@ -2066,7 +2191,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :load, :o, :open → browse and select session"),
Line::from(" :load, :o → browse and select session"),
Line::from(" :sessions, :ls → browse saved sessions"),
Line::from(""),
Line::from(vec![Span::styled(
@@ -2291,13 +2416,13 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let mut lines = vec![Line::from(Span::styled(name, style))];
// Add description if available and not empty
if let Some(description) = &session.description {
if !description.is_empty() {
lines.push(Line::from(Span::styled(
format!(" \"{}\"", description),
desc_style,
)));
}
if let Some(description) = &session.description
&& !description.is_empty()
{
lines.push(Line::from(Span::styled(
format!(" \"{}\"", description),
desc_style,
)));
}
// Add metadata line
@@ -2548,7 +2673,7 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
}
/// Format tool output JSON into a nice human-readable format
fn format_tool_output(content: &str) -> String {
pub(crate) fn format_tool_output(content: &str) -> String {
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
let mut output = String::new();
@@ -2592,23 +2717,23 @@ fn format_tool_output(content: &str) -> String {
}
// Snippet (truncated if too long)
if let Some(snippet) = result.get("snippet").and_then(|v| v.as_str()) {
if !snippet.is_empty() {
// Strip HTML tags
let clean_snippet = snippet
.replace("<b>", "")
.replace("</b>", "")
.replace("&#x27;", "'")
.replace("&quot;", "\"");
if let Some(snippet) = result.get("snippet").and_then(|v| v.as_str())
&& !snippet.is_empty()
{
// Strip HTML tags
let clean_snippet = snippet
.replace("<b>", "")
.replace("</b>", "")
.replace("&#x27;", "'")
.replace("&quot;", "\"");
// Truncate if too long
let truncated = if clean_snippet.len() > 200 {
format!("{}...", &clean_snippet[..197])
} else {
clean_snippet
};
output.push_str(&format!(" {}\n", truncated));
}
// Truncate if too long
let truncated = if clean_snippet.len() > 200 {
format!("{}...", &clean_snippet[..197])
} else {
clean_snippet
};
output.push_str(&format!(" {}\n", truncated));
}
// URL (shortened if too long)