docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-23 16:55:39 -04:00
1 parent ff0fb56c67
commit a02b555e66
1458 files changed
+10729 -21750

No files matched your search

@@ -51,7 +51,6 @@ pub struct AnchoredMedia {
/// `ui/render/renderable.rs`.
#[enum_delegate::register]
pub trait BlockContent {
/// Produce renderable content for the given context.
fn output(&self, ctx: &BlockContext) -> BlockOutput;
/// Accent line style (color, animation).
@@ -72,12 +71,10 @@ pub trait BlockContent {
self.accent(ctx)
}
/// Whether accent column gets block's background.
fn accent_background(&self, _ctx: &BlockContext) -> bool {
false
}
/// Block content area background.
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
BlockBackground::None
}
@@ -87,12 +84,10 @@ pub trait BlockContent {
true
}
/// Whether block supports raw mode toggle.
fn has_raw_mode(&self) -> bool {
false
}
/// Whether block can be collapsed/expanded.
fn is_foldable(&self) -> bool {
true
}
@@ -105,7 +100,7 @@ pub trait BlockContent {
/// The `is_running` parameter allows blocks to behave differently
/// while streaming (e.g., thinking blocks might skip Collapsed while running).
fn next_fold_mode(&self, current: DisplayMode, is_running: bool) -> DisplayMode {
let _ = is_running; // Default ignores running state
let _ = is_running;
match current {
DisplayMode::Collapsed => DisplayMode::Expanded,
DisplayMode::Truncated | DisplayMode::Expanded => DisplayMode::Collapsed,
@@ -360,15 +355,11 @@ impl BlockContent for StubBlock {
/// intercept `output()` to conditionally prepend bullets based on `has_bullet()`.
#[derive(Debug, Clone)]
pub enum RenderBlock {
/// Stub block for testing.
Stub(StubBlock),
/// User's prompt.
UserPrompt(UserPromptBlock),
/// Agent's response message.
AgentMessage(AgentMessageBlock),
/// Tool call result (Execute, Read, Edit, ListDir, Search, Other).
ToolCall(ToolCallBlock),
/// Thinking/reasoning content.
Thinking(ThinkingBlock),
/// System message (arbitrary text).
System(SystemMessageBlock),
@@ -384,7 +375,6 @@ pub enum RenderBlock {
ContextInfo(ContextInfoBlock),
}
/// Delegate a method call to the inner block variant.
macro_rules! delegate_block {
($self:expr, $method:ident ( $($arg:expr),* )) => {
match $self {
@@ -571,7 +561,6 @@ impl RenderBlock {
RenderBlock::Stub(StubBlock::non_groupable(text, accent_color))
}
/// Create a user prompt block.
pub fn user_prompt(text: impl Into<String>) -> Self {
RenderBlock::UserPrompt(UserPromptBlock::new(text))
}
@@ -606,7 +595,6 @@ impl RenderBlock {
RenderBlock::UserPrompt(UserPromptBlock::interjection(text))
}
/// Create an agent message block.
pub fn agent_message(text: impl Into<String>) -> Self {
RenderBlock::AgentMessage(AgentMessageBlock::new(text))
}
@@ -623,7 +611,6 @@ impl RenderBlock {
pub fn tool_call(kind: impl Into<String>, summary: impl Into<String>, success: bool) -> Self {
let kind_str = kind.into();
let mut block = ToolCallBlock::from_name(&kind_str, summary);
// Set error for Other type if not successful
if let ToolCallBlock::Other(ref mut b) = block
&& !success
{
@@ -646,7 +633,6 @@ impl RenderBlock {
RenderBlock::ToolCall(ToolCallBlock::Other(block))
}
/// Create an Execute tool block.
pub fn execute(command: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Execute(ExecuteToolCallBlock::new(command)))
}
@@ -665,7 +651,6 @@ impl RenderBlock {
RenderBlock::ToolCall(ToolCallBlock::Execute(block))
}
/// Create a Read tool block.
pub fn read(path: impl Into<String>, line_range: Option<LineRange>) -> Self {
let mut block = ReadToolCallBlock::new(path);
if let Some(range) = line_range {
@@ -674,19 +659,16 @@ impl RenderBlock {
RenderBlock::ToolCall(ToolCallBlock::Read(block))
}
/// Create a ListDir tool block.
pub fn list_dir(path: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::ListDir(ListDirToolCallBlock::new(path)))
}
/// Create a ListDir tool block with output.
pub fn list_dir_with_output(path: impl Into<String>, output: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::ListDir(
ListDirToolCallBlock::new(path).with_output(output),
))
}
/// Create a Search tool block with matches.
pub fn search(
pattern: impl Into<String>,
match_count: usize,
@@ -697,12 +679,10 @@ impl RenderBlock {
))
}
/// Create an Edit block with diff hunks.
pub fn edit_with_hunks(path: impl Into<String>, hunks: Vec<DiffHunk>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Edit(EditToolCallBlock::new(path, hunks)))
}
/// Create a failed Edit block (with error message).
pub fn edit_failed(path: impl Into<String>, error: impl Into<String>) -> Self {
RenderBlock::ToolCall(ToolCallBlock::Edit(
EditToolCallBlock::new(path, vec![]).with_error(error),
@@ -721,7 +701,6 @@ impl RenderBlock {
RenderBlock::ToolCall(ToolCallBlock::Edit(block))
}
/// Create a thinking block.
pub fn thinking(text: impl Into<String>) -> Self {
RenderBlock::Thinking(ThinkingBlock::new(text))
}
@@ -752,7 +731,6 @@ impl RenderBlock {
RenderBlock::Thinking(ThinkingBlock::streaming_replay())
}
/// Create a system message block.
pub fn system(text: impl Into<String>) -> Self {
RenderBlock::System(SystemMessageBlock::new(text))
}
@@ -769,7 +747,6 @@ impl RenderBlock {
RenderBlock::ContextInfo(ContextInfoBlock::new(snapshot, model))
}
/// Create a session event block.
pub fn session_event(event: SessionEvent) -> Self {
RenderBlock::SessionEvent(SessionEventBlock::new(event))
}
@@ -779,7 +756,6 @@ impl RenderBlock {
RenderBlock::BgTask(BgTaskBlock::started(command, task_id))
}
/// Create a "Task completed" background task block.
pub fn bg_task_completed(
command: impl Into<String>,
task_id: impl Into<String>,
@@ -788,7 +764,6 @@ impl RenderBlock {
RenderBlock::BgTask(BgTaskBlock::completed(command, task_id, elapsed))
}
/// Create a "Task failed" background task block.
pub fn bg_task_failed(
command: impl Into<String>,
task_id: impl Into<String>,
@@ -809,7 +784,6 @@ impl RenderBlock {
self
}
/// Get mutable access to a StubBlock if this is one.
pub fn as_stub_mut(&mut self) -> Option<&mut StubBlock> {
match self {
RenderBlock::Stub(b) => Some(b),
@@ -817,7 +791,6 @@ impl RenderBlock {
}
}
/// Get mutable access to a ToolCallBlock if this is one.
pub fn as_tool_call_mut(&mut self) -> Option<&mut ToolCallBlock> {
match self {
RenderBlock::ToolCall(b) => Some(b),
@@ -848,7 +821,6 @@ impl RenderBlock {
}
}
/// Get mutable access to a ThinkingBlock if this is one.
pub fn as_thinking_mut(&mut self) -> Option<&mut ThinkingBlock> {
match self {
RenderBlock::Thinking(b) => Some(b),
@@ -856,7 +828,6 @@ impl RenderBlock {
}
}
/// Check if this block is a UserPrompt.
pub fn is_user_prompt(&self) -> bool {
matches!(self, RenderBlock::UserPrompt(_))
}
@@ -894,7 +865,6 @@ impl RenderBlock {
matches!(self, RenderBlock::Subagent(_))
}
/// Check if this block is an AgentMessage.
pub fn is_agent_message(&self) -> bool {
matches!(self, RenderBlock::AgentMessage(_))
}
@@ -959,7 +929,7 @@ impl RenderBlock {
match self {
RenderBlock::UserPrompt(_) => Some(theme.text_primary),
RenderBlock::AgentMessage(_) => None, // No accent for agent messages
RenderBlock::AgentMessage(_) => None,
RenderBlock::ToolCall(block) => {
// Execute: Green for success, red for failure
// Read/Edit/ListDir/Search: No accent
@@ -978,7 +948,7 @@ impl RenderBlock {
Some(theme.accent_error)
}
}
_ => None, // No accent for Read/Edit/ListDir/Search
_ => None,
}
}
RenderBlock::Thinking(_) => Some(theme.accent_thinking),
@@ -1004,7 +974,6 @@ impl RenderBlock {
}
}
/// Whether this block has a normal fullscreen viewer.
pub fn has_normal_fullscreen_viewer(&self) -> bool {
match self {
RenderBlock::AgentMessage(_)
@@ -1025,14 +994,12 @@ impl RenderBlock {
}
}
/// Whether this block supports the fullscreen viewer.
pub fn supports_fullscreen(&self) -> bool {
self.has_normal_fullscreen_viewer()
|| !self.image_references().is_empty()
|| !self.video_references().is_empty()
}
/// Whether this block supports copy-to-clipboard.
pub fn supports_copy(&self) -> bool {
matches!(
self,
@@ -1052,7 +1019,6 @@ impl RenderBlock {
!matches!(self, RenderBlock::Stub(_))
}
/// Get the visible text for this block in its current display state.
pub fn copy_visible_text_in_state(&self, ctx: &BlockContext) -> Option<String> {
let rendered = self.rendered_output(ctx);
plain_text_from_output(&rendered.output, &rendered.boundaries)
@@ -1184,7 +1150,7 @@ impl RenderBlock {
match self {
RenderBlock::AgentMessage(block) => block.set_raw_mode(raw),
RenderBlock::Thinking(block) => block.set_raw_mode(raw),
_ => {} // Other blocks don't support raw mode
_ => {}
}
}
}
@@ -52,7 +52,6 @@ impl AgentMessageBlock {
}
}
/// Push a streaming chunk of markdown text.
pub fn push_chunk(&mut self, chunk: &str) {
self.content.push_chunk(chunk);
}
@@ -62,7 +61,6 @@ impl AgentMessageBlock {
self.content.push_chunk_deferred(chunk);
}
/// Get the source markdown text.
pub fn text(&self) -> String {
self.content.text()
}
@@ -99,7 +97,6 @@ impl AgentMessageBlock {
&self.content
}
/// Mutable access to the underlying markdown content.
pub fn content_mut(&mut self) -> &mut MarkdownContent {
&mut self.content
}
@@ -376,8 +373,6 @@ mod tests {
);
}
// -- diagram affordance rows ---------------------------------------------
mod affordances {
use super::*;
@@ -36,7 +36,6 @@ pub enum BgTaskKind {
/// Enter / Ctrl-F opens block viewer with stdout from the central store.
#[derive(Debug, Clone)]
pub struct BgTaskBlock {
/// The command that was run.
pub command: String,
/// Background task ID (for looking up stdout in central store).
pub task_id: String,
@@ -47,7 +46,6 @@ pub struct BgTaskBlock {
}
impl BgTaskBlock {
/// Create a "Task started" block.
pub fn started(command: impl Into<String>, task_id: impl Into<String>) -> Self {
Self {
command: command.into(),
@@ -57,7 +55,6 @@ impl BgTaskBlock {
}
}
/// Create a "Task completed" block.
pub fn completed(
command: impl Into<String>,
task_id: impl Into<String>,
@@ -71,7 +68,6 @@ impl BgTaskBlock {
}
}
/// Create a "Task failed" block.
pub fn failed(
command: impl Into<String>,
task_id: impl Into<String>,
@@ -23,7 +23,6 @@ pub struct BtwBlock {
}
impl BtwBlock {
/// Create a btw block from the question and response text.
pub fn new(question: impl Into<String>, response: impl Into<String>) -> Self {
Self {
question: question.into(),
@@ -31,7 +30,6 @@ impl BtwBlock {
}
}
/// Access the underlying markdown content.
pub fn content(&self) -> &MarkdownContent {
&self.content
}
@@ -52,7 +52,6 @@ use kigi_shell::session::{ContextInfo, count_detail};
/// enter the bar.
#[derive(Debug, Clone)]
pub struct ContextInfoBlock {
/// The captured context-window snapshot.
pub snapshot: ContextInfo,
/// Active model name at the time of capture (display-only).
pub model: String,
@@ -254,7 +253,6 @@ impl RowLayout {
}
impl ContextInfoBlock {
/// Create a new context-info block.
pub fn new(snapshot: ContextInfo, model: impl Into<String>) -> Self {
Self {
snapshot,
@@ -301,10 +299,10 @@ impl ContextInfoBlock {
// are the conversation the user is actually steering. System
// prompt uses the same diamond glyph as messages but in muted
// gray so it reads as a quiet base layer.
let system_color = quantize(theme.gray_bright); // gray
let tools_color = quantize(theme.accent_skill); // teal / skill accent
let messages_color = quantize(theme.text_primary); // primary (white)
let empty_color = quantize(theme.gray_dim); // free / outline
let system_color = quantize(theme.gray_bright);
let tools_color = quantize(theme.accent_skill);
let messages_color = quantize(theme.text_primary);
let empty_color = quantize(theme.gray_dim);
let overhead_color = quantize(theme.accent_verify);
// Categorical bar: 100 cells laid out as `bar.rows` rows of
@@ -314,11 +312,16 @@ impl ContextInfoBlock {
// Routed through `glyphs` so the diamonds degrade to CP437-safe
// stand-ins (`◆`→`♦`, `◇`→`○`) on legacy Windows consoles that
// can't render the U+25Cx diamonds.
let system_glyph = crate::glyphs::diamond_filled(); // ◆ (gray)
let tools_glyph = crate::glyphs::diamond_dotted(); // ◈
let messages_glyph = crate::glyphs::diamond_filled(); // ◆ (primary)
let free_glyph = crate::glyphs::diamond_hollow(); // ◇
let overhead_glyph = crate::glyphs::diamond_filled(); // ◆ (violet)
// ◆ (gray)
let system_glyph = crate::glyphs::diamond_filled();
// ◈
let tools_glyph = crate::glyphs::diamond_dotted();
// ◆ (primary)
let messages_glyph = crate::glyphs::diamond_filled();
// ◇
let free_glyph = crate::glyphs::diamond_hollow();
// ◆ (violet)
let overhead_glyph = crate::glyphs::diamond_filled();
let total_cells = bar.total();
let cells_for = |tokens: u64| -> usize {
@@ -421,9 +424,7 @@ impl ContextInfoBlock {
let label_style = Style::default().fg(theme.text_secondary);
let mut lines: Vec<Line<'static>> = vec![
// Header: bold white "Context"
Line::from(Span::styled("Context", primary)),
// Blank row between header and the at-a-glance summary
Line::from(""),
// Sub-header: token totals + percent. Uses `text_secondary` for
// a touch more contrast than `muted` so the at-a-glance numbers
@@ -448,7 +449,6 @@ impl ContextInfoBlock {
model.to_string(),
Style::default().fg(theme.gray_bright),
)),
// Blank space before the bar
Line::from(""),
];
lines.extend(bar_lines);
@@ -501,7 +501,6 @@ impl ContextInfoBlock {
lines.push(Line::from(""));
}
// Footer stats
lines.push(Line::from(Span::styled(
format!(
"Turns: {turn_count} \u{00b7} Tool calls: {tool_call_count} \u{00b7} Compactions: {compaction_count}"
@@ -598,7 +597,6 @@ impl BlockContent for ContextInfoBlock {
.map(|line| BlockLine::styled(line).with_selection_range(Some(0)))
.collect();
// Apply max_lines budget if set
let lines = if let Some(max) = ctx.max_lines {
let max = max as usize;
if all_lines.len() > max && max > 0 {
@@ -634,7 +632,8 @@ impl BlockContent for ContextInfoBlock {
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false // Compact like SystemMessageBlock
// Compact like SystemMessageBlock
false
}
fn has_raw_mode(&self) -> bool {
@@ -769,7 +768,8 @@ mod tests {
// avoid stacking two warning-styled lines that contradict each
// other (manual /compact vs. auto-compact about to fire).
let mut snap = snapshot();
snap.usage_pct = 85; // the historical default (and value in snapshot() helper)
// the historical default (and value in snapshot() helper)
snap.usage_pct = 85;
let block = ContextInfoBlock::new(snap, "kigi-4");
let theme = test_theme();
let lines = block.build_lines(&theme, BarLayout::WIDE);
@@ -821,7 +821,8 @@ mod tests {
#[test]
fn fmt_tok_big_switches_to_millions_at_one_million() {
assert_eq!(fmt_tok_big(999_999), fmt_tok(999_999)); // delegates below 1m
// delegates below 1m
assert_eq!(fmt_tok_big(999_999), fmt_tok(999_999));
assert_eq!(fmt_tok_big(1_000_000), "1.0m");
assert_eq!(fmt_tok_big(1_500_000), "1.5m");
assert_eq!(fmt_tok_big(2_345_678), "2.3m");
@@ -873,7 +874,6 @@ mod tests {
);
}
// -------------------------------------------------------------------
// Bar partition tests
//
// The bar lives at line indices 5..(5+layout.rows) (after header /
@@ -881,11 +881,11 @@ mod tests {
// spans separated by raw-space spans. To count cells per category,
// we walk the bar lines for the given layout and count spans whose
// content matches each category's glyph.
// -------------------------------------------------------------------
const SYSTEM_GLYPH_TEST: &str = "\u{25C6}";
const TOOLS_GLYPH_TEST: &str = "\u{25C8}";
const MESSAGES_GLYPH_TEST: &str = "\u{25C6}"; // same as system; distinguished by color in real render
// same as system; distinguished by color in real render
const MESSAGES_GLYPH_TEST: &str = "\u{25C6}";
const FREE_GLYPH_TEST: &str = "\u{25C7}";
/// `layout` tells the function how many bar rows to slice (5 for
@@ -898,7 +898,8 @@ mod tests {
let mut diamonds = 0usize;
let mut tools = 0usize;
let mut free = 0usize;
let bar_start = 5; // header / blank / tokens / model / blank
// header / blank / tokens / model / blank
let bar_start = 5;
let bar_end = bar_start + layout.rows;
for line in &lines[bar_start..bar_end] {
for span in &line.spans {
@@ -1112,13 +1113,11 @@ mod tests {
assert_eq!(percent_of_window(500_000, 1_000_000), "50%");
}
// -------------------------------------------------------------------
// Responsive bar layout tests
//
// The bar's shape (5×20 vs 10×10) is chosen by `BarLayout::for_width`
// based on terminal width, so narrow terminals get a square bar that
// still fits in their column budget.
// -------------------------------------------------------------------
#[test]
fn bar_layout_wide_is_5_rows_of_20() {
@@ -1238,9 +1237,7 @@ mod tests {
}
}
// -------------------------------------------------------------------
// Legend label color + responsive wrapping tests
// -------------------------------------------------------------------
/// Helper: find the legend row (or row 1, for the narrow layout)
/// whose label text starts with `label_prefix`. Returns the matching
@@ -184,7 +184,6 @@ impl MarkdownContent {
self.generation += 1;
}
/// Get the source markdown text.
pub fn text(&self) -> String {
self.state.borrow().renderer.source().to_string()
}
@@ -266,7 +265,6 @@ impl MarkdownContent {
self.generation
}
/// Whether the content is currently in raw mode.
pub fn is_raw(&self) -> bool {
self.current_raw
}
@@ -285,7 +283,8 @@ impl MarkdownContent {
}
state.cache_lines = Vec::new();
state.cache_joiners = Vec::new();
state.cache_generation = u64::MAX; // force rebuild on next use
// Force rebuild on next use.
state.cache_generation = u64::MAX;
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
}
@@ -319,7 +318,8 @@ impl MarkdownContent {
if state.cache_theme != current_theme {
state.renderer.set_style(md_style::style());
state.cache_theme = current_theme;
state.cache_generation = u64::MAX; // force cache miss
// Force cache miss.
state.cache_generation = u64::MAX;
// set_style resets renderer frozen state, so our tracking is stale
state.frozen_pre_wrap_count = 0;
state.frozen_wrapped_count = 0;
@@ -342,7 +342,7 @@ impl MarkdownContent {
let frozen_count = state.renderer.frozen_lines_count();
// --- Incremental wrapping ---
// Incremental wrapping:
//
// The renderer guarantees that view().lines[0..frozen_count] are stable.
// We only need to wrap:
@@ -373,13 +373,12 @@ impl MarkdownContent {
None
};
// Now mutate the cache (no more borrows of view/renderer)
// Truncate stale tail, keeping only the previously frozen wrapped output
// Now mutate the cache (no more borrows of view/renderer).
// Truncate stale tail, keeping only the already-frozen wrapped output.
let frozen_wc = state.frozen_wrapped_count;
state.cache_lines.truncate(frozen_wc);
state.cache_joiners.truncate(frozen_wc);
// Append newly frozen wrapped lines
if let Some((new_lines, new_joiners)) = new_frozen_wrapped {
state.cache_lines.extend(new_lines);
state.cache_joiners.extend(new_joiners);
@@ -387,7 +386,6 @@ impl MarkdownContent {
state.frozen_wrapped_count = state.cache_lines.len();
}
// Append tail wrapped lines
if let Some((tail_lines, tail_joiners)) = tail_wrapped {
state.cache_lines.extend(tail_lines);
state.cache_joiners.extend(tail_joiners);
@@ -651,7 +649,6 @@ mod tests {
let frozen_count_after_first = state.frozen_wrapped_count;
drop(state);
// Push more content
md.push_chunk("Third paragraph.\n\n");
let _ = md.output(width);
@@ -822,8 +822,6 @@ mod tests {
assert_eq!(prewrap_end_rows(&out.lines), vec![1, 3, 4]);
}
// -- theme mapping + cache filename --------------------------------------
#[test]
fn theme_is_dark_maps_kigiday_to_light_only() {
assert!(
@@ -683,7 +683,8 @@ impl BlockContent for SessionEventBlock {
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false // Compact like SystemMessageBlock
// Compact like SystemMessageBlock.
false
}
fn has_raw_mode(&self) -> bool {
@@ -47,21 +47,16 @@ pub enum SubagentBlockKind {
/// Enter / Ctrl-F opens the subagent view.
#[derive(Debug, Clone)]
pub struct SubagentBlock {
/// Human-readable description of the task.
pub description: String,
/// Child session ID (for opening the subagent view).
pub child_session_id: String,
/// Subagent type (e.g. "general-purpose", "explore").
pub subagent_type: String,
/// Named persona applied to this subagent, if any.
pub persona: Option<String>,
/// Role that supplied defaults for this subagent, if any.
pub role: Option<String>,
/// Effective model ID used by the subagent, if available.
pub model: Option<String>,
/// Whether the subagent was launched in background mode.
pub is_background: bool,
/// Lifecycle kind.
pub kind: SubagentBlockKind,
/// Live activity label from the child session's turn tracker.
///
@@ -159,11 +154,11 @@ impl SubagentBlock {
}
}
/// Truncate description and wrap in quotes for display.
fn quoted_desc(desc: &str, max_width: usize) -> String {
// Reserve 2 chars for quotes
if max_width <= 2 {
return "\u{201C}\u{2026}\u{201D}".to_string(); // "…"
// "…"
return "\u{201C}\u{2026}\u{201D}".to_string();
}
let inner = truncate_str(desc, max_width - 2);
format!("\u{201C}{inner}\u{201D}")
@@ -213,7 +208,6 @@ impl BlockContent for SubagentBlock {
spans.push(Span::styled(meta, muted));
Line::from(spans)
}
// Completed: Subagent completed in Xs: "description"
(SubagentBlockKind::Completed { elapsed }, _) => {
let time_str = format_duration(*elapsed);
// "Subagent completed in Xs: " = 26 + time_str.len()
@@ -225,7 +219,6 @@ impl BlockContent for SubagentBlock {
Span::styled(desc, muted),
])
}
// Failed: Subagent failed in Xs: "description"
(SubagentBlockKind::Failed { elapsed, error }, _) => {
let time_str = format_duration(*elapsed);
let detail = error
@@ -240,7 +233,6 @@ impl BlockContent for SubagentBlock {
Span::styled(desc, muted),
])
}
// Cancelled: Subagent cancelled in Xs: "description"
(SubagentBlockKind::Cancelled { elapsed }, _) => {
let time_str = format_duration(*elapsed);
// "Subagent cancelled in Xs: " = 26 + time_str.len()
@@ -7,15 +7,12 @@ use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockLine, BlockOutput, Selectable};
use crate::theme::Theme;
/// Block displaying a system message.
#[derive(Debug, Clone)]
pub struct SystemMessageBlock {
/// The message text.
pub text: String,
}
impl SystemMessageBlock {
/// Create a new system message block.
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
@@ -37,7 +34,6 @@ impl BlockContent for SystemMessageBlock {
.map(|line| BlockLine::styled(line).with_selection_range(Some(0)))
.collect();
// Apply max_lines budget if set
let lines = if let Some(max) = ctx.max_lines {
let max = max as usize;
if all_lines.len() > max && max > 0 {
@@ -69,11 +65,11 @@ impl BlockContent for SystemMessageBlock {
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None // System messages have no accent
None
}
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
false // System messages are compact
false
}
fn has_raw_mode(&self) -> bool {
@@ -81,11 +77,11 @@ impl BlockContent for SystemMessageBlock {
}
fn is_foldable(&self) -> bool {
false // System messages are short
false
}
fn is_selectable(&self) -> bool {
false // System messages are not navigable
false
}
fn is_groupable(&self) -> bool {
@@ -31,7 +31,6 @@ pub struct ThinkingBlock {
started_at: Option<std::time::Instant>,
}
impl ThinkingBlock {
/// Create a new thinking block with complete text.
pub fn new(text: impl Into<String>) -> Self {
Self {
content: MarkdownContent::new(text),
@@ -40,7 +39,6 @@ impl ThinkingBlock {
}
}
/// Create an empty block for streaming.
pub fn streaming() -> Self {
Self {
content: MarkdownContent::streaming(),
@@ -67,7 +65,6 @@ impl ThinkingBlock {
}
}
/// Push a streaming chunk of markdown text.
pub fn push_chunk(&mut self, chunk: &str) {
self.content.push_chunk(chunk);
}
@@ -84,9 +81,6 @@ impl ThinkingBlock {
/// not the server-reported delta.
pub fn finish(&mut self) {
self.content.finish();
// Freeze local elapsed if no server time has been set.
// The local timer (started_at → now) captures the full duration
// from block creation to finish, which is what the user perceives.
if self.elapsed_time_ms.is_none()
&& let Some(start) = self.started_at
{
@@ -94,7 +88,6 @@ impl ThinkingBlock {
}
}
/// Get the source text.
pub fn text(&self) -> String {
self.content.text()
}
@@ -112,8 +105,6 @@ impl ThinkingBlock {
}
}
/// Set the elapsed time (in milliseconds).
///
/// When set, the collapsed view will show "Thought for Xs".
pub fn set_elapsed_time_ms(&mut self, time_ms: Option<i64>) {
self.elapsed_time_ms = time_ms;
@@ -129,7 +120,6 @@ impl ThinkingBlock {
&self.content
}
/// Mutable access to the underlying markdown content.
pub fn content_mut(&mut self) -> &mut MarkdownContent {
&mut self.content
}
@@ -146,7 +136,6 @@ impl ThinkingBlock {
}
}
/// Format elapsed time for display.
fn format_time(&self) -> Option<String> {
self.elapsed_time_ms.map(|ms| {
let secs = ms as f64 / 1000.0;
@@ -259,7 +248,6 @@ impl ThinkingBlock {
let total = wrapped.lines.len();
if total <= n {
// Content fits within N lines, show all (with blending)
let output = BlockOutput {
lines: wrapped
.lines
@@ -280,15 +268,12 @@ impl ThinkingBlock {
return self.maybe_prepend_header(output, ctx);
}
// Build truncated output: "…" + last N lines
let theme = Theme::current();
let mut output_lines = Vec::with_capacity(n + 1);
// Ellipsis line
let ellipsis = Line::from(Span::styled("…", theme.muted()));
output_lines.push(ellipsis.into());
// Last N lines (with blending)
for i in (total - n)..total {
output_lines.push(Self::thinking_body_line(
&wrapped.lines[i],
@@ -383,9 +368,9 @@ impl BlockContent for ThinkingBlock {
/// This means collapsed thinking shows gray bullet, running thinking syncs with accent.
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
if ctx.is_running {
self.accent(ctx) // sync bullet with accent animation when running
self.accent(ctx)
} else {
None // default gray/primary
None
}
}
@@ -107,7 +107,6 @@ impl Default for DiffRenderConfig {
}
}
/// Layout constants.
const INDENT: &str = " ";
const GUTTER_GAP: &str = " ";
const CONTENT_GAP: &str = " ";
@@ -179,7 +178,6 @@ fn render_diff_hunks_core(
for (i, hunk) in hunks.iter().enumerate() {
if i > 0 && !lines.is_empty() && !config.hunk_separator.is_empty() {
// Add separator between hunks (no background)
let indent = if config.indent { INDENT } else { "" };
let sep_text = match hunk_gap_lines(&hunks[i - 1], hunk) {
Some(1) => format!("{} 1 unchanged line", config.hunk_separator),
@@ -477,21 +475,16 @@ fn assemble_diff_line_outputs(
/// Compute where background starts based on config.
fn compute_bg_start(config: &DiffRenderConfig, gutter_width: usize, indent_width: usize) -> u16 {
if config.gutter_bg {
// Gutter bg is on - check if we should skip indent
// indent_bg = true means skip indent (keep it clean)
// indent_bg = false means include indent in background
if config.indent && config.indent_bg {
indent_width as u16
} else {
0
}
} else {
// Gutter bg is off - color from content only
gutter_width as u16
}
}
/// Simple word-wrap implementation.
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 || text.is_empty() {
return vec![text.to_string()];
@@ -537,7 +530,6 @@ struct GutterLayout {
dual: bool,
}
/// Calculate gutter layout for a hunk.
fn gutter_layout(hunk: &DiffHunk, config: &DiffRenderConfig) -> GutterLayout {
let mut max_old = 1usize;
let mut max_new = 1usize;
@@ -572,7 +564,6 @@ fn gutter_layout(hunk: &DiffHunk, config: &DiffRenderConfig) -> GutterLayout {
}
}
/// Render the line number gutter.
fn render_gutter(
spans: &mut Vec<Span<'static>>,
line: &crate::diff::DiffLine,
@@ -582,13 +573,11 @@ fn render_gutter(
) {
let gutter_style = Style::default().fg(theme.diff_gutter_fg);
// Indent (configurable)
if config.indent {
spans.push(Span::raw(INDENT));
}
if layout.dual {
// Dual mode: two columns (old + new) like GitHub unified diff
let w_old = layout.width_old;
let w_new = layout.width_new;
match line.tag {
@@ -615,7 +604,6 @@ fn render_gutter(
}
}
} else {
// Single mode: one column with the relevant line number
let w = layout.width_new;
match line.tag {
ChangeTag::Equal => {
@@ -636,7 +624,6 @@ fn render_gutter(
}
}
// Gap between gutter and content
spans.push(Span::raw(CONTENT_GAP));
}
@@ -647,7 +634,6 @@ fn painted(text: &str, style: Style) -> Span<'static> {
Span::styled(text.to_string(), style)
}
/// Render content spans with syntax highlighting.
fn render_content_spans(
content: &str,
tag: ChangeTag,
@@ -666,7 +652,6 @@ fn render_content_spans(
return spans;
}
// Try syntax highlighting
if let Some(hl) = highlighter.as_mut()
&& let Ok(ranges) = hl.highlight_line(&format!("{content}\n"), &syntect.syntax_set)
{
@@ -688,7 +673,6 @@ fn render_content_spans(
}
}
// Fallback: plain text
let style = match tag {
ChangeTag::Equal => Style::default().fg(theme.diff_equal_fg),
ChangeTag::Delete | ChangeTag::Insert => Style::default().fg(theme.text_primary),
@@ -703,12 +687,9 @@ fn syntect_to_ratatui_fg(style: SyntectStyle) -> Style {
crate::syntax::syntect_to_ratatui_fg(style)
}
/// Edit tool call block - displays file edit with diff.
#[derive(Debug, Clone)]
pub struct EditToolCallBlock {
/// File path being edited.
pub path: String,
/// Diff hunks.
pub hunks: Vec<DiffHunk>,
/// Number of edits (for multi-edit display).
pub edit_count: usize,
@@ -732,8 +713,6 @@ pub struct EditToolCallBlock {
}
impl EditToolCallBlock {
/// Create a new edit block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
@@ -766,13 +745,11 @@ impl EditToolCallBlock {
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
@@ -800,7 +777,6 @@ impl EditToolCallBlock {
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
@@ -810,7 +786,6 @@ impl EditToolCallBlock {
}
}
/// Set edit count explicitly.
pub fn with_edit_count(mut self, count: usize) -> Self {
self.edit_count = count;
self
@@ -823,14 +798,12 @@ impl EditToolCallBlock {
diff_hunks_to_patch(&self.path, &self.hunks)
}
/// Set hunks (mutable).
pub fn set_hunks(&mut self, hunks: Vec<DiffHunk>) {
self.edit_count = hunks.len().max(1);
self.change_counts = Self::compute_changes(&hunks);
self.hunks = hunks;
}
/// Count insertions and deletions across all hunks.
fn compute_changes(hunks: &[DiffHunk]) -> (usize, usize) {
let mut insertions = 0;
let mut deletions = 0;
@@ -846,7 +819,6 @@ impl EditToolCallBlock {
(insertions, deletions)
}
/// Get pre-computed insertion/deletion counts.
fn count_changes(&self) -> (usize, usize) {
self.change_counts
}
@@ -1156,7 +1128,6 @@ impl EditToolCallBlock {
match ctx.mode {
DisplayMode::Collapsed => {
// Collapsed: just header line. Path (span 1) is selectable.
let line = self.header_line(
&theme,
muted_collapsed,
@@ -1185,7 +1156,6 @@ impl EditToolCallBlock {
})
}
DisplayMode::Truncated | DisplayMode::Expanded => {
// Convert appearance config to render config
let config = DiffRenderConfig {
indent: edit_cfg.indent,
gutter_bg: edit_cfg.gutter_bg,
@@ -1193,7 +1163,6 @@ impl EditToolCallBlock {
hunk_separator: edit_cfg.hunk_separator.clone(),
dual_line_numbers: edit_cfg.dual_line_numbers,
};
// Header with word-wrap and hanging indent
let header = self.header_line(
&theme,
false,
@@ -1237,7 +1206,6 @@ impl EditToolCallBlock {
});
}
// Error message (non-selectable decoration)
if let Some(err) = &self.error {
lines.push(BlockLine::separator(Line::from("")));
for line in err.lines() {
@@ -1248,7 +1216,6 @@ impl EditToolCallBlock {
}
}
// Diff content with syntax highlighting and full-width backgrounds
if !self.hunks.is_empty() {
// Empty line after header (non-selectable)
lines.push(BlockLine::separator(Line::from("")));
@@ -1283,7 +1250,6 @@ impl EditToolCallBlock {
}
}
// Apply max_lines if set
if let Some(max) = ctx.max_lines {
lines.truncate(max as usize);
}
@@ -1304,8 +1270,7 @@ impl BlockContent for EditToolCallBlock {
}
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
// Edit blocks: use config accent color if set, otherwise no accent.
// Note: errors no longer show red accent — they show red bullet instead.
// Failed edits are marked by a red bullet(), not this accent.
ctx.appearance
.scrollback
.blocks
@@ -1341,7 +1306,6 @@ impl BlockContent for EditToolCallBlock {
}
fn is_foldable(&self) -> bool {
// Foldable if there are hunks to show, or an error message to reveal.
!self.hunks.is_empty() || self.error.is_some()
}
@@ -1356,7 +1320,8 @@ impl BlockContent for EditToolCallBlock {
if self.error.is_some() {
Some(DisplayMode::Collapsed)
} else {
None // keep current mode (the config-aware default, or a user fold)
// Keep current mode (the config-aware default, or a user fold).
None
}
}
@@ -1740,7 +1705,8 @@ mod tests {
let block = EditToolCallBlock::new("src/lib.rs", vec![make_hunk()]);
let ctx = test_ctx();
let output = block.output(&ctx);
assert_eq!(output.lines.len(), 6); // header + empty + 4 diff
// header + empty + 4 diff
assert_eq!(output.lines.len(), 6);
}
#[test]
@@ -1749,11 +1715,16 @@ mod tests {
let ctx = test_ctx();
let output = block.output(&ctx);
assert_eq!(output.lines[0].background, None); // header
assert_eq!(output.lines[2].background, None); // equal
assert!(output.lines[3].background.is_some()); // delete
assert!(output.lines[4].background.is_some()); // insert
assert_eq!(output.lines[5].background, None); // equal
// header
assert_eq!(output.lines[0].background, None);
// equal
assert_eq!(output.lines[2].background, None);
// delete
assert!(output.lines[3].background.is_some());
// insert
assert!(output.lines[4].background.is_some());
// equal
assert_eq!(output.lines[5].background, None);
// Insert/delete shading is semantic, NOT a decorative panel — it must
// survive minimal mode's flat rendering (EntryRenderer::flat_background),
@@ -2226,8 +2197,6 @@ mod tests {
);
}
// --- dual_line_numbers = true snapshots ---
fn dual_config() -> DiffRenderConfig {
DiffRenderConfig {
dual_line_numbers: true,
@@ -2402,15 +2371,12 @@ mod tests {
line2,
);
// content_text should also have expanded tabs.
assert!(
!outputs[1].content_text.contains('\t'),
"content_text should also have tabs expanded",
);
}
// ── Edit syntax-highlight harness (triple-quote spill) ──
//
// Asserts use **raw syntect RGB** (not ratatui FG after quantize). Under
// `NO_COLOR` quantize maps every RGB → Reset, which would make keyword vs
// string asserts tautological / false.
@@ -2530,10 +2496,12 @@ class ProcessQueueItem(BaseModel):
fn fixture_python_close_hunk() -> (String, DiffHunk, usize) {
let (file, close_ln) = fixture_python_parts();
let all: Vec<&str> = file.lines().collect();
let start = close_ln - 1; // 0-based
// 0-based
let start = close_ln - 1;
let mut hunk = DiffHunk::new();
for (i, line) in all[start..].iter().enumerate() {
let ln = start + i + 1; // 1-based
// 1-based
let ln = start + i + 1;
hunk.push(DiffLine {
text: format!("{line}\n"),
lo: ln,
@@ -2545,7 +2513,8 @@ class ProcessQueueItem(BaseModel):
.iter()
.position(|l| l.contains("notes") || l.contains("category_id"))
.expect("field line in hunk");
(file, hunk, start + field_offset + 1) // 1-based field ln
// 1-based field ln
(file, hunk, start + field_offset + 1)
}
/// Fix pin: file-scoped field styles match full-file raw styles
@@ -2692,7 +2661,8 @@ class ProcessQueueItem(BaseModel):
let path = Path::new("probe.py");
let file = "x = 1\ny = 2\n";
let hunk = vec![DiffLine {
text: "x = 999\n".into(), // differs from disk line 1
// differs from disk line 1
text: "x = 999\n".into(),
lo: 1,
ln: 1,
tag: ChangeTag::Insert,
@@ -14,20 +14,17 @@ use crate::theme::Theme;
const EXECUTE_STDOUT_RANGE_BASE: u16 = 1;
/// Execute tool call - runs a shell command.
#[derive(Debug, Clone)]
pub struct ExecuteToolCallBlock {
/// Full command that was run (search / copy_meta / export source of truth).
pub command: String,
/// Error message if the command failed (None = success).
pub error: Option<String>,
/// Optional description of what the command does.
pub description: Option<String>,
/// The terminal output. Streamed incrementally.
/// Streamed incrementally.
pub output: Option<String>,
/// When the tool started running (Phase 2: time tracking).
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
/// Elapsed time in ms, set once the command finishes.
pub elapsed_ms: Option<i64>,
/// Whether this is a user-initiated bash-mode (`!`) command.
/// Streams as a truncated live tail, expands to full output on finish.
@@ -54,25 +51,21 @@ impl ExecuteToolCallBlock {
}
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Set description.
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
/// Set output.
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.output = Some(output.into());
self
}
/// Push streaming output chunk.
pub fn push_output(&mut self, chunk: &str) {
match &mut self.output {
Some(o) => o.push_str(chunk),
@@ -93,7 +86,7 @@ impl ExecuteToolCallBlock {
}
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
/// Sets error; also computes elapsed time from `started_at` if not already set.
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
@@ -103,12 +96,11 @@ impl ExecuteToolCallBlock {
self.error = error;
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Get elapsed time in ms. Returns current elapsed if still running, or stored value if finished (Phase 2).
/// Returns current elapsed if still running, or the stored value once finished.
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
@@ -118,9 +110,7 @@ impl ExecuteToolCallBlock {
}
}
/// Get copyable text for this block (stdout output).
///
/// Returns the output string if available, or empty string if no output.
/// Copyable text: rendered stdout output, or empty string if none.
pub fn copy_text(&self) -> String {
self.output
.as_deref()
@@ -312,7 +302,8 @@ impl ExecuteToolCallBlock {
// multi-line command display use `push_command_soft_wrap`).
let title_owned;
let title = if title.trim().is_empty() {
"\u{2026}" // …
// "\u{2026}" is the ellipsis character "…".
"\u{2026}"
} else if title.contains('\n') {
title_owned = title.replace('\n', " ");
title_owned.as_str()
@@ -332,7 +323,7 @@ impl ExecuteToolCallBlock {
/// Header lines for the execute block (description-first when a description exists).
///
/// Without description (unchanged):
/// Without description:
/// - Shell: `$ command`
/// - Label: `Run [(user) ]command`
///
@@ -530,7 +521,8 @@ impl ExecuteToolCallBlock {
width,
extra_indent,
false,
true, // include $ command when expanded/truncated
// include $ command when expanded/truncated
true,
);
if self.output.is_none()
@@ -566,11 +558,9 @@ impl ExecuteToolCallBlock {
word_wrap_lines_with_joiners(styled_lines, width.saturating_sub(2).max(20));
let total = wrapped.len();
// Apply truncation if specified and content exceeds limits
if let Some((first, last)) = truncate {
let threshold = first + last;
if total > threshold {
// First N lines: stdout range base
for (wrapped_line, joiner) in wrapped.iter().zip(joiners.iter()).take(first) {
lines.push(
BlockLine::styled(wrapped_line.clone())
@@ -584,7 +574,7 @@ impl ExecuteToolCallBlock {
BlockLine::separator(Line::from(Span::styled("\u{2026}", theme.muted())))
.with_panel_background(theme.bg_dark),
);
// Last M lines: range base + 1 (distinct from first chunk)
// Distinct selection range from the first chunk (range base + 1).
for (wrapped_line, joiner) in
wrapped.iter().zip(joiners.iter()).skip(total - last)
{
@@ -596,7 +586,6 @@ impl ExecuteToolCallBlock {
);
}
} else {
// Content fits, show all with same range
for (wrapped_line, joiner) in wrapped.into_iter().zip(joiners) {
lines.push(
BlockLine::styled(wrapped_line)
@@ -607,7 +596,6 @@ impl ExecuteToolCallBlock {
}
}
} else {
// No truncation, show all with same range
for (wrapped_line, joiner) in wrapped.into_iter().zip(joiners) {
lines.push(
BlockLine::styled(wrapped_line)
@@ -671,7 +659,8 @@ impl BlockContent for ExecuteToolCallBlock {
content_width,
0,
true,
false, // hide command when description is the title
// hide command when description is the title
false,
);
BlockOutput { lines }
}
@@ -918,7 +907,8 @@ mod tests {
false,
40,
0,
false, // expanded: soft-wrap
// expanded: soft-wrap
false,
true,
);
assert!(
@@ -969,7 +959,8 @@ mod tests {
assert_eq!(headers.len(), 2);
let title = line_text(&headers[0].0);
assert_eq!(title, "Run (user) List files");
assert_eq!(headers[0].1, 2); // "Run " + "(user) "
// "Run " + "(user) "
assert_eq!(headers[0].1, 2);
}
#[test]
@@ -11,8 +11,6 @@ use ratatui::text::{Line, Span};
use crate::scrollback::types::{BlockLine, DisplayMode};
use crate::theme::Theme;
// ── Data types ────────────────────────────────────────────────────────
/// Status of a single hook execution within a batch.
#[derive(Debug, Clone)]
pub enum HookRunStatus {
@@ -63,11 +61,8 @@ impl ToolCallHookData {
}
}
// ── Rendering helpers ─────────────────────────────────────────────────
const INDENT: &str = " ";
/// Count successes and failures across all hook entries.
fn count_hooks(entries: &[&[HookRunEntry]]) -> (usize, usize) {
let mut success = 0usize;
let mut failed = 0usize;
@@ -176,7 +171,6 @@ fn render_hooks_expanded(event: &str, runs: &[HookRunEntry]) -> Vec<BlockLine> {
let theme = Theme::current();
let mut lines = Vec::new();
// If all hooks were skipped, render nothing.
if runs
.iter()
.all(|r| matches!(r.status, HookRunStatus::Skipped))
@@ -184,7 +178,6 @@ fn render_hooks_expanded(event: &str, runs: &[HookRunEntry]) -> Vec<BlockLine> {
return lines;
}
// Header: indented, bold, muted
lines.push(
Line::from(vec![Span::styled(
format!("{}{}", INDENT, event),
@@ -193,7 +186,6 @@ fn render_hooks_expanded(event: &str, runs: &[HookRunEntry]) -> Vec<BlockLine> {
.into(),
);
// Per-hook detail lines
lines.extend(render_hooks_expanded_inner(runs));
lines
@@ -261,7 +253,6 @@ fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec<BlockLine> {
}
}
// Truncated output (if present)
if let Some(ref output) = run.output {
let truncated = crate::render::line_utils::truncate_str(output, 120);
for out_line in truncated.lines().take(3) {
@@ -279,7 +270,6 @@ fn render_hooks_expanded_inner(runs: &[HookRunEntry]) -> Vec<BlockLine> {
lines
}
/// Render hook lines for a given display mode.
pub fn render_hooks_for_mode(
event: &str,
runs: &[HookRunEntry],
@@ -308,7 +298,6 @@ pub fn render_hooks_detail(runs: &[HookRunEntry], mode: DisplayMode) -> Vec<Bloc
}
}
/// Render a separator line (for use between tool output and hooks in expanded mode).
pub fn render_hook_separator() -> BlockLine {
render_separator()
}
@@ -11,7 +11,6 @@ use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode};
use crate::theme::Theme;
/// Block representing a lifecycle hook event.
#[derive(Debug, Clone)]
pub struct LifecycleEventBlock {
/// Event name (e.g. `user_prompt_submit`).
@@ -11,18 +11,15 @@ use crate::theme::Theme;
use super::TOOL_HEADER_RANGE;
/// List directory tool call.
#[derive(Debug, Clone)]
pub struct ListDirToolCallBlock {
/// Path to the directory.
pub path: String,
/// The formatted directory listing output.
pub output: String,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// When the tool started running (Phase 2: time tracking).
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
/// Elapsed time in ms, set once the command finishes.
pub elapsed_ms: Option<i64>,
}
@@ -42,24 +39,21 @@ impl ListDirToolCallBlock {
}
}
/// Set the output.
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.output = output.into();
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
/// Sets error; also computes elapsed time from `started_at` if not already set.
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
@@ -82,7 +76,7 @@ impl ListDirToolCallBlock {
}
}
/// Get elapsed time in ms (Phase 2).
/// Returns current elapsed if still running, or the stored value once finished.
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
@@ -92,7 +86,6 @@ impl ListDirToolCallBlock {
}
}
/// Set output (mutable).
pub fn set_output(&mut self, output: impl Into<String>) {
self.output = output.into();
}
@@ -164,7 +157,6 @@ impl BlockContent for ListDirToolCallBlock {
&self.output,
theme.primary(),
) {
// Indent output by 2 spaces
let mut spans = vec![Span::styled(" ".to_string(), theme.primary())];
spans.extend(rl.line.spans);
let mut block_line: BlockLine = Line::from(spans).into();
@@ -181,7 +173,8 @@ impl BlockContent for ListDirToolCallBlock {
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None // ListDir blocks never have an accent line
// ListDir blocks never have an accent line.
None
}
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
@@ -206,7 +199,6 @@ impl BlockContent for ListDirToolCallBlock {
}
fn is_foldable(&self) -> bool {
// Not foldable if failed
if self.error.is_some() {
return false;
}
@@ -317,7 +317,6 @@ fn shorten_path(path: &str) -> &str {
pub fn parse_memory_results(output: &str) -> Vec<MemoryResult> {
let mut results = Vec::new();
// Split on "### Result " markers
for section in output.split("### Result ") {
// Skip the preamble ("Found N memory result(s):\n")
if !section.starts_with(|c: char| c.is_ascii_digit()) {
@@ -365,7 +364,6 @@ pub fn parse_memory_results(output: &str) -> Vec<MemoryResult> {
}
}
// Extract snippet between ``` markers
let full = section;
if let Some(code_start) = full.find("```\n") {
let after_start = &full[code_start + 4..];
@@ -58,12 +58,11 @@ pub struct LineRange {
}
impl LineRange {
/// Create a new line range.
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
/// Format as "(start:end)" for display.
/// Format as "start:end" for display.
pub fn display(&self) -> String {
format!("{}:{}", self.start, self.end)
}
@@ -20,9 +20,9 @@ pub struct OtherToolCallBlock {
pub error: Option<String>,
/// Optional output.
pub output: Option<String>,
/// When the tool started running (Phase 2: time tracking).
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
/// Image references detected in the tool output.
image_refs: Vec<crate::prompt_images::ScrollbackImageRef>,
@@ -100,7 +100,7 @@ impl OtherToolCallBlock {
None
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
/// Set error (mutable) — compute elapsed time if not already set.
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
@@ -123,7 +123,7 @@ impl OtherToolCallBlock {
}
}
/// Get elapsed time in ms (Phase 2).
/// Get elapsed time in ms.
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
@@ -327,9 +327,9 @@ impl BlockContent for OtherToolCallBlock {
let theme = Theme::current();
Some(AccentStyle::static_color(theme.accent_error))
} else if ctx.mode == DisplayMode::Collapsed {
None // default gray
None
} else {
self.accent(ctx) // inherit from accent when expanded/running
self.accent(ctx)
}
}
@@ -346,7 +346,6 @@ impl BlockContent for OtherToolCallBlock {
}
fn is_foldable(&self) -> bool {
// Not foldable if failed
if self.error.is_some() {
return false;
}
@@ -426,8 +425,6 @@ impl BlockContent for OtherToolCallBlock {
}
}
// ── AskUserQuestion output parser ────────────────────────────────────
/// Parse Q&A pairs from an AskUserQuestion tool result string.
///
/// Recognizes all three accepted output formats:
@@ -441,7 +438,6 @@ impl BlockContent for OtherToolCallBlock {
fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
// Path A: "User has answered your questions: "Q"="A", "Q"="A". You can now..."
if let Some(rest) = output.strip_prefix("User has answered your questions: ") {
// Strip the trailing ". You can now continue with the user's answers in mind."
let body = rest
.strip_suffix(". You can now continue with the user's answers in mind.")
.unwrap_or(rest);
@@ -450,7 +446,6 @@ fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
return vec![];
}
// Parse "Q1"="A1", "Q2"="A2" pairs.
// Split on `", "` that appears between pairs (after `"="value"`).
let mut pairs = Vec::new();
let mut remaining = body;
@@ -460,16 +455,17 @@ fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
if !remaining.starts_with('"') {
break;
}
remaining = &remaining[1..]; // skip opening "
// Skip opening quote.
remaining = &remaining[1..];
// Find the closing " before =
let Some(q_end) = remaining.find("\"=\"") else {
break;
};
let question = remaining[..q_end].to_string();
remaining = &remaining[q_end + 3..]; // skip "="
// Skip `"="`.
remaining = &remaining[q_end + 3..];
// Find the end of the answer: next `", "` pair start or end of string.
// The answer value continues until we hit `, "` (next pair) or end.
let answer_end = remaining.find(", \"").unwrap_or(remaining.len());
@@ -500,9 +496,9 @@ fn parse_ask_user_qa_pairs(output: &str) -> Vec<(String, String)> {
return pairs;
}
// Path D: cancelled
// Path D: cancelled — no Q&A to show.
if output.starts_with("User declined to answer") {
return vec![]; // No Q&A to show
return vec![];
}
// Paths B/C: plan mode — bullet format
@@ -29,18 +29,16 @@ pub enum ReadMediaKind {
Pdf { pages: usize },
}
/// Read file tool call.
#[derive(Debug, Clone)]
pub struct ReadToolCallBlock {
/// Path to the file being read.
pub path: String,
/// Line range if specified: [start, end] (1-based, inclusive).
pub line_range: Option<LineRange>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// When the tool started running (Phase 2: time tracking).
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
/// Raw file content (unformatted). `None` for errors, images, PDFs.
pub content: Option<String>,
@@ -53,8 +51,6 @@ pub struct ReadToolCallBlock {
}
impl ReadToolCallBlock {
/// Create a new read block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
@@ -72,26 +68,22 @@ impl ReadToolCallBlock {
}
}
/// Set line range.
pub fn with_line_range(mut self, range: LineRange) -> Self {
self.line_range = Some(range);
self
}
/// Set file content and total line count.
pub fn with_content(mut self, content: String, total_lines: usize) -> Self {
self.content = Some(content);
self.total_lines = Some(total_lines);
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
@@ -112,7 +104,7 @@ impl ReadToolCallBlock {
self.skill_name().is_some()
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
/// Compute elapsed time if not already set, then store the error.
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
@@ -135,7 +127,6 @@ impl ReadToolCallBlock {
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
@@ -70,10 +70,8 @@ pub struct SearchInputMeta {
pub multiline: bool,
}
/// Search/grep tool call.
#[derive(Debug, Clone)]
pub struct SearchToolCallBlock {
/// The search pattern.
pub pattern: String,
/// Total number of matches found.
pub match_count: usize,
@@ -86,15 +84,13 @@ pub struct SearchToolCallBlock {
pub error: Option<String>,
/// Extra metadata from the search input (path, glob, mode, etc.).
pub meta: SearchInputMeta,
/// When the tool started running (Phase 2: time tracking).
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion (Phase 2: time tracking).
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
}
impl SearchToolCallBlock {
/// Create a new search block.
///
/// Pre-completed blocks have no meaningful local timing — `started_at`
/// is `None`. Timing is only set for blocks that enter a running UI
/// state (via `set_last_running(true)` in `ScrollbackState`).
@@ -111,25 +107,22 @@ impl SearchToolCallBlock {
}
}
/// Set match count and file matches.
pub fn with_matches(mut self, match_count: usize, file_matches: Vec<SearchFileMatch>) -> Self {
self.match_count = match_count;
self.file_matches = file_matches;
self
}
/// Set error (marks as failed).
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
/// Check if successful (no error).
pub fn is_success(&self) -> bool {
self.error.is_none()
}
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
/// Compute elapsed time if not already set, then store the error.
pub fn set_error(&mut self, error: Option<String>) {
if self.elapsed_ms.is_none()
&& let Some(start) = self.started_at
@@ -152,7 +145,6 @@ impl SearchToolCallBlock {
}
}
/// Get elapsed time in ms (Phase 2).
pub fn elapsed_ms(&self) -> Option<i64> {
match self.elapsed_ms {
Some(ms) => Some(ms),
@@ -162,7 +154,6 @@ impl SearchToolCallBlock {
}
}
/// Set file matches (mutable).
pub fn set_file_matches(&mut self, match_count: usize, file_matches: Vec<SearchFileMatch>) {
self.match_count = match_count;
self.file_matches = file_matches;
@@ -192,7 +183,8 @@ impl SearchToolCallBlock {
}
}
SearchOutputMode::FilesWithMatches => {
let n = self.match_count; // match_count = # of files in this mode
// In this mode, match_count holds the number of files, not matches.
let n = self.match_count;
if n == 1 {
"(1 file)".to_string()
} else {
@@ -257,17 +249,13 @@ impl SearchToolCallBlock {
let mut spans = vec![Span::styled("Search ".to_string(), bold_style)];
// Search term: either promoted glob or quoted pattern
if self.is_trivial_pattern()
&& let Some(ref glob) = self.meta.glob
{
// Case 1: glob IS the search term — no quotes, string-styled
spans.push(Span::styled(glob.to_string(), pattern_style));
} else {
// Cases 2 & 3: quoted regex pattern
spans.push(Span::styled(format!("{:?}", self.pattern), pattern_style));
// Case 2: glob shown as first "in" scope (string-styled, not path)
if let Some(ref glob) = self.meta.glob {
spans.push(Span::styled(" in ".to_string(), text_style));
spans.push(Span::styled(glob.to_string(), pattern_style));
@@ -428,10 +416,8 @@ impl BlockContent for SearchToolCallBlock {
let has_results = !self.file_matches.is_empty() || !self.file_paths.is_empty();
if has_results {
// Blank line before results
lines.push(Line::from("").into());
} else if self.match_count == 0 {
// No results — show a hint
lines.push(Line::from("").into());
lines.push(
Line::from(Span::styled(" (no results)".to_string(), theme.muted()))
@@ -448,11 +434,9 @@ impl BlockContent for SearchToolCallBlock {
for (i, file_match) in self.file_matches.iter().enumerate() {
if i > 0 {
// Blank line between file groups
lines.push(Line::from("").into());
}
// File path line
lines.push(
BlockLine::from(Line::from(Span::styled(
format!("{}{}", indent, file_match.path),
@@ -488,7 +472,7 @@ impl BlockContent for SearchToolCallBlock {
// path part in path color, ":N" in normal fg.
if let Some(colon_pos) = path.rfind(':') {
let file_part = &path[..colon_pos];
let count_part = &path[colon_pos..]; // includes ':'
let count_part = &path[colon_pos..];
Line::from(vec![
Span::styled(
format!("{indent}{file_part}"),
@@ -519,7 +503,7 @@ impl BlockContent for SearchToolCallBlock {
}
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
None // Search blocks never have an accent line
None
}
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
@@ -24,17 +24,13 @@ pub struct DiscoveredTool {
/// Search tool call — discovering MCP integration tools by keyword.
#[derive(Debug, Clone)]
pub struct SearchToolCallBlock {
/// The search query.
pub query: String,
/// Limit parameter from the input (None = default 8).
pub limit: Option<u8>,
/// Number of results found.
pub result_count: usize,
/// Discovered tools (parsed from output).
pub results: Vec<DiscoveredTool>,
/// Raw output content (full JSON) for the fullscreen viewer.
pub content: Option<String>,
/// Error message if the tool call failed.
pub error: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
@@ -23,7 +23,6 @@ pub struct UseToolCallBlock {
pub input_args: Vec<(String, String)>,
/// Output text from the dispatched tool.
pub output: Option<String>,
/// Error message if the tool call failed.
pub error: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
@@ -11,27 +11,22 @@ use crate::scrollback::types::{
};
use crate::theme::Theme;
/// Max lines of content shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
/// Web fetch tool call — fetching a URL and returning markdown content.
#[derive(Debug, Clone)]
pub struct WebFetchToolCallBlock {
/// The fetched URL.
pub url: String,
/// HTTP status code (e.g. 200, 404).
/// `Option` because the block exists pre-completion (pending/running state)
/// before any response data arrives.
pub status_code: Option<u16>,
/// Content type (e.g. "markdown", "text/plain").
pub content_type: Option<String>,
/// Content size in bytes.
pub bytes: Option<usize>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// Fetched content (markdown or raw text).
pub output: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
@@ -232,7 +227,6 @@ impl BlockContent for WebFetchToolCallBlock {
})
.collect();
// Metadata line (status, content_type, size).
if let Some(meta) = self.metadata_line(&theme) {
lines.push(BlockLine::separator(Line::from("")));
lines.push(BlockLine::separator(meta));
@@ -13,7 +13,6 @@ use crate::scrollback::types::{
};
use crate::theme::Theme;
/// Max lines of content shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
/// Max number of domain names shown in the sources summary line.
@@ -22,15 +21,12 @@ const MAX_INLINE_SOURCES: usize = 3;
/// Web search tool call — searching the web and returning markdown results.
#[derive(Debug, Clone)]
pub struct WebSearchToolCallBlock {
/// The search query.
pub query: String,
/// Markdown-formatted search results.
pub content: Option<String>,
/// Source URLs from the search.
pub citations: Vec<String>,
/// Error message if the tool call failed (None = success).
pub error: Option<String>,
/// When the tool started running.
pub started_at: Option<std::time::Instant>,
/// Elapsed time in ms after completion.
pub elapsed_ms: Option<i64>,
@@ -80,7 +80,6 @@ fn token_styled_line(
/// Block displaying a user's prompt.
#[derive(Debug, Clone)]
pub struct UserPromptBlock {
/// The user's input text.
pub text: String,
/// Whether this was a bash command (! prefix).
pub is_bash: bool,
@@ -136,7 +135,8 @@ impl UserPromptBlock {
let token_end = first_line
.find(char::is_whitespace)
.unwrap_or(first_line.len());
#[allow(clippy::single_range_in_vec_init)] // field is multi-range capable
// field is multi-range capable
#[allow(clippy::single_range_in_vec_init)]
let skill_token_ranges = if token_end > 0 {
vec![0..token_end]
} else {
@@ -279,7 +279,6 @@ impl UserPromptBlock {
let ellipsis = " \u{2026}";
let ellipsis_width = 2;
// Available width for text content (after prefix/indent)
let base_content_width = (width as usize).saturating_sub(prefix_width).max(1);
let mut all_lines: Vec<BlockLine> = Vec::new();
@@ -288,7 +287,6 @@ impl UserPromptBlock {
for (logical_idx, line_text) in logical_lines.iter().enumerate() {
if line_text.is_empty() {
// Empty line - just show prefix/indent
let indent = " ".repeat(prefix_width);
let line = if logical_idx == 0 {
Line::from(vec![Span::styled(prefix.to_string(), prefix_style)])
@@ -296,8 +294,9 @@ impl UserPromptBlock {
Line::from(vec![Span::styled(indent, prefix_style)])
};
let block_line = with_band(BlockLine {
// empty content range past prefix
selectable: if has_visible_prefix {
Selectable::Spans(1..1) // empty content range past prefix
Selectable::Spans(1..1)
} else {
Selectable::All
},
@@ -311,7 +310,6 @@ impl UserPromptBlock {
if let Some(max) = max_lines
&& all_lines.len() >= max
{
// There's more content if we haven't processed all logical lines
let has_more = logical_idx + 1 < total_logical;
if has_more {
// Add ellipsis to last line
@@ -326,7 +324,6 @@ impl UserPromptBlock {
continue;
}
// Wrap this line's content.
let content_line = if self.skill_token_ranges.is_empty() {
Line::from(Span::styled(line_text.to_string(), text_style))
} else {
@@ -359,10 +356,8 @@ impl UserPromptBlock {
let indent: String = " ".repeat(prefix_width);
let line_prefix = if is_first_line { prefix } else { &indent };
// Check if this will be the last allowed line
let will_be_last = max_lines.is_some_and(|max| all_lines.len() + 1 == max);
// Check if there's more content after this line
let has_more_wrapped = wrap_idx + 1 < wrapped_count;
let has_more_logical = logical_idx + 1 < total_logical;
let has_more = has_more_wrapped || has_more_logical;
@@ -389,7 +384,6 @@ impl UserPromptBlock {
.next()
.unwrap_or_else(Line::default);
// Build final line with prefix, content, and ellipsis
let mut spans = vec![Span::styled(line_prefix.to_string(), prefix_style)];
spans.extend(final_content.spans.into_iter().map(|s| Span {
content: s.content.to_string().into(),
@@ -398,7 +392,8 @@ impl UserPromptBlock {
spans.push(Span::styled(ellipsis.to_string(), text_style));
let content_start = if has_visible_prefix { 1 } else { 0 };
let content_end = spans.len() - 1; // exclude ellipsis
// exclude ellipsis
let content_end = spans.len() - 1;
let block_line = with_band(BlockLine {
content: Line::from(spans),
selectable: if content_start < content_end {
@@ -414,7 +409,6 @@ impl UserPromptBlock {
return all_lines;
}
// Normal line (not truncated)
let mut spans = vec![Span::styled(line_prefix.to_string(), prefix_style)];
spans.extend(wrapped_line.spans.into_iter().map(|s| Span {
content: s.content.to_string().into(),
@@ -443,7 +437,6 @@ impl UserPromptBlock {
}
}
// Handle empty text
if all_lines.is_empty() {
all_lines.push(with_band(BlockLine {
content: Line::from(Span::styled(prefix.trim().to_string(), prefix_style)),
@@ -481,7 +474,8 @@ impl BlockContent for UserPromptBlock {
}
fn accent_background(&self, _ctx: &BlockContext) -> bool {
true // fill accent column with block bg so it matches content
// fill accent column with block bg so it matches content
true
}
fn background(&self, ctx: &BlockContext) -> BlockBackground {
@@ -595,7 +589,8 @@ mod tests {
fn test_ellipsis_fits_within_width() {
// Create a prompt that wraps to exactly fill lines
let block = UserPromptBlock::new("aaaa bbbb cccc dddd eeee ffff");
let width = 15; // Narrow width to force wrapping
// Narrow width to force wrapping
let width = 15;
let lines = block.wrap_prompt_lines(width, Some(2), true, false);
assert_eq!(lines.len(), 2);
@@ -672,8 +667,6 @@ mod tests {
assert_eq!(line1[1].style.fg, Some(theme.text_primary));
}
// --- Mid-text skill token styling (with_skill_tokens) ---
#[test]
fn mid_text_token_only_token_is_teal() {
let text = "great /pr-workflow all good now";
@@ -734,15 +727,21 @@ mod tests {
#[test]
fn invalid_token_ranges_are_dropped() {
let text = "héllo /model now"; // 'é' is 2 bytes: "/model" = 7..13
// 'é' is 2 bytes: "/model" = 7..13
let text = "héllo /model now";
let block = UserPromptBlock::with_skill_tokens(
text,
vec![
2..3, // not a char boundary (inside 'é')
40..50, // out of bounds
9..9, // empty
7..13, // valid token
10..15, // overlaps the kept 7..13
// not a char boundary (inside 'é')
2..3,
// out of bounds
40..50,
// empty
9..9,
// valid token
7..13,
// overlaps the kept 7..13
10..15,
],
);
assert_eq!(block.skill_token_ranges, vec![7..13]);
@@ -769,8 +768,6 @@ mod tests {
assert_eq!(lines[0].content.spans[1].style.fg, Some(theme.text_primary));
}
// --- Token styling across soft-wrap and collapsed truncation ---
/// Concatenated content of a line's skill-accent spans.
fn teal_text(line: &Line, theme: &Theme) -> String {
line.spans
@@ -851,7 +848,8 @@ mod tests {
assert_eq!(lines.len(), 3);
assert!(line_text(&lines[0].content).starts_with(crate::glyphs::prompt_arrow()));
assert!(line_text(&lines[1].content).starts_with(" ")); // continuation indent
// continuation indent
assert!(line_text(&lines[1].content).starts_with(" "));
assert!(line_text(&lines[2].content).starts_with(" "));
}
@@ -921,8 +919,6 @@ mod tests {
}
}
// --- Selection metadata tests ---
#[test]
fn test_prompt_lines_have_selection_range() {
let block = UserPromptBlock::new("hello");
@@ -1020,8 +1016,6 @@ mod tests {
}
}
// --- Fold behavior tests ---
#[test]
fn test_short_prompt_not_foldable() {
let block = UserPromptBlock::new("hello");
@@ -1045,8 +1039,9 @@ mod tests {
#[test]
fn test_long_single_line_is_foldable() {
// A single line long enough to wrap past 3 visual lines at 60-char width
let long_line = "a ".repeat(120); // 240 chars → 4 visual lines at 60
// A single line long enough to wrap past 3 visual lines at 60-char width:
// 240 chars → 4 visual lines at 60
let long_line = "a ".repeat(120);
let block = UserPromptBlock::new(long_line);
assert!(block.is_foldable());
assert_eq!(block.default_display_mode(), DisplayMode::Collapsed);
@@ -1054,8 +1049,9 @@ mod tests {
#[test]
fn test_short_single_line_not_foldable() {
// A single line that fits in 3 visual lines at 60-char width
let short_line = "a ".repeat(60); // 120 chars → 2 visual lines at 60
// A single line that fits in 3 visual lines at 60-char width:
// 120 chars → 2 visual lines at 60
let short_line = "a ".repeat(60);
let block = UserPromptBlock::new(short_line);
assert!(!block.is_foldable());
}
@@ -49,7 +49,6 @@ impl EntryId {
Self(id)
}
/// Get the raw ID value.
pub fn value(self) -> u64 {
self.0
}
@@ -58,10 +57,8 @@ impl EntryId {
/// A scrollback entry: block content + display state.
#[derive(Debug, Clone)]
pub struct ScrollbackEntry {
/// Unique identifier for this entry.
pub id: EntryId,
/// The block content.
pub block: RenderBlock,
/// Whether block is still running (for animation, auto-collapse).
@@ -76,7 +73,6 @@ pub struct ScrollbackEntry {
/// `question_view` state via `ScrollbackState::set_pending_user_input`.
pub is_pending_user_input: bool,
/// Current display mode.
pub display_mode: DisplayMode,
pub display_mode_pinned: bool,
@@ -87,7 +83,6 @@ pub struct ScrollbackEntry {
/// Hook data attached to this entry (only meaningful for ToolCall blocks).
pub hook_data: Option<super::blocks::tool::ToolCallHookData>,
/// When this entry was created (local time).
pub created_at: Option<DateTime<Local>>,
/// When this entry finished running (monotonic). Used by the renderer
@@ -215,13 +210,11 @@ impl ScrollbackEntry {
}
}
/// Set the display mode (builder pattern).
pub fn with_display_mode(mut self, mode: DisplayMode) -> Self {
self.display_mode = mode;
self
}
/// Toggle raw mode if the block supports it.
pub fn toggle_raw(&mut self) {
if self.block.has_raw_mode() {
self.raw = !self.raw;
@@ -252,12 +245,10 @@ impl ScrollbackEntry {
}
}
/// Get the current display mode.
pub fn display_mode(&self) -> DisplayMode {
self.display_mode
}
/// Set the display mode.
pub fn set_display_mode(&mut self, mode: DisplayMode) {
if self.display_mode != mode {
self.display_mode = mode;
@@ -275,7 +266,6 @@ impl ScrollbackEntry {
self.invalidate_cache();
}
/// Invalidate cached output.
pub fn invalidate_cache(&mut self) {
*self.cached_output.borrow_mut() = None;
*self.cached_truncated_height.borrow_mut() = None;
@@ -297,7 +287,6 @@ impl ScrollbackEntry {
had_output
}
/// Memoized cheap height-estimate line count for `content_width`, if cached.
pub fn cached_estimate_lines(&self, content_width: u16) -> Option<u16> {
self.cached_estimate_lines
.borrow()
@@ -305,7 +294,6 @@ impl ScrollbackEntry {
.map(|(_, lines)| lines)
}
/// Store the cheap height-estimate line count for `content_width`.
pub fn store_estimate_lines(&self, content_width: u16, lines: u16) {
*self.cached_estimate_lines.borrow_mut() = Some((content_width, lines));
}
@@ -352,11 +340,10 @@ impl ScrollbackEntry {
&& cached.is_selected == effective_selected
&& cached.cwd == cwd_key
{
return; // cache hit
return;
}
}
// Cache miss — regenerate
let ctx = BlockContext {
mode: self.display_mode,
is_running: self.is_running,
@@ -487,7 +474,6 @@ impl ScrollbackEntry {
&cache.as_ref().unwrap().rendered.output
}
/// Get a BlockContext for this entry.
pub fn context(
&self,
width: u16,
@@ -571,7 +557,6 @@ impl ScrollbackEntry {
self.rendered_output_with_hooks(ctx).output
}
/// Get a BlockContext for this entry with a row budget.
pub fn context_with_budget(
&self,
width: u16,
@@ -23,26 +23,21 @@ use crate::appearance::LayoutConfig;
/// not as part of this layout. Scrollbar is handled separately.
#[derive(Debug, Clone)]
pub struct HorizontalLayout {
/// Accent line column.
pub accent: Rect,
/// Left padding area (between accent and content).
pub left_padding: Rect,
/// Main content area.
pub content: Rect,
/// Right padding area.
pub right_padding: Rect,
}
impl HorizontalLayout {
/// Accent width is always 1.
pub const ACCENT: u16 = 1;
/// Create layout for the given area with config values.
pub fn new(area: Rect, config: &LayoutConfig) -> Self {
let [accent, left_padding, content, right_padding] = Layout::horizontal([
Constraint::Length(Self::ACCENT),
Constraint::Length(config.block_pad_left),
Constraint::Min(1), // Content takes remaining space
// Content takes remaining space
Constraint::Min(1),
Constraint::Length(config.block_pad_right),
])
.areas(area);
@@ -60,7 +55,6 @@ impl HorizontalLayout {
Self::new(area, &LayoutConfig::default())
}
/// Total chrome width for a given config.
pub fn chrome_width(config: &LayoutConfig) -> u16 {
Self::ACCENT + config.block_pad_left + config.block_pad_right
}
@@ -81,12 +75,10 @@ impl HorizontalLayout {
}
}
/// Get the accent column area.
pub fn accent_area(&self) -> Rect {
self.accent
}
/// Get the content width (for BlockContext).
pub fn content_width(&self) -> u16 {
self.content.width
}
@@ -104,10 +96,9 @@ impl HorizontalLayout {
///
/// Returns the area where selection borders should be drawn.
pub fn selection_area(&self) -> Rect {
// Selection extends 1 column left of accent into outer padding
// and 1 column right of entry into gap_left area
let x = self.accent.x.saturating_sub(1);
let width = self.entry_content_area().width + 2; // +1 left, +1 right
// +1 left, +1 right
let width = self.entry_content_area().width + 2;
Rect {
x,
@@ -155,16 +146,13 @@ mod tests {
let area = Rect::new(0, 0, 80, 10);
let layout = HorizontalLayout::new(area, &config);
// Check widths
assert_eq!(layout.accent.width, 1);
assert_eq!(layout.left_padding.width, config.block_pad_left);
assert_eq!(layout.right_padding.width, config.block_pad_right);
// Content should be 80 - chrome
let chrome = HorizontalLayout::chrome_width(&config);
assert_eq!(layout.content.width, 80 - chrome);
// All have same height
assert_eq!(layout.accent.height, 10);
assert_eq!(layout.content.height, 10);
}
@@ -177,9 +165,7 @@ mod tests {
let entry_area = layout.entry_content_area();
// Entry area starts at accent column
assert_eq!(entry_area.x, layout.accent.x);
// Width includes accent + left_pad + content + right_pad
assert_eq!(
entry_area.width,
1 + config.block_pad_left + layout.content.width + config.block_pad_right
@@ -198,7 +184,6 @@ mod tests {
assert_eq!(row_layout.accent.height, 3);
assert_eq!(row_layout.content.y, 5);
assert_eq!(row_layout.content.height, 3);
// X positions should be unchanged
assert_eq!(row_layout.accent.x, layout.accent.x);
assert_eq!(row_layout.content.x, layout.content.x);
}
@@ -212,11 +197,8 @@ mod tests {
let selection = layout.selection_area();
// Selection area should extend 1 column LEFT of accent into outer padding
assert_eq!(selection.x, layout.accent.x - 1);
// Width should be entry_content_area width + 2 (1 left, 1 right)
assert_eq!(selection.width, layout.entry_content_area().width + 2);
// Y and height same as accent
assert_eq!(selection.y, layout.accent.y);
assert_eq!(selection.height, layout.accent.height);
}
@@ -232,7 +214,6 @@ mod tests {
// Selection at edge should saturate at x=0 (no underflow)
assert_eq!(selection.x, 0);
// Width is entry width + 2
assert_eq!(selection.width, layout.entry_content_area().width + 2);
}
}
@@ -21,8 +21,6 @@ pub struct VisibleLink {
}
impl VisibleLink {
/// Check whether screen position `(col, row)` falls inside any of
/// this link's row segments.
pub fn contains(&self, col: u16, row: u16) -> bool {
self.rects
.iter()
@@ -45,12 +43,10 @@ pub struct VisibleLinkMap {
}
impl VisibleLinkMap {
/// Find the link at a given screen position, if any.
pub fn link_at(&self, col: u16, row: u16) -> Option<&VisibleLink> {
self.links.iter().find(|link| link.contains(col, row))
}
/// Whether this map is stale relative to the current scrollback generation.
pub fn is_stale(&self, current_generation: u64) -> bool {
self.generation != current_generation
}
@@ -117,13 +113,12 @@ impl VisibleLinkMap {
}
}
/// Truncate to the first `n` links (used to drop previously-appended
/// overlay links before re-appending for the current frame).
/// Truncate to the first `n` links (used to drop overlay links appended
/// earlier this frame before re-appending).
pub fn truncate(&mut self, n: usize) {
self.links.truncate(n);
}
/// Number of links currently in the map.
pub fn len(&self) -> usize {
self.links.len()
}
@@ -200,7 +195,6 @@ mod tests {
map.rebuild(1, &overlay, vec![]);
assert_eq!(map.links().len(), 1);
// Hit inside the link
let hit = map.link_at(15, 5);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://example.com");
@@ -247,7 +241,8 @@ mod tests {
fn zero_width_links_are_skipped() {
let mut map = VisibleLinkMap::default();
let overlay = make_overlay(vec![
(0, 5, 5, "https://zero-width.com", None), // col_start == col_end
// col_start == col_end
(0, 5, 5, "https://zero-width.com", None),
(0, 5, 10, "https://valid.com", None),
]);
map.rebuild(1, &overlay, vec![]);
@@ -267,12 +262,10 @@ mod tests {
map.rebuild(1, &overlay, citations);
assert_eq!(map.links().len(), 2);
// Markdown link
let hit = map.link_at(3, 0);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://md-link.com");
// Citation link
let hit = map.link_at(15, 10);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://citation.com");
@@ -313,14 +306,11 @@ mod tests {
]);
map.rebuild(1, &overlay, vec![]);
// Should be 1 logical link with 2 rects
assert_eq!(map.links().len(), 1);
assert_eq!(map.links()[0].rects.len(), 2);
assert_eq!(&*map.links()[0].url, "https://wrapped.com");
// Hit on first row segment
assert!(map.link_at(15, 3).is_some());
// Hit on second row segment
assert!(map.link_at(5, 4).is_some());
// Miss between segments (wrong col on row 4)
assert!(map.link_at(20, 4).is_none());
@@ -28,7 +28,6 @@ pub mod text_selection;
pub mod types;
pub mod wrappers;
// Re-exports for convenience
pub use block::{BlockContent, RenderBlock};
pub use blocks::{
AgentMessageBlock, SystemMessageBlock, ThinkingBlock, ToolCallBlock, UserPromptBlock,
@@ -296,7 +296,6 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
"entry_layouts_cache must match entries length - was prepare_layout() called?"
);
// Create horizontal layout for this viewport using config
let layout = HorizontalLayout::new(viewport, &appearance.scrollback.layout);
// Total height = y of first passed entry + span of this slice (incl. gaps).
@@ -326,31 +325,27 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
let entry_start = y;
let entry_end = entry_start + height as usize;
// Skip if completely above viewport
if entry_end <= viewport_start {
y = entry_end + entry_layouts_cache[i].gap_after as usize;
continue;
}
// Stop if completely below viewport
if entry_start >= viewport_end {
break;
}
let logical_idx = i + entry_index_base;
// Calculate visibility
let top_clipped = entry_start < viewport_start;
let bottom_clipped = entry_end > viewport_end;
// Calculate render position (narrowed to u16 for screen coordinates —
// these deltas are always within viewport height which fits in u16).
// Narrowed to u16 for screen coordinates — these deltas are always
// within viewport height, which fits in u16.
let render_y: u16 = if top_clipped {
viewport.y
} else {
viewport.y + (entry_start - viewport_start) as u16
};
// Calculate visible height
let skip_rows: u16 = if top_clipped {
(viewport_start - entry_start) as u16
} else {
@@ -362,7 +357,6 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
height - skip_rows
};
// Calculate actual render height (clamped to viewport)
let render_height = visible_height.min(viewport.height);
if render_height == 0 {
@@ -370,7 +364,6 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
continue;
}
// Create the area for this entry's content
let entry_row_layout = layout.for_row(render_y, render_height);
let entry_content_area = entry_row_layout.entry_content_area();
@@ -454,12 +447,10 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
}
}
// Use cached output for selection model building.
// EntryRenderer::render() above already populated the cache for non-selected
// entries. For selected entries, ensure_cached() will compute and cache the
// output once. This avoids a redundant block.output() call (which includes
// expensive syntax highlighting for edit blocks) that was previously done
// via effective_output() on every frame.
// entries. For selected entries, ensure_cached() computes and caches the
// output once, avoiding a redundant block.output() call (which includes
// expensive syntax highlighting for edit blocks) on every frame.
//
// Must use the same effective width as the renderer (reduced by timestamp
// reservation for message blocks) to avoid cache thrashing.
@@ -561,8 +552,9 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
// so the highlighted set can diverge from the index match set: a
// match split across a soft-wrap boundary highlights on neither row,
// and markdown markers present in source but absent on screen won't
// highlight. This is the intended decoupling — navigation/counting
// use the index; on-screen highlighting follows what's drawn.
// highlight. This is the intended decoupling: navigation and
// counting rely on the index, while on-screen highlighting
// follows what's drawn.
if let Some(re) = search_highlight {
highlight_text.clear();
line_plain_text_into(&line.content, &mut highlight_text);
@@ -869,7 +861,6 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
}
}
// Track selected entry
if selected_idx == Some(logical_idx) {
let selection_area = entry_row_layout.selection_area();
result.selected_area = Some(SelectedEntryArea {
@@ -1041,7 +1032,7 @@ mod tests {
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
} // placeholder
}
})
.collect();
@@ -1890,8 +1881,8 @@ mod tests {
/// The aggregated vocabulary is owned by the "Group tool calls" setting:
/// toggled off, a span-fed truncation header keeps the plain "N more"
/// count (with the toggle off, previously verb-groupable tools feed
/// truncation runs — they must not come back verb-labeled anyway).
/// count (tools that are verb-groupable with the toggle on still feed
/// truncation runs when it's off — they must not come back verb-labeled).
#[test]
fn truncation_header_keeps_plain_count_when_group_tool_verbs_off() {
use crate::scrollback::ScrollbackState;
@@ -2262,7 +2253,7 @@ mod tests {
// output computed at `block.content_width`. Re-deriving them at the
// same width must produce the same line count so block_line_idx values
// remain valid; deriving at the wider `pane_content_width` produces a
// different wrapping (the bug `finish_text_drag` previously triggered).
// different wrapping — the mismatch `finish_text_drag` is guarded against.
let appearance = AppearanceConfig::default();
let model_lines = result.selection_model.ranges[0].lines.len();
let entry_lines_narrow = entries[0]
@@ -2286,8 +2277,6 @@ mod tests {
);
}
// ── map_hyperlinks_to_overlay ──
use crate::scrollback::types::BlockLine;
use ratatui::text::Line as RatatuiLine;
@@ -2446,11 +2435,14 @@ mod tests {
#[test]
fn overlay_content_line_offset_skips_header_lines() {
// Simulates BtwBlock: header + separator + markdown body
// Simulates BtwBlock: header (offset 0) + separator (offset 1) + markdown body (offset 2).
let output = make_block_output(&[
("/btw question", None), // header (offset 0)
("", None), // separator (offset 1)
("body text", None), // markdown body line 0
// header (offset 0)
("/btw question", None),
// separator (offset 1)
("", None),
// markdown body line 0
("body text", None),
]);
// Hyperlink on markdown body line_index=0, cols 0..4
let links = [make_hyperlink(0, 0..4, "https://body.com", 1)];
@@ -2486,8 +2478,10 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
let link = &overlay.links()[0];
assert_eq!(link.screen_row, 5);
assert_eq!(link.col_start, 5); // content_x(2) + local_col_start(3)
assert_eq!(link.col_end, 8); // content_x(2) + local_col_end(6)
// content_x(2) + local_col_start(3)
assert_eq!(link.col_start, 5);
// content_x(2) + local_col_end(6)
assert_eq!(link.col_end, 8);
}
#[test]
@@ -2500,8 +2494,6 @@ mod tests {
assert!(overlay.is_empty());
}
// ── URL scanning for non-markdown blocks ──
#[test]
fn execute_block_urls_get_overlay_links() {
let entries = vec![ScrollbackEntry::new(RenderBlock::execute_with_output(
@@ -3044,9 +3036,9 @@ mod tests {
"collapsed slot must not map hidden member content"
);
// Collapsed Read headers show basename only (`a1.rs`), so the plain-
// text path scanner no longer sees the absolute `/tmp/verbgeo/…`
// string and will not emit a file link for it. Folded members still
// expose no links; expanded members remain selectable (asserted above).
// text path scanner never sees the absolute `/tmp/verbgeo/…` string
// and emits no file link for it. Folded members still expose no
// links; expanded members remain selectable (asserted above).
let member_link_rows = |expanded: bool| {
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
@@ -4125,8 +4117,7 @@ mod tests {
/// A tool-media overlay/image placement exposes the click-to-copy filepath as
/// its `filepath_screen_rect`, anchored on the block's second output row (the
/// filepath line) and sharing the image's content-column origin. This is the
/// assertion that the removed `has_filepath_line` flag previously carried.
/// filepath line) and sharing the image's content-column origin.
#[test]
fn tool_media_overlay_exposes_filepath_click_rect() {
use crate::scrollback::blocks::tool::{OtherToolCallBlock, ToolCallBlock};
@@ -53,7 +53,6 @@ pub(crate) struct RenderOutputWithSelectionBoundaries {
}
impl ScrollbackPane {
/// Create a new scrollback pane.
pub fn new() -> Self {
Self {
is_active: false,
@@ -65,7 +64,6 @@ impl ScrollbackPane {
}
}
/// Set active state.
pub fn active(mut self, active: bool) -> Self {
self.is_active = active;
self
@@ -82,7 +80,6 @@ impl ScrollbackPane {
self
}
/// Set the entry under the mouse cursor.
pub fn with_hovered_entry(mut self, hovered: Option<usize>) -> Self {
self.hovered_entry = hovered;
self
@@ -143,7 +140,7 @@ impl ScrollbackPane {
self,
area: Rect,
buf: &mut Buffer,
state: &ScrollbackState, // Now immutable!
state: &ScrollbackState,
scratch: &mut ScratchBuffer,
) -> RenderOutput {
self.render_with_scratch_and_selection_boundaries(area, buf, state, scratch)
@@ -161,7 +158,6 @@ impl ScrollbackPane {
return RenderOutputWithSelectionBoundaries::default();
}
// Get scroll info for Viewport to render scrollbar
let (scroll_offset, viewport_height, total_height) = state.scroll_info();
let scroll_info = ScrollInfo {
scroll_offset,
@@ -171,12 +167,11 @@ impl ScrollbackPane {
let theme = Theme::current();
// NOTE: All layout preparation (viewport, heights, follow mode, cache invalidation)
// is now done by state.prepare_layout() BEFORE render is called.
// This keeps render as close to pure as possible.
// All layout preparation (viewport, heights, follow mode, cache
// invalidation) happens in state.prepare_layout(), called before
// render, keeping render as close to pure as possible.
// Branch based on view mode - render to full area
// Scrollbar is now rendered by Viewport at the correct position
// Scrollbar is rendered by Viewport, not here.
let mut output = match state.view_mode() {
ViewMode::SingleTurn => self.render_single_turn(area, buf, state, &theme, scratch),
ViewMode::AllTurns => {
@@ -190,7 +185,6 @@ impl ScrollbackPane {
// not the scrollback owns focus — hover is a mouse signal.
self.render_tool_call_hover(buf, area, state, &theme);
// Add scroll info for Viewport to render scrollbar
output.output.scroll_info = Some(scroll_info);
output
}
@@ -297,8 +291,6 @@ impl ScrollbackPane {
);
}
// Comment markers for sticky header rendering follow below
// Unified sticky header rendering for both SingleTurn and AllTurns modes.
//
// The key insight: SingleTurn is just AllTurns with a filtered range.
@@ -319,7 +311,7 @@ impl ScrollbackPane {
&self,
area: Rect,
buf: &mut Buffer,
state: &ScrollbackState, // Now immutable!
state: &ScrollbackState,
theme: &Theme,
scratch: &mut ScratchBuffer,
) -> RenderOutputWithSelectionBoundaries {
@@ -327,8 +319,6 @@ impl ScrollbackPane {
self.render_with_sticky_headers(area, buf, state, theme, visible_range, scratch)
}
// Unified sticky header rendering
/// Render entries with sticky section headers.
///
/// This is the unified rendering path for both SingleTurn and AllTurns modes.
@@ -343,7 +333,7 @@ impl ScrollbackPane {
&self,
area: Rect,
buf: &mut Buffer,
state: &ScrollbackState, // Now immutable!
state: &ScrollbackState,
theme: &Theme,
entry_range: Range<usize>,
scratch: &mut ScratchBuffer,
@@ -352,7 +342,6 @@ impl ScrollbackPane {
let layout = HorizontalLayout::new(area, layout_cfg);
let entry_content_width = layout.entry_content_area().width;
// Build prompt descriptors for the given entry range.
// For SingleTurn, this gives us relative y_virtual coordinates.
// For AllTurns, this is equivalent to the full range.
let prompts = self.build_prompt_descriptors_for_range(
@@ -396,8 +385,6 @@ impl ScrollbackPane {
area
};
// Render pushed header (if any) - this one is being pushed off
// Also track selection info for pushed headers
let mut pushed_header_selection_box: Option<SelectionBox> = None;
if let Some(ref pushed) = sticky.pushed {
let visible_height = pushed.visible_height();
@@ -430,7 +417,6 @@ impl ScrollbackPane {
visible_height as f32 / (pushed.render_height.saturating_add(1)) as f32;
fade_region(buf, header_area, theme.bg_base, opacity);
// Compute selection box for pushed header if it's selected
if self.is_active && state.selected() == Some(pushed.entry_idx) {
let layout = HorizontalLayout::new(header_area, layout_cfg);
let selection_area = layout.selection_area();
@@ -489,7 +475,6 @@ impl ScrollbackPane {
// NOTE: The gap row is part of the header area height.
// It should naturally be empty since we don't render anything there.
// Compute selection box for pinned header if it's selected
let mut pinned_header_selection_box: Option<SelectionBox> = None;
if self.is_active
&& let Some((selection_area, entry_idx)) = pinned_header_selection
@@ -513,9 +498,7 @@ impl ScrollbackPane {
pinned_header_selection_box = Some(sel_box);
}
// Render content
if content_area.height > 0 {
// Use the entry_range for content rendering (same range we built descriptors for)
let visible_range = entry_range.clone();
// Use the sticky layout's scroll_for_content() which maintains bottom line continuity.
@@ -566,16 +549,16 @@ impl ScrollbackPane {
/// - SingleTurn: range = visible_entry_range() (one turn's entries)
/// - AllTurns: range = 0..len() (all entries)
///
/// NOTE: This method now uses cached data from prepare_layout().
/// Heights are NOT recomputed - they come from the LayoutCache.
/// NOTE: Uses cached data from prepare_layout(); heights are not
/// recomputed here, they come from the LayoutCache.
fn build_prompt_descriptors_for_range(
&self,
state: &ScrollbackState,
_entry_content_width: u16, // Kept for API compat, but not used (heights from cache)
_theme: &Theme, // Kept for API compat, but not used
// Kept for API compat; unused since heights come from cache.
_entry_content_width: u16,
_theme: &Theme,
entry_range: Range<usize>,
) -> Vec<PromptDescriptor> {
// Get cached data - must be valid after prepare_layout()
let cached_descriptors = state
.get_cached_prompt_descriptors()
.expect("layout cache must be valid - was prepare_layout() called?");
@@ -591,8 +574,6 @@ impl ScrollbackPane {
.copied()
.unwrap_or(0);
// Filter descriptors to only those in the entry range,
// and adjust y_virtual to be relative to range start
cached_descriptors
.iter()
.filter(|p| entry_range.contains(&p.entry_idx))
@@ -615,7 +596,7 @@ impl ScrollbackPane {
&self,
buf: &mut Buffer,
area: Rect,
state: &ScrollbackState, // Now immutable! No entry mutation needed.
state: &ScrollbackState,
entry_idx: usize,
theme: &Theme,
render_height: u16,
@@ -632,8 +613,7 @@ impl ScrollbackPane {
let layout = HorizontalLayout::new(area, &appearance.scrollback.layout);
// Compute content lines from render_height
// The block adds vpad (2 rows) if has_vpad is true
// The block adds vpad (2 rows) if has_vpad is true.
let cwd = state.cwd();
let has_vpad = entry
.block
@@ -691,7 +671,6 @@ impl ScrollbackPane {
let scratch_buf = scratch.prepared(area.width, render_height);
let scratch_area = Rect::new(0, 0, area.width, render_height);
// Render full header to scratch using existing method
Self::render_entry_with_ctx_static(
entry,
&ctx,
@@ -701,7 +680,6 @@ impl ScrollbackPane {
mouse_pos,
);
// Copy visible rows (after clip_top) to output buffer
for dy in 0..visible_height {
let src_y = clip_top + dy;
let dst_y = area.y + dy;
@@ -740,15 +718,12 @@ impl ScrollbackPane {
let left_pad = layout.left_padding;
let right_pad = layout.right_padding;
// Get block output with max_lines budget
let output = entry.block.output(ctx);
let block_has_vpad = entry.block.has_vpad(ctx);
// Get accent background preference and block background
let accent_has_bg = entry.block.accent_background(ctx);
let block_bg = entry.block.background(ctx);
// Resolve the background color from block_bg
let bg_color = match block_bg {
BlockBackground::None => None,
BlockBackground::Light => Some(theme.bg_light),
@@ -760,7 +735,6 @@ impl ScrollbackPane {
// If less space, skip vpad to prioritize content.
let use_vpad = block_has_vpad && content_area.height >= 3;
// Calculate actual content height
let content_height = output.len() as u16;
let total_height = content_height + if use_vpad { 2 } else { 0 };
@@ -771,19 +745,16 @@ impl ScrollbackPane {
let fill_height = total_height.min(area.height);
for y in content_area.y..content_area.y + fill_height {
// Fill left padding
for x in left_pad.x..left_pad.x + left_pad.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(bg_style);
}
}
// Fill content area
for x in content_area.x..content_area.x + content_area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(bg_style);
}
}
// Fill right padding
for x in right_pad.x..right_pad.x + right_pad.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(bg_style);
@@ -803,7 +774,6 @@ impl ScrollbackPane {
if y >= content_area.y + content_area.height {
break;
}
// Render line in the content area (not overlapping with accent)
buf.set_line_safe(content_area.x, y, &line.content, content_area.width);
y += 1;
}
@@ -850,7 +820,6 @@ impl ScrollbackPane {
let color = accent.color;
let accent_area = layout.accent;
// Determine accent background color based on accent_has_bg and block_bg
let accent_bg = if accent_has_bg {
match block_bg {
BlockBackground::None => theme.bg_base,
@@ -881,8 +850,6 @@ impl ScrollbackPane {
}
}
// Shared Rendering Helpers
/// Render the main content area (shared between SingleTurn and AllTurns).
///
/// Returns the selection box for content entries (if any), to be rendered by the frame.
@@ -892,17 +859,19 @@ impl ScrollbackPane {
area: Rect,
content_area: Rect,
buf: &mut Buffer,
state: &ScrollbackState, // Now immutable!
state: &ScrollbackState,
theme: &Theme,
visible_range: Range<usize>,
scroll_for_content: usize,
_prompt_content_height: u16, // Unused after Phase 2 (total_height now from cache)
// Unused; total_height now comes from the cache instead.
_prompt_content_height: u16,
pinned_header_selection_area: Option<Rect>,
pinned_entry_idx: Option<usize>, // Entry idx shown in header (to skip in content selection)
header_has_selection: bool, // Whether selection was already computed for header
// Entry idx shown in header (to skip in content selection).
pinned_entry_idx: Option<usize>,
// Whether selection was already computed for the header.
header_has_selection: bool,
_scratch: &mut ScratchBuffer,
) -> RenderOutputWithSelectionBoundaries {
// Layout cache must be valid after prepare_layout().
let all_layouts = state
.get_cached_entry_layouts()
.expect("layout cache must be valid - was prepare_layout() called?");
@@ -965,8 +934,8 @@ impl ScrollbackPane {
let result = rendered.result;
let selection_boundaries = rendered.selection_boundaries;
// NOTE: total_height is now computed by prepare_layout() before render,
// so we don't update it here. The result.total_height is only used locally
// NOTE: total_height comes from prepare_layout() (called before render)
// and isn't updated here; result.total_height is only used locally,
// if needed for debugging.
// Capture selected entry's screen area for inline button positioning.
@@ -1086,13 +1055,12 @@ impl ScrollbackPane {
&& let Some(ref selected) = selected_area
&& let Some(selected_abs) = state.selected()
{
// Determine the selection range (group or individual)
let split_mode = state.appearance().scrollback.display.group_selection_split;
let sel_range = state.group_range_of(selected_abs, split_mode);
if sel_range.len() <= 1 {
// Singleton (non-groupable, or expanded in Mode B, or lone groupable):
// Use the individual entry's area directly (same as before).
// use the individual entry's area directly.
let top_clipped = selected.top_clipped
|| (selected.area.y == content_area.y && content_area.y == 0);
let bottom = selected.area.y + selected.area.height;
@@ -1127,7 +1095,6 @@ impl ScrollbackPane {
let viewport_start = scroll_for_content;
let viewport_end = scroll_for_content + content_area.height as usize;
// Clip to viewport
let visible_start_vy = group_start_vy.max(viewport_start);
let visible_end_vy = group_end_vy.min(viewport_end);
@@ -141,10 +141,6 @@ fn scan_matches(entries: &[IndexedEntry], matcher: &TextMatcher) -> Vec<Scrollba
matches
}
// ---------------------------------------------------------------------------
// Background search daemon
// ---------------------------------------------------------------------------
/// Latest scan results published by the daemon for the UI thread to pick up.
///
/// `generation` bumps on every write — including writes that produce no matches
@@ -11,7 +11,6 @@ use crate::render::osc8::LinkOverlay;
use crate::scrollback::text_selection::ResolvedSelectionModel;
use crate::theme::Theme;
/// Box drawing characters for selection border.
mod border_chars {
pub const TOP_LEFT: char = '┌';
pub const TOP_RIGHT: char = '┐';
@@ -97,12 +96,10 @@ pub struct ScrollInfo {
}
impl RenderOutput {
/// Create empty render output.
pub fn new() -> Self {
Self::default()
}
/// Create render output with a selection box.
pub fn with_selection_box(selection_box: SelectionBox) -> Self {
Self {
selection_box: Some(selection_box),
@@ -115,7 +112,6 @@ impl RenderOutput {
}
}
/// Add scroll info to the output.
pub fn with_scroll_info(mut self, scroll_info: ScrollInfo) -> Self {
self.scroll_info = Some(scroll_info);
self
@@ -123,7 +119,6 @@ impl RenderOutput {
}
impl SelectionBox {
/// Create a new selection box with the given inner area and style.
pub fn new(inner_area: Rect, style: Style) -> Self {
Self {
inner_area,
@@ -136,13 +131,11 @@ impl SelectionBox {
}
}
/// Set whether the top is clipped (no top corners).
pub fn with_top_clipped(mut self, clipped: bool) -> Self {
self.top_clipped = clipped;
self
}
/// Set whether the bottom is clipped (no bottom corners).
pub fn with_bottom_clipped(mut self, clipped: bool) -> Self {
self.bottom_clipped = clipped;
self
@@ -208,7 +201,6 @@ impl SelectionBox {
let y_top = area.y;
let y_bottom = area.y + area.height.saturating_sub(1);
// Draw side borders
for y in y_top..=y_bottom {
let is_first_row = y == y_top;
let is_last_row = y == y_bottom;
@@ -229,7 +221,6 @@ impl SelectionBox {
}
}
// Draw top corners (if not clipped and there's room)
if !self.top_clipped && y_top > 0 {
let corner_y = y_top - 1;
if let Some(cell) = buf.cell_mut((left_x, corner_y)) {
@@ -253,7 +244,6 @@ impl SelectionBox {
}
}
// Draw bottom corners (if not clipped)
if !self.bottom_clipped {
let corner_y = y_bottom + 1;
if let Some(cell) = buf.cell_mut((left_x, corner_y)) {
@@ -284,7 +274,6 @@ mod tests {
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_eq!(buf.cell((9, 1)).unwrap().symbol(), "┐");
// Check side borders at y=2..=5 (all solid, not clipped)
for y in 2..=5 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
@@ -304,21 +293,17 @@ mod tests {
selection.render(&mut buf);
// Top corners should NOT be drawn
assert_ne!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_ne!(buf.cell((9, 1)).unwrap().symbol(), "┐");
// First row (y=2) should have DASHED borders
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 2)).unwrap().symbol(), "┆");
// Middle rows should have solid borders
for y in 3..=5 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
// Bottom corners should be drawn
assert_eq!(buf.cell((0, 6)).unwrap().symbol(), "└");
}
@@ -331,21 +316,17 @@ mod tests {
selection.render(&mut buf);
// Top corners should be drawn
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_eq!(buf.cell((9, 1)).unwrap().symbol(), "┐");
// First rows should have solid borders
for y in 2..=4 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
// Last row (y=5) should have DASHED borders
assert_eq!(buf.cell((0, 5)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 5)).unwrap().symbol(), "┆");
// Bottom corners should NOT be drawn
assert_ne!(buf.cell((0, 6)).unwrap().symbol(), "└");
assert_ne!(buf.cell((9, 6)).unwrap().symbol(), "┘");
}
@@ -360,21 +341,17 @@ mod tests {
selection.render(&mut buf);
// No corners should be drawn
assert_ne!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_ne!(buf.cell((0, 6)).unwrap().symbol(), "└");
// First row (y=2) should have DASHED borders
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 2)).unwrap().symbol(), "┆");
// Middle rows should have solid borders
for y in 3..=4 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
// Last row (y=5) should have DASHED borders
assert_eq!(buf.cell((0, 5)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 5)).unwrap().symbol(), "┆");
}
@@ -394,7 +371,6 @@ mod tests {
assert_eq!(buf.cell((0, 3)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 3)).unwrap().symbol(), "┆");
// No corners
assert_ne!(buf.cell((0, 2)).unwrap().symbol(), "┌");
assert_ne!(buf.cell((0, 4)).unwrap().symbol(), "└");
}
@@ -413,7 +389,6 @@ mod tests {
assert_eq!(buf.cell((0, 3)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 3)).unwrap().symbol(), "┆");
// Bottom corners should be drawn
assert_eq!(buf.cell((0, 4)).unwrap().symbol(), "└");
assert_eq!(buf.cell((9, 4)).unwrap().symbol(), "┘");
}
@@ -427,12 +402,10 @@ mod tests {
selection.render(&mut buf);
// Side borders at y=0..=3 (all solid, not clipped)
for y in 0..=3 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
}
// Bottom corners at y=4
assert_eq!(buf.cell((0, 4)).unwrap().symbol(), "└");
}
}
@@ -326,7 +326,8 @@ impl ScrollbackState {
height: header_h,
},
top_clipped,
false, // header prompts are never bottom-clipped
// header prompts are never bottom-clipped
false,
));
}
@@ -369,7 +370,8 @@ impl ScrollbackState {
let header_rows = sticky.header_screen_rows();
let content_top = scrollback_area.y + header_rows;
if screen_y + visible_height <= content_top {
return None; // Entirely behind header
// Entirely behind header
return None;
}
let mut top_clipped = top_clipped;
if screen_y < content_top {
@@ -863,19 +865,15 @@ impl ScrollbackState {
/// Returns the prompt entry index if it should be pinned.
/// Pins when scroll_offset > 0 to keep the turn's prompt visible.
pub fn pinned_prompt_index(&self) -> Option<usize> {
// Only pin in SingleTurn mode or when we have a current turn
let turn_idx = self.current_turn?;
let turn = self.turns.get(turn_idx)?;
let prompt_idx = turn.prompt_index;
// Check if prompt is in the visible range
let visible_range = self.visible_entry_range();
if !visible_range.contains(&prompt_idx) {
return None;
}
// Pin the prompt when we've scrolled at all
// This keeps the turn's prompt visible while scrolling through content
if prompt_idx == visible_range.start && self.scroll_offset > 0 {
Some(prompt_idx)
} else {
@@ -905,22 +903,19 @@ impl ScrollbackState {
/// Ensure layout cache is valid for the given width.
/// Rebuilds the cache if needed.
pub(super) fn ensure_layout_cache(&mut self, width: u16) {
// Check if cache is valid
if let Some(ref cache) = self.layout_cache
&& cache.width == width
&& cache.entries.len() == self.entries.len()
{
return; // Cache is valid
return;
}
// Rebuild the cache
self.rebuild_layout_cache(width);
}
/// Compute total content height from the layout cache.
///
/// Call after `ensure_layout_cache()` to derive total_height from cached entry heights.
/// This replaces the old `precompute_total_height()` approach.
///
/// Only sums heights for entries in `visible_entry_range()`. In SingleTurn mode,
/// this means only the current turn's entries are counted, preventing scroll_down
@@ -974,7 +969,8 @@ impl ScrollbackState {
for (id, idx) in dirty_entries {
if idx >= cache.entries.len() {
continue; // Entry added after cache was built
// Entry added after cache was built
continue;
}
let Some((_, entry)) = self.entries.get_index(idx) else {
@@ -1732,9 +1728,9 @@ mod tests {
}
/// After an incremental extend, the next `prepare_layout` must NOT do a
/// Case 1 full rebuild. We assert this indirectly: dirty_heights stays
/// empty (push doesn't dirty existing entries), gaps_may_be_dirty is
/// false (we updated the gap inline), and the cache pointer is preserved.
/// Case 1 full rebuild. Asserted indirectly: dirty_heights stays
/// empty (push doesn't dirty existing entries), gaps_may_be_dirty stays
/// false (the gap is patched inline), and the cache pointer is preserved.
#[test]
fn test_push_does_not_set_gaps_may_be_dirty_after_successful_extend() {
let mut state = ScrollbackState::new();
@@ -1821,7 +1817,7 @@ mod tests {
let mut y = 0usize;
for &h in heights {
virtual_y.push(y);
let gap_after = 1u16; // constant for now
let gap_after = 1u16;
entries.push(EntryLayoutInfo {
height: h,
gap_after,
@@ -1920,14 +1916,17 @@ mod tests {
let all = 0..3;
assert_eq!(cache.entry_at_content_y(0, all.clone()), Some(0));
assert_eq!(cache.entry_at_content_y(1, all.clone()), None); // gap
// gap
assert_eq!(cache.entry_at_content_y(1, all.clone()), None);
assert_eq!(cache.entry_at_content_y(2, all.clone()), Some(1));
assert_eq!(cache.entry_at_content_y(3, all.clone()), None); // gap
// gap
assert_eq!(cache.entry_at_content_y(3, all.clone()), None);
assert_eq!(cache.entry_at_content_y(4, all.clone()), Some(2));
assert_eq!(cache.entry_at_content_y(5, all.clone()), None); // past end
// past end
assert_eq!(cache.entry_at_content_y(5, all.clone()), None);
}
// ── Hit-testing with sticky headers ──────────────────────────────
// Hit-testing with sticky headers
/// Set up a scrollback state with a prompt + N response blocks,
/// prepare layout, and return it.
@@ -2036,8 +2035,8 @@ mod tests {
"Entry 1 should be at screen row {entry1_screen_row}, heights={heights:?}, virtual_y={virtual_y:?}"
);
// Gap between entry 0 and entry 1
let gap_row = heights[0]; // right after entry 0 ends
// Gap between entry 0 and entry 1, right after entry 0 ends
let gap_row = heights[0];
assert_eq!(
state.entry_index_at_screen_row(gap_row, area),
None,
@@ -2219,7 +2218,7 @@ mod tests {
);
}
// ── Lazy viewport height measurement (fast large-session resume) ──
// Lazy viewport height measurement (fast large-session resume)
/// Number of entries that have actually been laid out (markdown-rendered).
/// Estimated entries never populate the entry's output cache.
@@ -2593,7 +2592,8 @@ mod tests {
// re-pins top/bottom) left it off-center. With the target region measured
// first, the target sits at the exact viewport center and stays there.
let mut state = bulk_load_wrapping(60);
state.prepare_layout(20, 8); // bottom-pinned; target is off-screen
// bottom-pinned; target is off-screen
state.prepare_layout(20, 8);
let target = 15;
assert!(
!measured_at(&state, target),
@@ -2601,7 +2601,8 @@ mod tests {
);
state.scroll_to_entry_center(target);
state.prepare_layout(20, 8); // settle runs here; target must NOT drift
// settle runs here; target must NOT drift
state.prepare_layout(20, 8);
assert!(measured_at(&state, target), "target measured exactly");
assert_eq!(
@@ -2660,7 +2661,8 @@ mod tests {
fn assert_resize_keeps_anchor_at_top(from_width: u16, to_width: u16) {
let mut state = resize_anchor_state();
let height = 20u16;
let anchor = 10usize; // a short, non-wrapping entry past the wrapping block
// a short, non-wrapping entry past the wrapping block
let anchor = 10usize;
state.prepare_layout(from_width, height);
let top = {
@@ -2669,7 +2671,8 @@ mod tests {
vy[anchor] - vy[range.start]
};
state.set_scroll_offset(top);
state.prepare_layout(from_width, height); // settle the "before" layout
// settle the "before" layout
state.prepare_layout(from_width, height);
assert!(!state.is_follow_mode());
assert!(
@@ -2749,7 +2752,8 @@ mod tests {
let height = 12u16;
let narrow = 30u16;
let wide = 120u16;
let anchor = 0usize; // a long wrapping entry (one wrapping logical line)
// a long wrapping entry (one wrapping logical line)
let anchor = 0usize;
state.prepare_layout(narrow, height);
// Measure the anchor exactly by putting it at the top, then park the
@@ -2794,7 +2798,8 @@ mod tests {
let _theme = pin_theme();
let mut state = resize_anchor_state();
let height = 20u16;
let anchor = 10usize; // short, non-wrapping; the gap after it is 1 row
// short, non-wrapping; the gap after it is 1 row
let anchor = 10usize;
state.prepare_layout(80, height);
// Park the top on the 1-row gap after entry 10 (the row just before 11).
@@ -3060,7 +3065,8 @@ mod tests {
// A streaming chunk (Case 2: dirty heights, cache kept) while scrolled up
// into an unmeasured region must still measure the visible region.
let mut state = bulk_load_wrapping(200);
state.prepare_layout(20, 10); // measures only the bottom
// measures only the bottom
state.prepare_layout(20, 10);
// Scroll up WITHOUT a render so the middle stays estimated.
state.set_scroll_offset(300);
@@ -3402,7 +3408,7 @@ mod tests {
);
}
// ── Paint window (per-frame viewport sub-range) ──
// Paint window (per-frame viewport sub-range)
/// Build parallel `(virtual_y, layouts)` fixtures from `(height, gap_after)`
/// rows, marking `verb_headers` indices as verb-group headers. Headers
@@ -216,7 +216,8 @@ impl ScrollbackState {
pub fn new() -> Self {
Self {
entries: IndexMap::new(),
next_id: 1, // Start at 1 so 0 can be a sentinel
// Start at 1 so 0 can be a sentinel.
next_id: 1,
running: HashSet::new(),
flashing: Vec::new(),
dirty_heights: HashSet::new(),
@@ -1086,7 +1087,7 @@ impl ScrollbackState {
self.bump_content_generation();
}
// ── Minimal-mode committed frontier ──────────────────────────────────
// Minimal-mode committed frontier.
//
// These support `crate::minimal`'s commit pipeline (print finalized blocks
// into native scrollback). The authoritative state is the `committed`
@@ -2498,7 +2499,8 @@ mod tests {
let id2 = state.push_block(bash_block());
state.set_last_running(true);
if let Some(e) = state.get_by_id_mut(id2) {
e.display_mode = DisplayMode::Collapsed; // manual fold
// manual fold
e.display_mode = DisplayMode::Collapsed;
}
state.finish_running(id2);
assert_eq!(
@@ -2508,7 +2510,7 @@ mod tests {
);
}
// ── Animation gating: off-screen running entries must not redraw ────
// Animation gating: off-screen running entries must not redraw.
/// A running entry scrolled far out of the viewport must not demand
/// animation ticks or redraws — the wave accent can't be seen, and
@@ -2603,7 +2605,7 @@ mod tests {
);
}
// ── Off-screen render-cache eviction ─────────────────────────────────
// Off-screen render-cache eviction.
/// Entries far outside the viewport lose their cached render output
/// (the dominant long-session allocation); entries in the keep zone and
@@ -2794,20 +2796,24 @@ mod tests {
state.push_block(user_block("Q2"));
state.push_block(stub_block("A2"));
assert_eq!(state.current_turn(), Some(1)); // Last turn by default
// Last turn by default.
assert_eq!(state.current_turn(), Some(1));
assert!(state.prev_turn());
assert_eq!(state.current_turn(), Some(0));
assert_eq!(state.selected(), Some(0)); // Jumped to turn 0's prompt
// Jumped to turn 0's prompt.
assert_eq!(state.selected(), Some(0));
assert!(state.next_turn());
assert_eq!(state.current_turn(), Some(1));
assert_eq!(state.selected(), Some(2)); // Jumped to turn 1's prompt
// Jumped to turn 1's prompt.
assert_eq!(state.selected(), Some(2));
// At last turn, l re-activates it (scrolls prompt to top)
assert!(state.next_turn());
assert_eq!(state.current_turn(), Some(1));
assert_eq!(state.selected(), Some(2)); // Still on turn 1's prompt
// Still on turn 1's prompt.
assert_eq!(state.selected(), Some(2));
}
#[test]
@@ -36,7 +36,6 @@ fn response_anchor_in_range(
impl ScrollbackState {
// Turn Navigation
/// Rebuild turn index from entries.
pub(super) fn rebuild_turns(&mut self) {
self.turns.clear();
let mut current_start: Option<usize> = None;
@@ -60,11 +59,11 @@ impl ScrollbackState {
self.turns.push(Turn {
prompt_index: start,
end_index: self.entries.len(),
status: TurnStatus::Running, // Last turn may still be active
// Last turn may still be active
status: TurnStatus::Running,
});
}
// Update current_turn
self.current_turn = if self.turns.is_empty() {
None
} else if let Some(sel) = self.selected {
@@ -74,29 +73,24 @@ impl ScrollbackState {
};
}
/// Find which turn contains a given entry index.
pub fn turn_containing(&self, entry_index: usize) -> Option<usize> {
self.turns
.iter()
.position(|t| entry_index >= t.prompt_index && entry_index < t.end_index)
}
/// Get current turn index.
pub fn current_turn(&self) -> Option<usize> {
self.current_turn
}
/// Get total number of turns.
pub fn turn_count(&self) -> usize {
self.turns.len()
}
/// Get a turn by index.
pub fn turn(&self, index: usize) -> Option<&Turn> {
self.turns.get(index)
}
/// Get all turns.
pub fn turns(&self) -> &[Turn] {
&self.turns
}
@@ -163,7 +157,6 @@ impl ScrollbackState {
/// If we're before the first turn (e.g., at system messages), jumps to the first turn.
/// Otherwise jumps to the next turn.
pub fn next_turn(&mut self) -> bool {
// If no turns exist, nothing to do
if self.turns.is_empty() {
return false;
}
@@ -194,14 +187,13 @@ impl ScrollbackState {
/// If already on a prompt, jumps to the previous turn's prompt.
/// If at first turn's prompt and pre-turn exists with selectable entries, jumps there.
pub fn prev_turn(&mut self) -> bool {
// If no turns exist, nothing to do
if self.turns.is_empty() {
return false;
}
// Check if we're currently in pre-turn (current_turn is None)
if self.current_turn.is_none() {
return false; // Already at pre-turn, can't go further back
// Already at pre-turn, can't go further back
return false;
}
let current = self.current_turn.unwrap();
@@ -227,7 +219,8 @@ impl ScrollbackState {
let range = 0..current_turn.prompt_index;
let first_selectable = self.find_first_selectable_in_range(range);
if first_selectable.is_none() {
return false; // No selectable entries in pre-turn
// No selectable entries in pre-turn
return false;
}
self.current_turn = None;
if self.view_mode == ViewMode::SingleTurn {
@@ -240,10 +233,10 @@ impl ScrollbackState {
}
return true;
}
return false; // No pre-turn, already at first turn's prompt
// No pre-turn, already at first turn's prompt
return false;
}
// Go to previous turn's prompt
self.activate_turn(current - 1);
true
}
@@ -350,7 +343,6 @@ impl ScrollbackState {
false
}
/// Set status of the last turn.
pub fn set_last_turn_status(&mut self, status: TurnStatus) {
if let Some(turn) = self.turns.last_mut() {
turn.status = status;
@@ -359,7 +351,6 @@ impl ScrollbackState {
// Scrolling
/// Scroll up by n rows.
pub fn scroll_up(&mut self, rows: u16) {
self.scroll_offset = self.scroll_offset.saturating_sub(rows as usize);
self.follow_mode = false;
@@ -461,22 +452,19 @@ impl ScrollbackState {
self.scroll_down(self.viewport_height / 2);
}
/// Go to top.
pub fn goto_top(&mut self) {
self.scroll_offset = 0;
self.follow_mode = false;
let range = self.visible_entry_range();
if !range.is_empty() {
// Find first selectable entry
self.selected = self.find_first_selectable_in_range(range);
self.sync_current_turn();
}
self.bump_generation();
}
/// Go to bottom.
pub fn goto_bottom(&mut self) {
// Set scroll to bottom - fills screen with content, last entry at very bottom
// Fills screen with content, last entry at very bottom.
let max_offset = self
.total_height
.saturating_sub(self.viewport_height as usize);
@@ -491,14 +479,12 @@ impl ScrollbackState {
let range = self.visible_entry_range();
if !range.is_empty() {
// Find last selectable entry
self.selected = self.find_last_selectable_in_range(range);
self.sync_current_turn();
}
self.bump_generation();
}
/// Toggle follow mode.
pub fn toggle_follow(&mut self) {
self.follow_mode = !self.follow_mode;
if self.follow_mode {
@@ -534,7 +520,6 @@ impl ScrollbackState {
self.follow_preserve_scroll = true;
}
/// Check if follow mode is enabled.
pub fn is_follow_mode(&self) -> bool {
self.follow_mode
}
@@ -604,10 +589,10 @@ impl ScrollbackState {
.scroll
.min_scroll_lines(self.viewport_height) as usize;
// Get visible range - positions are RELATIVE to this range
// Positions below are RELATIVE to this range.
let visible_range = self.visible_entry_range();
if !visible_range.contains(&selected_idx) {
return; // Selected entry not in visible range, nothing to do
return;
}
// Stay O(viewport): only an OFF-viewport selection (the path that scrolls)
@@ -640,30 +625,24 @@ impl ScrollbackState {
let entry_height = cache.entries[selected_idx].height;
let entry_bottom = entry_y + entry_height as usize;
// Build prompt descriptors relative to visible range (for sticky layout)
let relative_prompts = self.build_relative_prompt_descriptors(cache, &visible_range);
// Compute sticky header at current scroll position
let sticky =
compute_sticky_layout(self.scroll_offset, self.viewport_height, &relative_prompts);
let header_height = sticky.header_screen_rows();
// Content area bounds
let content_top = self.scroll_offset + header_height as usize;
let viewport_bottom = self.scroll_offset + self.viewport_height as usize;
// Effective bounds with margin
let effective_top = content_top + top_margin;
let effective_bottom = viewport_bottom.saturating_sub(bottom_margin);
// Check if entry is fully visible (with margin)
let is_fully_visible = entry_y >= effective_top && entry_bottom <= effective_bottom;
if is_fully_visible {
return; // No scroll needed
return;
}
// Check if entry fits in viewport content area
let content_height = self.viewport_height.saturating_sub(header_height);
let entry_fits = entry_height <= content_height;
@@ -727,7 +706,6 @@ impl ScrollbackState {
let actual_scroll = self.scroll_offset.abs_diff(old_scroll);
if actual_scroll > 0 && actual_scroll < min_scroll {
// Scroll more to meet minimum
let max_scroll = self
.total_height
.saturating_sub(self.viewport_height as usize);
@@ -763,8 +741,9 @@ impl ScrollbackState {
.iter()
.filter(|p| visible_range.contains(&p.entry_idx))
.map(|p| PromptDescriptor {
entry_idx: p.entry_idx, // Keep absolute index for lookup
y_virtual: p.y_virtual.saturating_sub(base_y), // Relative Y
// entry_idx stays absolute (for lookup); y_virtual is made relative.
entry_idx: p.entry_idx,
y_virtual: p.y_virtual.saturating_sub(base_y),
full_height: p.full_height,
min_height: p.min_height,
sticky: p.sticky,
@@ -805,7 +784,8 @@ impl ScrollbackState {
let content_top_at_target = target_scroll + header_at_target as usize;
if entry_y >= content_top_at_target {
break; // Entry would be visible at this scroll position
// Entry would be visible at this scroll position
break;
}
// Need to scroll up more to account for header
@@ -840,7 +820,6 @@ impl ScrollbackState {
return None;
}
// Ensure layout cache is valid
self.ensure_layout_cache(self.last_width);
// Measure the target exactly first (settle only re-pins top/bottom, never
// an arbitrary target). Measuring above is safe: callers consume the
@@ -1132,14 +1111,22 @@ mod tests {
#[test]
fn test_response_anchor_trailing_run_skips_interleaved_messages() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(agent_block("Let me look out loud")); // 1 - mid-turn speak
state.push_block(tool_block("ls")); // 2
state.push_block(agent_block("Found it, checking more")); // 3 - mid-turn speak
state.push_block(tool_block("cat")); // 4
state.push_block(agent_block("Final answer part 1")); // 5 - trailing run start
state.push_block(agent_block("Final answer part 2")); // 6
state.push_block(RenderBlock::system("turn done")); // 7 - doesn't break the run
// 0
state.push_block(user_block("Q1"));
// 1 - mid-turn speak
state.push_block(agent_block("Let me look out loud"));
// 2
state.push_block(tool_block("ls"));
// 3 - mid-turn speak
state.push_block(agent_block("Found it, checking more"));
// 4
state.push_block(tool_block("cat"));
// 5 - trailing run start
state.push_block(agent_block("Final answer part 1"));
// 6
state.push_block(agent_block("Final answer part 2"));
// 7 - doesn't break the run
state.push_block(RenderBlock::system("turn done"));
state.prepare_layout(80, 6);
// The anchor is the trailing run's first message, not a mid-turn one
@@ -1150,9 +1137,12 @@ mod tests {
#[test]
fn test_streaming_response_real_path() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(tool_block("ls")); // 1
let id = state.start_streaming_agent(); // 2
// 0
state.push_block(user_block("Q1"));
// 1
state.push_block(tool_block("ls"));
// 2
let id = state.start_streaming_agent();
state.prepare_layout(80, 6);
// Empty streaming placeholder doesn't qualify
@@ -1175,7 +1165,8 @@ mod tests {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1"));
state.push_block(agent_block("Working on it"));
state.push_block(tool_block("cargo build")); // still running
// still running
state.push_block(tool_block("cargo build"));
state.prepare_layout(80, 6);
assert!(!state.prev_response());
@@ -1196,12 +1187,18 @@ mod tests {
#[test]
fn test_response_navigation_walks_anchor_offsets() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(tall_agent_block()); // 1
state.push_block(user_block("Q2")); // 2
state.push_block(tool_block("cat")); // 3 - tool-only turn, skipped
state.push_block(user_block("Q3")); // 4
state.push_block(tall_agent_block()); // 5
// 0
state.push_block(user_block("Q1"));
// 1
state.push_block(tall_agent_block());
// 2
state.push_block(user_block("Q2"));
// 3 - tool-only turn, skipped
state.push_block(tool_block("cat"));
// 4
state.push_block(user_block("Q3"));
// 5
state.push_block(tall_agent_block());
state.prepare_layout(80, 6);
// J at the follow-mode bottom is a no-op (no anchor top below)
@@ -1241,11 +1238,14 @@ mod tests {
#[test]
fn test_next_response_snaps_current_turn_from_work_region() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
// 0
state.push_block(user_block("Q1"));
// 1-6
for i in 0..6 {
state.push_block(tool_block(&format!("tool {i}"))); // 1-6
state.push_block(tool_block(&format!("tool {i}")));
}
state.push_block(tall_agent_block()); // 7
// 7
state.push_block(tall_agent_block());
state.prepare_layout(80, 6);
state.scroll_to_entry_top(3);
@@ -1266,10 +1266,12 @@ mod tests {
#[test]
fn test_prompt_queued_mid_stream_response_reachable() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
let id = state.start_streaming_agent(); // 1
// Queued prompt closes the streaming turn while its message is empty
state.push_block(user_block("Q2")); // 2
// 0
state.push_block(user_block("Q1"));
// 1
let id = state.start_streaming_agent();
// 2 - queued prompt closes the streaming turn while its message is empty
state.push_block(user_block("Q2"));
state.prepare_layout(80, 6);
assert!(!state.prev_response());
@@ -1286,10 +1288,14 @@ mod tests {
#[test]
fn test_response_navigation_single_turn_mode() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(agent_block("A1")); // 1
state.push_block(user_block("Q2")); // 2
state.push_block(tall_agent_block()); // 3
// 0
state.push_block(user_block("Q1"));
// 1
state.push_block(agent_block("A1"));
// 2
state.push_block(user_block("Q2"));
// 3
state.push_block(tall_agent_block());
state.view_mode = ViewMode::SingleTurn;
state.prepare_layout(80, 6);
@@ -1325,7 +1331,8 @@ mod tests {
assert_eq!(state.scroll_offset, 60);
state.goto_bottom();
assert_eq!(state.scroll_offset, 80); // 100 - 20
// 100 - 20
assert_eq!(state.scroll_offset, 80);
assert!(state.is_follow_mode());
state.goto_top();
@@ -1333,11 +1340,11 @@ mod tests {
assert!(!state.is_follow_mode());
}
// Overscroll → follow contract (changed from the original double-hit
// shape): a scroll-down that is ALREADY clamped at the bottom re-engages
// follow on the FIRST event — there is no flag-arming intermediate tick.
// A scroll-down that lands at the bottom (moved real rows) still never
// engages, preserving the fast-scroll landing protection.
// Overscroll → follow contract: a scroll-down that is ALREADY clamped at
// the bottom re-engages follow on the FIRST event — there is no
// flag-arming intermediate tick. A scroll-down that lands at the bottom
// (moved real rows) still never engages, preserving the fast-scroll
// landing protection.
#[test]
fn clamped_scroll_down_at_bottom_engages_follow_on_first_event() {
@@ -1366,7 +1373,8 @@ mod tests {
let mut state = ScrollbackState::new();
state.total_height = 100;
state.viewport_height = 20;
state.scroll_up(1); // exit follow (offset stays 0)
// Exit follow (offset stays 0).
state.scroll_up(1);
state.scroll_offset = 50;
// Clamped 50 → 80: real rows moved, so this is a landing, not an
@@ -1776,9 +1784,8 @@ mod tests {
let at_second_line = state.scroll_offset();
// The second logical line sits past every wrapped row of the first, so
// the reveal scrolls more than a single row down. With the old logical-
// index nudge the delta would be exactly 1; mapping through the wrapped
// output makes it the wrapped-row count of line 0.
// the reveal scrolls more than a single row down: mapping through the
// wrapped output yields the wrapped-row count of line 0.
let delta = at_second_line.saturating_sub(at_first_line);
assert!(
delta > 1,
@@ -1825,7 +1832,7 @@ mod tests {
.set_display_mode(DisplayMode::Expanded);
// Logical line 0 maps past the two non-selectable header rows. Thinking
// has no vpad, so the old "count every hard break" logic returned 0 here.
// has no vpad, so naively counting hard breaks would misreport 0 here.
let row0 = state.rendered_row_offset_within_entry(think_idx, 0);
assert!(
row0 >= 2,
@@ -17,7 +17,6 @@ struct FoldAnchor {
impl ScrollbackState {
// View Mode
/// Get current view mode.
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
@@ -57,12 +56,10 @@ impl ScrollbackState {
// Selection
/// Get selected index.
pub fn selected(&self) -> Option<usize> {
self.selected
}
/// Set selected index.
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index.filter(|&i| i < self.entries.len());
if let Some(sel) = self.selected {
@@ -83,7 +80,6 @@ impl ScrollbackState {
self.selection_box = selection_box;
}
/// Take the selection box (consumes it).
pub fn take_selection_box(&mut self) -> Option<SelectionBox> {
self.selection_box.take()
}
@@ -97,7 +93,6 @@ impl ScrollbackState {
return;
}
// Find starting position
let start = match self.selected {
None => range.start,
Some(i) if i < range.start => range.start,
@@ -137,18 +132,15 @@ impl ScrollbackState {
return;
}
// Find starting position
let start = match self.selected {
None => range.end - 1,
Some(i) if i >= range.end => range.end - 1,
Some(i) if i <= range.start => {
// Already at start, can't go further back
return;
}
Some(i) => i - 1,
};
// Find previous selectable entry (iterate backwards, skip hidden entries)
for idx in (range.start..=start).rev() {
if self.is_entry_hidden(idx) {
continue;
@@ -162,10 +154,8 @@ impl ScrollbackState {
return;
}
}
// No selectable entry found before current - stay where we are
}
/// Clear selection.
pub fn clear_selection(&mut self) {
self.selected = None;
}
@@ -178,18 +168,15 @@ impl ScrollbackState {
return;
}
// Find last selectable entry in range
self.selected = self.find_last_selectable_in_range(range);
}
/// Keep current_turn in sync with selection.
pub(super) fn sync_current_turn(&mut self) {
if let Some(sel) = self.selected {
self.current_turn = self.turn_containing(sel);
}
}
/// Find the first selectable entry in a range.
pub(super) fn find_first_selectable_in_range(&self, range: Range<usize>) -> Option<usize> {
for idx in range {
if let Some(entry) = self.entries.get_index(idx).map(|(_, v)| v)
@@ -201,7 +188,6 @@ impl ScrollbackState {
None
}
/// Find the last selectable entry in a range.
pub(super) fn find_last_selectable_in_range(&self, range: Range<usize>) -> Option<usize> {
for idx in range.rev() {
if let Some(entry) = self.entries.get_index(idx).map(|(_, v)| v)
@@ -243,7 +229,6 @@ impl ScrollbackState {
}
}
/// Toggle fold on selected entry.
pub fn toggle_fold_selected(&mut self) {
if let Some(i) = self.selected
&& let Some((_, entry)) = self.entries.get_index(i)
@@ -260,11 +245,9 @@ impl ScrollbackState {
fn fold_selected_impl(&mut self, mutate: impl FnOnce(&mut ScrollbackEntry)) {
let Some(i) = self.selected else { return };
// 1. Capture state before the fold
let anchor = self.capture_fold_anchor(i);
let respect_manual_folds = self.appearance.scrollback.scroll.respect_manual_folds;
// 2. Apply the fold mutation
let mut grew = false;
if let Some((id, entry)) = self.entries.get_index_mut(i) {
let mode_before = entry.display_mode;
@@ -379,7 +362,6 @@ impl ScrollbackState {
// on the next frame, which calls handle_follow_mode and could snap to bottom.
self.dirty_heights.clear();
// Anchor scroll or ensure visible
if anchor_on_fold {
if let Some(vy_before) = anchor.vy_before
&& let Some(ref cache) = self.layout_cache
@@ -431,7 +413,6 @@ impl ScrollbackState {
}
}
/// Toggle raw mode on selected entry.
pub fn toggle_raw_selected(&mut self) {
// Invisible on a group header (content hidden); skip the rebuild.
if self.is_selected_group_header() {
@@ -447,7 +428,6 @@ impl ScrollbackState {
self.bump_generation();
}
/// Collapse all foldable entries.
pub fn collapse_all(&mut self) {
let mut changed_ids = Vec::new();
for (id, entry) in &mut self.entries {
@@ -467,7 +447,6 @@ impl ScrollbackState {
self.bump_generation();
}
/// Expand all foldable entries.
pub fn expand_all(&mut self) {
let mut changed_ids = Vec::new();
for (id, entry) in &mut self.entries {
@@ -728,7 +707,6 @@ impl ScrollbackState {
let Some(sel) = self.selected else {
return false;
};
// Find the group range containing the selected entry
let group = self.group_range_of(sel, true);
let Some((&first_id, _)) = self.entries.get_index(group.start) else {
return false;
@@ -761,9 +739,7 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Color;
// -----------------------------------------------------------------------
// Group gap tests (Phase 1c)
// -----------------------------------------------------------------------
/// Helper: create an expanded groupable stub block.
fn expanded_groupable(text: &str) -> ScrollbackEntry {
@@ -998,9 +974,7 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Group range tests (Phase 3a)
// -----------------------------------------------------------------------
#[test]
fn test_group_range_mode_a_all_collapsed() {
@@ -1023,7 +997,7 @@ mod tests {
// Mode A: expanded block doesn't break the group
let mut state = ScrollbackState::new();
state.push(collapsed_groupable("a"));
state.push(expanded_groupable("b")); // expanded
state.push(expanded_groupable("b"));
state.push(collapsed_groupable("c"));
// Mode A: all 3 in one group
@@ -1038,14 +1012,16 @@ mod tests {
let mut state = ScrollbackState::new();
state.push(collapsed_groupable("a"));
state.push(collapsed_groupable("b"));
state.push(expanded_groupable("c")); // expanded — breaks run
// "c" is expanded, breaking the run
state.push(expanded_groupable("c"));
state.push(collapsed_groupable("d"));
state.push(collapsed_groupable("e"));
// Mode B: [a,b] and [d,e] are separate sub-groups, c is singleton
assert_eq!(state.group_range_of(0, true), 0..2);
assert_eq!(state.group_range_of(1, true), 0..2);
assert_eq!(state.group_range_of(2, true), 2..3); // expanded → singleton
// expanded → singleton
assert_eq!(state.group_range_of(2, true), 2..3);
assert_eq!(state.group_range_of(3, true), 3..5);
assert_eq!(state.group_range_of(4, true), 3..5);
}
@@ -1058,9 +1034,9 @@ mod tests {
state.push(collapsed_groupable("tool"));
state.push(non_groupable_entry("agent2"));
assert_eq!(state.group_range_of(0, false), 0..1); // non-groupable
assert_eq!(state.group_range_of(1, false), 1..2); // lone groupable
assert_eq!(state.group_range_of(2, false), 2..3); // non-groupable
assert_eq!(state.group_range_of(0, false), 0..1);
assert_eq!(state.group_range_of(1, false), 1..2);
assert_eq!(state.group_range_of(2, false), 2..3);
}
#[test]
@@ -1408,7 +1384,7 @@ mod tests {
assert_eq!(gaps_after[0], 1, "prompt→a gap still 1 after expand");
}
// ── Group truncation tests ──
// Group truncation tests
#[test]
fn truncation_disabled_when_max_visible_zero() {
@@ -1521,26 +1497,27 @@ mod tests {
push_tool_calls(&mut state, 6);
state.prepare_layout(80, 40);
// Select first visible entry
state.selected = None;
state.select_next(); // should land on entry 0 (group header, height=1)
// Lands on entry 0 (group header, height=1)
state.select_next();
assert_eq!(state.selected, Some(0));
state.select_next(); // should skip entries 1,2 (height=0) → entry 3
// Skips entries 1,2 (height=0) → entry 3
state.select_next();
assert_eq!(state.selected, Some(3));
state.select_next(); // entry 4
state.select_next();
assert_eq!(state.selected, Some(4));
// Go back
state.select_prev(); // entry 3
state.select_prev();
assert_eq!(state.selected, Some(3));
state.select_prev(); // should skip entries 2,1 (height=0) → entry 0
// Skips entries 2,1 (height=0) → entry 0
state.select_prev();
assert_eq!(state.selected, Some(0));
}
// ── Verb-group fold tests ──
// Verb-group fold tests
fn verb_state() -> ScrollbackState {
crate::appearance::cache::set_group_tool_verbs(true);
@@ -2591,7 +2568,8 @@ mod tests {
// Also test collapse from inside the group (entry 3):
state.selected = Some(0);
state.toggle_group_expansion(); // re-expand via expand header
// re-expand via expand header
state.toggle_group_expansion();
state.prepare_layout(80, 40);
state.selected = Some(3);
let collapsed = state.collapse_group_if_expanded();
@@ -2756,8 +2734,8 @@ mod tests {
push_tool_calls(&mut state, 6);
state.prepare_layout(80, 40);
// Manually place selection on a hidden entry
state.selected = Some(1); // height=0
// Manually place selection on a hidden entry (height=0)
state.selected = Some(1);
state.fixup_hidden_selection();
// Should move to entry 0 (the group header)
@@ -146,11 +146,14 @@ mod tests {
#[test]
fn timeline_entries_one_per_turn_in_order() {
let mut state = ScrollbackState::new();
state.push_block(stub_block("session banner")); // 0: pre-turn
state.push_block(user_block("first question")); // 1
state.push_block(agent_block("first answer")); // 2
state.push_block(user_block("second question")); // 3
state.push_block(tool_block("ls")); // 4
// Entry 0: pre-turn.
state.push_block(stub_block("session banner"));
// Entry 1: this turn's prompt.
state.push_block(user_block("first question"));
state.push_block(agent_block("first answer"));
// Entry 3: this turn's prompt.
state.push_block(user_block("second question"));
state.push_block(tool_block("ls"));
state.prepare_layout(80, 10);
let entries = state.timeline_entries();
@@ -180,12 +183,12 @@ mod tests {
#[test]
fn active_turn_tracks_viewport_top() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(tall_agent_block()); // 1
state.push_block(user_block("Q2")); // 2
state.push_block(tall_agent_block()); // 3
state.push_block(user_block("Q3")); // 4
state.push_block(tall_agent_block()); // 5
state.push_block(user_block("Q1"));
state.push_block(tall_agent_block());
state.push_block(user_block("Q2"));
state.push_block(tall_agent_block());
state.push_block(user_block("Q3"));
state.push_block(tall_agent_block());
state.prepare_layout(80, 6);
state.goto_top();
@@ -202,8 +205,8 @@ mod tests {
fn active_turn_stays_top_anchored_at_the_bottom() {
// A screenful of short trailing turns: even at the bottom the
// active turn is the one owning the top row (the web-timeline
// rule) — never a newest-turn clamp, whose one-step-off-bottom
// highlight leap and stuck-▲ chevron this replaced.
// rule), not a newest-turn clamp — a clamp causes a one-step-off-
// bottom highlight leap and a stuck-▲ chevron.
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1"));
state.push_block(tall_agent_block());
@@ -218,7 +221,8 @@ mod tests {
assert!(at_bottom < 6, "top-anchored, not the newest: {at_bottom}");
// Nudging off the bottom moves the highlight at most one boundary
// (the old clamp leapt from the newest turn to the top-anchored one).
// — a newest-turn clamp would leap straight from the newest turn to
// the top-anchored one.
state.scroll_up(1);
let nudged = state.active_turn_for_viewport().expect("still in a turn");
assert!(
@@ -312,9 +316,9 @@ mod tests {
#[test]
fn down_chevron_enters_trailing_turns_at_the_bottom() {
// Reported bug: a cluster of short turns fills the final screenful,
// leaving ▼ dim at the bottom even though clicking those ticks jumped
// to them. ▼ now targets the next turn — the same turn a tick click
// A cluster of short turns fills the final screenful: ▼ must not
// dim at the bottom, since clicking the ticks directly still jumps
// to them. ▼ targets the next turn — the same turn a tick click
// resolves to (both go through jump_to_turn).
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1"));
@@ -1,20 +1,16 @@
use std::ops::Range;
/// Status of a turn in the conversation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TurnStatus {
/// Turn is currently running (agent is responding).
/// Agent is currently responding.
#[default]
Running,
/// Turn completed successfully.
Completed,
/// Turn failed (error occurred).
Failed,
/// Turn was cancelled by user.
/// Cancelled by the user.
Cancelled,
}
/// Navigation direction for block selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavDirection {
/// Moving down (j key) - should show top of block first.
@@ -31,34 +27,27 @@ pub struct Turn {
pub prompt_index: usize,
/// Index past the last entry (exclusive, like Range).
pub end_index: usize,
/// Current status.
pub status: TurnStatus,
}
impl Turn {
/// Get the range of entry indices in this turn.
pub fn range(&self) -> Range<usize> {
self.prompt_index..self.end_index
}
/// Number of entries in this turn.
pub fn len(&self) -> usize {
self.end_index - self.prompt_index
}
/// Whether this turn is empty.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// How the scrollback is displayed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
/// Show all turns in a single timeline.
#[default]
AllTurns,
/// Show only a single turn.
SingleTurn,
}
@@ -48,13 +48,11 @@ pub struct PromptDescriptor {
}
/// Minimum height for pinned headers.
/// This is a reasonable default - blocks should be at least this tall.
pub const MIN_PINNED_HEIGHT: u16 = 4;
/// A prompt to be rendered in the sticky header area.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderedPrompt {
/// Entry index of the prompt.
pub entry_idx: usize,
/// Total height budget for rendering this prompt.
@@ -75,13 +73,11 @@ pub struct RenderedPrompt {
}
impl RenderedPrompt {
/// Number of rows actually visible on screen after clipping.
#[inline]
pub fn visible_height(&self) -> u16 {
self.render_height.saturating_sub(self.clip_top)
}
/// Whether this prompt needs a scratch buffer for rendering.
/// True when clip_top > 0 (we need to render full then copy partial).
#[inline]
pub fn needs_scratch_buffer(&self) -> bool {
@@ -138,13 +134,10 @@ impl StickyHeaderLayout {
return 0;
}
// During push (only pushed, no pinned), no gap after header.
// This keeps scroll_for_content constant, ensuring smooth scrolling.
if self.pushed.is_some() && self.pinned.is_none() {
return self.header_content_height(); // No gap
return self.header_content_height();
}
// Pinned header: add gap after for visual separation
self.header_content_height() + HEADER_CONTENT_GAP
}
@@ -186,7 +179,6 @@ impl StickyHeaderLayout {
scroll_offset + self.header_screen_rows() as usize
}
/// Whether any sticky header is present.
#[inline]
pub fn has_header(&self) -> bool {
self.pushed.is_some() || self.pinned.is_some()
@@ -392,7 +384,8 @@ pub fn compute_sticky_layout(
render_height: pushed_render_height,
clip_top: push_clip,
}),
pinned: None, // Next prompt stays inline, not pinned
// Next prompt stays inline, not pinned.
pinned: None,
}
}
None => {
@@ -505,25 +498,21 @@ mod tests {
fn test_gradual_collapse_just_scrolled_past() {
// Prompt at y=0 with full_height=8
// scroll_offset=1 means we just scrolled past by 1 row
// render_height = 8 - 1 = 7
let prompts = make_prompts(&[(0, 8)]);
let layout = compute_sticky_layout(1, 24, &prompts);
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.render_height, 7); // 8 - 1
// header_screen_rows = render_height + gap = 7 + 1 = 8
assert_eq!(pinned.render_height, 7);
assert_eq!(layout.header_screen_rows(), 8);
}
#[test]
fn test_gradual_collapse_more_scrolled() {
// scroll_offset=3: render_height = 8 - 3 = 5
let prompts = make_prompts(&[(0, 8)]);
let layout = compute_sticky_layout(3, 24, &prompts);
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.render_height, 5); // 8 - 3
// header_screen_rows = 5 + 1 = 6
assert_eq!(pinned.render_height, 5);
assert_eq!(layout.header_screen_rows(), 6);
}
@@ -534,8 +523,7 @@ mod tests {
let layout = compute_sticky_layout(6, 24, &prompts);
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.render_height, 4); // clamped to min
// header_screen_rows = 4 + 1 = 5
assert_eq!(pinned.render_height, 4);
assert_eq!(layout.header_screen_rows(), 5);
}
@@ -546,8 +534,7 @@ mod tests {
let layout = compute_sticky_layout(10, 24, &prompts);
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.render_height, 4); // stays at min
// header_screen_rows = 4 + 1 = 5
assert_eq!(pinned.render_height, 4);
assert_eq!(layout.header_screen_rows(), 5);
}
@@ -560,8 +547,8 @@ mod tests {
let layout = compute_sticky_layout(10, 24, &prompts);
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.render_height, 2); // respects custom min_height
assert_eq!(layout.header_screen_rows(), 3); // 2 + 1 gap
assert_eq!(pinned.render_height, 2);
assert_eq!(layout.header_screen_rows(), 3);
}
#[test]
@@ -571,11 +558,13 @@ mod tests {
// never measured (it sits above the viewport when pinned). The collapsed
// sticky header must never exceed the full inline height — otherwise a
// 1-row prompt is padded out to the seed with empty rows.
// full_height: a real 1-row prompt.
// min_height: stale `MAX_TRUNCATED_HEADER_HEIGHT` lazy seed.
let prompts = vec![PromptDescriptor {
entry_idx: 0,
y_virtual: 0,
full_height: 1, // a real 1-row prompt
min_height: 6, // stale `MAX_TRUNCATED_HEADER_HEIGHT` lazy seed
full_height: 1,
min_height: 6,
sticky: true,
}];
let layout = compute_sticky_layout(10, 24, &prompts);
@@ -598,8 +587,8 @@ mod tests {
let layout = compute_sticky_layout(10, 24, &prompts);
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.render_height, 1); // floored to 1
assert_eq!(layout.header_screen_rows(), 2); // 1 + 1 gap
assert_eq!(pinned.render_height, 1);
assert_eq!(layout.header_screen_rows(), 2);
}
// Bottom Line Continuity Tests
@@ -619,10 +608,7 @@ mod tests {
let content_height = layout.content_height(viewport);
let scroll_for_content = layout.scroll_for_content(scroll);
// Bottom line of content area
let bottom_line = scroll_for_content as u16 + content_height - 1;
// Expected bottom line (the invariant)
let expected_bottom = scroll as u16 + viewport - 1;
assert_eq!(
@@ -667,7 +653,7 @@ mod tests {
// At scroll=5: header stays at 5 (min 4 + 1gap), scroll_for_content = 5 + 5 = 10
// NOW it starts increasing because we hit minimum!
let layout5 = compute_sticky_layout(5, 24, &prompts);
assert_eq!(layout5.header_screen_rows(), 5); // min 4 + 1 gap
assert_eq!(layout5.header_screen_rows(), 5);
assert_eq!(layout5.scroll_for_content(5), 10);
// At scroll=6: header stays at 5 (min), scroll_for_content = 6 + 5 = 11
@@ -716,7 +702,6 @@ mod tests {
// Prompt 1 is now pinned (prompt 0 is no longer relevant)
let pinned = layout.pinned.unwrap();
assert_eq!(pinned.entry_idx, 1);
// Gradual collapse: 8 - 1 = 7
assert_eq!(pinned.render_height, 7);
}
@@ -727,8 +712,8 @@ mod tests {
render_height: 5,
clip_top: 1,
};
assert_eq!(rp.visible_height(), 4); // 5 - 1
assert!(rp.needs_scratch_buffer()); // clip_top > 0
assert_eq!(rp.visible_height(), 4);
assert!(rp.needs_scratch_buffer());
let rp2 = RenderedPrompt {
entry_idx: 0,
@@ -744,13 +729,14 @@ mod tests {
let prompts = make_prompts(&[(0, 8)]);
let layout = compute_sticky_layout(5, 24, &prompts);
// Verify helper methods
assert!(layout.has_header());
assert_eq!(layout.pinned_entry_idx(), Some(0));
assert_eq!(layout.pinned_screen_row(), Some(0)); // No pushed, so pinned starts at 0
// No pushed, so pinned starts at 0.
assert_eq!(layout.pinned_screen_row(), Some(0));
// min render_height=4, gap is at row 4
assert_eq!(layout.gap_row(), Some(4));
assert_eq!(layout.content_height(24), 24 - 5); // viewport - header_screen_rows (4+1gap=5)
// viewport - header_screen_rows (4+1gap=5)
assert_eq!(layout.content_height(24), 24 - 5);
}
/// Detailed trace test for gradual collapse behavior.
@@ -776,23 +762,19 @@ mod tests {
assert!(layout1.has_header(), "scroll=1: should have header");
assert_eq!(layout1.pinned.unwrap().render_height, 7);
// scroll=2: render_height = 8 - 2 = 6
let layout2 = compute_sticky_layout(2, 24, &prompts);
assert_eq!(layout2.pinned.unwrap().render_height, 6);
// scroll=3: render_height = 8 - 3 = 5
let layout3 = compute_sticky_layout(3, 24, &prompts);
assert_eq!(layout3.pinned.unwrap().render_height, 5);
// scroll=4: render_height = 8 - 4 = 4 (min)
let layout4 = compute_sticky_layout(4, 24, &prompts);
assert_eq!(layout4.pinned.unwrap().render_height, 4);
// scroll=5: render_height = 8 - 5 = 3, clamped to 4 (min)
// scroll=5: render_height would be 3, clamped to min (4)
let layout5 = compute_sticky_layout(5, 24, &prompts);
assert_eq!(layout5.pinned.unwrap().render_height, 4);
// scroll=10: still at min
let layout10 = compute_sticky_layout(10, 24, &prompts);
assert_eq!(layout10.pinned.unwrap().render_height, 4);
}
@@ -827,7 +809,7 @@ mod tests {
let layout6 = compute_sticky_layout(6, 24, &prompts);
assert!(layout6.has_header());
assert_eq!(layout6.pinned.unwrap().entry_idx, 1);
assert_eq!(layout6.pinned.unwrap().render_height, 7); // 8 - 1
assert_eq!(layout6.pinned.unwrap().render_height, 7);
// scroll=9: prompt 1 at min height
let layout9 = compute_sticky_layout(9, 24, &prompts);
@@ -1069,7 +1051,8 @@ mod tests {
PromptDescriptor {
entry_idx: 0,
y_virtual: 0,
full_height: 3, // Less than MIN_PINNED_HEIGHT!
// full_height less than MIN_PINNED_HEIGHT!
full_height: 3,
min_height: 4,
sticky: true,
},
@@ -1094,7 +1077,7 @@ mod tests {
"Pushed header should use full_height for small prompts, not inflated min_height"
);
assert_eq!(pushed.visible_height(), 1);
// clip_top = 3 - 1 = 2 (clips 2 rows, shows bottom 1 row of actual content)
// clips 2 rows, showing the bottom 1 row of actual content
assert_eq!(pushed.clip_top, 2);
}
@@ -1166,7 +1149,7 @@ mod tests {
},
];
let layout1 = compute_sticky_layout(2, 20, &prompts1);
assert_eq!(layout1.pushed.unwrap().render_height, 3); // min(3, 4)
assert_eq!(layout1.pushed.unwrap().render_height, 3);
// Case 2: full_height=6, scroll=2 → render_height would be max(4, 4)=4
// pushed should use min(6, 4) = 4
@@ -1187,7 +1170,7 @@ mod tests {
},
];
let layout2 = compute_sticky_layout(5, 20, &prompts2);
assert_eq!(layout2.pushed.unwrap().render_height, 4); // min(6, 4)
assert_eq!(layout2.pushed.unwrap().render_height, 4);
// Case 3: full_height=4, render_height=4 → equal, should be 4
let prompts3 = vec![
@@ -1207,7 +1190,7 @@ mod tests {
},
];
let layout3 = compute_sticky_layout(3, 20, &prompts3);
assert_eq!(layout3.pushed.unwrap().render_height, 4); // min(4, 4)
assert_eq!(layout3.pushed.unwrap().render_height, 4);
}
/// Test smooth scrolling with adjacent small prompts (the original bug scenario).
@@ -1261,7 +1244,8 @@ mod tests {
"Should use full_height=3, not inflated 4"
);
assert_eq!(pushed2.visible_height(), 1);
assert_eq!(pushed2.clip_top, 2); // Show bottom 1 row of 3-row content
// Shows the bottom 1 row of the 3-row content.
assert_eq!(pushed2.clip_top, 2);
// scroll=1: A's bottom 2 rows visible
let layout1 = compute_sticky_layout(1, 7, &prompts);
@@ -1269,7 +1253,8 @@ mod tests {
let pushed1 = layout1.pushed.unwrap();
assert_eq!(pushed1.render_height, 3);
assert_eq!(pushed1.visible_height(), 2);
assert_eq!(pushed1.clip_top, 1); // Show bottom 2 rows of 3-row content
// Shows the bottom 2 rows of the 3-row content.
assert_eq!(pushed1.clip_top, 1);
}
#[test]
@@ -1349,7 +1334,8 @@ mod tests {
for row in pinned_start..pinned_start + pinned.visible_height() {
assert_eq!(layout.entry_at_header_row(row), Some(5));
}
return; // Found the transition state
// Found the transition state.
return;
}
}
// It's ok if the exact layout doesn't produce both — geometry varies
@@ -1400,7 +1386,8 @@ mod tests {
y_virtual: 12,
full_height: 8,
min_height: 4,
sticky: false, // expanded user prompt
// expanded user prompt
sticky: false,
},
];
@@ -52,7 +52,8 @@ enum GridLine {
Other,
}
const BAR: char = '\u{2502}'; // │
// │
const BAR: char = '\u{2502}';
/// Chars permitted before a grid's left edge: indentation and blockquote
/// bars (`│ `-prefixed tables render inside quotes with fully selectable
@@ -80,10 +81,14 @@ fn grapheme_cols(text: &str) -> impl Iterator<Item = (u16, char)> + '_ {
/// indentation/blockquote prefix. Returns the junction columns (corners
/// included) and the row family, or `None` when the line is not a border row.
fn parse_border_row(text: &str) -> Option<(Vec<u16>, BorderKind)> {
let (kind, mid, close) = ('\u{250C}', '\u{252C}', '\u{2510}'); // ┌ ┬ ┐
let (dkind, dmid, dclose) = ('\u{251C}', '\u{253C}', '\u{2524}'); // ├ ┼ ┤
let (bkind, bmid, bclose) = ('\u{2514}', '\u{2534}', '\u{2518}'); // └ ┴ ┘
const H: char = '\u{2500}'; // ─
// ┌ ┬ ┐
let (kind, mid, close) = ('\u{250C}', '\u{252C}', '\u{2510}');
// ├ ┼ ┤
let (dkind, dmid, dclose) = ('\u{251C}', '\u{253C}', '\u{2524}');
// └ ┴ ┘
let (bkind, bmid, bclose) = ('\u{2514}', '\u{2534}', '\u{2518}');
// ─
const H: char = '\u{2500}';
let mut junctions: Vec<u16> = Vec::new();
let mut family: Option<BorderKind> = None;
@@ -585,7 +590,8 @@ mod tests {
let broken: &[&str] = &[
"┌─────┬─────┐",
"│ A │ B │",
"├────────┼──┤", // misaligned divider
// misaligned divider
"├────────┼──┤",
"│ one │ two │",
"└─────┴─────┘",
];
@@ -13,10 +13,6 @@ use crate::scrollback::table_geometry::{CellRef, TableGeometry};
use crate::scrollback::types::SelectionBoundary;
use crate::theme::Theme;
// ---------------------------------------------------------------------------
// Auto-scroll types
// ---------------------------------------------------------------------------
/// Direction for drag auto-scroll.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoScrollDirection {
@@ -991,7 +987,6 @@ pub(crate) fn reconstruct_full_selection_text_with_boundaries(
};
let width = cols.end.saturating_sub(cols.start);
// Determine column slice for this line.
let col_range = if start_bl == end_bl {
let s = min(drag.anchor.col_within_range, drag.head.col_within_range);
let e = max(drag.anchor.col_within_range, drag.head.col_within_range).saturating_add(1);
@@ -1044,9 +1039,7 @@ fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
&& row < rect.y.saturating_add(rect.height)
}
// ---------------------------------------------------------------------------
// Word / URL boundary detection (for double-click selection)
// ---------------------------------------------------------------------------
/// All printable ASCII punctuation except underscore, matching tmux's
/// `word-separators` default from `options-table.c`.
@@ -1398,9 +1391,7 @@ mod tests {
assert_eq!(right.col_within_range, 3);
}
// -----------------------------------------------------------------------
// hit_test_nearest_in_range tests
// -----------------------------------------------------------------------
/// One selectable line for the nearest-in-range fixtures.
fn nearest_line(
@@ -1750,7 +1741,8 @@ mod tests {
// The anchor (block_line_idx=9) is now off-screen. During autoscroll
// the resolver misses there and the caller keeps the previous head
// (pinned by active_drag_motion_miss_keeps_previous_head).
let new_head = head; // head was at block_line_idx=5
// head was at block_line_idx=5
let new_head = head;
let scrolled_drag = ActiveTextDrag {
anchor,
@@ -1779,9 +1771,7 @@ mod tests {
assert!(!text.contains("line nine"), "off-screen line not in model");
}
// -----------------------------------------------------------------------
// word_boundaries_at_col tests
// -----------------------------------------------------------------------
/// Shorthand for tests using the default tmux separator set.
fn wb(text: &str, col: u16) -> Range<u16> {
@@ -1815,7 +1805,8 @@ mod tests {
fn word_boundaries_underscore_joins_words() {
// "foo_bar" → all Word class
assert_eq!(wb("foo_bar", 0), 0..7);
assert_eq!(wb("foo_bar", 3), 0..7); // on '_'
// on '_'
assert_eq!(wb("foo_bar", 3), 0..7);
assert_eq!(wb("foo_bar", 6), 0..7);
}
@@ -1871,8 +1862,10 @@ mod tests {
// so they are Word class (matching tmux). Each occupies 2 display cols.
// "a\u{754c}b" → all Word class → 0..4
assert_eq!(wb("a\u{754c}b", 0), 0..4);
assert_eq!(wb("a\u{754c}b", 1), 0..4); // first col of wide char
assert_eq!(wb("a\u{754c}b", 2), 0..4); // second col of wide char
// first col of wide char
assert_eq!(wb("a\u{754c}b", 1), 0..4);
// second col of wide char
assert_eq!(wb("a\u{754c}b", 2), 0..4);
assert_eq!(wb("a\u{754c}b", 3), 0..4);
}
@@ -1991,9 +1984,7 @@ mod tests {
assert_eq!(word_boundaries_at_col("user@host", 0, seps), 0..9);
}
// -----------------------------------------------------------------------
// strip_trailing_url_punctuation tests
// -----------------------------------------------------------------------
#[test]
fn strip_trailing_no_punctuation() {
@@ -2079,9 +2070,7 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// url_range_at_col tests
// -----------------------------------------------------------------------
#[test]
fn url_range_simple_https() {
@@ -2225,14 +2214,13 @@ mod tests {
let url = url_range_at_col(text, 6);
assert!(url.is_some());
// On plain word → url_range returns None, word_boundaries takes over
let non_url_col = 36; // inside "this_word"
// inside "this_word"
let non_url_col = 36;
assert_eq!(url_range_at_col(text, non_url_col), None);
assert_eq!(wb(text, non_url_col), 34..43);
}
// -----------------------------------------------------------------------
// selected_cols_for_endpoints tests
// -----------------------------------------------------------------------
fn make_test_line(
block_line_idx: usize,
@@ -2378,9 +2366,7 @@ mod tests {
assert_eq!(via_wrapper, Some(0..20));
}
// -----------------------------------------------------------------------
// render_persistent_selection_overlay tests
// -----------------------------------------------------------------------
/// Marker foreground: test themes quantize to no color, so the
/// highlight takes its reverse-video path (a modifier change).
@@ -2714,7 +2700,7 @@ mod tests {
assert_eq!(hit.col_within_range, 2);
}
// ── Table-aware selection ────────────────────────────────────────────
// Table-aware selection
const TABLE_LINES: &[&str] = &[
"┌─────────┬────────┐",
@@ -2817,7 +2803,8 @@ mod tests {
#[test]
fn resolve_kind_dead_zone_and_hysteresis() {
let geom = table_geometry();
let anchor = table_hit(3, 3); // Name/Alice cell
// Name/Alice cell
let anchor = table_hit(3, 3);
let cell = SelectionKind::TableCell;
// Junction column (10), own padding (9), and the neighbor's padding
// (11) keep the cell selection — no escalation on a small overshoot.
@@ -617,8 +617,10 @@ mod tests {
#[test]
fn test_selectable_cols() {
let line = Line::from(vec![
Span::raw("prefix: "), // 8 chars, span 0
Span::raw("content"), // 7 chars, span 1
// 8 chars, span 0
Span::raw("prefix: "),
// 7 chars, span 1
Span::raw("content"),
]);
// All spans selectable
@@ -22,12 +22,10 @@ pub struct Accented<'a, T> {
}
impl<'a, T> Accented<'a, T> {
/// Create a new accented wrapper.
pub fn new(inner: &'a T, style: Style) -> Self {
Self { inner, style }
}
/// Create with just a foreground color.
pub fn with_fg(inner: &'a T, color: ratatui::style::Color) -> Self {
Self {
inner,
@@ -48,16 +46,13 @@ impl<T: Renderable> Renderable for Accented<'_, T> {
return;
}
// Split horizontally: [accent (1)] [content (rest)]
let [accent_area, content_area] =
Layout::horizontal([Constraint::Length(1), Constraint::Min(0)]).areas(area);
// Draw accent line for all rows
for y in accent_area.y..accent_area.y + accent_area.height {
buf.set_string(accent_area.x, y, crate::glyphs::accent_bar(), self.style);
}
// Render inner content
if content_area.width > 0 {
self.inner.render(content_area, buf);
}
@@ -112,7 +107,6 @@ mod tests {
let mut buf = Buffer::empty(area);
accented.render(area, &mut buf);
// Check accent line is in column 0
assert_eq!(
buf.cell((0, 0)).unwrap().symbol(),
crate::glyphs::accent_bar()
@@ -122,7 +116,6 @@ mod tests {
crate::glyphs::accent_bar()
);
// Check content starts at column 1
assert_eq!(buf.cell((1, 0)).unwrap().symbol(), "H");
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), "i");
}
@@ -137,6 +130,7 @@ mod tests {
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
accented.render(area, &mut buf); // Should not panic
// Should not panic
accented.render(area, &mut buf);
}
}
@@ -105,7 +105,8 @@ impl<B: BlockContent> Renderable for BlockRenderer<'_, B> {
let has_vpad = self.block.has_vpad(&ctx);
let content_height = output.len() as u16;
let vpad = if has_vpad { 2 } else { 0 }; // top + bottom
// top + bottom
let vpad = if has_vpad { 2 } else { 0 };
content_height + vpad
}
@@ -120,10 +121,8 @@ impl<B: BlockContent> Renderable for BlockRenderer<'_, B> {
let has_vpad = self.block.has_vpad(&ctx);
let block_bg = self.block.background(&ctx);
// Resolve background color
let bg_color = self.resolve_background(block_bg);
// Fill background if specified
if let Some(bg) = bg_color {
let bg_style = Style::default().bg(bg);
for y in area.y..area.y + area.height {
@@ -143,13 +142,11 @@ impl<B: BlockContent> Renderable for BlockRenderer<'_, B> {
row += 1;
}
// Content lines
for line in &output.lines {
if row >= max_row {
break;
}
// Apply line-specific background if set
// Respects bg_start_col for partial background
if let Some(line_bg) = line.background {
let bg_x = area.x + line.bg_start_col;
@@ -160,13 +157,11 @@ impl<B: BlockContent> Renderable for BlockRenderer<'_, B> {
}
}
// Render the line content
buf.set_line_safe(area.x, row, &line.content, area.width);
row += 1;
}
// Bottom vpad (empty row) - just skip, background already applied
// (no explicit rendering needed)
}
}
@@ -212,7 +207,6 @@ mod tests {
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
// All cells should have red background
for y in 0..3 {
for x in 0..10 {
assert_eq!(buf.cell((x, y)).unwrap().bg, Color::Red);
@@ -326,7 +326,8 @@ impl<'a> EntryRenderer<'a> {
/// Legacy fixed chrome-width estimate for callers with no appearance to
/// borrow (off-screen mermaid sizing); the live value is `chrome_width()`.
pub const CHROME_WIDTH: u16 = 1 + 2 + 1; // accent + left_pad + right_pad (legacy)
// accent + left_pad + right_pad (legacy)
pub const CHROME_WIDTH: u16 = 1 + 2 + 1;
/// Whether this entry should display a timestamp on the first content line.
///
@@ -346,7 +347,8 @@ impl<'a> EntryRenderer<'a> {
/// never collides with the timestamp overlay.
fn timestamp_reserved(&self) -> u16 {
if self.appearance.show_timestamps && self.should_show_timestamp() {
10 // max short format: " 12:30 PM"
// max short format: " 12:30 PM"
10
} else {
0
}
@@ -680,10 +682,10 @@ impl Renderable for EntryRenderer<'_> {
])
.areas(area);
// Build context directly — avoid calling effective_output() here since
// we only need the context for background/accent decisions, not the output.
// This eliminates a full block.output() call (including syntax highlighting
// for edit blocks) that was previously thrown away.
// Only background/accent decisions are needed here, so build the
// context directly instead of going through effective_output() —
// that would run a full block.output() (including syntax
// highlighting for edit blocks) just to discard it.
let mut ctx = self
.entry
.context(content_area.width, &self.appearance, self.cwd);
@@ -711,7 +713,6 @@ impl Renderable for EntryRenderer<'_> {
let full_start_y = area.y;
let full_end_y = area.y + area.height;
// Fill accent area if block wants accent background
if self.entry.block.accent_background(&ctx) {
for y in full_start_y..full_end_y {
for x in accent_area.x..accent_area.x + accent_area.width {
@@ -722,7 +723,6 @@ impl Renderable for EntryRenderer<'_> {
}
}
// Fill left padding
for y in full_start_y..full_end_y {
for x in left_pad.x..left_pad.x + left_pad.width {
if let Some(cell) = buf.cell_mut((x, y)) {
@@ -731,7 +731,6 @@ impl Renderable for EntryRenderer<'_> {
}
}
// Fill content area
for y in full_start_y..full_end_y {
for x in content_area.x..content_area.x + content_area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
@@ -740,7 +739,6 @@ impl Renderable for EntryRenderer<'_> {
}
}
// Fill right padding
for y in full_start_y..full_end_y {
for x in right_pad.x..right_pad.x + right_pad.width {
if let Some(cell) = buf.cell_mut((x, y)) {
@@ -840,7 +838,6 @@ impl Renderable for EntryRenderer<'_> {
);
}
} else {
// Static accent: full color
let style = Style::default().fg(color);
for y in accent_area.y..accent_area.y + accent_area.height {
buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style);
@@ -886,7 +883,6 @@ impl Renderable for EntryRenderer<'_> {
// so a wide glyph can't strand a ghost; per-row bg keeps code blocks whole.
let own_gutter = ts_reserved > 0 && bg_color.is_none() && content_area.width > ts_reserved;
// Content lines — skip the first `content_skip` lines
for line in output.lines.iter().skip(content_skip as usize) {
if row >= max_row {
break;
@@ -1092,7 +1088,8 @@ mod tests {
#[test]
fn test_chrome_width_constant() {
assert_eq!(EntryRenderer::CHROME_WIDTH, 4); // 1 + 2 + 1
// 1 + 2 + 1
assert_eq!(EntryRenderer::CHROME_WIDTH, 4);
}
#[test]
@@ -1236,7 +1233,8 @@ mod tests {
// AgentMessage has no vpad → first content row at y=0.
// Hover the rightmost 10 cols of that row to trigger expansion.
let hover_x = width - 2 - 5; // inside the timestamp zone
// inside the timestamp zone
let hover_x = width - 2 - 5;
let renderer = EntryRenderer::new(&entry, &theme).with_mouse_pos(Some((hover_x, 0)));
let height = renderer.desired_height(width);
@@ -1414,8 +1412,6 @@ mod tests {
}
}
// ── timestamp gutter ownership (ghost-glyph regression) ──
#[test]
fn gutter_cleared_on_non_first_content_row_without_background() {
// Regression: nothing writes the timestamp gutter on rows past the
@@ -1573,8 +1569,6 @@ mod tests {
);
}
// ── estimate_height (lazy off-screen sizing) ──
#[test]
fn estimate_matches_exact_for_plain_single_line() {
let _theme = pin_theme();
@@ -1760,9 +1754,12 @@ mod tests {
let entry = ScrollbackEntry::new(RenderBlock::stub(text, Color::Blue));
EntryRenderer::new(&entry, &theme).estimate_height(14)
};
let wide = height("一二三四五六七八九十"); // display 20, bytes 30
let ascii_same_display = height("xxxxxxxxxxxxxxxxxxxx"); // display 20, bytes 20
let ascii_same_bytes = height("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // display 30, bytes 30
// display 20, bytes 30
let wide = height("一二三四五六七八九十");
// display 20, bytes 20
let ascii_same_display = height("xxxxxxxxxxxxxxxxxxxx");
// display 30, bytes 30
let ascii_same_bytes = height("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
assert_eq!(
wide, ascii_same_display,
"wraps by display width: wide(20 cols) == ascii(20 cols)"
@@ -1773,8 +1770,6 @@ mod tests {
);
}
// ── diagram affordance-row reservation ──
/// A diagram under `auto`/`on` reserves exactly one extra row — the
/// affordance row — and the off-screen estimate accounts for it so a bulk
/// load never under-reserves. Rendering is lazy, so the row is always present
@@ -1838,8 +1833,6 @@ mod tests {
crate::appearance::cache::set_show_thinking_blocks(false);
}
// ── flat_background × per-line backgrounds (minimal mode) ──
/// Whether any cell in the buffer carries `bg` as its background.
fn buf_has_bg(buf: &Buffer, bg: Color) -> bool {
let area = *buf.area();
@@ -43,7 +43,6 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Color;
/// Test that wrappers can be composed together.
#[test]
fn test_wrapper_composition() {
let block = StubBlock::new("Hello", Color::Blue);
@@ -57,7 +56,6 @@ mod tests {
// Padded: takes 3 columns (2+1), height unchanged
assert_eq!(padded.desired_height(80), 3);
// Render and verify structure
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
padded.render(area, &mut buf);
@@ -24,7 +24,6 @@ pub struct Padded<'a, T> {
}
impl<'a, T> Padded<'a, T> {
/// Create a new padded wrapper.
pub fn new(inner: &'a T, left: u16, right: u16) -> Self {
Self {
inner,
@@ -48,7 +47,6 @@ impl<'a, T> Padded<'a, T> {
impl<T: Renderable> Renderable for Padded<'_, T> {
fn desired_height(&self, width: u16) -> u16 {
// Padding takes left + right columns
let content_width = width.saturating_sub(self.left + self.right);
self.inner.desired_height(content_width)
}
@@ -58,7 +56,6 @@ impl<T: Renderable> Renderable for Padded<'_, T> {
return;
}
// Fill background if specified
if let Some(bg) = self.bg {
let bg_style = Style::default().bg(bg);
for y in area.y..area.y + area.height {
@@ -70,7 +67,6 @@ impl<T: Renderable> Renderable for Padded<'_, T> {
}
}
// Split horizontally: [left pad] [content] [right pad]
let [_left_area, content_area, _right_area] = Layout::horizontal([
Constraint::Length(self.left),
Constraint::Min(0),
@@ -78,7 +74,6 @@ impl<T: Renderable> Renderable for Padded<'_, T> {
])
.areas(area);
// Render inner content
if content_area.width > 0 {
self.inner.render(content_area, buf);
}
@@ -91,7 +86,6 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Color;
/// Simple test content that renders as fixed height.
struct TestContent {
height: u16,
text: &'static str,
@@ -117,7 +111,6 @@ mod tests {
};
let padded = Padded::new(&content, 2, 1);
// Width 80 -> content gets 77 (80 - 2 - 1)
assert_eq!(padded.desired_height(80), 3);
}
@@ -129,8 +122,6 @@ mod tests {
};
let padded = Padded::standard(&content);
// Standard is 2 left, 1 right
// Width 10 -> content gets 7
assert_eq!(padded.desired_height(10), 1);
}
@@ -163,7 +154,6 @@ mod tests {
let mut buf = Buffer::empty(area);
padded.render(area, &mut buf);
// All cells should have blue background
for x in 0..5 {
let cell = buf.cell((x, 0)).unwrap();
assert_eq!(cell.bg, Color::Blue);