diff --git a/poc-agent/src/tui.rs b/poc-agent/src/tui.rs index 88b095f..a6b3d6f 100644 --- a/poc-agent/src/tui.rs +++ b/poc-agent/src/tui.rs @@ -9,7 +9,7 @@ // Uses ratatui + crossterm. The App struct holds all TUI state and // handles rendering. Input is processed from crossterm key events. -use unicode_width::UnicodeWidthStr; +use unicode_width::UnicodeWidthChar; use crossterm::{ event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, @@ -17,7 +17,6 @@ use crossterm::{ }; use ratatui::{ backend::CrosstermBackend, - buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, @@ -268,6 +267,79 @@ fn wrapped_height_line(line: &Line<'_>, width: usize) -> usize { ((w + width - 1) / width).max(1) } +/// 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 +} + +/// 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> { tui_markdown::from_str(md) @@ -772,14 +844,11 @@ impl App { // Draw conversation pane (with input line) let conv_active = self.active_pane == ActivePane::Conversation; - // Calculate input height: account for both newlines and wrapping + // 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 = full_input - .split('\n') - .map(|line| wrapped_height(line, input_width)) - .sum::() + let input_height = word_wrap_breaks(&full_input, input_width).len() .max(1) .min(5) as u16; @@ -818,48 +887,13 @@ impl App { let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false }); frame.render_widget(input_para, input_area); - // Cursor position: scan the rendered buffer to find where the cursor should be. - // This matches ratatui's actual word wrapping instead of trying to simulate it. - let buffer = frame.buffer_mut(); - - // Convert byte index to character index for the input portion - let input_chars_before_cursor = self.input[..self.cursor].chars().count(); - let cursor_char_pos = prompt.chars().count() + input_chars_before_cursor; - - let mut char_count = 0usize; - let mut cursor_x = input_area.x; - let mut cursor_y = input_area.y; - let mut found = false; - - // Walk through the rendered buffer, counting characters until we reach the cursor position - for y in input_area.y..input_area.y + input_area.height { - for x in input_area.x..input_area.x + input_area.width { - if let Some(cell) = buffer.cell((x, y)) { - let symbol = cell.symbol(); - // Count visible characters (skip zero-width and empty) - if !symbol.is_empty() { - if char_count == cursor_char_pos { - // Found the cursor position - this is where the next char would go - cursor_x = x; - cursor_y = y; - found = true; - break; - } - char_count += 1; - } else if char_count == cursor_char_pos { - // Empty cell but we've reached the cursor position - cursor_x = x; - cursor_y = y; - found = true; - break; - } - } - } - if found { - break; - } - } - + // 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)); }