Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/hi-tui/src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
67 changes: 60 additions & 7 deletions crates/hi-tui/src/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<usize> = 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
Expand Down Expand Up @@ -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<u32> = 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.
Expand All @@ -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<Line<'static>> = 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))
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions crates/hi-tui/src/app/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
));
Expand Down
7 changes: 7 additions & 0 deletions crates/hi-tui/src/app/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions crates/hi-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -422,7 +427,7 @@ impl TranscriptEntry {
) -> Vec<Line<'static>> {
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 {
Expand Down Expand Up @@ -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::<Vec<_>>().join("\n")
Expand Down
42 changes: 24 additions & 18 deletions crates/hi-tui/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,25 +375,31 @@ fn find_double_star(chars: &[char], from: usize) -> Option<usize> {
/// 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)]
Expand Down
37 changes: 37 additions & 0 deletions crates/hi-tui/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
7 changes: 4 additions & 3 deletions scripts/file_size_baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading