ui: two-column layout for conversation pane with marker gutter

Split conversation pane into 2-char gutter + text column. Gutter shows
● markers at turn boundaries (Cyan for user, Magenta for assistant),
aligned with the input area's ' > ' gutter.

Key changes:
- Added Marker enum (None/User/Assistant) and parallel markers vec
- Track turn boundaries via pending_marker field
- New draw_conversation_pane() with visual row computation for wrapping
- Both gutter and text scroll synchronously by visual line offset

This fixes the wrapping alignment issue where continuation lines
aligned under markers instead of under the text.
This commit is contained in:
Kent Overstreet 2026-03-21 19:15:13 -04:00
parent 78b22d6cae
commit acc878b9a4
4 changed files with 254 additions and 201 deletions

15
Cargo.lock generated
View file

@ -2804,6 +2804,7 @@ dependencies = [
"tiktoken-rs", "tiktoken-rs",
"tokio", "tokio",
"tui-markdown", "tui-markdown",
"tui-textarea-2",
"unicode-width", "unicode-width",
"walkdir", "walkdir",
] ]
@ -4601,6 +4602,20 @@ dependencies = [
"tracing", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"

View file

@ -33,3 +33,4 @@ json5 = "0.4"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
tui-markdown = "0.3" tui-markdown = "0.3"
unicode-width = "0.2.2" unicode-width = "0.2.2"
tui-textarea = { version = "0.10.2", package = "tui-textarea-2" }

View file

@ -1014,6 +1014,9 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
let mut render_interval = tokio::time::interval(Duration::from_millis(50)); let mut render_interval = tokio::time::interval(Duration::from_millis(50));
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 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 // Initial render
drain_ui_messages(&mut ui_rx, &mut app); drain_ui_messages(&mut ui_rx, &mut app);
terminal.draw(|f| app.draw(f))?; terminal.draw(|f| app.draw(f))?;

View file

@ -9,7 +9,6 @@
// Uses ratatui + crossterm. The App struct holds all TUI state and // Uses ratatui + crossterm. The App struct holds all TUI state and
// handles rendering. Input is processed from crossterm key events. // handles rendering. Input is processed from crossterm key events.
use unicode_width::UnicodeWidthChar;
use crossterm::{ use crossterm::{
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}, event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
@ -89,12 +88,23 @@ enum ActivePane {
/// unbounded memory growth during long sessions. /// unbounded memory growth during long sessions.
const MAX_PANE_LINES: usize = 10_000; 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. /// A scrollable text pane with auto-scroll behavior.
/// ///
/// Scroll offset is in visual (wrapped) lines so that auto-scroll /// Scroll offset is in visual (wrapped) lines so that auto-scroll
/// correctly tracks the bottom even when long lines wrap. /// correctly tracks the bottom even when long lines wrap.
struct PaneState { struct PaneState {
lines: Vec<Line<'static>>, lines: Vec<Line<'static>>,
/// Turn markers — parallel to lines, same length.
markers: Vec<Marker>,
/// Current line being built (no trailing newline yet) — plain mode only. /// Current line being built (no trailing newline yet) — plain mode only.
current_line: String, current_line: String,
/// Color applied to streaming text (set before append_text) — plain mode only. /// Color applied to streaming text (set before append_text) — plain mode only.
@ -103,6 +113,8 @@ struct PaneState {
md_buffer: String, md_buffer: String,
/// Whether this pane parses streaming text as markdown. /// Whether this pane parses streaming text as markdown.
use_markdown: bool, 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 offset in visual (wrapped) lines from the top.
scroll: u16, scroll: u16,
/// Whether the user has scrolled away from the bottom. /// Whether the user has scrolled away from the bottom.
@ -117,10 +129,12 @@ impl PaneState {
fn new(use_markdown: bool) -> Self { fn new(use_markdown: bool) -> Self {
Self { Self {
lines: Vec::new(), lines: Vec::new(),
markers: Vec::new(),
current_line: String::new(), current_line: String::new(),
current_color: Color::Reset, current_color: Color::Reset,
md_buffer: String::new(), md_buffer: String::new(),
use_markdown, use_markdown,
pending_marker: Marker::None,
scroll: 0, scroll: 0,
pinned: false, pinned: false,
last_total_lines: 0, last_total_lines: 0,
@ -133,6 +147,7 @@ impl PaneState {
if self.lines.len() > MAX_PANE_LINES { if self.lines.len() > MAX_PANE_LINES {
let excess = self.lines.len() - MAX_PANE_LINES; let excess = self.lines.len() - MAX_PANE_LINES;
self.lines.drain(..excess); self.lines.drain(..excess);
self.markers.drain(..excess);
// Approximate: reduce scroll by the wrapped height of evicted lines. // Approximate: reduce scroll by the wrapped height of evicted lines.
// Not perfectly accurate but prevents scroll from jumping wildly. // Not perfectly accurate but prevents scroll from jumping wildly.
self.scroll = self.scroll.saturating_sub(excess as u16); self.scroll = self.scroll.saturating_sub(excess as u16);
@ -152,6 +167,7 @@ impl PaneState {
if ch == '\n' { if ch == '\n' {
let line = std::mem::take(&mut self.current_line); let line = std::mem::take(&mut self.current_line);
self.lines.push(Line::styled(line, Style::default().fg(self.current_color))); self.lines.push(Line::styled(line, Style::default().fg(self.current_color)));
self.markers.push(Marker::None);
} else if ch == '\t' { } else if ch == '\t' {
self.current_line.push_str(" "); self.current_line.push_str(" ");
} else if ch.is_control() || is_zero_width(ch) { } else if ch.is_control() || is_zero_width(ch) {
@ -167,20 +183,35 @@ impl PaneState {
/// Finalize any pending content (markdown buffer or current line). /// Finalize any pending content (markdown buffer or current line).
fn flush_pending(&mut self) { fn flush_pending(&mut self) {
if self.use_markdown && !self.md_buffer.is_empty() { 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(); self.md_buffer.clear();
} }
if !self.current_line.is_empty() { if !self.current_line.is_empty() {
let line = std::mem::take(&mut self.current_line); let line = std::mem::take(&mut self.current_line);
self.lines.push(Line::styled(line, Style::default().fg(self.current_color))); 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 /// Push a complete line with a color. Flushes any pending
/// markdown or plain-text content first. /// markdown or plain-text content first.
fn push_line(&mut self, line: String, color: Color) { 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.flush_pending();
self.lines.push(Line::styled(strip_ansi(&line), Style::default().fg(color))); self.lines.push(Line::styled(strip_ansi(&line), Style::default().fg(color)));
self.markers.push(marker);
self.evict(); self.evict();
} }
@ -203,91 +234,42 @@ impl PaneState {
/// any pending content (live-parsed markdown or in-progress plain line). /// any pending content (live-parsed markdown or in-progress plain line).
/// Scrolling is handled by Paragraph::scroll(). /// Scrolling is handled by Paragraph::scroll().
fn all_lines(&self) -> Vec<Line<'static>> { fn all_lines(&self) -> Vec<Line<'static>> {
let mut result: Vec<Line<'static>> = 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<Line<'static>>, Vec<Marker>) {
let mut lines: Vec<Line<'static>> = self.lines.clone();
let mut markers: Vec<Marker> = self.markers.clone();
if self.use_markdown && !self.md_buffer.is_empty() { 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() { } else if !self.current_line.is_empty() {
result.push(Line::styled( lines.push(Line::styled(
self.current_line.clone(), self.current_line.clone(),
Style::default().fg(self.current_color), Style::default().fg(self.current_color),
)); ));
markers.push(self.pending_marker);
} }
result (lines, markers)
} }
} }
/// Compute soft line break positions for word-wrapped text. /// Create a new textarea with standard settings (word wrap, no cursor line highlight).
/// Returns the character index where each soft line starts. fn new_textarea(lines: Vec<String>) -> tui_textarea::TextArea<'static> {
/// Matches ratatui Wrap { trim: false } — breaks at word boundaries. let mut ta = tui_textarea::TextArea::new(lines);
fn word_wrap_breaks(text: &str, width: usize) -> Vec<usize> { ta.set_cursor_line_style(Style::default());
let mut breaks = vec![0usize]; ta.set_wrap_mode(tui_textarea::WrapMode::Word);
ta
if width == 0 {
return breaks;
}
let chars: Vec<char> = text.chars().collect();
let mut col = 0usize;
let mut last_space: Option<usize> = 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<char> = 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. /// Parse markdown text into owned ratatui Lines.
fn parse_markdown(md: &str) -> Vec<Line<'static>> { fn parse_markdown(md: &str) -> Vec<Line<'static>> {
@ -325,16 +307,16 @@ pub struct App {
activity: String, activity: String,
/// When the current turn started (for elapsed timer). /// When the current turn started (for elapsed timer).
turn_started: Option<std::time::Instant>, turn_started: Option<std::time::Instant>,
/// Whether to emit a ● marker before the next assistant TextDelta.
needs_assistant_marker: bool,
/// Number of running child processes (updated by main loop). /// Number of running child processes (updated by main loop).
pub running_processes: u32, pub running_processes: u32,
/// Current reasoning effort level (for status display). /// Current reasoning effort level (for status display).
pub reasoning_effort: String, pub reasoning_effort: String,
active_tools: Vec<ActiveTool>, active_tools: Vec<ActiveTool>,
active_pane: ActivePane, active_pane: ActivePane,
/// User input buffer. /// User input editor (handles wrapping, cursor positioning).
pub input: String, pub textarea: tui_textarea::TextArea<'static>,
/// Cursor position within input.
pub cursor: usize,
/// Input history for up/down navigation. /// Input history for up/down navigation.
input_history: Vec<String>, input_history: Vec<String>,
history_index: Option<usize>, history_index: Option<usize>,
@ -391,12 +373,12 @@ impl App {
}, },
activity: String::new(), activity: String::new(),
turn_started: None, turn_started: None,
needs_assistant_marker: false,
running_processes: 0, running_processes: 0,
reasoning_effort: "none".to_string(), reasoning_effort: "none".to_string(),
active_tools: Vec::new(), active_tools: Vec::new(),
active_pane: ActivePane::Conversation, active_pane: ActivePane::Conversation,
input: String::new(), textarea: new_textarea(vec![String::new()]),
cursor: 0,
input_history: Vec::new(), input_history: Vec::new(),
history_index: None, history_index: None,
should_quit: false, should_quit: false,
@ -419,6 +401,10 @@ impl App {
match msg { match msg {
UiMessage::TextDelta(text, target) => match target { UiMessage::TextDelta(text, target) => match target {
StreamTarget::Conversation => { 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.current_color = Color::Reset;
self.conversation.append_text(&text); self.conversation.append_text(&text);
} }
@ -428,9 +414,10 @@ impl App {
} }
}, },
UiMessage::UserInput(text) => { UiMessage::UserInput(text) => {
self.conversation.push_line(format!("you> {}", text), Color::Green); self.conversation.push_line_with_marker(text.clone(), Color::Cyan, Marker::User);
// Mark turn start // Mark turn start — next TextDelta gets an assistant marker
self.turn_started = Some(std::time::Instant::now()); self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
self.status.turn_tools = 0; self.status.turn_tools = 0;
} }
UiMessage::ToolCall { name, args_summary } => { UiMessage::ToolCall { name, args_summary } => {
@ -453,6 +440,7 @@ impl App {
self.autonomous.push_line(text, Color::Yellow); self.autonomous.push_line(text, Color::Yellow);
// DMN turn start // DMN turn start
self.turn_started = Some(std::time::Instant::now()); self.turn_started = Some(std::time::Instant::now());
self.needs_assistant_marker = true;
self.status.turn_tools = 0; self.status.turn_tools = 0;
} }
UiMessage::StatusUpdate(info) => { UiMessage::StatusUpdate(info) => {
@ -588,84 +576,52 @@ impl App {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
self.hotkey_actions.push(HotkeyAction::Interrupt); self.hotkey_actions.push(HotkeyAction::Interrupt);
return;
} }
KeyCode::Enter => { KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT)
if key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => {
|| key.modifiers.contains(KeyModifiers::SHIFT) // Submit input
{ let input: String = self.textarea.lines().join("\n");
self.input.insert(self.cursor, '\n'); if !input.is_empty() {
self.cursor += 1; if self.input_history.last().map_or(true, |h| h != &input) {
} else if !self.input.is_empty() { self.input_history.push(input.clone());
let line = self.input.clone();
if self.input_history.last().map_or(true, |h| h != &line) {
self.input_history.push(line.clone());
} }
self.history_index = None; self.history_index = None;
self.submitted.push(line); self.submitted.push(input);
self.input.clear(); self.textarea = new_textarea(vec![String::new()]);
self.cursor = 0;
} }
} }
KeyCode::Backspace => { KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.cursor > 0 { self.scroll_active_up(3);
self.cursor -= 1; }
self.input.remove(self.cursor); 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 => { KeyCode::Down if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.cursor < self.input.len() { if let Some(idx) = self.history_index {
self.input.remove(self.cursor); if idx + 1 < self.input_history.len() {
} self.history_index = Some(idx + 1);
} let mut ta = new_textarea(
KeyCode::Left => { self.input_history[idx + 1].lines().map(String::from).collect()
if self.cursor > 0 { );
self.cursor -= 1; ta.move_cursor(tui_textarea::CursorMove::End);
} self.textarea = ta;
} } else {
KeyCode::Right => { self.history_index = None;
if self.cursor < self.input.len() { self.textarea = new_textarea(vec![String::new()]);
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;
}
} }
} }
} }
@ -676,18 +632,16 @@ impl App {
self.scroll_active_down(10); self.scroll_active_down(10);
} }
KeyCode::Tab => { KeyCode::Tab => {
// Cycle active pane
self.active_pane = match self.active_pane { self.active_pane = match self.active_pane {
ActivePane::Autonomous => ActivePane::Tools, ActivePane::Autonomous => ActivePane::Tools,
ActivePane::Tools => ActivePane::Conversation, ActivePane::Tools => ActivePane::Conversation,
ActivePane::Conversation => ActivePane::Autonomous, ActivePane::Conversation => ActivePane::Autonomous,
}; };
} }
KeyCode::Char(c) => { _ => {
self.input.insert(self.cursor, c); // Delegate all other keys to the textarea widget
self.cursor += 1; self.textarea.input(key);
} }
_ => {}
} }
} }
@ -799,59 +753,41 @@ impl App {
// Draw conversation pane (with input line) // Draw conversation pane (with input line)
let conv_active = self.active_pane == ActivePane::Conversation; let conv_active = self.active_pane == ActivePane::Conversation;
// Calculate input height using word wrap (matches ratatui Wrap behavior) // Input area: compute visual height, split, render gutter + textarea
let prompt = "you> "; let input_text = self.textarea.lines().join("\n");
let full_input = format!("{}{}", prompt, &self.input); let input_para_measure = Paragraph::new(input_text).wrap(Wrap { trim: false });
let input_width = conv_area.width as usize; let input_line_count = (input_para_measure.line_count(conv_area.width.saturating_sub(5)) as u16)
let input_height = word_wrap_breaks(&full_input, input_width).len()
.max(1) .max(1)
.min(5) as u16; .min(5);
// Split conversation area: text + input lines
let conv_chunks = Layout::default() let conv_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(1), // conversation text Constraint::Min(1), // conversation text
Constraint::Length(input_height), // input area Constraint::Length(input_line_count), // input area
]) ])
.split(conv_area); .split(conv_area);
let text_area = conv_chunks[0]; let text_area_rect = conv_chunks[0];
let input_area = conv_chunks[1]; 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 // " > " gutter + textarea, aligned with conversation messages
let input_lines: Vec<Line> = full_input let input_chunks = Layout::default()
.split('\n') .direction(Direction::Horizontal)
.enumerate() .constraints([
.map(|(i, part)| { Constraint::Length(3), // " > " gutter
if i == 0 && part.len() >= prompt.len() { Constraint::Min(1), // textarea
Line::from(vec![ ])
Span::styled( .split(input_area);
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);
// Cursor position: simulate word wrap to find visual (col, row) let gutter = Paragraph::new(Line::styled(
let cursor_char_pos = prompt.chars().count() " > ",
+ self.input[..self.cursor].chars().count(); Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
let (cx, cy) = cursor_visual_pos(&full_input, cursor_char_pos, input_area.width); ));
let cursor_x = cx + input_area.x; frame.render_widget(gutter, input_chunks[0]);
let cursor_y = cy + input_area.y; frame.render_widget(&self.textarea, input_chunks[1]);
if cursor_y < input_area.y + input_area.height {
frame.set_cursor_position((cursor_x, cursor_y));
}
// Draw active tools overlay // Draw active tools overlay
if !self.active_tools.is_empty() { 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<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). /// Draw a scrollable text pane (free function to avoid borrow issues).
fn draw_pane( fn draw_pane(
frame: &mut Frame, frame: &mut Frame,