From 239e3c92f5366dd0e03ae42d77a1379c70c1f9c7 Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Wed, 24 Jun 2026 14:00:50 +0200 Subject: [PATCH 1/3] show recipient address on the send confirmation --- src/main.rs | 21 +++++++++++++++++++++ src/ui/input.rs | 28 +++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 4881a86..8476b81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2732,6 +2732,27 @@ mod tests { assert!(matches!(h.app.view, app::View::Thread(_))); } + #[test] + fn e2e_confirm_shows_recipient_address() { + let mut h = TuiHarness::new(); + + h.press_char('n'); + h.press_enter(); // select first contact + let recipient = h.app.msg_recipient.clone().expect("recipient set").1; + + h.type_text("hi"); + h.press_enter(); + h.install_confirm_for_pending_send(); + assert_eq!(h.app.overlay, Some(Overlay::Confirm)); + + let screen = h.screen(); + let prefix = &recipient[..recipient.len().min(10)]; + assert!( + screen.contains(prefix), + "confirm prompt should show the recipient address (looking for {prefix:?})" + ); + } + #[test] fn e2e_standalone_public_message_flow_submits_to_outbox() { let mut h = TuiHarness::new(); diff --git a/src/ui/input.rs b/src/ui/input.rs index 3ceefe9..3500041 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -288,7 +288,33 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { ), Span::styled(fee_text, Style::default().fg(palette::ACCENT)), ]); - frame.render_widget(Paragraph::new(vec![sep, preview_line, confirm_line]), area); + + // Show who the message is going to so the recipient can be verified + // before sending. Channels/groups have no single address, so keep the + // plain separator there. Replaces the separator line to avoid changing + // the overlay height. + let recipient = if let Some((_, ss58)) = &app.msg_recipient { + Some(ss58.clone()) + } else if let crate::app::View::Thread(idx) = app.view { + app.session.threads.get(idx).map(|t| t.peer_ss58.clone()) + } else { + None + }; + let header = match recipient { + Some(ss58) => Line::from(vec![ + Span::raw(" "), + Span::styled("To ", Style::default().fg(palette::MUTED)), + Span::styled( + fit(&ss58, usize::from(area.width).saturating_sub(4)), + Style::default().fg(palette::ACCENT), + ), + ]), + None => sep, + }; + frame.render_widget( + Paragraph::new(vec![header, preview_line, confirm_line]), + area, + ); } Some(Overlay::SenderPicker) | Some(Overlay::Help) From b28a260795417ab65efb971f57185a826642d692 Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Wed, 24 Jun 2026 14:21:16 +0200 Subject: [PATCH 2/3] show every line of a multi-line message on confirm --- src/main.rs | 11 +++++ src/ui/input.rs | 117 +++++++++++++++++++++++++++++------------------- src/ui/mod.rs | 3 ++ 3 files changed, 85 insertions(+), 46 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8476b81..79ad037 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2753,6 +2753,17 @@ mod tests { ); } + #[test] + fn e2e_confirm_shows_multiline_message() { + let mut h = TuiHarness::new(); + h.app.pending_text = Some("first line\nsecond line".to_string()); + h.app.overlay = Some(Overlay::Confirm); + + let screen = h.screen(); + assert!(screen.contains("first line"), "confirm should show line 1"); + assert!(screen.contains("second line"), "confirm should show line 2"); + } + #[test] fn e2e_standalone_public_message_flow_submits_to_outbox() { let mut h = TuiHarness::new(); diff --git a/src/ui/input.rs b/src/ui/input.rs index 3500041..56f11d3 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -161,6 +161,68 @@ fn render_single_input( } } +/// Preview lines for the Confirm overlay: the channel being created, or every +/// line of the message being sent (capped). Used both to render the overlay and +/// to size it (`render_main_panel`), so the height matches the content. +pub(super) fn confirm_preview(app: &App, width: u16) -> Vec> { + let reset = Style::default().fg(ratatui::style::Color::Reset); + let muted = Style::default().fg(palette::MUTED); + let max = usize::from(width).saturating_sub(4); + + if let Some(name) = &app.pending_channel_name { + let desc = app.pending_channel_desc.as_deref().unwrap_or(""); + let text = if desc.is_empty() { + format!(" #{name}") + } else { + format!(" #{name} -- {desc}") + }; + return vec![Line::from(Span::styled(fit(&text, max), reset))]; + } + + let Some(text) = &app.pending_text else { + return vec![Line::raw("")]; + }; + if text.is_empty() { + return vec![Line::from(Span::styled( + " empty", + muted.add_modifier(Modifier::ITALIC), + ))]; + } + + let total = text.lines().count(); + if total <= 1 { + let first = text.lines().next().unwrap_or(""); + return vec![Line::from(Span::styled( + fit(&format!(" \"{first}\" ({} chars)", text.len()), max), + reset, + ))]; + } + + // Multi-line message: show each line (capped), then a dim summary line. + const MAX_LINES: usize = 6; + let mut lines: Vec> = text + .lines() + .take(MAX_LINES) + .map(|l| { + Line::from(Span::styled( + format!(" {}", fit(l, max.saturating_sub(2))), + reset, + )) + }) + .collect(); + let suffix = if total > MAX_LINES { + format!( + " \u{2026} +{} more lines ({} chars)", + total - MAX_LINES, + text.len() + ) + } else { + format!(" ({} chars)", text.len()) + }; + lines.push(Line::from(Span::styled(suffix, muted))); + lines +} + pub fn render(frame: &mut Frame, app: &App, area: Rect) { let sep = sep_line(area.width); @@ -235,48 +297,11 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { Some(fee) => format!("Fee: {fee}"), None => format!("{} Estimating fee", app.spinner_1()), }; - - let is_channel = app.is_pending_channel(); - let action = if is_channel { "Create?" } else { "Send?" }; - - let (preview, is_empty_preview) = if let Some(name) = &app.pending_channel_name { - let desc = app.pending_channel_desc.as_deref().unwrap_or(""); - let text = if desc.is_empty() { - format!(" #{name}") - } else { - format!(" #{name} -- {desc}") - }; - let max = usize::from(area.width) - 2; - let trimmed = if text.len() > max { - format!("{}\u{2026}", &text[..max.saturating_sub(1)]) - } else { - text - }; - (trimmed, false) - } else if let Some(text) = &app.pending_text { - let first = text.lines().next().unwrap_or(""); - let max = (usize::from(area.width)).saturating_sub(16); - let display = if first.len() > max { - format!("\"{}\u{2026}\"", &first[..max.saturating_sub(3)]) - } else { - format!("\"{first}\"") - }; - if text.is_empty() { - (" empty".to_string(), true) - } else { - (format!(" {display} ({} chars)", text.len()), false) - } + let action = if app.is_pending_channel() { + "Create?" } else { - (String::new(), false) + "Send?" }; - let preview_style = if is_empty_preview { - Style::default() - .fg(palette::MUTED) - .add_modifier(Modifier::ITALIC) - } else { - Style::default().fg(ratatui::style::Color::Reset) - }; - let preview_line = Line::from(vec![Span::styled(preview, preview_style)]); let confirm_line = Line::from(vec![ Span::raw(" "), @@ -291,8 +316,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { // Show who the message is going to so the recipient can be verified // before sending. Channels/groups have no single address, so keep the - // plain separator there. Replaces the separator line to avoid changing - // the overlay height. + // plain separator there. let recipient = if let Some((_, ss58)) = &app.msg_recipient { Some(ss58.clone()) } else if let crate::app::View::Thread(idx) = app.view { @@ -311,10 +335,11 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { ]), None => sep, }; - frame.render_widget( - Paragraph::new(vec![header, preview_line, confirm_line]), - area, - ); + + let mut lines = vec![header]; + lines.extend(confirm_preview(app, area.width)); + lines.push(confirm_line); + frame.render_widget(Paragraph::new(lines), area); } Some(Overlay::SenderPicker) | Some(Overlay::Help) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 36ea86d..7d2e5bd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -132,6 +132,9 @@ fn render_main_panel(frame: &mut Frame, app: &mut App, area: Rect) { let text_lines = if app.is_composing() && !app.input.is_empty() { app.input.split('\n').count().clamp(1, 4) + } else if matches!(app.overlay, Some(crate::app::Overlay::Confirm)) { + // Grow to fit the message preview (header + preview + confirm line). + input::confirm_preview(app, inner.width).len() } else { 1 }; From 5a6f127c559d5798826333977c38fe688af2e73b Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Wed, 24 Jun 2026 15:36:14 +0200 Subject: [PATCH 3/3] show arrows when composer lines scroll off-screen --- src/main.rs | 25 +++++++++++++++++++++++++ src/ui/composer.rs | 9 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 79ad037..d83924a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2764,6 +2764,31 @@ mod tests { assert!(screen.contains("second line"), "confirm should show line 2"); } + #[test] + fn e2e_composer_indicates_hidden_lines() { + let mut h = TuiHarness::new(); + h.press_char('n'); + h.press_enter(); // -> focus Composer in a thread + h.app.input.set("l1\nl2\nl3\nl4\nl5\nl6".to_string()); + + // cursor sits at the end, so earlier lines are scrolled off above. + let scrolled_down = h.screen(); + assert!( + scrolled_down.contains('\u{2191}'), + "composer should show an up arrow when lines are hidden above" + ); + + // move to the top, so later lines are scrolled off below. + for _ in 0..6 { + h.app.input.move_line_up(); + } + let scrolled_up = h.screen(); + assert!( + scrolled_up.contains('\u{2193}'), + "composer should show a down arrow when lines are hidden below" + ); + } + #[test] fn e2e_standalone_public_message_flow_submits_to_outbox() { let mut h = TuiHarness::new(); diff --git a/src/ui/composer.rs b/src/ui/composer.rs index 0c2352c..4e93e2f 100644 --- a/src/ui/composer.rs +++ b/src/ui/composer.rs @@ -330,7 +330,14 @@ pub fn render_composer(frame: &mut Frame, app: &App, sep: Line<'_>, area: Rect) avail, None, ); - let line_prompt = if i == scroll_start && scroll_start == 0 { + // Gutter: the prompt on the first line, or an arrow when there are more + // lines scrolled off above/below (so vertical overflow is visible, like + // the "..." for an over-long single line). + let line_prompt = if i == scroll_start && scroll_start > 0 { + "\u{2191} " + } else if i == scroll_end - 1 && scroll_end < total_lines { + "\u{2193} " + } else if i == scroll_start && scroll_start == 0 { prompt } else { " "