mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/scrollback): avoid padding-only bottom row
This commit is contained in:
@@ -37886,19 +37886,6 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
|
||||
.map(|rc| rc.is_collapsed())
|
||||
.unwrap_or(false);
|
||||
if this_collapsed {
|
||||
if let Some(j) = next_visible_idx {
|
||||
if let Some(next_cell) = cells[j].cell {
|
||||
if matches!(
|
||||
next_cell.kind(),
|
||||
history_cell::HistoryCellType::Exec { .. }
|
||||
) {
|
||||
should_add_spacing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if this_collapsed {
|
||||
let prev_collapsed = prev_visible_idx
|
||||
.and_then(|j| cells[j]
|
||||
@@ -38054,6 +38041,51 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
scroll_from_top = scroll_from_top.saturating_sub(overscan_extra);
|
||||
}
|
||||
|
||||
if clamped_scroll_offset == 0 && content_area.height == 1 {
|
||||
let history_len = self.history_cells.len();
|
||||
let last_kind = if request_count == 0 {
|
||||
None
|
||||
} else if request_count - 1 < history_len {
|
||||
Some(self.history_cells[request_count - 1].kind())
|
||||
} else {
|
||||
let mut extra_idx = (request_count - 1).saturating_sub(history_len);
|
||||
if let Some(ref cell) = self.active_exec_cell {
|
||||
if extra_idx == 0 {
|
||||
Some(cell.kind())
|
||||
} else {
|
||||
extra_idx = extra_idx.saturating_sub(1);
|
||||
if let Some(ref cell) = streaming_cell {
|
||||
if extra_idx == 0 {
|
||||
Some(cell.kind())
|
||||
} else {
|
||||
extra_idx = extra_idx.saturating_sub(1);
|
||||
queued_preview_cells
|
||||
.get(extra_idx)
|
||||
.map(|cell| cell.kind())
|
||||
}
|
||||
} else {
|
||||
queued_preview_cells.get(extra_idx).map(|cell| cell.kind())
|
||||
}
|
||||
}
|
||||
} else if let Some(ref cell) = streaming_cell {
|
||||
if extra_idx == 0 {
|
||||
Some(cell.kind())
|
||||
} else {
|
||||
extra_idx = extra_idx.saturating_sub(1);
|
||||
queued_preview_cells.get(extra_idx).map(|cell| cell.kind())
|
||||
}
|
||||
} else {
|
||||
queued_preview_cells.get(extra_idx).map(|cell| cell.kind())
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(last_kind, Some(history_cell::HistoryCellType::Assistant)) {
|
||||
// Assistant cells have bottom padding; when the history viewport is only
|
||||
// one row tall (e.g., large composer), avoid landing on a blank padding row.
|
||||
scroll_from_top = scroll_from_top.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
target: "code_tui::scrollback",
|
||||
total_height,
|
||||
|
||||
@@ -26,6 +26,7 @@ const PLAIN_SENTINEL: &str = "SENTINEL-PLAIN";
|
||||
const STREAM_SENTINEL: &str = "SENTINEL-STREAM";
|
||||
const EXEC_SENTINEL: &str = "SENTINEL-EXEC";
|
||||
const BROWSER_SENTINEL: &str = "SENTINEL-BROWSER";
|
||||
const COLLAPSED_EXEC_SENTINEL: &str = "SENTINEL-COLLAPSED-EXEC";
|
||||
|
||||
fn seed_plain_transcript(harness: &mut ChatWidgetHarness) {
|
||||
for idx in 0..8 {
|
||||
@@ -77,6 +78,135 @@ fn seed_reasoning_transcript(harness: &mut ChatWidgetHarness, suffix: &str) {
|
||||
));
|
||||
}
|
||||
|
||||
fn seed_reasoning_stream_only(harness: &mut ChatWidgetHarness) {
|
||||
harness.push_user_prompt("Share your reasoning.");
|
||||
|
||||
let reasoning_chunks = [
|
||||
"Evaluating viewport overscan heuristics.",
|
||||
"Tracking spacer toggles near viewport multiples.",
|
||||
"Checking collapsed spacing adjustments.",
|
||||
];
|
||||
|
||||
for (idx, chunk) in reasoning_chunks.iter().enumerate() {
|
||||
harness.handle_event(Event {
|
||||
id: "reasoning-stream".into(),
|
||||
event_seq: (idx + 1) as u64,
|
||||
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
|
||||
delta: format!("{chunk}\n"),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(idx as u64),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let final_reasoning = reasoning_chunks.join("\n");
|
||||
harness.handle_event(Event {
|
||||
id: "reasoning-stream".into(),
|
||||
event_seq: (reasoning_chunks.len() + 1) as u64,
|
||||
msg: EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
text: final_reasoning,
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some((reasoning_chunks.len() + 1) as u64),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_reasoning_before_exec_keeps_last_line() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
seed_plain_transcript(&mut harness);
|
||||
seed_reasoning_stream_only(&mut harness);
|
||||
harness.flush_into_widget();
|
||||
|
||||
// Hide reasoning so it collapses into a compact title cell.
|
||||
harness.send_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
|
||||
let mut seq = 0_u64;
|
||||
let order = |seq: &mut u64| OrderMeta {
|
||||
request_ordinal: 42,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(*seq),
|
||||
};
|
||||
let call_id = "exec-cutoff-collapsed".to_string();
|
||||
let cwd = PathBuf::from("/workspace");
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-begin-cutoff-collapsed".into(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), "printf".into()],
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: Vec::new(),
|
||||
}),
|
||||
order: Some(order(&mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-stdout-cutoff-collapsed".into(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: call_id.clone(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: ByteBuf::from(
|
||||
format!("line 1\nline 2\nline 3\n").into_bytes(),
|
||||
),
|
||||
}),
|
||||
order: Some(order(&mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-end-cutoff-collapsed".into(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(120),
|
||||
}),
|
||||
order: Some(order(&mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "tail-answer".into(),
|
||||
event_seq: 3,
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: format!(
|
||||
"Final tail marker to validate scroll-to-bottom. {COLLAPSED_EXEC_SENTINEL}"
|
||||
),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 43,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
harness.flush_into_widget();
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 10);
|
||||
let metrics = layout_metrics(&harness);
|
||||
let viewport_h = harness.history_viewport_height();
|
||||
assert!(
|
||||
output.contains(COLLAPSED_EXEC_SENTINEL),
|
||||
"collapsed reasoning -> exec transcript should keep the final sentinel visible (viewport_h={viewport_h}, scroll_offset={}, max_scroll={}):\n{}",
|
||||
metrics.scroll_offset,
|
||||
metrics.last_max_scroll,
|
||||
output
|
||||
);
|
||||
assert_eq!(
|
||||
metrics.scroll_offset, 0,
|
||||
"transcript should stay pinned to bottom"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Scenario {
|
||||
Plain,
|
||||
|
||||
Reference in New Issue
Block a user