mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
fix(tui): streaming remap and resize
This commit is contained in:
committed by
Felipe Coury
parent
4afa1d0a77
commit
2fd2259286
@@ -4244,12 +4244,15 @@ impl ChatWidget {
|
||||
if w == 0 {
|
||||
None
|
||||
} else {
|
||||
// Keep a 1-column minimum for active stream controllers so they can
|
||||
// continue accepting deltas on ultra-narrow layouts.
|
||||
Some(crate::width::usable_content_width(w, reserved_cols).unwrap_or(1))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_resize(&mut self, width: u16) {
|
||||
let had_rendered_width = self.last_rendered_width.get().is_some();
|
||||
self.last_rendered_width.set(Some(width as usize));
|
||||
let stream_width = self.current_stream_width(2);
|
||||
let plan_stream_width = self.current_stream_width(4);
|
||||
@@ -4260,6 +4263,9 @@ impl ChatWidget {
|
||||
controller.set_width(plan_stream_width);
|
||||
}
|
||||
self.sync_active_stream_tail();
|
||||
if !had_rendered_width {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 {
|
||||
|
||||
@@ -126,6 +126,21 @@ async fn current_stream_width_clamps_to_minimum_when_reserved_columns_exhaust_wi
|
||||
assert_eq!(chat.current_stream_width(4), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_terminal_resize_initial_width_requests_redraw() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
let (draw_tx, mut draw_rx) = tokio::sync::broadcast::channel(8);
|
||||
chat.frame_requester = FrameRequester::new(draw_tx);
|
||||
chat.last_rendered_width.set(None);
|
||||
|
||||
chat.on_terminal_resize(120);
|
||||
|
||||
let draw = tokio::time::timeout(std::time::Duration::from_millis(200), draw_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for redraw request");
|
||||
assert!(draw.is_ok(), "expected redraw notification to be sent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_to_history_does_not_commit_transient_stream_tail_after_controller_clear() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
@@ -182,7 +182,7 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Fence {
|
||||
marker: char,
|
||||
marker: u8,
|
||||
len: usize,
|
||||
is_markdown: bool,
|
||||
}
|
||||
@@ -198,11 +198,15 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd
|
||||
return None;
|
||||
}
|
||||
let trimmed = &without_newline[leading_ws..];
|
||||
let marker = trimmed.chars().next()?;
|
||||
if marker != '`' && marker != '~' {
|
||||
let marker = *trimmed.as_bytes().first()?;
|
||||
if marker != b'`' && marker != b'~' {
|
||||
return None;
|
||||
}
|
||||
let len = trimmed.chars().take_while(|ch| *ch == marker).count();
|
||||
let len = trimmed
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.take_while(|byte| **byte == marker)
|
||||
.count();
|
||||
if len < 3 {
|
||||
return None;
|
||||
}
|
||||
@@ -227,10 +231,14 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd
|
||||
return false;
|
||||
}
|
||||
let trimmed = &without_newline[leading_ws..];
|
||||
if !trimmed.starts_with(fence.marker) {
|
||||
if !trimmed.as_bytes().starts_with(&[fence.marker]) {
|
||||
return false;
|
||||
}
|
||||
let len = trimmed.chars().take_while(|ch| *ch == fence.marker).count();
|
||||
let len = trimmed
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.take_while(|byte| **byte == fence.marker)
|
||||
.count();
|
||||
if len < fence.len {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -85,8 +85,6 @@ struct MarkdownStyles {
|
||||
|
||||
impl Default for MarkdownStyles {
|
||||
fn default() -> Self {
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
Self {
|
||||
h1: Style::new().bold().underlined(),
|
||||
h2: Style::new().bold(),
|
||||
@@ -1487,9 +1485,12 @@ where
|
||||
tag_start += 1;
|
||||
}
|
||||
|
||||
if tag_start < bytes.len()
|
||||
&& bytes[tag_start].is_ascii_alphabetic()
|
||||
&& bytes[tag_start + 1..].contains(&b'>')
|
||||
if bytes
|
||||
.get(tag_start)
|
||||
.is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes
|
||||
.get(tag_start + 1..)
|
||||
.is_some_and(|suffix| suffix.contains(&b'>'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -584,8 +584,24 @@ impl TranscriptOverlay {
|
||||
let clamped_end = range.end.min(self.cells.len());
|
||||
let clamped_start = range.start.min(clamped_end);
|
||||
if clamped_start < clamped_end {
|
||||
let removed = clamped_end - clamped_start;
|
||||
if let Some(highlight_cell) = self.highlight_cell.as_mut()
|
||||
&& *highlight_cell >= clamped_start
|
||||
{
|
||||
if *highlight_cell < clamped_end {
|
||||
*highlight_cell = clamped_start;
|
||||
} else {
|
||||
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
self.cells
|
||||
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
|
||||
if self
|
||||
.highlight_cell
|
||||
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
|
||||
{
|
||||
self.highlight_cell = None;
|
||||
}
|
||||
self.rebuild_renderables();
|
||||
}
|
||||
if follow_bottom {
|
||||
@@ -1117,6 +1133,60 @@ mod tests {
|
||||
assert_eq!(overlay.view.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
(0..6)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(3));
|
||||
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.highlight_cell,
|
||||
Some(2),
|
||||
"highlight inside consolidated range should point to replacement cell",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
(0..7)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(6));
|
||||
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.highlight_cell,
|
||||
Some(4),
|
||||
"highlight after consolidated range should shift left by removed cells",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_overlay_snapshot_basic() {
|
||||
// Prepare a static overlay with a few lines and a title
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
//!
|
||||
//! - `emitted_stable_len <= enqueued_stable_len <= rendered_lines.len()`.
|
||||
//! - `rendered_line_end_source_bytes.len() == rendered_lines.len()`.
|
||||
//! - `rendered_line_end_source_totals.len() == rendered_lines.len()`.
|
||||
//! - `raw_source` is append-only until `reset()`; never modified mid-stream.
|
||||
//! - Tail starts exactly at `enqueued_stable_len`.
|
||||
//! - During confirmed table streaming, only lines from the table header onward
|
||||
@@ -51,6 +52,7 @@ use std::time::Instant;
|
||||
use crate::table_detect::is_table_delimiter_line;
|
||||
use crate::table_detect::is_table_header_line;
|
||||
use crate::table_detect::parse_table_segments;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::StreamState;
|
||||
|
||||
@@ -77,6 +79,8 @@ struct StreamCore {
|
||||
rendered_lines: Vec<Line<'static>>,
|
||||
/// Per-rendered-line source byte offsets (raw source coordinates).
|
||||
rendered_line_end_source_bytes: Vec<usize>,
|
||||
/// Per-rendered-line total count of lines sharing that source byte offset.
|
||||
rendered_line_end_source_totals: Vec<usize>,
|
||||
/// Lines enqueued into the commit-animation queue.
|
||||
enqueued_stable_len: usize,
|
||||
/// Lines actually emitted to scrollback.
|
||||
@@ -109,6 +113,7 @@ impl StreamCore {
|
||||
raw_source: String::new(),
|
||||
rendered_lines: Vec::new(),
|
||||
rendered_line_end_source_bytes: Vec::new(),
|
||||
rendered_line_end_source_totals: Vec::new(),
|
||||
enqueued_stable_len: 0,
|
||||
emitted_stable_len: 0,
|
||||
cwd: cwd.to_path_buf(),
|
||||
@@ -227,6 +232,7 @@ impl StreamCore {
|
||||
self.raw_source.clear();
|
||||
self.rendered_lines.clear();
|
||||
self.rendered_line_end_source_bytes.clear();
|
||||
self.rendered_line_end_source_totals.clear();
|
||||
self.enqueued_stable_len = 0;
|
||||
self.emitted_stable_len = 0;
|
||||
self.emitted_stable_source_bytes = 0;
|
||||
@@ -245,10 +251,23 @@ impl StreamCore {
|
||||
);
|
||||
self.rendered_lines = rendered.lines;
|
||||
self.rendered_line_end_source_bytes = rendered.line_end_source_bytes;
|
||||
let mut source_totals: HashMap<usize, usize> = HashMap::new();
|
||||
for source_end in &self.rendered_line_end_source_bytes {
|
||||
*source_totals.entry(*source_end).or_insert(0) += 1;
|
||||
}
|
||||
self.rendered_line_end_source_totals = self
|
||||
.rendered_line_end_source_bytes
|
||||
.iter()
|
||||
.map(|source_end| source_totals.get(source_end).copied().unwrap_or(1))
|
||||
.collect();
|
||||
debug_assert_eq!(
|
||||
self.rendered_line_end_source_bytes.len(),
|
||||
self.rendered_lines.len()
|
||||
);
|
||||
debug_assert_eq!(
|
||||
self.rendered_line_end_source_totals.len(),
|
||||
self.rendered_lines.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Advance `enqueued_stable_len` toward the target stable boundary and enqueue any
|
||||
@@ -383,16 +402,19 @@ impl StreamCore {
|
||||
}
|
||||
|
||||
let end = emitted_end.min(self.rendered_line_end_source_bytes.len());
|
||||
for line_end_source in &self.rendered_line_end_source_bytes[emitted_start..end] {
|
||||
for (offset, line_end_source) in self.rendered_line_end_source_bytes[emitted_start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let line_index = emitted_start + offset;
|
||||
if *line_end_source > self.emitted_stable_source_bytes {
|
||||
self.emitted_stable_source_bytes = *line_end_source;
|
||||
self.emitted_stable_lines_at_source_byte = 1;
|
||||
self.emitted_stable_lines_at_source_byte_total = self
|
||||
.rendered_line_end_source_bytes
|
||||
.iter()
|
||||
.filter(|source| **source == *line_end_source)
|
||||
.count()
|
||||
.max(1);
|
||||
.rendered_line_end_source_totals
|
||||
.get(line_index)
|
||||
.copied()
|
||||
.unwrap_or(1);
|
||||
} else if *line_end_source == self.emitted_stable_source_bytes {
|
||||
self.emitted_stable_lines_at_source_byte += 1;
|
||||
}
|
||||
@@ -447,11 +469,10 @@ impl StreamCore {
|
||||
.take_while(|line_end| **line_end == boundary)
|
||||
.count();
|
||||
let total_at_boundary = self
|
||||
.rendered_line_end_source_bytes
|
||||
.iter()
|
||||
.filter(|line_end| **line_end == boundary)
|
||||
.count()
|
||||
.max(1);
|
||||
.rendered_line_end_source_totals
|
||||
.get(emitted_len - 1)
|
||||
.copied()
|
||||
.unwrap_or(1);
|
||||
|
||||
self.emitted_stable_source_bytes = boundary;
|
||||
self.emitted_stable_lines_at_source_byte = emitted_at_boundary;
|
||||
@@ -1203,6 +1224,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_emitted_len_preserves_source_boundary_tie_breaks() {
|
||||
let cwd = test_cwd();
|
||||
let mut core = StreamCore::new(Some(80), &cwd);
|
||||
core.raw_source = "line\nline\nline\n".to_string();
|
||||
core.rendered_lines = vec![
|
||||
Line::from("row-0"),
|
||||
Line::from("row-1"),
|
||||
Line::from("row-2"),
|
||||
Line::from("row-3"),
|
||||
Line::from("row-4"),
|
||||
];
|
||||
core.rendered_line_end_source_bytes = vec![5, 5, 9, 14, 14];
|
||||
core.rendered_line_end_source_totals = vec![2, 2, 1, 2, 2];
|
||||
core.emitted_stable_len = 3;
|
||||
core.emitted_stable_source_bytes = 5;
|
||||
core.emitted_stable_lines_at_source_byte_total = 2;
|
||||
|
||||
core.emitted_stable_lines_at_source_byte = 1;
|
||||
assert_eq!(
|
||||
core.remap_emitted_len_from_source_boundary(),
|
||||
1,
|
||||
"expected one emitted line at source boundary when one was previously emitted",
|
||||
);
|
||||
|
||||
core.emitted_stable_lines_at_source_byte = 2;
|
||||
assert_eq!(
|
||||
core.remap_emitted_len_from_source_boundary(),
|
||||
2,
|
||||
"expected all lines at source boundary when boundary was fully emitted",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
||||
let mut ctrl = stream_controller(/*width*/ None);
|
||||
|
||||
Reference in New Issue
Block a user