consciousness/src/user/chat.rs

663 lines
25 KiB
Rust
Raw Normal View History

// 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,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
};
use super::{
ActivePane, App, HotkeyAction, Marker, PaneState, ScreenAction, ScreenView,
new_textarea, screen_legend,
};
use crate::user::ui_channel::{UiMessage, StreamTarget};
enum PaneTarget {
Conversation,
ConversationAssistant,
Tools,
ToolResult,
}
pub(crate) struct InteractScreen {
pub(crate) autonomous: PaneState,
pub(crate) conversation: PaneState,
pub(crate) tools: PaneState,
pub(crate) textarea: tui_textarea::TextArea<'static>,
pub(crate) input_history: Vec<String>,
pub(crate) history_index: Option<usize>,
pub(crate) active_pane: ActivePane,
pub(crate) pane_areas: [Rect; 3],
pub(crate) needs_assistant_marker: bool,
pub(crate) turn_started: Option<std::time::Instant>,
pub(crate) call_started: Option<std::time::Instant>,
pub(crate) call_timeout_secs: u64,
// State sync with agent — double buffer
last_generation: u64,
last_entries: Vec<crate::agent::context::ConversationEntry>,
/// Reference to agent for state sync
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
}
impl InteractScreen {
pub fn new(agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) -> Self {
Self {
autonomous: PaneState::new(true),
conversation: PaneState::new(true),
tools: PaneState::new(false),
textarea: new_textarea(vec![String::new()]),
input_history: Vec::new(),
history_index: None,
active_pane: ActivePane::Conversation,
pane_areas: [Rect::default(); 3],
needs_assistant_marker: false,
turn_started: None,
call_started: None,
call_timeout_secs: 60,
last_generation: 0,
last_entries: Vec::new(),
agent,
}
}
/// Route an agent entry to the appropriate pane.
/// Returns None for entries that shouldn't be displayed (memory, system).
fn route_entry(&mut self, entry: &crate::agent::context::ConversationEntry) -> Option<&mut PaneState> {
use crate::agent::api::types::Role;
use crate::agent::context::ConversationEntry;
if let ConversationEntry::Memory { .. } = entry {
return None;
}
let msg = entry.message();
let text = msg.content_text().to_string();
match msg.role {
if text.is_empty() { return None; }
if text.starts_with("<system-reminder>") { return None; }
Role::User => Some(&mut self.conversation),
Role::Assistant => {
// Tool calls → tools pane
if let Some(ref calls) = msg.tool_calls {
for call in calls {
let line = format!("[{}] {}",
call.function.name,
call.function.arguments.chars().take(80).collect::<String>());
// TODO: return multiple targets — for now just return first tool call
return Some((PaneTarget::Tools, line, Marker::None));
}
}
Some((PaneTarget::ConversationAssistant, text, Marker::Assistant))
}
Role::Tool => Some(&mut self.tools),
Role::System => None,
}
}
/// Sync conversation display from agent entries.
fn sync_from_agent(&mut self) {
let agent = self.agent.blocking_lock();
let gen = agent.generation;
let entries = agent.entries();
// Phase 1: detect desync and pop
if gen != self.last_generation {
self.conversation = PaneState::new(true);
self.autonomous = PaneState::new(true);
self.tools = PaneState::new(false);
self.last_entries.clear();
} else {
// Pop entries from the tail that don't match
while !self.last_entries.is_empty() {
let i = self.last_entries.len() - 1;
if entries.get(i) == Some(&self.last_entries[i]) {
break;
}
let popped = self.last_entries.pop().unwrap();
if let Some((target, _, _)) = Self::route_entry(&popped) {
match target {
PaneTarget::Conversation | PaneTarget::ConversationAssistant
=> self.conversation.pop_line(),
PaneTarget::Tools | PaneTarget::ToolResult
=> self.tools.pop_line(),
}
}
}
}
// Phase 2: push new entries
let start = self.last_entries.len();
for entry in entries.iter().skip(start) {
if let Some((target, text, marker)) = Self::route_entry(entry) {
match target {
PaneTarget::Conversation => {
self.conversation.push_line_with_marker(text, Color::Cyan, marker);
}
PaneTarget::ConversationAssistant => {
self.conversation.push_line_with_marker(text, Color::Reset, marker);
}
PaneTarget::Tools => {
self.tools.push_line(text, Color::Yellow);
}
PaneTarget::ToolResult => {
for line in text.lines().take(20) {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
}
}
}
self.last_entries.push(entry.clone());
}
self.last_generation = gen;
}
/// Process a UiMessage — update pane state.
pub fn handle_ui_message(&mut self, msg: &UiMessage, app: &mut App) {
match msg {
UiMessage::TextDelta(text, target) => match target {
StreamTarget::Conversation => {
if self.needs_assistant_marker {
self.conversation.pending_marker = Marker::Assistant;
self.needs_assistant_marker = false;
}
self.conversation.current_color = Color::Reset;
self.conversation.append_text(text);
}
StreamTarget::Autonomous => {
self.autonomous.current_color = Color::Reset;
self.autonomous.append_text(text);
}
},
UiMessage::UserInput(text) => {
self.conversation.push_line_with_marker(text.clone(), Color::Cyan, Marker::User);
self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
app.status.turn_tools = 0;
}
UiMessage::ToolCall { name, args_summary } => {
app.status.turn_tools += 1;
let line = if args_summary.is_empty() { format!("[{}]", name) }
else { format!("[{}] {}", name, args_summary) };
self.tools.push_line(line, Color::Yellow);
}
UiMessage::ToolResult { result, .. } => {
for line in result.lines() {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
self.tools.push_line(String::new(), Color::Reset);
}
UiMessage::DmnAnnotation(text) => {
self.autonomous.push_line(text.clone(), Color::Yellow);
self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
app.status.turn_tools = 0;
}
UiMessage::StatusUpdate(info) => {
if !info.dmn_state.is_empty() {
app.status.dmn_state = info.dmn_state.clone();
app.status.dmn_turns = info.dmn_turns;
app.status.dmn_max_turns = info.dmn_max_turns;
}
if info.prompt_tokens > 0 { app.status.prompt_tokens = info.prompt_tokens; }
if !info.model.is_empty() { app.status.model = info.model.clone(); }
if !info.context_budget.is_empty() { app.status.context_budget = info.context_budget.clone(); }
}
UiMessage::Activity(text) => {
if text.is_empty() {
self.call_started = None;
} else if app.activity.is_empty() || self.call_started.is_none() {
self.call_started = Some(std::time::Instant::now());
self.call_timeout_secs = crate::config::get().api_stream_timeout_secs;
}
app.activity = text.clone();
}
UiMessage::Reasoning(text) => {
self.autonomous.current_color = Color::DarkGray;
self.autonomous.append_text(text);
}
UiMessage::Debug(text) => {
self.tools.push_line(format!("[debug] {}", text), Color::DarkGray);
}
UiMessage::Info(text) => {
self.conversation.push_line(text.clone(), Color::Cyan);
}
UiMessage::ContextInfoUpdate(info) => { app.context_info = Some(info.clone()); }
UiMessage::AgentUpdate(agents) => { app.agent_state = agents.clone(); }
_ => {}
}
}
fn scroll_active_up(&mut self, n: u16) {
match self.active_pane {
ActivePane::Autonomous => self.autonomous.scroll_up(n),
ActivePane::Conversation => self.conversation.scroll_up(n),
ActivePane::Tools => self.tools.scroll_up(n),
}
}
fn scroll_active_down(&mut self, n: u16) {
match self.active_pane {
ActivePane::Autonomous => self.autonomous.scroll_down(n),
ActivePane::Conversation => self.conversation.scroll_down(n),
ActivePane::Tools => self.tools.scroll_down(n),
}
}
pub fn handle_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_active_up(3),
MouseEventKind::ScrollDown => self.scroll_active_down(3),
MouseEventKind::Down(MouseButton::Left) => {
let (x, y) = (mouse.column, mouse.row);
for (i, area) in self.pane_areas.iter().enumerate() {
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
self.active_pane = match i { 0 => ActivePane::Autonomous, 1 => ActivePane::Conversation, _ => ActivePane::Tools };
break;
}
}
}
_ => {}
}
}
/// Draw the main (F1) screen — four-pane layout with status bar.
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
// Main layout: content area + active tools overlay + status bar
let active_tools = app.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 !app.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 app.status.turn_tools > 0 {
format!(" ({}t)", app.status.turn_tools)
} else {
String::new()
};
let activity_part = if app.activity.is_empty() {
String::new()
} else {
format!(" | {}{}{}", app.activity, tools_info, timer)
};
let budget_part = if app.status.context_budget.is_empty() {
String::new()
} else {
format!(" [{}]", app.status.context_budget)
};
let left_status = format!(
" {} | {}/{} dmn | {}K tok in{}{}",
app.status.dmn_state,
app.status.dmn_turns,
app.status.dmn_max_turns,
app.status.prompt_tokens / 1000,
budget_part,
activity_part,
);
let proc_indicator = if app.running_processes > 0 {
format!(" {}proc", app.running_processes)
} else {
String::new()
};
let reason_indicator = if app.reasoning_effort != "none" {
format!(" reason:{}", app.reasoning_effort)
} else {
String::new()
};
let right_legend = format!(
"{}{} ^P:pause ^R:reason ^K:kill | {} ",
reason_indicator,
proc_indicator,
app.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);
}
}
impl ScreenView for InteractScreen {
fn label(&self) -> &'static str { "interact" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
// Handle keys
if let Some(key) = key {
match key.code {
KeyCode::Esc => return Some(ScreenAction::Hotkey(HotkeyAction::Interrupt)),
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => {
let input: String = self.textarea.lines().join("\n");
if !input.is_empty() {
if self.input_history.last().map_or(true, |h| h != &input) {
self.input_history.push(input.clone());
}
self.history_index = None;
// TODO: push to submitted via app or return action
}
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_up(3),
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_down(3),
KeyCode::Up => {
if !self.input_history.is_empty() {
let idx = match self.history_index { None => self.input_history.len() - 1, Some(i) => i.saturating_sub(1) };
self.history_index = Some(idx);
let mut ta = new_textarea(self.input_history[idx].lines().map(String::from).collect());
ta.move_cursor(tui_textarea::CursorMove::End);
self.textarea = ta;
}
}
KeyCode::Down => {
if let Some(idx) = self.history_index {
if idx + 1 < self.input_history.len() {
self.history_index = Some(idx + 1);
let mut ta = new_textarea(self.input_history[idx + 1].lines().map(String::from).collect());
ta.move_cursor(tui_textarea::CursorMove::End);
self.textarea = ta;
} else {
self.history_index = None;
self.textarea = new_textarea(vec![String::new()]);
}
}
}
KeyCode::PageUp => self.scroll_active_up(10),
KeyCode::PageDown => self.scroll_active_down(10),
KeyCode::Tab => {
self.active_pane = match self.active_pane {
ActivePane::Autonomous => ActivePane::Tools,
ActivePane::Tools => ActivePane::Conversation,
ActivePane::Conversation => ActivePane::Autonomous,
};
}
_ => { self.textarea.input(key); }
}
}
// Sync state from agent
self.sync_from_agent();
// Draw
self.draw_main(frame, area, app);
None
}
}
/// 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);
}