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:
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);
|
||||
|
||||
Reference in new issue
Block a user