diff --git a/Cargo.lock b/Cargo.lock index f13096b..0d12bd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2804,6 +2804,7 @@ dependencies = [ "tiktoken-rs", "tokio", "tui-markdown", + "tui-textarea-2", "unicode-width", "walkdir", ] @@ -4601,6 +4602,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tui-textarea-2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a31ca0965e3ff6a7ac5ecb02b20a88b4f68ebf138d8ae438e8510b27a1f00f" +dependencies = [ + "crossterm 0.29.0", + "portable-atomic", + "ratatui-core", + "ratatui-widgets", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/poc-agent/Cargo.toml b/poc-agent/Cargo.toml index 9b44b0c..16d9ffc 100644 --- a/poc-agent/Cargo.toml +++ b/poc-agent/Cargo.toml @@ -33,3 +33,4 @@ json5 = "0.4" clap = { version = "4", features = ["derive"] } tui-markdown = "0.3" unicode-width = "0.2.2" +tui-textarea = { version = "0.10.2", package = "tui-textarea-2" } diff --git a/poc-agent/src/main.rs b/poc-agent/src/main.rs index 3e29436..a8430b5 100644 --- a/poc-agent/src/main.rs +++ b/poc-agent/src/main.rs @@ -1014,6 +1014,9 @@ async fn run(cli: cli::CliArgs) -> Result<()> { let mut render_interval = tokio::time::interval(Duration::from_millis(50)); render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Hide terminal cursor — tui-textarea renders its own cursor as a styled cell + terminal.hide_cursor()?; + // Initial render drain_ui_messages(&mut ui_rx, &mut app); terminal.draw(|f| app.draw(f))?; diff --git a/poc-agent/src/tui.rs b/poc-agent/src/tui.rs index a31fd46..05e8032 100644 --- a/poc-agent/src/tui.rs +++ b/poc-agent/src/tui.rs @@ -9,7 +9,6 @@ // Uses ratatui + crossterm. The App struct holds all TUI state and // handles rendering. Input is processed from crossterm key events. -use unicode_width::UnicodeWidthChar; use crossterm::{ event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, @@ -89,12 +88,23 @@ enum ActivePane { /// unbounded memory growth during long sessions. const MAX_PANE_LINES: usize = 10_000; +/// Turn marker for the conversation pane gutter. +#[derive(Clone, Copy, PartialEq, Default)] +enum Marker { + #[default] + None, + User, + Assistant, +} + /// A scrollable text pane with auto-scroll behavior. /// /// Scroll offset is in visual (wrapped) lines so that auto-scroll /// correctly tracks the bottom even when long lines wrap. struct PaneState { lines: Vec>, + /// Turn markers — parallel to lines, same length. + markers: Vec, /// Current line being built (no trailing newline yet) — plain mode only. current_line: String, /// Color applied to streaming text (set before append_text) — plain mode only. @@ -103,6 +113,8 @@ struct PaneState { md_buffer: String, /// Whether this pane parses streaming text as markdown. use_markdown: bool, + /// Marker to apply to the next line pushed (for turn start tracking). + pending_marker: Marker, /// Scroll offset in visual (wrapped) lines from the top. scroll: u16, /// Whether the user has scrolled away from the bottom. @@ -117,10 +129,12 @@ 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, @@ -133,6 +147,7 @@ impl PaneState { if self.lines.len() > MAX_PANE_LINES { let excess = self.lines.len() - MAX_PANE_LINES; self.lines.drain(..excess); + self.markers.drain(..excess); // Approximate: reduce scroll by the wrapped height of evicted lines. // Not perfectly accurate but prevents scroll from jumping wildly. self.scroll = self.scroll.saturating_sub(excess as u16); @@ -152,6 +167,7 @@ impl PaneState { 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) { @@ -167,20 +183,35 @@ impl PaneState { /// Finalize any pending content (markdown buffer or current line). fn flush_pending(&mut self) { if self.use_markdown && !self.md_buffer.is_empty() { - self.lines.extend(parse_markdown(&self.md_buffer)); + 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)); } } /// Push a complete line with a color. Flushes any pending /// markdown or plain-text content first. 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(); } @@ -203,91 +234,42 @@ impl PaneState { /// any pending content (live-parsed markdown or in-progress plain line). /// Scrolling is handled by Paragraph::scroll(). fn all_lines(&self) -> Vec> { - let mut result: Vec> = self.lines.clone(); + let (lines, _) = self.all_lines_with_markers(); + lines + } + + /// Get lines and their markers together. Used by the two-column + /// conversation renderer to know where to place gutter markers. + 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() { - result.extend(parse_markdown(&self.md_buffer)); + 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() { - result.push(Line::styled( + lines.push(Line::styled( self.current_line.clone(), Style::default().fg(self.current_color), )); + markers.push(self.pending_marker); } - result + (lines, markers) } } -/// Compute soft line break positions for word-wrapped text. -/// Returns the character index where each soft line starts. -/// Matches ratatui Wrap { trim: false } — breaks at word boundaries. -fn word_wrap_breaks(text: &str, width: usize) -> Vec { - let mut breaks = vec![0usize]; - - if width == 0 { - return breaks; - } - - let chars: Vec = text.chars().collect(); - let mut col = 0usize; - let mut last_space: Option = None; - - for (i, &ch) in chars.iter().enumerate() { - if ch == '\n' { - breaks.push(i + 1); - col = 0; - last_space = None; - continue; - } - - let cw = UnicodeWidthChar::width(ch).unwrap_or(0); - - if col + cw > width && col > 0 { - if let Some(sp) = last_space { - breaks.push(sp); - col = 0; - last_space = None; - for j in sp..i { - col += UnicodeWidthChar::width(chars[j]).unwrap_or(0); - if chars[j] == ' ' { - last_space = Some(j + 1); - } - } - } else { - breaks.push(i); - col = 0; - } - } - - if ch == ' ' { - last_space = Some(i + 1); - } - col += cw; - } - - breaks +/// Create a new textarea with standard settings (word wrap, no cursor line highlight). +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 } -/// Compute visual (col, row) for a character position in word-wrapped text. -fn cursor_visual_pos(text: &str, char_pos: usize, width: u16) -> (u16, u16) { - let breaks = word_wrap_breaks(text, width as usize); - let chars: Vec = text.chars().collect(); - - for r in 0..breaks.len() { - let start = breaks[r]; - let end = breaks.get(r + 1).copied().unwrap_or(chars.len()); - - if char_pos < end || r == breaks.len() - 1 { - let mut col = 0u16; - for j in start..char_pos.min(end) { - if chars[j] != '\n' { - col += UnicodeWidthChar::width(chars[j]).unwrap_or(0) as u16; - } - } - return (col, r as u16); - } - } - - (0, 0) -} /// Parse markdown text into owned ratatui Lines. fn parse_markdown(md: &str) -> Vec> { @@ -325,16 +307,16 @@ pub struct App { activity: String, /// When the current turn started (for elapsed timer). turn_started: Option, + /// Whether to emit a ● marker before the next assistant TextDelta. + needs_assistant_marker: bool, /// Number of running child processes (updated by main loop). pub running_processes: u32, /// Current reasoning effort level (for status display). pub reasoning_effort: String, active_tools: Vec, active_pane: ActivePane, - /// User input buffer. - pub input: String, - /// Cursor position within input. - pub cursor: usize, + /// User input editor (handles wrapping, cursor positioning). + pub textarea: tui_textarea::TextArea<'static>, /// Input history for up/down navigation. input_history: Vec, history_index: Option, @@ -391,12 +373,12 @@ impl App { }, activity: String::new(), turn_started: None, + needs_assistant_marker: false, running_processes: 0, reasoning_effort: "none".to_string(), active_tools: Vec::new(), active_pane: ActivePane::Conversation, - input: String::new(), - cursor: 0, + textarea: new_textarea(vec![String::new()]), input_history: Vec::new(), history_index: None, should_quit: false, @@ -419,6 +401,10 @@ impl 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); } @@ -428,9 +414,10 @@ impl App { } }, UiMessage::UserInput(text) => { - self.conversation.push_line(format!("you> {}", text), Color::Green); - // Mark turn start + self.conversation.push_line_with_marker(text.clone(), Color::Cyan, Marker::User); + // Mark turn start — next TextDelta gets an assistant marker self.turn_started = Some(std::time::Instant::now()); + self.needs_assistant_marker = true; self.status.turn_tools = 0; } UiMessage::ToolCall { name, args_summary } => { @@ -453,6 +440,7 @@ impl App { self.autonomous.push_line(text, Color::Yellow); // DMN turn start self.turn_started = Some(std::time::Instant::now()); + self.needs_assistant_marker = true; self.status.turn_tools = 0; } UiMessage::StatusUpdate(info) => { @@ -588,84 +576,52 @@ impl App { match key.code { KeyCode::Esc => { self.hotkey_actions.push(HotkeyAction::Interrupt); - return; } - KeyCode::Enter => { - if key.modifiers.contains(KeyModifiers::ALT) - || key.modifiers.contains(KeyModifiers::SHIFT) - { - self.input.insert(self.cursor, '\n'); - self.cursor += 1; - } else if !self.input.is_empty() { - let line = self.input.clone(); - if self.input_history.last().map_or(true, |h| h != &line) { - self.input_history.push(line.clone()); + KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::SHIFT) => { + // Submit input + 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.submitted.push(line); - self.input.clear(); - self.cursor = 0; + self.submitted.push(input); + self.textarea = new_textarea(vec![String::new()]); } } - KeyCode::Backspace => { - if self.cursor > 0 { - self.cursor -= 1; - self.input.remove(self.cursor); + 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 !key.modifiers.contains(KeyModifiers::CONTROL) => { + 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::Delete => { - if self.cursor < self.input.len() { - self.input.remove(self.cursor); - } - } - KeyCode::Left => { - if self.cursor > 0 { - self.cursor -= 1; - } - } - KeyCode::Right => { - if self.cursor < self.input.len() { - self.cursor += 1; - } - } - KeyCode::Home => { - self.cursor = 0; - } - KeyCode::End => { - self.cursor = self.input.len(); - } - KeyCode::Up => { - // If Ctrl is held, scroll the active pane - if key.modifiers.contains(KeyModifiers::CONTROL) { - self.scroll_active_up(3); - } else { - // History navigation - 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); - self.input = self.input_history[idx].clone(); - self.cursor = self.input.len(); - } - } - } - KeyCode::Down => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - self.scroll_active_down(3); - } else { - // History navigation - if let Some(idx) = self.history_index { - if idx + 1 < self.input_history.len() { - self.history_index = Some(idx + 1); - self.input = self.input_history[idx + 1].clone(); - self.cursor = self.input.len(); - } else { - self.history_index = None; - self.input.clear(); - self.cursor = 0; - } + KeyCode::Down if !key.modifiers.contains(KeyModifiers::CONTROL) => { + 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()]); } } } @@ -676,18 +632,16 @@ impl App { self.scroll_active_down(10); } KeyCode::Tab => { - // Cycle active pane self.active_pane = match self.active_pane { ActivePane::Autonomous => ActivePane::Tools, ActivePane::Tools => ActivePane::Conversation, ActivePane::Conversation => ActivePane::Autonomous, }; } - KeyCode::Char(c) => { - self.input.insert(self.cursor, c); - self.cursor += 1; + _ => { + // Delegate all other keys to the textarea widget + self.textarea.input(key); } - _ => {} } } @@ -799,59 +753,41 @@ impl App { // Draw conversation pane (with input line) let conv_active = self.active_pane == ActivePane::Conversation; - // Calculate input height using word wrap (matches ratatui Wrap behavior) - let prompt = "you> "; - let full_input = format!("{}{}", prompt, &self.input); - let input_width = conv_area.width as usize; - let input_height = word_wrap_breaks(&full_input, input_width).len() + // 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) as u16; + .min(5); - // Split conversation area: text + input lines let conv_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(1), // conversation text - Constraint::Length(input_height), // input area + Constraint::Min(1), // conversation text + Constraint::Length(input_line_count), // input area ]) .split(conv_area); - let text_area = conv_chunks[0]; + let text_area_rect = conv_chunks[0]; let input_area = conv_chunks[1]; - draw_pane(frame, text_area, "conversation", &mut self.conversation, conv_active); + draw_conversation_pane(frame, text_area_rect, &mut self.conversation, conv_active); - // Input lines — split on newlines, style the prompt on the first line - let input_lines: Vec = full_input - .split('\n') - .enumerate() - .map(|(i, part)| { - if i == 0 && part.len() >= prompt.len() { - Line::from(vec![ - Span::styled( - prompt, - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), - ), - Span::raw(part[prompt.len()..].to_string()), - ]) - } else { - Line::raw(part.to_string()) - } - }) - .collect(); - let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false }); - frame.render_widget(input_para, input_area); + // " > " 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); - // Cursor position: simulate word wrap to find visual (col, row) - let cursor_char_pos = prompt.chars().count() - + self.input[..self.cursor].chars().count(); - let (cx, cy) = cursor_visual_pos(&full_input, cursor_char_pos, input_area.width); - let cursor_x = cx + input_area.x; - let cursor_y = cy + input_area.y; - - if cursor_y < input_area.y + input_area.height { - frame.set_cursor_position((cursor_x, cursor_y)); - } + 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 !self.active_tools.is_empty() { @@ -1101,6 +1037,104 @@ impl App { } } +/// Draw the conversation pane with a two-column layout: marker gutter + text. +/// The gutter shows ● 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 = 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> = 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,