mirror of
https://github.com/openai/codex.git
synced 2026-04-09 17:11:44 +03:00
Compare commits
2 Commits
main
...
canvrno/tu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2375c721a3 | ||
|
|
bbf2554b98 |
@@ -3985,10 +3985,7 @@ impl App {
|
||||
event: TuiEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
if matches!(event, TuiEvent::Draw) {
|
||||
let size = tui.terminal.size()?;
|
||||
if size != tui.terminal.last_known_screen_size {
|
||||
self.refresh_status_line();
|
||||
}
|
||||
self.sync_terminal_size_dependent_state(tui)?;
|
||||
}
|
||||
|
||||
if self.overlay.is_some() {
|
||||
@@ -4040,6 +4037,15 @@ impl App {
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
|
||||
fn sync_terminal_size_dependent_state(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
let size = tui.terminal.size()?;
|
||||
if size != tui.terminal.last_known_screen_size {
|
||||
self.chat_widget.update_stream_render_width(size.width);
|
||||
self.refresh_status_line();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -4256,7 +4262,8 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
let display_width = tui.terminal.size()?.width;
|
||||
let mut display = cell.display_lines(display_width);
|
||||
if !display.is_empty() {
|
||||
// Only insert a separating blank line for new cells that are not
|
||||
// part of an ongoing stream. Streaming continuations should not
|
||||
@@ -4300,6 +4307,7 @@ impl App {
|
||||
self.commit_anim_running.store(false, Ordering::Release);
|
||||
}
|
||||
AppEvent::CommitTick => {
|
||||
self.sync_terminal_size_dependent_state(tui)?;
|
||||
self.chat_widget.on_commit_tick();
|
||||
}
|
||||
AppEvent::Exit(mode) => {
|
||||
@@ -5709,6 +5717,8 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: ThreadBufferedEvent,
|
||||
) -> Result<()> {
|
||||
self.sync_terminal_size_dependent_state(tui)?;
|
||||
|
||||
// Capture this before any potential thread switch: we only want to clear
|
||||
// the exit marker when the currently active thread acknowledges shutdown.
|
||||
let pending_shutdown_exit_completed = matches!(
|
||||
|
||||
@@ -4026,6 +4026,16 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_stream_render_width(&mut self, terminal_width: u16) {
|
||||
let terminal_width = usize::from(terminal_width);
|
||||
if let Some(controller) = self.stream_controller.as_mut() {
|
||||
controller.set_width(Some(terminal_width.saturating_sub(2)));
|
||||
}
|
||||
if let Some(controller) = self.plan_stream_controller.as_mut() {
|
||||
controller.set_width(Some(terminal_width.saturating_sub(4)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle completion of an `AgentMessage` turn item.
|
||||
///
|
||||
/// Commentary completion sets a deferred restore flag so the status row
|
||||
|
||||
@@ -32,23 +32,29 @@ use ratatui::text::Span;
|
||||
/// Selects the terminal escape strategy for inserting history lines above the viewport.
|
||||
///
|
||||
/// Standard terminals support `DECSTBM` scroll regions and Reverse Index (`ESC M`),
|
||||
/// which let us slide existing content down without redrawing it. Zellij silently
|
||||
/// drops or mishandles those sequences, so `Zellij` mode falls back to emitting
|
||||
/// newlines at the bottom of the screen and writing lines at absolute positions.
|
||||
/// which let us slide existing content down without redrawing it. Some terminal
|
||||
/// stacks either mishandle those sequences or do not promote content that scrolls
|
||||
/// out of a bounded scroll region into native scrollback; `BottomNewline` mode
|
||||
/// falls back to emitting newlines at the bottom of the screen and writing lines
|
||||
/// at absolute positions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InsertHistoryMode {
|
||||
Standard,
|
||||
Zellij,
|
||||
BottomNewline,
|
||||
}
|
||||
|
||||
impl InsertHistoryMode {
|
||||
pub fn new(is_zellij: bool) -> Self {
|
||||
if is_zellij {
|
||||
Self::Zellij
|
||||
pub fn from_bottom_newline_preference(prefers_bottom_newline: bool) -> Self {
|
||||
if prefers_bottom_newline {
|
||||
Self::BottomNewline
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_full_repaint(self) -> bool {
|
||||
matches!(self, Self::BottomNewline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||
@@ -66,9 +72,9 @@ where
|
||||
/// Insert `lines` above the viewport, using the escape strategy selected by `mode`.
|
||||
///
|
||||
/// In `Standard` mode this manipulates DECSTBM scroll regions to slide existing
|
||||
/// scrollback down and writes new lines into the freed space. In `Zellij` mode it
|
||||
/// emits newlines at the screen bottom to create space (since Zellij ignores scroll
|
||||
/// region escapes) and writes lines at computed absolute positions. Both modes
|
||||
/// scrollback down and writes new lines into the freed space. In `BottomNewline`
|
||||
/// mode it emits newlines at the screen bottom to create space and writes lines
|
||||
/// at computed absolute positions. Both modes
|
||||
/// update `terminal.viewport_area` so subsequent draw passes know where the
|
||||
/// viewport moved to.
|
||||
pub fn insert_history_lines_with_mode<B>(
|
||||
@@ -116,7 +122,7 @@ where
|
||||
}
|
||||
let wrapped_lines = wrapped_rows as u16;
|
||||
|
||||
if matches!(mode, InsertHistoryMode::Zellij) {
|
||||
if matches!(mode, InsertHistoryMode::BottomNewline) {
|
||||
let space_below = screen_size.height.saturating_sub(area.bottom());
|
||||
let shift_down = wrapped_lines.min(space_below);
|
||||
let scroll_up_amount = wrapped_lines.saturating_sub(shift_down);
|
||||
@@ -801,7 +807,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_zellij_mode_inserts_history_and_updates_viewport() {
|
||||
fn vt100_bottom_newline_mode_inserts_history_and_updates_viewport() {
|
||||
let width: u16 = 32;
|
||||
let height: u16 = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
@@ -810,7 +816,7 @@ mod tests {
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
let line: Line<'static> = Line::from("zellij history");
|
||||
insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::Zellij)
|
||||
insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::BottomNewline)
|
||||
.expect("insert zellij history");
|
||||
|
||||
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
|
||||
|
||||
@@ -3,12 +3,14 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::markdown;
|
||||
use crate::render::line_utils::is_blank_line_spaces_only;
|
||||
|
||||
/// Newline-gated accumulator that renders markdown and commits only fully
|
||||
/// completed logical lines.
|
||||
pub(crate) struct MarkdownStreamCollector {
|
||||
buffer: String,
|
||||
committed_line_count: usize,
|
||||
committed_source_len: usize,
|
||||
width: Option<usize>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
@@ -24,6 +26,7 @@ impl MarkdownStreamCollector {
|
||||
Self {
|
||||
buffer: String::new(),
|
||||
committed_line_count: 0,
|
||||
committed_source_len: 0,
|
||||
width,
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
@@ -32,6 +35,18 @@ impl MarkdownStreamCollector {
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.committed_line_count = 0;
|
||||
self.committed_source_len = 0;
|
||||
}
|
||||
|
||||
pub fn set_width(&mut self, width: Option<usize>) {
|
||||
if self.width == width {
|
||||
return;
|
||||
}
|
||||
|
||||
self.width = width;
|
||||
// The rendered line index is width-dependent; preserve the source commit boundary and
|
||||
// recompute how many rendered lines it occupies at the new width.
|
||||
self.committed_line_count = self.rendered_committed_line_count();
|
||||
}
|
||||
|
||||
pub fn push_delta(&mut self, delta: &str) {
|
||||
@@ -43,35 +58,37 @@ impl MarkdownStreamCollector {
|
||||
/// since the last commit. When the buffer does not end with a newline, the
|
||||
/// final rendered line is considered incomplete and is not emitted.
|
||||
pub fn commit_complete_lines(&mut self) -> Vec<Line<'static>> {
|
||||
let source = self.buffer.clone();
|
||||
let last_newline_idx = source.rfind('\n');
|
||||
let source = if let Some(last_newline_idx) = last_newline_idx {
|
||||
source[..=last_newline_idx].to_string()
|
||||
} else {
|
||||
let Some(last_newline_idx) = self.buffer.rfind('\n') else {
|
||||
return Vec::new();
|
||||
};
|
||||
let commit_source_len = last_newline_idx + 1;
|
||||
let source = &self.buffer[..commit_source_len];
|
||||
let mut rendered: Vec<Line<'static>> = Vec::new();
|
||||
markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
|
||||
let mut complete_line_count = rendered.len();
|
||||
if complete_line_count > 0
|
||||
&& crate::render::line_utils::is_blank_line_spaces_only(
|
||||
&rendered[complete_line_count - 1],
|
||||
)
|
||||
{
|
||||
complete_line_count -= 1;
|
||||
}
|
||||
markdown::append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered);
|
||||
let complete_line_count = complete_line_count(&rendered);
|
||||
|
||||
if self.committed_line_count >= complete_line_count {
|
||||
return Vec::new();
|
||||
}
|
||||
let out = if self.committed_line_count >= complete_line_count {
|
||||
Vec::new()
|
||||
} else {
|
||||
rendered[self.committed_line_count..complete_line_count].to_vec()
|
||||
};
|
||||
|
||||
let out_slice = &rendered[self.committed_line_count..complete_line_count];
|
||||
|
||||
let out = out_slice.to_vec();
|
||||
self.committed_line_count = complete_line_count;
|
||||
self.committed_line_count = complete_line_count.max(self.committed_line_count);
|
||||
self.committed_source_len = commit_source_len;
|
||||
out
|
||||
}
|
||||
|
||||
fn rendered_committed_line_count(&self) -> usize {
|
||||
if self.committed_source_len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let source = &self.buffer[..self.committed_source_len];
|
||||
let mut rendered: Vec<Line<'static>> = Vec::new();
|
||||
markdown::append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered);
|
||||
complete_line_count(&rendered)
|
||||
}
|
||||
|
||||
/// Finalize the stream: emit all remaining lines beyond the last commit.
|
||||
/// If the buffer does not end with a newline, a temporary one is appended
|
||||
/// for rendering. Optionally unwraps ```markdown language fences in
|
||||
@@ -106,6 +123,14 @@ impl MarkdownStreamCollector {
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_line_count(rendered: &[Line<'static>]) -> usize {
|
||||
let mut complete_line_count = rendered.len();
|
||||
if complete_line_count > 0 && is_blank_line_spaces_only(&rendered[complete_line_count - 1]) {
|
||||
complete_line_count -= 1;
|
||||
}
|
||||
complete_line_count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_cwd() -> PathBuf {
|
||||
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
|
||||
@@ -156,6 +181,24 @@ mod tests {
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_width_preserves_source_commit_position() {
|
||||
let mut c = super::MarkdownStreamCollector::new(Some(16), &super::test_cwd());
|
||||
c.push_delta("- first second third fourth\n");
|
||||
|
||||
let first = c.commit_complete_lines();
|
||||
assert_eq!(
|
||||
lines_to_plain_strings(&first),
|
||||
vec!["- first second", " third fourth"]
|
||||
);
|
||||
|
||||
c.set_width(Some(40));
|
||||
c.push_delta("- fifth sixth\n");
|
||||
|
||||
let next = c.commit_complete_lines();
|
||||
assert_eq!(lines_to_plain_strings(&next), vec!["- fifth sixth"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_blockquote_simple_is_green() {
|
||||
let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], /*finalize*/ true);
|
||||
|
||||
@@ -100,6 +100,11 @@ impl StreamController {
|
||||
self.state.oldest_queued_age(now)
|
||||
}
|
||||
|
||||
/// Updates the markdown render width used for future stream commits.
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.state.set_width(width);
|
||||
}
|
||||
|
||||
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
@@ -204,6 +209,11 @@ impl PlanStreamController {
|
||||
self.state.oldest_queued_age(now)
|
||||
}
|
||||
|
||||
/// Updates the markdown render width used for future stream commits.
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.state.set_width(width);
|
||||
}
|
||||
|
||||
fn emit(
|
||||
&mut self,
|
||||
lines: Vec<Line<'static>>,
|
||||
|
||||
@@ -51,6 +51,10 @@ impl StreamState {
|
||||
self.queued_lines.clear();
|
||||
self.has_seen_delta = false;
|
||||
}
|
||||
/// Updates the markdown render width used for future stream commits.
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.collector.set_width(width);
|
||||
}
|
||||
/// Drains one queued line from the front of the queue.
|
||||
pub(crate) fn step(&mut self) -> Vec<Line<'static>> {
|
||||
self.queued_lines
|
||||
|
||||
@@ -40,6 +40,7 @@ use tokio_stream::Stream;
|
||||
pub use self::frame_requester::FrameRequester;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::notifications::DesktopNotificationBackend;
|
||||
use crate::notifications::detect_backend;
|
||||
use crate::tui::event_stream::EventBroker;
|
||||
@@ -47,6 +48,8 @@ use crate::tui::event_stream::TuiEventStream;
|
||||
#[cfg(unix)]
|
||||
use crate::tui::job_control::SuspendContext;
|
||||
use codex_config::types::NotificationMethod;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
|
||||
mod event_stream;
|
||||
mod frame_rate_limiter;
|
||||
@@ -255,10 +258,25 @@ pub struct Tui {
|
||||
enhanced_keys_supported: bool,
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
is_zellij: bool,
|
||||
history_insertion_mode: InsertHistoryMode,
|
||||
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
|
||||
alt_screen_enabled: bool,
|
||||
}
|
||||
|
||||
fn history_insertion_mode_for_terminal(terminal_info: &TerminalInfo) -> InsertHistoryMode {
|
||||
InsertHistoryMode::from_bottom_newline_preference(
|
||||
terminal_info.is_zellij()
|
||||
|| terminal_prefers_bottom_newline_history_insertion(terminal_info.name),
|
||||
)
|
||||
}
|
||||
|
||||
fn terminal_prefers_bottom_newline_history_insertion(terminal_name: TerminalName) -> bool {
|
||||
matches!(
|
||||
terminal_name,
|
||||
TerminalName::VsCode | TerminalName::WindowsTerminal
|
||||
)
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new(terminal: Terminal) -> Self {
|
||||
let (draw_tx, _) = broadcast::channel(1);
|
||||
@@ -270,10 +288,9 @@ impl Tui {
|
||||
// Cache this to avoid contention with the event reader.
|
||||
supports_color::on_cached(supports_color::Stream::Stdout);
|
||||
let _ = crate::terminal_palette::default_colors();
|
||||
let is_zellij = matches!(
|
||||
codex_terminal_detection::terminal_info().multiplexer,
|
||||
Some(codex_terminal_detection::Multiplexer::Zellij {})
|
||||
);
|
||||
let terminal_info = codex_terminal_detection::terminal_info();
|
||||
let is_zellij = terminal_info.is_zellij();
|
||||
let history_insertion_mode = history_insertion_mode_for_terminal(&terminal_info);
|
||||
|
||||
Self {
|
||||
frame_requester,
|
||||
@@ -289,6 +306,7 @@ impl Tui {
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
is_zellij,
|
||||
history_insertion_mode,
|
||||
alt_screen_enabled: true,
|
||||
}
|
||||
}
|
||||
@@ -512,12 +530,13 @@ impl Tui {
|
||||
}
|
||||
|
||||
/// Write any buffered history lines above the viewport and clear the buffer.
|
||||
/// Returns `true` when Zellij mode was used, signaling that the caller must
|
||||
/// invalidate the diff buffer for a full repaint.
|
||||
/// Returns `true` when the selected insertion mode moved screen content
|
||||
/// outside ratatui's diff model, signaling that the caller must invalidate
|
||||
/// the diff buffer for a full repaint.
|
||||
fn flush_pending_history_lines(
|
||||
terminal: &mut Terminal,
|
||||
pending_history_lines: &mut Vec<Line<'static>>,
|
||||
is_zellij: bool,
|
||||
history_insertion_mode: InsertHistoryMode,
|
||||
) -> Result<bool> {
|
||||
if pending_history_lines.is_empty() {
|
||||
return Ok(false);
|
||||
@@ -526,10 +545,10 @@ impl Tui {
|
||||
crate::insert_history::insert_history_lines_with_mode(
|
||||
terminal,
|
||||
pending_history_lines.clone(),
|
||||
crate::insert_history::InsertHistoryMode::new(is_zellij),
|
||||
history_insertion_mode,
|
||||
)?;
|
||||
pending_history_lines.clear();
|
||||
Ok(is_zellij)
|
||||
Ok(history_insertion_mode.requires_full_repaint())
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
@@ -565,7 +584,7 @@ impl Tui {
|
||||
needs_full_repaint |= Self::flush_pending_history_lines(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
self.history_insertion_mode,
|
||||
)?;
|
||||
|
||||
if needs_full_repaint {
|
||||
@@ -614,3 +633,25 @@ impl Tui {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bottom_newline_history_insertion_is_used_for_known_scrollback_problem_terminals() {
|
||||
assert!(terminal_prefers_bottom_newline_history_insertion(
|
||||
TerminalName::VsCode
|
||||
));
|
||||
assert!(terminal_prefers_bottom_newline_history_insertion(
|
||||
TerminalName::WindowsTerminal
|
||||
));
|
||||
|
||||
assert!(!terminal_prefers_bottom_newline_history_insertion(
|
||||
TerminalName::Iterm2
|
||||
));
|
||||
assert!(!terminal_prefers_bottom_newline_history_insertion(
|
||||
TerminalName::Unknown
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user