diff --git a/src/user/chat.rs b/src/user/chat.rs index 247a8d7..5eabb7c 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -13,11 +13,20 @@ use ratatui::{ }; use super::{ - ActivePane, App, HotkeyAction, Marker, PaneState, ScreenAction, ScreenView, - new_textarea, screen_legend, + App, HotkeyAction, ScreenAction, ScreenView, + screen_legend, }; use crate::user::ui_channel::{UiMessage, StreamTarget}; +/// Turn marker for the conversation pane gutter. +#[derive(Clone, Copy, PartialEq, Default)] +enum Marker { + #[default] + None, + User, + Assistant, +} + enum PaneTarget { Conversation, ConversationAssistant, @@ -25,28 +34,223 @@ enum PaneTarget { ToolResult, } +const MAX_PANE_LINES: usize = 10_000; + +/// Which pane receives scroll keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActivePane { + Autonomous, + Conversation, + Tools, +} + +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 is_zero_width(ch: char) -> bool { + matches!(ch, + '\u{200B}'..='\u{200F}' | + '\u{2028}'..='\u{202F}' | + '\u{2060}'..='\u{2069}' | + '\u{FEFF}' + ) +} + +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: u16, + pinned: bool, + last_total_lines: u16, + last_height: u16, +} + +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: 0, pinned: false, + last_total_lines: 0, last_height: 20, + } + } + + 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 = self.scroll.saturating_sub(excess as u16); + } + } + + 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); + } else if ch == '\t' { + self.current_line.push_str(" "); + } else if ch.is_control() || is_zero_width(ch) { + } else { + self.current_line.push(ch); + } + } + } + self.evict(); + } + + 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 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(); + } + + fn scroll_up(&mut self, n: u16) { + self.scroll = self.scroll.saturating_sub(n); + self.pinned = true; + } + + fn scroll_down(&mut self, n: u16) { + let max = self.last_total_lines.saturating_sub(self.last_height); + self.scroll = (self.scroll + n).min(max); + if self.scroll >= max { self.pinned = false; } + } + + 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) + } +} + 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, - pub(crate) history_index: Option, - pub(crate) active_pane: ActivePane, - pub(crate) pane_areas: [Rect; 3], - pub(crate) needs_assistant_marker: bool, - pub(crate) turn_started: Option, - pub(crate) call_started: Option, - pub(crate) call_timeout_secs: u64, + autonomous: PaneState, + conversation: PaneState, + tools: PaneState, + textarea: tui_textarea::TextArea<'static>, + input_history: Vec, + history_index: Option, + active_pane: ActivePane, + pane_areas: [Rect; 3], + needs_assistant_marker: bool, + turn_started: Option, + call_started: Option, + call_timeout_secs: u64, // State sync with agent — double buffer last_generation: u64, last_entries: Vec, /// Reference to agent for state sync - agent: std::sync::Arc>, + agent: std::sync::Arc>, } impl InteractScreen { - pub fn new(agent: std::sync::Arc>) -> Self { + pub fn new(agent: std::sync::Arc>) -> Self { Self { autonomous: PaneState::new(true), conversation: PaneState::new(true), @@ -115,12 +319,12 @@ impl InteractScreen { /// Sync conversation display from agent entries. fn sync_from_agent(&mut self) { - let agent = self.agent.blocking_lock(); - let gen = agent.generation; + let agent = self.agent.lock().unwrap(); + let generation = agent.generation; let entries = agent.entries(); // Phase 1: detect desync and pop - if gen != self.last_generation { + if generation != self.last_generation { self.conversation = PaneState::new(true); self.autonomous = PaneState::new(true); self.tools = PaneState::new(false); @@ -165,7 +369,7 @@ impl InteractScreen { self.last_entries.push(entry.clone()); } - self.last_generation = gen; + self.last_generation = generation; } /// Process a UiMessage — update pane state. @@ -260,7 +464,7 @@ impl InteractScreen { } } - pub fn handle_mouse(&mut self, mouse: MouseEvent) { + fn handle_mouse(&mut self, mouse: MouseEvent) { match mouse.kind { MouseEventKind::ScrollUp => self.scroll_active_up(3), MouseEventKind::ScrollDown => self.scroll_active_down(3), @@ -278,7 +482,7 @@ impl InteractScreen { } /// 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) { + 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; diff --git a/src/user/mod.rs b/src/user/mod.rs index 0a1967f..c0234b9 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -21,8 +21,6 @@ use ratatui::crossterm::{ }; use ratatui::{ backend::CrosstermBackend, - style::{Color, Style}, - text::{Line, Span}, }; use std::io; @@ -48,210 +46,6 @@ pub(crate) fn screen_legend() -> String { SCREEN_LEGEND.get().cloned().unwrap_or_default() } -pub(crate) 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 -} - -pub(crate) fn is_zero_width(ch: char) -> bool { - matches!(ch, - '\u{200B}'..='\u{200F}' | - '\u{2028}'..='\u{202F}' | - '\u{2060}'..='\u{2069}' | - '\u{FEFF}' - ) -} - -/// Which pane receives scroll keys. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ActivePane { - Autonomous, - Conversation, - Tools, -} - -const MAX_PANE_LINES: usize = 10_000; - -/// Turn marker for the conversation pane gutter. -#[derive(Clone, Copy, PartialEq, Default)] -pub(crate) enum Marker { - #[default] - None, - User, - Assistant, -} - -pub(crate) struct PaneState { - pub(crate) lines: Vec>, - pub(crate) markers: Vec, - pub(crate) current_line: String, - pub(crate) current_color: Color, - pub(crate) md_buffer: String, - pub(crate) use_markdown: bool, - pub(crate) pending_marker: Marker, - pub(crate) scroll: u16, - pub(crate) pinned: bool, - pub(crate) last_total_lines: u16, - pub(crate) last_height: u16, -} - -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: 0, pinned: false, - last_total_lines: 0, last_height: 20, - } - } - - 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 = self.scroll.saturating_sub(excess as u16); - } - } - - 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); - } else if ch == '\t' { - self.current_line.push_str(" "); - } else if ch.is_control() || is_zero_width(ch) { - } else { - self.current_line.push(ch); - } - } - } - self.evict(); - } - - pub(crate) 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 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(); - } - - pub(crate) fn pop_line(&mut self) { - self.lines.pop(); - self.markers.pop(); - } - - fn scroll_up(&mut self, n: u16) { - self.scroll = self.scroll.saturating_sub(n); - self.pinned = true; - } - - fn scroll_down(&mut self, n: u16) { - let max = self.last_total_lines.saturating_sub(self.last_height); - self.scroll = (self.scroll + n).min(max); - if self.scroll >= max { self.pinned = false; } - } - - pub(crate) fn all_lines(&self) -> Vec> { - let (lines, _) = self.all_lines_with_markers(); - lines - } - - pub(crate) 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) - } -} - -pub(crate) 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 -} - -pub(crate) 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() -} - /// Action returned from a screen's tick method. pub enum ScreenAction { /// Switch to screen at this index