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:
@@ -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 0‑5
|
||||
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("'", "'")
|
||||
.replace(""", "\"");
|
||||
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("'", "'")
|
||||
.replace(""", "\"");
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user