diff --git a/crates/hi-tui/src/app/commands.rs b/crates/hi-tui/src/app/commands.rs index 4f733741..d3138bf8 100644 --- a/crates/hi-tui/src/app/commands.rs +++ b/crates/hi-tui/src/app/commands.rs @@ -196,7 +196,9 @@ impl crate::App { body.push_str("\n## transcript\n"); for entry in &self.transcript { match entry { - crate::TranscriptEntry::Line(_) | crate::TranscriptEntry::ToolOutput { .. } => { + crate::TranscriptEntry::Line(_) + | crate::TranscriptEntry::UserPrompt(_) + | crate::TranscriptEntry::ToolOutput { .. } => { body.push_str(&entry.text()); body.push('\n'); } diff --git a/crates/hi-tui/src/app/render.rs b/crates/hi-tui/src/app/render.rs index a1d0bd36..76712a57 100644 --- a/crates/hi-tui/src/app/render.rs +++ b/crates/hi-tui/src/app/render.rs @@ -7,7 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Paragraph, Wrap}; use crate::model_picker::{display_capabilities, display_price, display_window}; -use crate::render::{diff_lines, dim, markdown_line, wrapped_height}; +use crate::render::{diff_lines, dim, markdown_line, wrapped_line_height}; use crate::util::{clip_reason, fmt_count, fmt_elapsed, fmt_rate_limits}; use crate::{PICKER_ROWS, SPINNER, TurnEventKind, TurnState}; @@ -540,11 +540,15 @@ impl crate::App { .add_modifier(Modifier::ITALIC), )); } - lines.extend( - self.transcript - .iter() - .flat_map(|e| e.flatten(self.show_reasoning, self.show_tool_output)), - ); + // Build the flattened lines, recording where each user prompt starts so + // its position can be pinned as a sticky header when scrolled past. + let mut prompt_line_starts: Vec = Vec::new(); + for entry in &self.transcript { + if matches!(entry, crate::TranscriptEntry::UserPrompt(_)) { + prompt_line_starts.push(lines.len()); + } + lines.extend(entry.flatten(self.show_reasoning, self.show_tool_output)); + } if let Some((style, markdown, text)) = &self.pending { // Style the in-progress line live (headings, bold, code, …) so prose // doesn't snap into formatting only when its newline lands. The line @@ -576,7 +580,17 @@ impl crate::App { } } } - let total = wrapped_height(&lines, inner_w); + // Per-line wrapped heights → prefix sums, so the total matches the render + // path exactly AND each prompt's wrapped-row offset is available for the + // sticky-header lookup, all in one measuring pass. + let mut prefix: Vec = Vec::with_capacity(lines.len() + 1); + let mut cum = 0u32; + prefix.push(0); + for line in &lines { + cum = cum.saturating_add(wrapped_line_height(line, inner_w) as u32); + prefix.push(cum); + } + let total = cum.min(u16::MAX as u32) as u16; let max_scroll = total.saturating_sub(inner_h); // Cache the geometry so scroll events (which fire outside render) can clamp // and detect the bottom. @@ -591,6 +605,19 @@ impl crate::App { self.scroll }; + // Sticky header: when scrolled past a prompt, pin the most recent prompt + // at or above the viewport top (only if it's *strictly* above — a prompt + // sitting at the top row is already visible). `None` while following. + let sticky_prompt: Option> = if self.following { + None + } else { + prompt_line_starts + .iter() + .rev() + .find(|&&idx| (prefix[idx] as u16) < scroll) + .map(|&idx| lines[idx].clone()) + }; + let mut block = Block::bordered() .border_type(BorderType::Rounded) .border_style(Style::default().fg(th.prompt_border)) @@ -621,6 +648,32 @@ impl crate::App { .scroll((scroll, 0)); frame.render_widget(para, rows[0]); + // Overlay the sticky prompt header on the top inner row, so scrolling + // through long output always shows which prompt it belongs to. A subtle + // band (truecolor) marks it as pinned rather than in-flow content. + if let Some(mut sticky) = sticky_prompt + && inner_h >= 1 + && inner_w >= 1 + { + if th.paints_backgrounds() { + sticky.style = sticky.style.bg(th.band_user); + let used: usize = sticky.spans.iter().map(|s| s.content.chars().count()).sum(); + if (used as u16) < inner_w { + sticky.spans.push(Span::styled( + " ".repeat(inner_w as usize - used), + Style::default().bg(th.band_user), + )); + } + } + let sticky_area = ratatui::layout::Rect { + x: rows[0].x + 1, + y: rows[0].y + 1, + width: inner_w, + height: 1, + }; + frame.render_widget(Paragraph::new(vec![sticky]), sticky_area); + } + // --- Bottom region: a fetch/plan spinner, the model picker, or the input bar. --- if let Some(request) = &self.confirmation { let details = request.details(); diff --git a/crates/hi-tui/src/app/run.rs b/crates/hi-tui/src/app/run.rs index 9d0ca654..feaeb9aa 100644 --- a/crates/hi-tui/src/app/run.rs +++ b/crates/hi-tui/src/app/run.rs @@ -1591,7 +1591,7 @@ pub async fn run( // Mirrors the main turn path so cancellation, // background-process cleanup, and session-state // rewind are handled identically. - app.push(ratatui::text::Line::styled( + app.push_user_prompt(ratatui::text::Line::styled( format!("❯ {run_line}"), ratatui::style::Style::default() .fg(crate::theme::theme().accent_user), @@ -2114,7 +2114,7 @@ pub async fn run( }; // --- Turn phase: run the agent behind a channel, staying responsive. --- - app.push(ratatui::text::Line::styled( + app.push_user_prompt(ratatui::text::Line::styled( format!("❯ {run_line}"), ratatui::style::Style::default().fg(crate::theme::theme().accent_user), )); diff --git a/crates/hi-tui/src/app/transcript.rs b/crates/hi-tui/src/app/transcript.rs index 59797d16..535567dc 100644 --- a/crates/hi-tui/src/app/transcript.rs +++ b/crates/hi-tui/src/app/transcript.rs @@ -50,6 +50,13 @@ impl crate::App { self.cap_transcript(); } + /// Push a user-prompt echo as a structurally-distinct entry so the render + /// pass can pin it as a sticky header when scrolled past. + pub(crate) fn push_user_prompt(&mut self, line: Line<'static>) { + self.transcript.push(TranscriptEntry::UserPrompt(line)); + self.cap_transcript(); + } + /// Bound the transcript so a very long session can't overflow the u16 scroll /// range, slow the per-frame render clone, or grow memory without limit. Older /// lines scroll off the top (the full session is still in the JSONL log). Only diff --git a/crates/hi-tui/src/lib.rs b/crates/hi-tui/src/lib.rs index 58312b5b..8ff7d2c2 100644 --- a/crates/hi-tui/src/lib.rs +++ b/crates/hi-tui/src/lib.rs @@ -371,6 +371,11 @@ pub(crate) fn apply_metadata( #[derive(Clone)] pub(crate) enum TranscriptEntry { Line(Line<'static>), + /// A user prompt echo (`❯ …`). Structurally distinct from a plain `Line` so + /// the render pass can find prompt boundaries for sticky headers — when the + /// transcript is scrolled past a prompt, that prompt pins to the top so the + /// visible output always shows which request it belongs to. + UserPrompt(Line<'static>), /// Assistant reasoning/thinking, buffered until the reasoning phase ends. /// Shown collapsed ("thought for Ns") unless `show_reasoning` is on. Reasoning { @@ -422,7 +427,7 @@ impl TranscriptEntry { ) -> Vec> { let th = crate::theme::theme(); match self { - TranscriptEntry::Line(line) => vec![line.clone()], + TranscriptEntry::Line(line) | TranscriptEntry::UserPrompt(line) => vec![line.clone()], TranscriptEntry::Reasoning { text, elapsed } => { let secs = elapsed.as_secs(); let label = if secs >= 60 { @@ -489,7 +494,7 @@ impl TranscriptEntry { /// content regardless of collapse state). pub(crate) fn text(&self) -> String { match self { - TranscriptEntry::Line(line) => line_text(line), + TranscriptEntry::Line(line) | TranscriptEntry::UserPrompt(line) => line_text(line), TranscriptEntry::Reasoning { text, .. } => text.clone(), TranscriptEntry::ToolOutput { body } => { body.iter().map(line_text).collect::>().join("\n") diff --git a/crates/hi-tui/src/render.rs b/crates/hi-tui/src/render.rs index ebe9254f..76123bf2 100644 --- a/crates/hi-tui/src/render.rs +++ b/crates/hi-tui/src/render.rs @@ -375,25 +375,31 @@ fn find_double_star(chars: &[char], from: usize) -> Option { /// undercounted whenever word-boundary wrapping produced extra rows — the /// accumulated shortfall made `max_scroll` too small and the bottom of a /// long message was clipped off-screen. +/// Wrapped row count for a single line at `width` (the same `WordWrapper` the +/// render path uses). `width == 0` → 1 row. Used to locate sticky-header +/// positions without re-measuring the whole transcript. +pub(crate) fn wrapped_line_height(line: &Line, width: u16) -> u16 { + if width == 0 { + return 1; + } + ratatui::widgets::Paragraph::new(vec![line.clone()]) + .wrap(ratatui::widgets::Wrap { trim: false }) + .line_count(width) + .min(u16::MAX as usize) as u16 +} + +/// Total wrapped height of `lines` at `width`, saturating at `u16::MAX`. The +/// render path now sums [`wrapped_line_height`] inline (it also needs each +/// prefix offset for sticky headers); this stays for the overflow-saturation +/// regression test, which pins that a very tall transcript can't wrap the sum +/// back to a tiny value and freeze scrolling. +#[cfg(test)] pub(crate) fn wrapped_height(lines: &[Line], width: u16) -> u16 { - // Sum in usize and saturate to u16. A long transcript can exceed u16 rows, and - // a u16 sum (or `as u16` per line) would wrap to a tiny value — zeroing - // max_scroll and freezing scrolling. u16::MAX is also ratatui's scroll ceiling. - let total: usize = if width == 0 { - lines.len() - } else { - // `line_count` includes the block's vertical space (borders). We pass - // the *inner* width and no block, so it returns the pure text height. - // Each call constructs a small Paragraph — cheap relative to rendering. - let mut sum = 0usize; - for line in lines { - let para = ratatui::widgets::Paragraph::new(vec![line.clone()]) - .wrap(ratatui::widgets::Wrap { trim: false }); - sum = sum.saturating_add(para.line_count(width)); - } - sum - }; - total.min(u16::MAX as usize) as u16 + let mut sum = 0u32; + for line in lines { + sum = sum.saturating_add(wrapped_line_height(line, width) as u32); + } + sum.min(u16::MAX as u32) as u16 } #[cfg(test)] diff --git a/crates/hi-tui/src/tests.rs b/crates/hi-tui/src/tests.rs index 72069939..193385c5 100644 --- a/crates/hi-tui/src/tests.rs +++ b/crates/hi-tui/src/tests.rs @@ -2535,3 +2535,40 @@ fn tool_output_body_carries_panel_background_when_theme_paints() { ); } } + +#[test] +fn sticky_prompt_header_pins_when_scrolled_past() { + let mut app = test_app("pipe", "glm-5.2"); + app.push_user_prompt(Line::styled( + "❯ first question about the parser", + Style::default().fg(crate::theme::theme().accent_user), + )); + // A long block of output so the prompt scrolls off the top. + for i in 0..60 { + app.push(Line::raw(format!("output line {i}"))); + } + let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap(); + + // First render (following = bottom-pinned): NO sticky, top row is the border. + term.draw(|f| app.render(f)).unwrap(); + let bottom_pinned = dump(&term); + let first_content_row = bottom_pinned.lines().nth(1).unwrap_or(""); + assert!( + !first_content_row.contains("first question"), + "while following, the prompt is not pinned: {first_content_row:?}" + ); + + // Scroll up to the top: the prompt is now above the viewport → pinned. + app.following = false; + app.scroll = 0; // top of transcript + // At scroll 0 the prompt IS visible (offset 0 == scroll), so it should NOT + // pin. Now scroll down past it. + app.scroll = 30; + term.draw(|f| app.render(f)).unwrap(); + let scrolled = dump(&term); + let top_content_row = scrolled.lines().nth(1).unwrap_or(""); + assert!( + top_content_row.contains("first question"), + "the governing prompt pins to the top when scrolled past: {top_content_row:?}" + ); +} diff --git a/scripts/file_size_baseline.txt b/scripts/file_size_baseline.txt index 560501b0..3754806b 100644 --- a/scripts/file_size_baseline.txt +++ b/scripts/file_size_baseline.txt @@ -28,12 +28,13 @@ 3111 crates/hi-tools/src/tools.rs 1489 crates/hi-tools/src/transaction.rs 1301 crates/hi-tools/src/web.rs -894 crates/hi-tui/src/app/commands.rs -1216 crates/hi-tui/src/app/render.rs +896 crates/hi-tui/src/app/commands.rs +1269 crates/hi-tui/src/app/render.rs 2569 crates/hi-tui/src/app/run.rs 1758 crates/hi-tui/src/dashboard.rs 2527 crates/hi-tui/src/loops.rs -2537 crates/hi-tui/src/tests.rs +2574 crates/hi-tui/src/tests.rs 934 crates/hi-tui/src/watch.rs 1016 crates/hi-agent/src/goal.rs 1476 crates/hi-agent/src/tests/goal.rs +801 crates/hi-tui/src/lib.rs