342 lines
12 KiB
Rust
342 lines
12 KiB
Rust
// main_screen.rs — F1 main view rendering
|
|
//
|
|
// The default four-pane layout: autonomous, conversation, tools, status bar.
|
|
// Contains draw_main (the App method), draw_conversation_pane, and draw_pane.
|
|
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, Paragraph, Wrap},
|
|
Frame,
|
|
};
|
|
|
|
use super::{ActivePane, App, Marker, PaneState, SCREEN_LEGEND};
|
|
|
|
impl App {
|
|
/// Draw the main (F1) screen — four-pane layout with status bar.
|
|
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect) {
|
|
// Main layout: content area + active tools overlay + status bar
|
|
let active_tools = self.active_tools.lock().unwrap();
|
|
let tool_lines = active_tools.len() as u16;
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(3), // content area
|
|
Constraint::Length(tool_lines), // active tools (0 when empty)
|
|
Constraint::Length(1), // status bar
|
|
])
|
|
.split(size);
|
|
|
|
let content_area = main_chunks[0];
|
|
let tools_overlay_area = main_chunks[1];
|
|
let status_area = main_chunks[2];
|
|
|
|
// Content: left column (55%) + right column (45%)
|
|
let columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(55),
|
|
Constraint::Percentage(45),
|
|
])
|
|
.split(content_area);
|
|
|
|
let left_col = columns[0];
|
|
let right_col = columns[1];
|
|
|
|
// Left column: autonomous (35%) + conversation (65%)
|
|
let left_panes = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(35),
|
|
Constraint::Percentage(65),
|
|
])
|
|
.split(left_col);
|
|
|
|
let auto_area = left_panes[0];
|
|
let conv_area = left_panes[1];
|
|
|
|
// Store pane areas for mouse click detection
|
|
self.pane_areas = [auto_area, conv_area, right_col];
|
|
|
|
// Draw autonomous pane
|
|
let auto_active = self.active_pane == ActivePane::Autonomous;
|
|
draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active,
|
|
Some(SCREEN_LEGEND));
|
|
|
|
// Draw tools pane
|
|
let tools_active = self.active_pane == ActivePane::Tools;
|
|
draw_pane(frame, right_col, "tools", &mut self.tools, tools_active, None);
|
|
|
|
// Draw conversation pane (with input line)
|
|
let conv_active = self.active_pane == ActivePane::Conversation;
|
|
|
|
// Input area: compute visual height, split, render gutter + textarea
|
|
let input_text = self.textarea.lines().join("\n");
|
|
let input_para_measure = Paragraph::new(input_text).wrap(Wrap { trim: false });
|
|
let input_line_count = (input_para_measure.line_count(conv_area.width.saturating_sub(5)) as u16)
|
|
.max(1)
|
|
.min(5);
|
|
|
|
let conv_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(1), // conversation text
|
|
Constraint::Length(input_line_count), // input area
|
|
])
|
|
.split(conv_area);
|
|
|
|
let text_area_rect = conv_chunks[0];
|
|
let input_area = conv_chunks[1];
|
|
|
|
draw_conversation_pane(frame, text_area_rect, &mut self.conversation, conv_active);
|
|
|
|
// " > " gutter + textarea, aligned with conversation messages
|
|
let input_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Length(3), // " > " gutter
|
|
Constraint::Min(1), // textarea
|
|
])
|
|
.split(input_area);
|
|
|
|
let gutter = Paragraph::new(Line::styled(
|
|
" > ",
|
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
|
));
|
|
frame.render_widget(gutter, input_chunks[0]);
|
|
frame.render_widget(&self.textarea, input_chunks[1]);
|
|
|
|
// Draw active tools overlay
|
|
if !active_tools.is_empty() {
|
|
let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM);
|
|
let tool_text: Vec<Line> = active_tools.iter().map(|t| {
|
|
let elapsed = t.started.elapsed().as_secs();
|
|
let line = if t.detail.is_empty() {
|
|
format!(" [{}] ({}s)", t.name, elapsed)
|
|
} else {
|
|
format!(" [{}] {} ({}s)", t.name, t.detail, elapsed)
|
|
};
|
|
Line::styled(line, tool_style)
|
|
}).collect();
|
|
let tool_para = Paragraph::new(tool_text);
|
|
frame.render_widget(tool_para, tools_overlay_area);
|
|
}
|
|
|
|
// Draw status bar with live activity indicator
|
|
let timer = if !self.activity.is_empty() {
|
|
let total = self.turn_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
|
let call = self.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
|
format!(" {}s, {}/{}s", total, call, self.call_timeout_secs)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let tools_info = if self.status.turn_tools > 0 {
|
|
format!(" ({}t)", self.status.turn_tools)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let activity_part = if self.activity.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" | {}{}{}", self.activity, tools_info, timer)
|
|
};
|
|
|
|
let budget_part = if self.status.context_budget.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" [{}]", self.status.context_budget)
|
|
};
|
|
|
|
let left_status = format!(
|
|
" {} | {}/{} dmn | {}K tok in{}{}",
|
|
self.status.dmn_state,
|
|
self.status.dmn_turns,
|
|
self.status.dmn_max_turns,
|
|
self.status.prompt_tokens / 1000,
|
|
budget_part,
|
|
activity_part,
|
|
);
|
|
|
|
let proc_indicator = if self.running_processes > 0 {
|
|
format!(" {}proc", self.running_processes)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let reason_indicator = if self.reasoning_effort != "none" {
|
|
format!(" reason:{}", self.reasoning_effort)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let right_legend = format!(
|
|
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
|
|
reason_indicator,
|
|
proc_indicator,
|
|
self.status.model,
|
|
);
|
|
|
|
// Pad the middle to fill the status bar
|
|
let total_width = status_area.width as usize;
|
|
let used = left_status.len() + right_legend.len();
|
|
let padding = if total_width > used {
|
|
" ".repeat(total_width - used)
|
|
} else {
|
|
" ".to_string()
|
|
};
|
|
|
|
let status = Paragraph::new(Line::from(vec![
|
|
Span::styled(&left_status, Style::default().fg(Color::White).bg(Color::DarkGray)),
|
|
Span::styled(padding, Style::default().bg(Color::DarkGray)),
|
|
Span::styled(
|
|
right_legend,
|
|
Style::default().fg(Color::DarkGray).bg(Color::Gray),
|
|
),
|
|
]));
|
|
|
|
frame.render_widget(status, status_area);
|
|
}
|
|
}
|
|
|
|
/// Draw the conversation pane with a two-column layout: marker gutter + text.
|
|
/// The gutter shows a marker at turn boundaries, aligned with the input gutter.
|
|
fn draw_conversation_pane(
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
pane: &mut PaneState,
|
|
is_active: bool,
|
|
) {
|
|
let border_style = if is_active {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
let block = Block::default()
|
|
.title(" conversation ")
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style);
|
|
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.width < 5 || inner.height == 0 {
|
|
return;
|
|
}
|
|
|
|
// Split inner area into gutter (2 chars) + text
|
|
let cols = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Length(2),
|
|
Constraint::Min(1),
|
|
])
|
|
.split(inner);
|
|
|
|
let gutter_area = cols[0];
|
|
let text_area = cols[1];
|
|
|
|
// Get lines and markers
|
|
let (lines, markers) = pane.all_lines_with_markers();
|
|
let text_width = text_area.width;
|
|
|
|
// Compute visual row for each logical line (accounting for word wrap)
|
|
let mut visual_rows: Vec<u16> = Vec::with_capacity(lines.len());
|
|
let mut cumulative: u16 = 0;
|
|
for line in &lines {
|
|
visual_rows.push(cumulative);
|
|
let para = Paragraph::new(line.clone()).wrap(Wrap { trim: false });
|
|
let height = para.line_count(text_width) as u16;
|
|
cumulative += height.max(1);
|
|
}
|
|
let total_visual = cumulative;
|
|
|
|
pane.last_total_lines = total_visual;
|
|
pane.last_height = inner.height;
|
|
|
|
if !pane.pinned {
|
|
pane.scroll = total_visual.saturating_sub(inner.height);
|
|
}
|
|
|
|
// Render text column
|
|
let text_para = Paragraph::new(lines.clone())
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((pane.scroll, 0));
|
|
frame.render_widget(text_para, text_area);
|
|
|
|
// Render gutter markers at the correct visual rows
|
|
let mut gutter_lines: Vec<Line<'static>> = Vec::new();
|
|
let mut next_visual = 0u16;
|
|
for (i, &marker) in markers.iter().enumerate() {
|
|
let row = visual_rows[i];
|
|
// Fill blank lines up to this marker's row
|
|
while next_visual < row {
|
|
gutter_lines.push(Line::raw(""));
|
|
next_visual += 1;
|
|
}
|
|
let marker_text = match marker {
|
|
Marker::User => Line::styled("● ", Style::default().fg(Color::Cyan)),
|
|
Marker::Assistant => Line::styled("● ", Style::default().fg(Color::Magenta)),
|
|
Marker::None => Line::raw(""),
|
|
};
|
|
gutter_lines.push(marker_text);
|
|
next_visual = row + 1;
|
|
|
|
// Fill remaining visual lines for this logical line (wrap continuation)
|
|
let para = Paragraph::new(lines[i].clone()).wrap(Wrap { trim: false });
|
|
let height = para.line_count(text_width) as u16;
|
|
for _ in 1..height.max(1) {
|
|
gutter_lines.push(Line::raw(""));
|
|
next_visual += 1;
|
|
}
|
|
}
|
|
|
|
let gutter_para = Paragraph::new(gutter_lines)
|
|
.scroll((pane.scroll, 0));
|
|
frame.render_widget(gutter_para, gutter_area);
|
|
}
|
|
|
|
/// Draw a scrollable text pane (free function to avoid borrow issues).
|
|
fn draw_pane(
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
title: &str,
|
|
pane: &mut PaneState,
|
|
is_active: bool,
|
|
left_title: Option<&str>,
|
|
) {
|
|
let inner_height = area.height.saturating_sub(2);
|
|
|
|
let border_style = if is_active {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
let mut block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style);
|
|
if let Some(left) = left_title {
|
|
block = block
|
|
.title_top(Line::from(left).left_aligned())
|
|
.title_top(Line::from(format!(" {} ", title)).right_aligned());
|
|
} else {
|
|
block = block.title(format!(" {} ", title));
|
|
}
|
|
|
|
let lines = pane.all_lines();
|
|
let paragraph = Paragraph::new(lines)
|
|
.block(block.clone())
|
|
.wrap(Wrap { trim: false });
|
|
|
|
// Let ratatui tell us the total visual lines — no homegrown wrapping math.
|
|
let total = paragraph.line_count(area.width.saturating_sub(2)) as u16;
|
|
pane.last_total_lines = total;
|
|
pane.last_height = inner_height;
|
|
|
|
if !pane.pinned {
|
|
pane.scroll = total.saturating_sub(inner_height);
|
|
}
|
|
|
|
let paragraph = paragraph.scroll((pane.scroll, 0));
|
|
frame.render_widget(paragraph, area);
|
|
}
|