mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
fix(tui): address stream interrupt and markdown edge cases
Improve resize reflow scheduling so due reflows request an immediate frame instead of waiting for unrelated redraws, and clear queued stream output on `Op::Interrupt` so stale lines stop rendering immediately. Harden markdown/table parsing by treating tab indentation as 4-column whitespace in `strip_line_indent` and ignoring escaped-only pipes in `parse_table_segments` unless explicit outer pipes are present.
This commit is contained in:
committed by
Felipe Coury
parent
36761c5863
commit
341efe5d60
+23
-7
@@ -3323,16 +3323,29 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_resize_reflow(&mut self) {
|
||||
self.resize_reflow_pending_until = Some(Instant::now() + RESIZE_REFLOW_DEBOUNCE);
|
||||
fn schedule_resize_reflow(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
let due_now = self
|
||||
.resize_reflow_pending_until
|
||||
.is_some_and(|deadline| now >= deadline);
|
||||
self.resize_reflow_pending_until = Some(now + RESIZE_REFLOW_DEBOUNCE);
|
||||
due_now
|
||||
}
|
||||
|
||||
/// After stream consolidation, schedule a follow-up reflow if one ran mid-stream.
|
||||
fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) {
|
||||
if self.reflow_ran_during_stream {
|
||||
self.schedule_resize_reflow();
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(RESIZE_REFLOW_DEBOUNCE);
|
||||
if self.schedule_resize_reflow() {
|
||||
tui.frame_requester().schedule_frame();
|
||||
} else {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(RESIZE_REFLOW_DEBOUNCE);
|
||||
}
|
||||
} else if self
|
||||
.resize_reflow_pending_until
|
||||
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.reflow_ran_during_stream = false;
|
||||
}
|
||||
@@ -3347,8 +3360,11 @@ impl App {
|
||||
let width_changed = previous_width.is_some_and(|width| width != size.width);
|
||||
if width_changed {
|
||||
self.chat_widget.on_terminal_resize(size.width);
|
||||
self.schedule_resize_reflow();
|
||||
frame_requester.schedule_frame_in(RESIZE_REFLOW_DEBOUNCE);
|
||||
if self.schedule_resize_reflow() {
|
||||
frame_requester.schedule_frame();
|
||||
} else {
|
||||
frame_requester.schedule_frame_in(RESIZE_REFLOW_DEBOUNCE);
|
||||
}
|
||||
} else if previous_width.is_none() {
|
||||
self.chat_widget.on_terminal_resize(size.width);
|
||||
}
|
||||
|
||||
@@ -10599,6 +10599,14 @@ impl ChatWidget {
|
||||
&& self.agent_turn_running
|
||||
{
|
||||
self.interrupt_requested_for_turn = true;
|
||||
if let Some(controller) = self.stream_controller.as_mut() {
|
||||
controller.clear_queue();
|
||||
}
|
||||
if let Some(controller) = self.plan_stream_controller.as_mut() {
|
||||
controller.clear_queue();
|
||||
}
|
||||
self.clear_active_stream_tail();
|
||||
self.request_redraw();
|
||||
}
|
||||
if op.is_review() && !self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.set_task_running(/*running*/ true);
|
||||
|
||||
@@ -101,15 +101,25 @@ fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> {
|
||||
// (which makes it an indented code line per CommonMark).
|
||||
fn strip_line_indent(line: &str) -> Option<&str> {
|
||||
let without_newline = line.strip_suffix('\n').unwrap_or(line);
|
||||
let leading_ws = without_newline
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.take_while(|byte| **byte == b' ')
|
||||
.count();
|
||||
if leading_ws > 3 {
|
||||
return None;
|
||||
let mut byte_idx = 0usize;
|
||||
let mut column = 0usize;
|
||||
for b in without_newline.as_bytes() {
|
||||
match b {
|
||||
b' ' => {
|
||||
byte_idx += 1;
|
||||
column += 1;
|
||||
}
|
||||
b'\t' => {
|
||||
byte_idx += 1;
|
||||
column += 4;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
if column >= 4 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(&without_newline[leading_ws..])
|
||||
Some(&without_newline[byte_idx..])
|
||||
}
|
||||
|
||||
// Parse an opening fence line, returning the fence metadata and whether
|
||||
@@ -223,11 +233,10 @@ fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> {
|
||||
}
|
||||
ActiveFence::MarkdownCandidate(mut data) => {
|
||||
if is_close_fence(line, data.fence) {
|
||||
if markdown_fence_contains_table(&content_from_ranges(
|
||||
markdown_source,
|
||||
&data.content_ranges,
|
||||
), data.fence.is_blockquoted)
|
||||
{
|
||||
if markdown_fence_contains_table(
|
||||
&content_from_ranges(markdown_source, &data.content_ranges),
|
||||
data.fence.is_blockquoted,
|
||||
) {
|
||||
for range in data.content_ranges {
|
||||
push_source_range(range);
|
||||
}
|
||||
|
||||
@@ -326,9 +326,9 @@ impl StreamCore {
|
||||
let holdback_state = self.holdback_scanner.state();
|
||||
let tail_budget = match holdback_state {
|
||||
TableHoldbackState::Confirmed { table_start: start }
|
||||
| TableHoldbackState::PendingHeader { header_start: start } => {
|
||||
self.tail_budget_from_source_start(start)
|
||||
}
|
||||
| TableHoldbackState::PendingHeader {
|
||||
header_start: start,
|
||||
} => self.tail_budget_from_source_start(start),
|
||||
TableHoldbackState::None => 0,
|
||||
};
|
||||
tracing::trace!(
|
||||
@@ -468,6 +468,11 @@ impl StreamController {
|
||||
self.core.has_tail()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_queue(&mut self) {
|
||||
self.core.state.clear_queue();
|
||||
self.core.enqueued_stable_len = self.core.emitted_stable_len;
|
||||
}
|
||||
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.core.set_width(width);
|
||||
}
|
||||
@@ -553,6 +558,11 @@ impl PlanStreamController {
|
||||
self.core.oldest_queued_age(now)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_queue(&mut self) {
|
||||
self.core.state.clear_queue();
|
||||
self.core.enqueued_stable_len = self.core.emitted_stable_len;
|
||||
}
|
||||
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.core.set_width(width);
|
||||
}
|
||||
|
||||
@@ -28,21 +28,23 @@
|
||||
|
||||
/// Split a pipe-delimited line into trimmed segments.
|
||||
///
|
||||
/// Returns `None` if the line is empty or has no `|` marker.
|
||||
/// Returns `None` if the line is empty or has no unescaped separator marker.
|
||||
/// Leading/trailing pipes are stripped before splitting.
|
||||
pub(crate) fn parse_table_segments(line: &str) -> Option<Vec<&str>> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || !trimmed.contains('|') {
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_outer_pipe = trimmed.starts_with('|') || trimmed.ends_with('|');
|
||||
let content = trimmed.strip_prefix('|').unwrap_or(trimmed);
|
||||
let content = content.strip_suffix('|').unwrap_or(content);
|
||||
let raw_segments = split_unescaped_pipe(content);
|
||||
if !has_outer_pipe && raw_segments.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let segments: Vec<&str> = split_unescaped_pipe(content)
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.collect();
|
||||
let segments: Vec<&str> = raw_segments.into_iter().map(str::trim).collect();
|
||||
(!segments.is_empty()).then_some(segments)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user