// 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 ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}; use super::{App, ScreenView, screen_legend}; use crate::agent::context::{AstNode, NodeBody, Role, Ast}; use crate::mind::MindCommand; // --- Slash command table --- type CmdHandler = fn(&InteractScreen, &str); struct SlashCommand { name: &'static str, help: &'static str, handler: CmdHandler, } fn commands() -> Vec { vec![ SlashCommand { name: "/quit", help: "Exit consciousness", handler: |_, _| {} }, SlashCommand { name: "/new", help: "Start fresh session (saves current)", handler: |s, _| { let _ = s.mind_tx.send(MindCommand::NewSession); } }, SlashCommand { name: "/save", help: "Save session to disk", handler: |s, _| { if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("saved"); } } }, SlashCommand { name: "/model", help: "Show/switch model (/model )", handler: |s, arg| { if arg.is_empty() { if let Ok(mut ag) = s.agent.state.try_lock() { let names = s.agent.app_config.model_names(); let label = if names.is_empty() { format!("model: {}", s.agent.model()) } else { format!("model: {} ({})", s.agent.model(), names.join(", ")) }; ag.notify(label); } } else { let agent = s.agent.clone(); let name = arg.to_string(); tokio::spawn(async move { let _act = crate::agent::start_activity(&agent, format!("switching to {}...", name)).await; cmd_switch_model(&agent, &name).await; }); } } }, SlashCommand { name: "/score", help: "Score memory importance (full matrix)", handler: |s, _| { let _ = s.mind_tx.send(MindCommand::ScoreFull); } }, SlashCommand { name: "/dmn", help: "Show DMN state", handler: |s, _| { let st = s.shared_mind.lock().unwrap(); if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify(format!("DMN: {:?} ({}/{})", st.dmn, st.dmn_turns, st.max_dmn_turns)); } } }, SlashCommand { name: "/sleep", help: "Put DMN to sleep", handler: |s, _| { let mut st = s.shared_mind.lock().unwrap(); st.dmn = crate::mind::subconscious::State::Resting { since: std::time::Instant::now() }; st.dmn_turns = 0; if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN sleeping"); } } }, SlashCommand { name: "/wake", help: "Wake DMN to foraging", handler: |s, _| { let mut st = s.shared_mind.lock().unwrap(); if matches!(st.dmn, crate::mind::subconscious::State::Off) { crate::mind::subconscious::set_off(false); } st.dmn = crate::mind::subconscious::State::Foraging; st.dmn_turns = 0; if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN foraging"); } } }, SlashCommand { name: "/pause", help: "Full stop — no autonomous ticks (Ctrl+P)", handler: |s, _| { let mut st = s.shared_mind.lock().unwrap(); st.dmn = crate::mind::subconscious::State::Paused; st.dmn_turns = 0; if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN paused"); } } }, SlashCommand { name: "/help", help: "Show this help", handler: |s, _| { notify_help(&s.agent); } }, ]} fn dispatch_command(input: &str) -> Option { let cmd_name = input.split_whitespace().next()?; commands().into_iter().find(|c| c.name == cmd_name) } /// Switch model — used by both /model command and tool-initiated switches. pub async fn cmd_switch_model( agent: &std::sync::Arc, name: &str, ) { let resolved = match agent.app_config.resolve_model(name) { Ok(r) => r, Err(e) => { agent.state.lock().await.notify(format!("model error: {}", e)); return; } }; let _new_client = crate::agent::api::ApiClient::new( &resolved.api_base, &resolved.api_key, &resolved.model_id, ); let prompt_changed = resolved.prompt_file != agent.prompt_file; if prompt_changed { agent.compact().await; agent.state.lock().await.notify(format!("switched to {} (recompacted)", resolved.model_id)); } else { agent.state.lock().await.notify(format!("switched to {}", resolved.model_id)); } } fn notify_help(agent: &std::sync::Arc) { if let Ok(mut ag) = agent.state.try_lock() { let mut help = String::new(); for cmd in &commands() { help.push_str(&format!("{:12} {}\n", cmd.name, cmd.help)); } help.push_str("Keys: Tab ^Up/Down PgUp/Down Mouse Esc ^P ^R ^K"); ag.notify(help); } } /// Turn marker for the conversation pane gutter. #[derive(Clone, Copy, PartialEq, Default)] enum Marker { #[default] None, User, Assistant, } impl Marker { fn gutter_span(self) -> Option> { match self { Marker::User => Some(Span::styled("● ", Style::default().fg(Color::Cyan))), Marker::Assistant => Some(Span::styled("● ", Style::default().fg(Color::Magenta))), Marker::None => None, } } } /// A line paired with a gutter marker, for use with ScrollPane. struct MarkedLine { line: Line<'static>, marker: Marker, } impl super::scroll_pane::ScrollItem for MarkedLine { fn content(&self) -> ratatui::text::Text<'_> { ratatui::text::Text::from(self.line.clone()) } fn gutter(&self) -> Option> { self.marker.gutter_span() } } #[derive(PartialEq)] enum PaneTarget { Conversation, ConversationAssistant, Tools, ToolResult, } const MAX_PANE_LINES: usize = 10_000; /// Which pane receives scroll keys. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ActivePane { Autonomous, Conversation, Tools, } /// Text selection within a pane. Anchor is where the click started, /// cursor is where the mouse currently is. They may be in either order. #[derive(Debug, Clone, PartialEq, Default)] struct Selection { anchor_line: usize, anchor_col: usize, cursor_line: usize, cursor_col: usize, } impl Selection { fn new(line: usize, col: usize) -> Self { Self { anchor_line: line, anchor_col: col, cursor_line: line, cursor_col: col } } fn extend(&mut self, line: usize, col: usize) { self.cursor_line = line; self.cursor_col = col; } /// Normalized range: (start_line, start_col, end_line, end_col) fn range(&self) -> (usize, usize, usize, usize) { if (self.anchor_line, self.anchor_col) <= (self.cursor_line, self.cursor_col) { (self.anchor_line, self.anchor_col, self.cursor_line, self.cursor_col) } else { (self.cursor_line, self.cursor_col, self.anchor_line, self.anchor_col) } } fn text(&self, lines: &[Line<'static>]) -> String { let (start_line, start_col, end_line, end_col) = self.range(); let mut result = String::new(); for (i, line) in lines.iter().enumerate() { if i < start_line || i > end_line { continue; } let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); let sc = if i == start_line { start_col } else { 0 }; let ec = if i == end_line { end_col } else { line_text.len() }; if sc < line_text.len() { if let Some(selected) = line_text.get(sc..ec.min(line_text.len())) { if !result.is_empty() { result.push('\n'); } result.push_str(selected); } } } result } } fn strip_ansi(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\x1b' { if chars.peek() == Some(&'[') { chars.next(); while let Some(&c) = chars.peek() { if c.is_ascii() && (0x20..=0x3F).contains(&(c as u8)) { chars.next(); } else { break; } } if let Some(&c) = chars.peek() { if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) { chars.next(); } } } else if let Some(&c) = chars.peek() { if c.is_ascii() && (0x40..=0x5F).contains(&(c as u8)) { chars.next(); } } } else { out.push(ch); } } out } fn new_textarea(lines: Vec) -> tui_textarea::TextArea<'static> { let mut ta = tui_textarea::TextArea::new(lines); ta.set_cursor_line_style(Style::default()); ta.set_wrap_mode(tui_textarea::WrapMode::Word); ta } fn parse_markdown(md: &str) -> Vec> { tui_markdown::from_str(md) .lines .into_iter() .map(|line| { let spans: Vec> = line.spans.into_iter() .map(|span| Span::styled(span.content.into_owned(), span.style)) .collect(); let mut result = Line::from(spans).style(line.style); result.alignment = line.alignment; result }) .collect() } struct PaneState { lines: Vec>, markers: Vec, current_line: String, current_color: Color, md_buffer: String, use_markdown: bool, pending_marker: Marker, scroll: super::scroll_pane::ScrollPaneState, selection: Option, } impl PaneState { fn new(use_markdown: bool) -> Self { Self { lines: Vec::new(), markers: Vec::new(), current_line: String::new(), current_color: Color::Reset, md_buffer: String::new(), use_markdown, pending_marker: Marker::None, scroll: super::scroll_pane::ScrollPaneState::new(), selection: None, } } fn evict(&mut self) { if self.lines.len() > MAX_PANE_LINES { let excess = self.lines.len() - MAX_PANE_LINES; self.lines.drain(..excess); self.markers.drain(..excess); self.scroll.invalidate(); } } fn flush_pending(&mut self) { if self.use_markdown && !self.md_buffer.is_empty() { let parsed = parse_markdown(&self.md_buffer); for (i, line) in parsed.into_iter().enumerate() { let marker = if i == 0 { std::mem::take(&mut self.pending_marker) } else { Marker::None }; self.lines.push(line); self.markers.push(marker); } self.md_buffer.clear(); } if !self.current_line.is_empty() { let line = std::mem::take(&mut self.current_line); self.lines.push(Line::styled(line, Style::default().fg(self.current_color))); self.markers.push(std::mem::take(&mut self.pending_marker)); } } fn push_line(&mut self, line: String, color: Color) { self.push_line_with_marker(line, color, Marker::None); } fn append_text(&mut self, text: &str) { let clean = strip_ansi(text); if self.use_markdown { self.md_buffer.push_str(&clean); } else { for ch in clean.chars() { if ch == '\n' { let line = std::mem::take(&mut self.current_line); self.lines.push(Line::styled(line, Style::default().fg(self.current_color))); self.markers.push(Marker::None); self.evict(); } else { self.current_line.push(ch); } } } } fn push_line_with_marker(&mut self, line: String, color: Color, marker: Marker) { self.flush_pending(); self.lines.push(Line::styled(strip_ansi(&line), Style::default().fg(color))); self.markers.push(marker); self.evict(); } fn pop_line(&mut self) { self.lines.pop(); self.markers.pop(); self.scroll.invalidate_from(self.lines.len()); } fn scroll_up(&mut self, n: u16) { self.scroll.scroll_up(n); } fn scroll_down(&mut self, n: u16) { self.scroll.scroll_down(n); } fn all_lines(&self) -> Vec> { let (lines, _) = self.all_lines_with_markers(); lines } fn all_lines_with_markers(&self) -> (Vec>, Vec) { let mut lines: Vec> = self.lines.clone(); let mut markers: Vec = self.markers.clone(); if self.use_markdown && !self.md_buffer.is_empty() { let parsed = parse_markdown(&self.md_buffer); let count = parsed.len(); lines.extend(parsed); if count > 0 { markers.push(self.pending_marker); markers.extend(std::iter::repeat(Marker::None).take(count - 1)); } } else if !self.current_line.is_empty() { lines.push(Line::styled(self.current_line.clone(), Style::default().fg(self.current_color))); markers.push(self.pending_marker); } (lines, markers) } /// Convert mouse coordinates (relative to pane) to line/column position. fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16) -> Option<(usize, usize)> { let (lines, _) = self.all_lines_with_markers(); self.scroll.screen_to_item(mouse_x, mouse_y, &lines) } /// Set the selection start position. fn start_selection(&mut self, line: usize, col: usize) { self.selection = Some(Selection::new(line, col)); } /// Update the selection end position. fn extend_selection(&mut self, line: usize, col: usize) { if let Some(ref mut sel) = self.selection { sel.extend(line, col); } } /// Get the selected text, or None if nothing is selected. fn get_selection(&self) -> Option { let (lines, _) = self.all_lines_with_markers(); self.selection.as_ref().map(|sel| sel.text(&lines)) } } pub(crate) struct InteractScreen { autonomous: PaneState, conversation: PaneState, tools: PaneState, textarea: tui_textarea::TextArea<'static>, input_history: Vec, history_index: Option, active_pane: ActivePane, pane_areas: [Rect; 3], // State sync with agent — double buffer last_generation: u64, last_entries: Vec, pending_display_count: usize, /// Reference to agent for state sync agent: std::sync::Arc, shared_mind: std::sync::Arc, mind_tx: tokio::sync::mpsc::UnboundedSender, } impl InteractScreen { pub fn new( agent: std::sync::Arc, shared_mind: std::sync::Arc, mind_tx: tokio::sync::mpsc::UnboundedSender, ) -> 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], last_generation: 0, last_entries: Vec::new(), pending_display_count: 0, agent, shared_mind, mind_tx, } } fn route_node(node: &AstNode) -> Vec<(PaneTarget, String, Marker)> { match node { AstNode::Leaf(leaf) => { let text = leaf.body().text().to_string(); match leaf.body() { NodeBody::Memory { .. } | NodeBody::Thinking(_) | NodeBody::Log(_) | NodeBody::Dmn(_) => vec![], NodeBody::Content(_) => { if text.is_empty() || text.starts_with("") { vec![] } else { vec![(PaneTarget::Conversation, text, Marker::User)] } } NodeBody::ToolCall { name, arguments } => { let line = format!("[{}] {}", name, arguments.chars().take(80).collect::()); vec![(PaneTarget::Tools, line, Marker::None)] } NodeBody::ToolResult(t) => { if t.is_empty() { vec![] } else { vec![(PaneTarget::ToolResult, text, Marker::None)] } } } } AstNode::Branch { role, children, .. } => { match role { Role::User => { let text: String = children.iter() .filter_map(|c| c.leaf()) .filter(|l| matches!(l.body(), NodeBody::Content(_))) .map(|l| l.body().text()) .collect::>() .join(""); if text.is_empty() || text.starts_with("") { vec![] } else { vec![(PaneTarget::Conversation, text, Marker::User)] } } Role::Assistant => { let mut items = Vec::new(); for child in children { items.extend(Self::route_node(child)); } // Re-tag content as assistant for item in &mut items { if item.0 == PaneTarget::Conversation { item.0 = PaneTarget::ConversationAssistant; item.2 = Marker::Assistant; } } items } Role::System => vec![], } } } } fn push_routed(&mut self, node: &AstNode) { for (target, text, marker) in Self::route_node(node) { match target { PaneTarget::Conversation => { self.conversation.current_color = Color::Cyan; self.conversation.append_text(&text); self.conversation.pending_marker = marker; self.conversation.flush_pending(); }, PaneTarget::ConversationAssistant => { self.conversation.current_color = Color::Reset; self.conversation.append_text(&text); self.conversation.pending_marker = marker; self.conversation.flush_pending(); }, 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); } } } } } fn pop_routed(&mut self, node: &AstNode) { for (target, _, _) in Self::route_node(node) { match target { PaneTarget::Conversation | PaneTarget::ConversationAssistant => self.conversation.pop_line(), PaneTarget::Tools | PaneTarget::ToolResult => self.tools.pop_line(), } } } fn sync_from_agent(&mut self) { for _ in 0..self.pending_display_count { self.conversation.pop_line(); } self.pending_display_count = 0; let (generation, entries) = { let st = match self.agent.state.try_lock() { Ok(st) => st, Err(_) => return, }; let generation = st.generation; drop(st); let ctx = match self.agent.context.try_lock() { Ok(ctx) => ctx, Err(_) => return, }; (generation, ctx.conversation().to_vec()) }; // Full reset on generation change if generation != self.last_generation { self.conversation = PaneState::new(true); self.autonomous = PaneState::new(true); self.tools = PaneState::new(false); self.last_entries.clear(); } // Detect changed entries (streaming updates mutate the last entry) // Walk backwards from the end, pop any that differ let mut pop_from = self.last_entries.len(); for i in (0..self.last_entries.len()).rev() { if i >= entries.len() { pop_from = i; continue; } // Compare token count as a cheap change detector if self.last_entries[i].tokens() != entries[i].tokens() { pop_from = i; } else { break; // entries before this haven't changed } } while self.last_entries.len() > pop_from { let popped = self.last_entries.pop().unwrap(); self.pop_routed(&popped); } // Push new/changed entries for node in entries.iter().skip(self.last_entries.len()) { self.push_routed(node); self.last_entries.push(node.clone()); } self.last_generation = generation; // Display pending input (queued in Mind, not yet accepted) let mind = self.shared_mind.lock().unwrap(); for input in &mind.input { self.conversation.push_line_with_marker( input.clone(), Color::DarkGray, Marker::User, ); self.pending_display_count += 1; } } /// Dispatch user input — slash commands or conversation. fn dispatch_input(&self, input: &str, app: &mut App) { let input = input.trim(); if input.is_empty() { return; } if input == "/quit" || input == "/exit" { app.should_quit = true; return; } if input.starts_with('/') { if let Some(cmd) = dispatch_command(input) { (cmd.handler)(self, &input[cmd.name.len()..].trim_start()); } else { if let Ok(mut ag) = self.agent.state.try_lock() { ag.notify(format!("unknown: {}", input.split_whitespace().next().unwrap_or(input))); } } return; } // Regular input → queue to Mind, then wake it self.shared_mind.lock().unwrap().input.push(input.to_string()); let _ = self.mind_tx.send(MindCommand::None); } 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), } } 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 }; let rel_x = x.saturating_sub(area.x); let rel_y = y.saturating_sub(area.y); self.selection_event(i, rel_x, rel_y, true); break; } } } MouseEventKind::Drag(MouseButton::Left) => { let (x, y) = (mouse.column, mouse.row); let i = match self.active_pane { ActivePane::Autonomous => 0, ActivePane::Conversation => 1, ActivePane::Tools => 2 }; let area = self.pane_areas[i]; if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height { let rel_x = x.saturating_sub(area.x); let rel_y = y.saturating_sub(area.y); self.selection_event(i, rel_x, rel_y, false); } } MouseEventKind::Up(MouseButton::Left) => { self.copy_selection_to_clipboard(); } MouseEventKind::Down(MouseButton::Middle) => { self.paste_from_selection(); } _ => {} } } /// Copy the current selection to the clipboard via OSC 52. fn copy_selection_to_clipboard(&self) { let text = match self.active_pane { ActivePane::Autonomous => self.autonomous.get_selection(), ActivePane::Conversation => self.conversation.get_selection(), ActivePane::Tools => self.tools.get_selection(), }; if let Some(ref selected_text) = text { if selected_text.is_empty() { return; } // OSC 52 clipboard copy use std::io::Write; use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(selected_text); let mut stdout = std::io::stdout().lock(); let _ = write!(stdout, "\x1b]52;c;{}\x07", encoded); let _ = stdout.flush(); } } /// Paste from tmux buffer via middle-click. fn paste_from_selection(&mut self) { let result = std::process::Command::new("tmux") .args(["save-buffer", "-"]).output(); if let Ok(output) = result { if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).into_owned(); if !text.is_empty() { self.textarea.insert_str(&text); } } } } fn pane_mut(&mut self, idx: usize) -> &mut PaneState { match idx { 0 => &mut self.autonomous, 1 => &mut self.conversation, _ => &mut self.tools } } fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) { let pane = self.pane_mut(pane_idx); if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y) { if start { pane.start_selection(line, col); } else { pane.extend_selection(line, col); } } self.copy_selection_to_clipboard(); } /// Draw the main (F1) screen — four-pane layout with status bar. fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) { // Main layout: content area + active tools overlay + status bar let st_guard = app.agent.state.try_lock().ok(); let tool_lines = st_guard.as_ref() .map(|st| st.active_tools.len() as u16).unwrap_or(0); 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]); if let Some(ref st) = st_guard { if !st.active_tools.is_empty() { let tool_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::DIM); let tool_text: Vec = st.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 elapsed = app.activity_started.map(|t| t.elapsed().as_secs()).unwrap_or(0); format!(" {}s", elapsed) } 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, events: &[ratatui::crossterm::event::Event], app: &mut App) { use ratatui::crossterm::event::Event; // Handle events for event in events { match event { Event::Key(key) if key.kind == ratatui::crossterm::event::KeyEventKind::Press => { match key.code { KeyCode::Esc => { let _ = self.mind_tx.send(crate::mind::MindCommand::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; self.dispatch_input(&input, app); self.textarea = new_textarea(vec![String::new()]); } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) => { // Ctrl+Shift+C: copy selection self.copy_selection_to_clipboard(); } // Paste: terminal handles Ctrl+Shift+V natively via bracketed paste 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); } } } Event::Mouse(mouse) => { self.handle_mouse(*mouse); } Event::Paste(text) => { self.textarea.insert_str(text); } _ => {} } } // Sync state from agent self.sync_from_agent(); // Read status from agent + mind state if let Ok(mut st) = self.agent.state.try_lock() { st.expire_activities(); app.status.prompt_tokens = st.last_prompt_tokens; app.status.model = self.agent.model().to_string(); app.activity = st.activities.last() .map(|a| a.label.clone()) .unwrap_or_default(); app.activity_started = st.activities.last() .map(|a| a.started); } if let Ok(ctx) = self.agent.context.try_lock() { let window = crate::agent::context::context_window(); if window > 0 { let sys = ctx.system().iter().map(|n| n.tokens()).sum::(); let id = ctx.identity().iter().map(|n| n.tokens()).sum::(); let jnl = ctx.journal().iter().map(|n| n.tokens()).sum::(); let mut mem = 0usize; let mut conv = 0usize; for n in ctx.conversation() { let t = n.tokens(); if matches!(n, AstNode::Leaf(l) if matches!(l.body(), NodeBody::Memory { .. })) { mem += t; } else { conv += t; } } let used = sys + id + jnl + mem + conv; let free = window.saturating_sub(used); let pct = |n: usize| if n == 0 { 0 } else { ((n * 100) / window).max(1) }; app.status.context_budget = format!( "sys:{}% id:{}% jnl:{}% mem:{}% conv:{}% free:{}%", pct(sys), pct(id), pct(jnl), pct(mem), pct(conv), pct(free), ); } } { let mind = self.shared_mind.lock().unwrap(); app.status.dmn_state = mind.dmn.label().to_string(); app.status.dmn_turns = mind.dmn_turns; app.status.dmn_max_turns = mind.max_dmn_turns; } // Draw self.draw_main(frame, area, app); } } /// 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, ) { use super::scroll_pane::ScrollPane; 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 (lines, markers) = pane.all_lines_with_markers(); // Apply selection highlighting let items: Vec = if let Some(ref sel) = pane.selection { let (sl, sc, el, ec) = sel.range(); lines.into_iter().zip(markers).enumerate().map(|(i, (line, marker))| { let line = if i >= sl && i <= el { let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); let start_col = if i == sl { sc } else { 0 }; let end_col = if i == el { ec } else { line_text.len() }; if start_col < end_col { let before = if start_col > 0 { &line_text[..start_col] } else { "" }; let selected = &line_text[start_col..end_col]; let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" }; let mut spans = Vec::new(); if !before.is_empty() { spans.push(Span::raw(before.to_string())); } spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White))); if !after.is_empty() { spans.push(Span::raw(after.to_string())); } Line::from(spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left)) } else { line } } else { line }; MarkedLine { line, marker } }).collect() } else { lines.into_iter().zip(markers).map(|(line, marker)| MarkedLine { line, marker }).collect() }; let widget = ScrollPane::new(&items) .block(block) .gutter_width(2) .pin_to_bottom(true); frame.render_stateful_widget(widget, area, &mut pane.scroll); } /// 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>, ) { use super::scroll_pane::ScrollPane; 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 widget = ScrollPane::new(&lines) .block(block) .pin_to_bottom(true); frame.render_stateful_widget(widget, area, &mut pane.scroll); }