split out src/mind
This commit is contained in:
parent
ce04568454
commit
79e384f005
21 changed files with 1865 additions and 2175 deletions
|
|
@ -1,342 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue