From 978f8ee00060947cc5db72c7e3bce7d5ae0921c1 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 4 Nov 2026 14:57:41 -0800 Subject: [PATCH 1/5] feat(tui): highlight user messages with full-width blue bands --- src/tui/theme.rs | 8 ++++++ src/tui/ui.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/tui/theme.rs b/src/tui/theme.rs index afc9a5b8..5263a5e9 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -91,6 +91,14 @@ pub const fn user_color() -> Color { Color::Blue } +/// Submitted prompts stand apart from assistant prose using ANSI palette colours. +pub fn user_message() -> Style { + Style::default() + .fg(Color::White) + .bg(user_color()) + .add_modifier(Modifier::BOLD) +} + pub const fn running_color() -> Color { Color::Cyan } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 3b8b4107..08579e58 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1327,7 +1327,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti } } let row_widths: Vec = visible.iter().map(ratatui::text::Line::width).collect(); - frame.render_widget(Paragraph::new(visible), inner); + draw_transcript_rows(frame, visible, inner); draw_selection(frame, app, inner, offset, &row_widths); for (block_index, source_index, destination, y) in visible_images { if let Some(destination) = destination { @@ -1651,6 +1651,18 @@ fn incremental_agent_rows( cached } +/// Paint each row's style across the viewport, including its trailing cells. +/// Keep visual fill out of the source spans used for selection and copying. +fn draw_transcript_rows(frame: &mut Frame<'_>, lines: Vec>, area: Rect) { + for (line, y) in lines.into_iter().zip(area.y..area.bottom()) { + let style = line.style; + frame.render_widget( + Paragraph::new(line).style(style), + Rect::new(area.x, y, area.width, 1), + ); + } +} + fn user_block_rows(message: &UserMessage, width: usize) -> Vec { let mut rows = Vec::new(); let mut line_start = 0; @@ -1681,6 +1693,17 @@ fn user_block_rows(message: &UserMessage, width: usize) -> Vec>(); + lines.push(Line::raw("reply")); + let mut terminal = Terminal::new(TestBackend::new(width as u16, height + 1)).unwrap(); + terminal + .draw(|frame| super::draw_transcript_rows(frame, lines, frame.area())) + .unwrap(); + let buffer = terminal.backend().buffer(); + let style = crate::tui::theme::user_message(); + for y in 0..height { + for x in 0..width as u16 { + // Ratatui resets the hidden cell occupied by a wide glyph. + if x > 0 && buffer[(x - 1, y)].symbol().width() > 1 { + continue; + } + let cell = &buffer[(x, y)]; + assert_eq!( + Some(cell.bg), + style.bg, + "background at {x},{y}, width {width}" + ); + assert_eq!( + Some(cell.fg), + style.fg, + "foreground at {x},{y}, width {width}" + ); + assert!(cell.modifier.contains(Modifier::BOLD)); + } + } + assert_ne!(Some(buffer[(0, height)].bg), style.bg); + assert!( + rows.iter() + .flat_map(|row| &row.2) + .any(|hit| hit.url == "https://example.com") + ); + } + } + #[test] fn user_attachment_links_remain_clickable() { let lines = [user_line( From 232983f8bcd0d816a59f2bd41edeaafbc2f05046 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 4 Nov 2026 15:07:32 -0800 Subject: [PATCH 2/5] fix(tui): distinguish user prompts with blue rules instead of selection-like fills --- src/tui/theme.rs | 8 --- src/tui/ui.rs | 129 +++++++++++++++++++++++------------------------ 2 files changed, 64 insertions(+), 73 deletions(-) diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 5263a5e9..afc9a5b8 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -91,14 +91,6 @@ pub const fn user_color() -> Color { Color::Blue } -/// Submitted prompts stand apart from assistant prose using ANSI palette colours. -pub fn user_message() -> Style { - Style::default() - .fg(Color::White) - .bg(user_color()) - .add_modifier(Modifier::BOLD) -} - pub const fn running_color() -> Color { Color::Cyan } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 08579e58..2fab1899 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1259,6 +1259,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti Vec::new(), String::new(), ); + let mut user_rules = Vec::new(); let mut visible_images: Vec<(usize, usize, Option, i16)> = Vec::new(); let mut materialize = |row: &crate::tui::app::CachedTranscriptRow| { visible.push(row.0.clone()); @@ -1294,6 +1295,11 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti break; } materialize(row); + if matches!(app.blocks[block_index], Block::User(_)) + && row_index + 1 == block.rows.len() + { + user_rules.push(absolute - offset); + } } for placement in &block.images { let image_start = content_start + placement.row; @@ -1327,7 +1333,14 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti } } let row_widths: Vec = visible.iter().map(ratatui::text::Line::width).collect(); - draw_transcript_rows(frame, visible, inner); + frame.render_widget(Paragraph::new(visible), inner); + for row in user_rules { + frame.render_widget( + Paragraph::new("━".repeat(inner.width as usize)) + .style(theme::bold(theme::user_color())), + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + ); + } draw_selection(frame, app, inner, offset, &row_widths); for (block_index, source_index, destination, y) in visible_images { if let Some(destination) = destination { @@ -1651,18 +1664,6 @@ fn incremental_agent_rows( cached } -/// Paint each row's style across the viewport, including its trailing cells. -/// Keep visual fill out of the source spans used for selection and copying. -fn draw_transcript_rows(frame: &mut Frame<'_>, lines: Vec>, area: Rect) { - for (line, y) in lines.into_iter().zip(area.y..area.bottom()) { - let style = line.style; - frame.render_widget( - Paragraph::new(line).style(style), - Rect::new(area.x, y, area.width, 1), - ); - } -} - fn user_block_rows(message: &UserMessage, width: usize) -> Vec { let mut rows = Vec::new(); let mut line_start = 0; @@ -1693,17 +1694,14 @@ fn user_block_rows(message: &UserMessage, width: usize) -> Vec>(); - lines.push(Line::raw("reply")); - let mut terminal = Terminal::new(TestBackend::new(width as u16, height + 1)).unwrap(); + })); + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + let mut images = ImageRuntime::with_picker(Picker::halfblocks()); terminal - .draw(|frame| super::draw_transcript_rows(frame, lines, frame.area())) + .draw(|frame| draw(frame, &mut app, &mut images)) .unwrap(); let buffer = terminal.backend().buffer(); - let style = crate::tui::theme::user_message(); - for y in 0..height { - for x in 0..width as u16 { - // Ratatui resets the hidden cell occupied by a wide glyph. - if x > 0 && buffer[(x - 1, y)].symbol().width() > 1 { - continue; - } - let cell = &buffer[(x, y)]; - assert_eq!( - Some(cell.bg), - style.bg, - "background at {x},{y}, width {width}" - ); - assert_eq!( - Some(cell.fg), - style.fg, - "foreground at {x},{y}, width {width}" - ); - assert!(cell.modifier.contains(Modifier::BOLD)); - } + let rule_y = (app.transcript_top as u16..24) + .find(|&y| buffer[(1, y)].symbol() == "━") + .unwrap(); + for x in 1..1 + app.transcript_width as u16 { + let cell = &buffer[(x, rule_y)]; + assert_eq!(cell.symbol(), "━"); + assert_eq!(cell.fg, theme::user_color()); + assert_eq!(cell.bg, Color::Reset); } - assert_ne!(Some(buffer[(0, height)].bg), style.bg); + let prompt = &buffer[(3, app.transcript_top as u16)]; + assert!(prompt.modifier.contains(Modifier::BOLD)); + assert_eq!(prompt.bg, Color::Reset); assert!( - rows.iter() - .flat_map(|row| &row.2) + app.row_links + .iter() + .flatten() .any(|hit| hit.url == "https://example.com") ); + app.selection = Some(Selection { + anchor: (0, 0), + head: (app.total_lines - 1, app.transcript_width - 1), + }); + let copied = app.selection_text().unwrap(); + assert!(copied.contains("link")); + assert!(!copied.contains('━')); } } @@ -6982,7 +6981,7 @@ mod tests { assert_eq!( rows.iter().map(|row| line_text(&row.0)).collect::>(), - ["› before", " Image #1", " ", " after", " "] + ["› before", " Image #1", " ", " after", " ", ""] ); assert_eq!(rows[1].2.len(), 1); assert_eq!(rows[1].2[0].url, "file:///tmp/image.png"); @@ -7083,11 +7082,11 @@ mod tests { refresh_transcript_cache_with_images(&mut app, &mut images, width); let cached = app.transcript_cache[0].as_ref().unwrap(); assert!(cached.images.is_empty()); - assert_eq!(cached.rows.len(), 1); + assert_eq!(cached.rows.len(), 2); assert_eq!(line_text(&cached.rows[0].0), "› Image #1"); assert_eq!(cached.rows[0].2.len(), 1); assert_eq!(cached.rows[0].2[0].url, "file:///tmp/image.png"); - assert_eq!(app.transcript_prefixes.last().copied(), Some(1)); + assert_eq!(app.transcript_prefixes.last().copied(), Some(2)); } let mut terminal = Terminal::new(TestBackend::new(40, 20)).unwrap(); @@ -7164,8 +7163,8 @@ mod tests { refresh_transcript_cache_with_images(&mut app, &mut images, width); let cached = app.transcript_cache[0].as_ref().unwrap(); assert!(cached.images.is_empty()); - assert_eq!(cached.rows.len(), 3); - assert_eq!(app.transcript_prefixes.last().copied(), Some(3)); + assert_eq!(cached.rows.len(), 4); + assert_eq!(app.transcript_prefixes.last().copied(), Some(4)); } let cached = app.transcript_cache[0].as_ref().unwrap(); let links = &cached.rows[1].2; From bf0405b2e4ae4031543610cd6b58e88ad90e2577 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 4 Nov 2026 15:13:44 -0800 Subject: [PATCH 3/5] fix(tui): match user message rules to the composer outline --- src/tui/ui.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 2fab1899..af3262a4 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1336,8 +1336,12 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti frame.render_widget(Paragraph::new(visible), inner); for row in user_rules { frame.render_widget( - Paragraph::new("━".repeat(inner.width as usize)) - .style(theme::bold(theme::user_color())), + Paragraph::new( + ratatui::symbols::border::ROUNDED + .horizontal_bottom + .repeat(inner.width as usize), + ) + .style(theme::accent()), Rect::new(inner.x, inner.y + row as u16, inner.width, 1), ); } @@ -6900,7 +6904,7 @@ mod tests { } #[test] - fn user_messages_have_bold_text_and_a_blue_rule_without_selection_fill() { + fn user_messages_have_bold_text_and_an_accent_rule_without_selection_fill() { use crate::tui::{app::Selection, theme}; use ratatui::style::{Color, Modifier}; @@ -6922,12 +6926,17 @@ mod tests { .unwrap(); let buffer = terminal.backend().buffer(); let rule_y = (app.transcript_top as u16..24) - .find(|&y| buffer[(1, y)].symbol() == "━") + .find(|&y| { + buffer[(1, y)].symbol() == ratatui::symbols::border::ROUNDED.horizontal_bottom + }) .unwrap(); for x in 1..1 + app.transcript_width as u16 { let cell = &buffer[(x, rule_y)]; - assert_eq!(cell.symbol(), "━"); - assert_eq!(cell.fg, theme::user_color()); + assert_eq!( + cell.symbol(), + ratatui::symbols::border::ROUNDED.horizontal_bottom + ); + assert_eq!(cell.fg, theme::accent_color()); assert_eq!(cell.bg, Color::Reset); } let prompt = &buffer[(3, app.transcript_top as u16)]; @@ -6945,7 +6954,7 @@ mod tests { }); let copied = app.selection_text().unwrap(); assert!(copied.contains("link")); - assert!(!copied.contains('━')); + assert!(!copied.contains('─')); } } From 8ac1b8c0623702ddbd4c782e16761ce6eff34312 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 4 Nov 2026 15:21:47 -0800 Subject: [PATCH 4/5] fix(tui): match submitted message carets to the input accent --- src/tui/ui.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index af3262a4..519cb6a5 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2074,7 +2074,7 @@ fn user_line(text: &str, first: bool) -> LinkedLine { let mut spans = vec![LinkedSpan { span: Span::styled( if first { "› " } else { " " }, - theme::bold(theme::user_color()), + theme::bold(theme::accent_color()), ), url: None, image_end: false, @@ -6939,6 +6939,10 @@ mod tests { assert_eq!(cell.fg, theme::accent_color()); assert_eq!(cell.bg, Color::Reset); } + let caret = &buffer[(1, app.transcript_top as u16)]; + assert_eq!(caret.symbol(), "›"); + assert_eq!(caret.fg, theme::accent_color()); + assert!(caret.modifier.contains(Modifier::BOLD)); let prompt = &buffer[(3, app.transcript_top as u16)]; assert!(prompt.modifier.contains(Modifier::BOLD)); assert_eq!(prompt.bg, Color::Reset); From 4d162a52982cac40a754f0b2f2cc40b7746af793 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Wed, 4 Nov 2026 15:31:02 -0800 Subject: [PATCH 5/5] fix(tui): exclude user rules from copied line breaks --- src/tui/ui.rs | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 519cb6a5..7fbd990e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1698,11 +1698,13 @@ fn user_block_rows(message: &UserMessage, width: usize) -> Vec