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",
"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"

View file

@ -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" }

View file

@ -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))?;

View file

@ -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<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: 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<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() {
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<usize> {
let mut breaks = vec![0usize];
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
/// Create a new textarea with standard settings (word wrap, no cursor line highlight).
fn new_textarea(lines: Vec<String>) -> 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<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.
fn parse_markdown(md: &str) -> Vec<Line<'static>> {
@ -325,16 +307,16 @@ pub struct App {
activity: String,
/// When the current turn started (for elapsed timer).
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).
pub running_processes: u32,
/// Current reasoning effort level (for status display).
pub reasoning_effort: String,
active_tools: Vec<ActiveTool>,
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<String>,
history_index: Option<usize>,
@ -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<Line> = 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<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).
fn draw_pane(
frame: &mut Frame,