mirror of
https://github.com/openai/codex.git
synced 2026-05-24 13:04:29 +00:00
Fixes multiple scrollback and terminal resize issues: #5538, #5576, #8352, #12223, #16165, and #15380. ## Why Codex writes finalized transcript output into terminal scrollback after wrapping it for the current viewport width. A later terminal resize could leave that scrollback shaped for the old width, so wider windows kept narrow output and narrower windows could show stale wrapping artifacts until enough new output replaced the visible area. This is also the foundation PR for responsive markdown tables. Table rendering needs finalized transcript content to be width-sensitive after insertion, not only while content is first streaming. Markdown table rendering itself stays in #18576. ## Stack - PR1: resize backlog reflow and interrupt cleanup - #18576: markdown table support ## What Changed - Rebuild source-backed transcript history when the terminal width changes. `terminal_resize_reflow` is introduced through the experimental feature system, but is enabled by default for this rollout so we can validate behavior across real terminals. - Preserve assistant and plan stream source so finalized streaming output can participate in resize reflow after consolidation. - Debounce resize work, but force a final source-backed reflow when a resize happened during active or unconsolidated streaming output. - Clear stale pending history lines on resize so old-width wrapped output is not emitted just before rebuilt scrollback. - Bound replay work with `[tui.terminal_resize_reflow].max_rows`: omitted uses terminal-specific defaults, `0` keeps all rendered rows, and a positive value sets an explicit cap. The cap applies both while initially replaying a resumed transcript into scrollback and when rebuilding scrollback after terminal resize. - Consolidate interrupted assistant streams before cleanup, then clear pending stream output and active-tail state consistently. - Move resize reflow and thread event buffering helpers out of `app.rs` into dedicated TUI modules. - Add focused coverage for resize reflow, feature-gated behavior, streaming source preservation, interrupted output cleanup, unicode-neutral text, terminal-specific row caps, and composer/layout stability. ## Runtime Bounds Resize reflow keeps only the most recent rendered rows when a row cap is active. The default is `auto`, which maps to the detected terminal's default scrollback size where Codex can identify it: VS Code `1000`, Windows Terminal `9001`, WezTerm `3500`, and Alacritty `10000`. Terminals without a dedicated mapping use the conservative fallback of `1000` rows. Users can override this with `[tui.terminal_resize_reflow] max_rows = N`, or set `max_rows = 0` to disable row limiting. ## Validation - `just fmt` - `git diff --check` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui reflow` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui transcript_reflow` - `just fix -p codex-tui` - PR CI in progress on the squashed branch
628 lines
20 KiB
Rust
628 lines
20 KiB
Rust
use crate::key_hint;
|
||
use crate::markdown_render::render_markdown_text_with_width;
|
||
use crate::render::Insets;
|
||
use crate::render::renderable::ColumnRenderable;
|
||
use crate::render::renderable::Renderable;
|
||
use crate::render::renderable::RenderableExt as _;
|
||
use crate::selection_list::selection_option_row;
|
||
use crate::tui::FrameRequester;
|
||
use crate::tui::Tui;
|
||
use crate::tui::TuiEvent;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::event::KeyModifiers;
|
||
use ratatui::prelude::Stylize as _;
|
||
use ratatui::prelude::Widget;
|
||
use ratatui::text::Line;
|
||
use ratatui::text::Span;
|
||
use ratatui::widgets::Clear;
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::widgets::WidgetRef;
|
||
use ratatui::widgets::Wrap;
|
||
use tokio_stream::StreamExt;
|
||
|
||
/// Outcome of the migration prompt.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) enum ModelMigrationOutcome {
|
||
Accepted,
|
||
Rejected,
|
||
Exit,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub(crate) struct ModelMigrationCopy {
|
||
pub heading: Vec<Span<'static>>,
|
||
pub content: Vec<Line<'static>>,
|
||
pub can_opt_out: bool,
|
||
pub markdown: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum MigrationMenuOption {
|
||
TryNewModel,
|
||
UseExistingModel,
|
||
}
|
||
|
||
impl MigrationMenuOption {
|
||
fn all() -> [Self; 2] {
|
||
[Self::TryNewModel, Self::UseExistingModel]
|
||
}
|
||
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
Self::TryNewModel => "Try new model",
|
||
Self::UseExistingModel => "Use existing model",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn migration_copy_for_models(
|
||
current_model: &str,
|
||
target_model: &str,
|
||
model_link: Option<String>,
|
||
migration_copy: Option<String>,
|
||
migration_markdown: Option<String>,
|
||
target_display_name: String,
|
||
target_description: Option<String>,
|
||
can_opt_out: bool,
|
||
) -> ModelMigrationCopy {
|
||
if let Some(migration_markdown) = migration_markdown {
|
||
return ModelMigrationCopy {
|
||
heading: Vec::new(),
|
||
content: Vec::new(),
|
||
can_opt_out,
|
||
markdown: Some(fill_migration_markdown(
|
||
&migration_markdown,
|
||
current_model,
|
||
target_model,
|
||
)),
|
||
};
|
||
}
|
||
|
||
let heading_text = Span::from(format!(
|
||
"Codex just got an upgrade. Introducing {target_display_name}."
|
||
))
|
||
.bold();
|
||
let description_line: Line<'static>;
|
||
if let Some(migration_copy) = &migration_copy {
|
||
description_line = Line::from(migration_copy.clone());
|
||
} else {
|
||
description_line = target_description
|
||
.filter(|desc| !desc.is_empty())
|
||
.map(Line::from)
|
||
.unwrap_or_else(|| {
|
||
Line::from(format!(
|
||
"{target_display_name} is recommended for better performance and reliability."
|
||
))
|
||
});
|
||
}
|
||
|
||
let mut content = vec![];
|
||
if migration_copy.is_none() {
|
||
content.push(Line::from(format!(
|
||
"We recommend switching from {current_model} to {target_model}."
|
||
)));
|
||
content.push(Line::from(""));
|
||
}
|
||
|
||
if let Some(model_link) = model_link {
|
||
content.push(Line::from(vec![
|
||
format!("{description_line} Learn more about {target_display_name} at ").into(),
|
||
model_link.cyan().underlined(),
|
||
]));
|
||
content.push(Line::from(""));
|
||
} else {
|
||
content.push(description_line);
|
||
content.push(Line::from(""));
|
||
}
|
||
|
||
if can_opt_out {
|
||
content.push(Line::from(format!(
|
||
"You can continue using {current_model} if you prefer."
|
||
)));
|
||
} else {
|
||
content.push(Line::from("Press enter to continue".dim()));
|
||
}
|
||
|
||
ModelMigrationCopy {
|
||
heading: vec![heading_text],
|
||
content,
|
||
can_opt_out,
|
||
markdown: None,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn run_model_migration_prompt(
|
||
tui: &mut Tui,
|
||
copy: ModelMigrationCopy,
|
||
) -> ModelMigrationOutcome {
|
||
let alt = AltScreenGuard::enter(tui);
|
||
let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy);
|
||
|
||
let _ = alt.tui.draw(u16::MAX, |frame| {
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
});
|
||
|
||
let events = alt.tui.event_stream();
|
||
tokio::pin!(events);
|
||
|
||
while !screen.is_done() {
|
||
if let Some(event) = events.next().await {
|
||
match event {
|
||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||
TuiEvent::Paste(_) => {}
|
||
TuiEvent::Draw | TuiEvent::Resize => {
|
||
let _ = alt.tui.draw(u16::MAX, |frame| {
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
screen.accept();
|
||
break;
|
||
}
|
||
}
|
||
|
||
screen.outcome()
|
||
}
|
||
|
||
struct ModelMigrationScreen {
|
||
request_frame: FrameRequester,
|
||
copy: ModelMigrationCopy,
|
||
done: bool,
|
||
outcome: ModelMigrationOutcome,
|
||
highlighted_option: MigrationMenuOption,
|
||
}
|
||
|
||
impl ModelMigrationScreen {
|
||
fn new(request_frame: FrameRequester, copy: ModelMigrationCopy) -> Self {
|
||
Self {
|
||
request_frame,
|
||
copy,
|
||
done: false,
|
||
outcome: ModelMigrationOutcome::Accepted,
|
||
highlighted_option: MigrationMenuOption::TryNewModel,
|
||
}
|
||
}
|
||
|
||
fn finish_with(&mut self, outcome: ModelMigrationOutcome) {
|
||
self.outcome = outcome;
|
||
self.done = true;
|
||
self.request_frame.schedule_frame();
|
||
}
|
||
|
||
fn accept(&mut self) {
|
||
self.finish_with(ModelMigrationOutcome::Accepted);
|
||
}
|
||
|
||
fn reject(&mut self) {
|
||
self.finish_with(ModelMigrationOutcome::Rejected);
|
||
}
|
||
|
||
fn exit(&mut self) {
|
||
self.finish_with(ModelMigrationOutcome::Exit);
|
||
}
|
||
|
||
fn confirm_selection(&mut self) {
|
||
if self.copy.can_opt_out {
|
||
match self.highlighted_option {
|
||
MigrationMenuOption::TryNewModel => self.accept(),
|
||
MigrationMenuOption::UseExistingModel => self.reject(),
|
||
}
|
||
} else {
|
||
self.accept();
|
||
}
|
||
}
|
||
|
||
fn highlight_option(&mut self, option: MigrationMenuOption) {
|
||
if self.highlighted_option != option {
|
||
self.highlighted_option = option;
|
||
self.request_frame.schedule_frame();
|
||
}
|
||
}
|
||
|
||
fn handle_key(&mut self, key_event: KeyEvent) {
|
||
if key_event.kind == KeyEventKind::Release {
|
||
return;
|
||
}
|
||
|
||
if is_ctrl_exit_combo(key_event) {
|
||
self.exit();
|
||
return;
|
||
}
|
||
|
||
if self.copy.can_opt_out {
|
||
self.handle_menu_key(key_event.code);
|
||
} else if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) {
|
||
self.accept();
|
||
}
|
||
}
|
||
|
||
fn is_done(&self) -> bool {
|
||
self.done
|
||
}
|
||
|
||
fn outcome(&self) -> ModelMigrationOutcome {
|
||
self.outcome
|
||
}
|
||
}
|
||
|
||
impl WidgetRef for &ModelMigrationScreen {
|
||
fn render_ref(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
|
||
Clear.render(area, buf);
|
||
|
||
let mut column = ColumnRenderable::new();
|
||
column.push("");
|
||
if let Some(markdown) = self.copy.markdown.as_ref() {
|
||
self.render_markdown_content(markdown, area.width, &mut column);
|
||
} else {
|
||
column.push(self.heading_line());
|
||
column.push(Line::from(""));
|
||
self.render_content(&mut column);
|
||
}
|
||
if self.copy.can_opt_out {
|
||
self.render_menu(&mut column);
|
||
}
|
||
|
||
column.render(area, buf);
|
||
}
|
||
}
|
||
|
||
impl ModelMigrationScreen {
|
||
fn handle_menu_key(&mut self, code: KeyCode) {
|
||
match code {
|
||
KeyCode::Up | KeyCode::Char('k') => {
|
||
self.highlight_option(MigrationMenuOption::TryNewModel);
|
||
}
|
||
KeyCode::Down | KeyCode::Char('j') => {
|
||
self.highlight_option(MigrationMenuOption::UseExistingModel);
|
||
}
|
||
KeyCode::Char('1') => {
|
||
self.highlight_option(MigrationMenuOption::TryNewModel);
|
||
self.accept();
|
||
}
|
||
KeyCode::Char('2') => {
|
||
self.highlight_option(MigrationMenuOption::UseExistingModel);
|
||
self.reject();
|
||
}
|
||
KeyCode::Enter | KeyCode::Esc => self.confirm_selection(),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn heading_line(&self) -> Line<'static> {
|
||
let mut heading = vec![Span::raw("> ")];
|
||
heading.extend(self.copy.heading.iter().cloned());
|
||
Line::from(heading)
|
||
}
|
||
|
||
fn render_content(&self, column: &mut ColumnRenderable) {
|
||
self.render_lines(&self.copy.content, column);
|
||
}
|
||
|
||
fn render_lines(&self, lines: &[Line<'static>], column: &mut ColumnRenderable) {
|
||
for line in lines {
|
||
column.push(
|
||
Paragraph::new(line.clone())
|
||
.wrap(Wrap { trim: false })
|
||
.inset(Insets::tlbr(
|
||
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||
)),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_markdown_content(
|
||
&self,
|
||
markdown: &str,
|
||
area_width: u16,
|
||
column: &mut ColumnRenderable,
|
||
) {
|
||
let horizontal_inset = 2;
|
||
let content_width = area_width.saturating_sub(horizontal_inset);
|
||
let wrap_width = (content_width > 0).then_some(content_width as usize);
|
||
let rendered = render_markdown_text_with_width(markdown, wrap_width);
|
||
for line in rendered.lines {
|
||
column.push(
|
||
Paragraph::new(line)
|
||
.wrap(Wrap { trim: false })
|
||
.inset(Insets::tlbr(
|
||
/*top*/ 0,
|
||
horizontal_inset,
|
||
/*bottom*/ 0,
|
||
/*right*/ 0,
|
||
)),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_menu(&self, column: &mut ColumnRenderable) {
|
||
column.push(Line::from(""));
|
||
column.push(
|
||
Paragraph::new("Choose how you'd like Codex to proceed.")
|
||
.wrap(Wrap { trim: false })
|
||
.inset(Insets::tlbr(
|
||
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||
)),
|
||
);
|
||
column.push(Line::from(""));
|
||
|
||
for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() {
|
||
column.push(selection_option_row(
|
||
idx,
|
||
option.label().to_string(),
|
||
self.highlighted_option == option,
|
||
));
|
||
}
|
||
|
||
column.push(Line::from(""));
|
||
column.push(
|
||
Line::from(vec![
|
||
"Use ".dim(),
|
||
key_hint::plain(KeyCode::Up).into(),
|
||
"/".dim(),
|
||
key_hint::plain(KeyCode::Down).into(),
|
||
" to move, press ".dim(),
|
||
key_hint::plain(KeyCode::Enter).into(),
|
||
" to confirm".dim(),
|
||
])
|
||
.inset(Insets::tlbr(
|
||
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||
)),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Render the prompt on the terminal's alternate screen so exiting or cancelling
|
||
// does not leave a large blank region in the normal scrollback. This does not
|
||
// change the prompt's appearance – only where it is drawn.
|
||
struct AltScreenGuard<'a> {
|
||
tui: &'a mut Tui,
|
||
}
|
||
|
||
impl<'a> AltScreenGuard<'a> {
|
||
fn enter(tui: &'a mut Tui) -> Self {
|
||
let _ = tui.enter_alt_screen();
|
||
Self { tui }
|
||
}
|
||
}
|
||
|
||
impl Drop for AltScreenGuard<'_> {
|
||
fn drop(&mut self) {
|
||
let _ = self.tui.leave_alt_screen();
|
||
}
|
||
}
|
||
|
||
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
|
||
key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||
}
|
||
|
||
fn fill_migration_markdown(template: &str, current_model: &str, target_model: &str) -> String {
|
||
template
|
||
.replace("{model_from}", current_model)
|
||
.replace("{model_to}", target_model)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::ModelMigrationCopy;
|
||
use super::ModelMigrationScreen;
|
||
use super::migration_copy_for_models;
|
||
use crate::custom_terminal::Terminal;
|
||
use crate::test_backend::VT100Backend;
|
||
use crate::tui::FrameRequester;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use insta::assert_snapshot;
|
||
use ratatui::layout::Rect;
|
||
|
||
#[test]
|
||
fn prompt_snapshot() {
|
||
let width: u16 = 60;
|
||
let height: u16 = 28;
|
||
let backend = VT100Backend::new(width, height);
|
||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||
|
||
let screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
migration_copy_for_models(
|
||
"gpt-5.1-codex-mini",
|
||
"gpt-5.1-codex-max",
|
||
/*model_link*/ None,
|
||
Some(
|
||
"Upgrade to gpt-5.2-codex for the latest and greatest agentic coding model."
|
||
.to_string(),
|
||
),
|
||
/*migration_markdown*/ None,
|
||
"gpt-5.1-codex-max".to_string(),
|
||
Some("Codex-optimized flagship for deep and fast reasoning.".to_string()),
|
||
/*can_opt_out*/ true,
|
||
),
|
||
);
|
||
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
}
|
||
terminal.flush().expect("flush");
|
||
|
||
assert_snapshot!("model_migration_prompt", terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_snapshot_gpt5_family() {
|
||
let backend = VT100Backend::new(/*width*/ 65, /*height*/ 22);
|
||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||
terminal.set_viewport_area(Rect::new(0, 0, 65, 22));
|
||
|
||
let screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
migration_copy_for_models(
|
||
"gpt-5",
|
||
"gpt-5.1",
|
||
Some("https://www.codex.com/models/gpt-5.1".to_string()),
|
||
/*migration_copy*/ None,
|
||
/*migration_markdown*/ None,
|
||
"gpt-5.1".to_string(),
|
||
Some("Broad world knowledge with strong general reasoning.".to_string()),
|
||
/*can_opt_out*/ false,
|
||
),
|
||
);
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
}
|
||
terminal.flush().expect("flush");
|
||
assert_snapshot!("model_migration_prompt_gpt5_family", terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_snapshot_gpt5_codex() {
|
||
let backend = VT100Backend::new(/*width*/ 60, /*height*/ 22);
|
||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||
terminal.set_viewport_area(Rect::new(0, 0, 60, 22));
|
||
|
||
let screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
migration_copy_for_models(
|
||
"gpt-5-codex",
|
||
"gpt-5.1-codex-max",
|
||
Some("https://www.codex.com/models/gpt-5.1-codex-max".to_string()),
|
||
/*migration_copy*/ None,
|
||
/*migration_markdown*/ None,
|
||
"gpt-5.1-codex-max".to_string(),
|
||
Some("Codex-optimized flagship for deep and fast reasoning.".to_string()),
|
||
/*can_opt_out*/ false,
|
||
),
|
||
);
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
}
|
||
terminal.flush().expect("flush");
|
||
assert_snapshot!("model_migration_prompt_gpt5_codex", terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_snapshot_gpt5_codex_mini() {
|
||
let backend = VT100Backend::new(/*width*/ 60, /*height*/ 22);
|
||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||
terminal.set_viewport_area(Rect::new(0, 0, 60, 22));
|
||
|
||
let screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
migration_copy_for_models(
|
||
"gpt-5-codex-mini",
|
||
"gpt-5.1-codex-mini",
|
||
Some("https://www.codex.com/models/gpt-5.1-codex-mini".to_string()),
|
||
/*migration_copy*/ None,
|
||
/*migration_markdown*/ None,
|
||
"gpt-5.1-codex-mini".to_string(),
|
||
Some("Optimized for codex. Cheaper, faster, but less capable.".to_string()),
|
||
/*can_opt_out*/ false,
|
||
),
|
||
);
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
}
|
||
terminal.flush().expect("flush");
|
||
assert_snapshot!("model_migration_prompt_gpt5_codex_mini", terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn escape_key_accepts_prompt() {
|
||
let mut screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
migration_copy_for_models(
|
||
"gpt-old",
|
||
"gpt-new",
|
||
Some("https://www.codex.com/models/gpt-new".to_string()),
|
||
/*migration_copy*/ None,
|
||
/*migration_markdown*/ None,
|
||
"gpt-new".to_string(),
|
||
Some("Latest recommended model for better performance.".to_string()),
|
||
/*can_opt_out*/ true,
|
||
),
|
||
);
|
||
|
||
// Simulate pressing Escape
|
||
screen.handle_key(KeyEvent::new(
|
||
KeyCode::Esc,
|
||
crossterm::event::KeyModifiers::NONE,
|
||
));
|
||
assert!(screen.is_done());
|
||
// Esc should not be treated as Exit – it accepts like Enter.
|
||
assert!(matches!(
|
||
screen.outcome(),
|
||
super::ModelMigrationOutcome::Accepted
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_use_existing_model_rejects_upgrade() {
|
||
let mut screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
migration_copy_for_models(
|
||
"gpt-old",
|
||
"gpt-new",
|
||
Some("https://www.codex.com/models/gpt-new".to_string()),
|
||
/*migration_copy*/ None,
|
||
/*migration_markdown*/ None,
|
||
"gpt-new".to_string(),
|
||
Some("Latest recommended model for better performance.".to_string()),
|
||
/*can_opt_out*/ true,
|
||
),
|
||
);
|
||
|
||
screen.handle_key(KeyEvent::new(
|
||
KeyCode::Down,
|
||
crossterm::event::KeyModifiers::NONE,
|
||
));
|
||
screen.handle_key(KeyEvent::new(
|
||
KeyCode::Enter,
|
||
crossterm::event::KeyModifiers::NONE,
|
||
));
|
||
|
||
assert!(screen.is_done());
|
||
assert!(matches!(
|
||
screen.outcome(),
|
||
super::ModelMigrationOutcome::Rejected
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn markdown_prompt_keeps_long_url_tail_visible_when_narrow() {
|
||
let long_url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42";
|
||
let screen = ModelMigrationScreen::new(
|
||
FrameRequester::test_dummy(),
|
||
ModelMigrationCopy {
|
||
heading: Vec::new(),
|
||
content: Vec::new(),
|
||
can_opt_out: false,
|
||
markdown: Some(long_url.to_string()),
|
||
},
|
||
);
|
||
|
||
let backend = VT100Backend::new(/*width*/ 40, /*height*/ 16);
|
||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||
terminal.set_viewport_area(Rect::new(0, 0, 40, 16));
|
||
|
||
{
|
||
let mut frame = terminal.get_frame();
|
||
frame.render_widget_ref(&screen, frame.area());
|
||
}
|
||
terminal.flush().expect("flush");
|
||
|
||
let rendered = terminal.backend().to_string();
|
||
assert!(
|
||
rendered.contains("tail42"),
|
||
"expected wrapped markdown URL tail to remain visible, got:\n{rendered}"
|
||
);
|
||
}
|
||
}
|