## Problem Long URLs containing `/` and `-` characters are split across multiple terminal lines by `textwrap`'s default hyphenation rules. This breaks terminal link detection: emulators can no longer identify the URL as clickable, and copy-paste yields a truncated fragment. The issue affects every view that renders user or agent text — exec output, history cells, markdown, the app-link setup screen, and the VT100 scrollback path. A secondary bug compounds the first: `desired_height()` calculations count logical lines rather than viewport rows. When a URL overflows its line and wraps visually, the height budget is too small, causing content to clip or leave gaps. Here is how the complete URL is interpreted by the terminal before (first line only) and after (complete URL): | Before | After | |---|---| | <img width="777" height="1002" alt="Screenshot 2026-02-17 at 7 59 11 PM" src="https://github.com/user-attachments/assets/193a89a0-7e56-49c5-8b76-53499a76e7e3" /> | <img width="777" height="1002" alt="Screenshot 2026-02-17 at 7 58 40 PM" src="https://github.com/user-attachments/assets/0b9b4c14-aafb-439f-9ffe-f6bba556f95e" /> | ## Mental model The TUI now treats URL-like tokens as atomic units that must never be split by the wrapping engine. Every call site that previously used `word_wrap_*` has been migrated to `adaptive_wrap_*`, which inspects each line for URL-like tokens and switches wrapping strategy accordingly: - **Non-URL lines** follow the existing `textwrap` path unchanged (word boundaries, optional indentation, hyphenation). - **URL-only lines** (with at most decorative markers like `│`, `-`, `1.`) are emitted unwrapped so terminal link detection works; ratatui's `Wrap { trim: false }` handles the final character wrap at render time. - **Mixed lines** (URL + substantive non-URL prose) flow through `adaptive_wrap_line` so prose wraps naturally at word boundaries while URL tokens remain unsplit. Height measurement everywhere now delegates to `Paragraph::line_count(width)`, which accounts for the visual row cost of overflowed lines. This single source of truth replaces ad-hoc line counting in individual cells. For terminal scrollback (the VT100 path that prints history when the TUI exits), URL-only lines are emitted unwrapped so the terminal's own link detector can find them. Mixed URL+prose lines use adaptive wrapping so surrounding text wraps naturally. Continuation rows are pre-cleared to avoid stale content artifacts. ## Non-goals - Full RFC 3986 URL parsing. The detector is a conservative heuristic that covers `scheme://host`, bare domains (`example.com/path`), `localhost:port`, and IPv4 hosts. IPv6 (`[::1]:8080`) and exotic schemes are intentionally excluded from v1. - Changing wrapping behavior for non-URL content. - Reflowing or reformatting existing terminal scrollback on resize. ## Tradeoffs | Decision | Upside | Downside | |----------|--------|----------| | Heuristic URL detection vs. full parser | Fast, zero-alloc on the hot path; conservative enough to reject file paths like `src/main.rs` | False negatives on obscure URL formats (they get split as before) | | Adaptive (three-path) wrapping | Non-URL lines are untouched — no behavior change, no perf cost; mixed lines wrap prose naturally while preserving URLs | Three wrapping strategies to reason about when debugging layout | | Row-based truncation with line-unit ellipsis | Accurate viewport budget; stable "N lines omitted" count across terminal widths | `truncate_lines_middle` is more complex (must compute per-line row cost) | | Unwrapped URL-only lines in scrollback | Terminal emulators detect clickable links; copy-paste gets the full URL | TUI and scrollback formatting diverge for URL-only lines | | Default `desired_height` via `Paragraph::line_count` | DRY — most cells inherit correct measurement | Cells with custom layout must remember to override | ## Architecture ```mermaid flowchart TD A["adaptive_wrap_*()"] --> B{"line_contains_url_like?"} B -- No URL tokens --> C["word_wrap_line<br/>(textwrap default)"] B -- Has URL tokens --> D{"mixed URL + prose?"} D -- "URL-only<br/>(+ decorative markers)" --> E["emit unwrapped<br/>(terminal char-wraps)"] D -- "Mixed<br/>(URL + substantive text)" --> F["adaptive_wrap_line<br/>(AsciiSpace + custom WordSplitter)"] C --> G["Paragraph::line_count(w)<br/>(single height truth)"] E --> G F --> G ``` **Changed files:** | File | Role | |------|------| | `wrapping.rs` | URL detection heuristics, mixed-line detection, `adaptive_wrap_*` functions, custom `WordSplitter` | | `exec_cell/render.rs` | Row-aware `truncate_lines_middle`, adaptive wrapping for command/output display | | `history_cell.rs` | Migrate all cell types to `adaptive_wrap_*`; default `desired_height` via `Paragraph::line_count` | | `insert_history.rs` | Three-path scrollback wrapping (unwrapped URL-only, adaptive mixed, word-wrapped text); continuation row clearing | | `app_link_view.rs` | Adaptive wrapping for setup URL; `desired_height` via `Paragraph::line_count` | | `markdown_render.rs` | Adaptive wrapping in `finish_paragraph` | | `model_migration.rs` | Viewport-aware wrapping for narrow-pane markdown | | `pager_overlay.rs` | `Wrap { trim: false }` for transcript and streaming chunks | | `queued_user_messages.rs` | Migrate to `adaptive_wrap_lines` | | `status/card.rs` | Migrate to `adaptive_wrap_lines` | ## Observability - **Ellipsis message** in truncated exec output reports omitted count in logical lines (stable across resize) rather than viewport rows (fluctuates). - URL detection is deterministic and stateless — no hidden caching or memoization to go stale. - Height mismatch bugs surface immediately as visual clipping or gaps; the `Paragraph::line_count` path is the same code ratatui uses at render time, so measurement and rendering cannot diverge. ## Tests 26 new unit tests across 7 files, covering: - **URL integrity**: assert a URL-like token appears on exactly one rendered line (not split across two). - **Height accuracy**: compare `desired_height()` against `Paragraph::line_count()` for URL-containing content. - **Row-aware truncation**: verify ellipsis counts logical lines and output fits within the row budget. - **Scrollback rendering**: VT100 backend tests confirm prefix and URL land on the same row; continuation rows are cleared; mixed URL+prose lines wrap prose while preserving URL tokens. - **Mixed URL+prose detection**: `line_has_mixed_url_and_non_url_tokens` correctly distinguishes lines with substantive non-URL text from lines with only decorative markers alongside a URL. - **Heuristic correctness**: positive matches (`https://...`, `example.com/path`, `localhost:3000/api`, `192.168.1.1:8080/health`) and negative matches (`src/main.rs`, `foo/bar`, `hello-world`). ## Risks and open items 1. **URL-like tokens in code output** (e.g. `example.com/api` inside a JSON blob) will trigger URL-preserving wrap on that line. This is acceptable — the worst case is a slightly wider line, not broken output. 2. **Very long non-URL tokens on a URL line** can only break at character boundaries (the custom splitter emits all char indices for non-URL words). On extremely narrow terminals this could overflow, but narrow terminals already degrade gracefully. 3. **No IPv6 support** — `[::1]:8080/path` will be treated as a non-URL and may get split. Can be added later without API changes. Fixes #5457
616 lines
19 KiB
Rust
616 lines
19 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 => {
|
||
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(0, 2, 0, 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(0, horizontal_inset, 0, 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(0, 2, 0, 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(0, 2, 0, 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",
|
||
None,
|
||
Some(
|
||
"Upgrade to gpt-5.2-codex for the latest and greatest agentic coding model."
|
||
.to_string(),
|
||
),
|
||
None,
|
||
"gpt-5.1-codex-max".to_string(),
|
||
Some("Codex-optimized flagship for deep and fast reasoning.".to_string()),
|
||
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(65, 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()),
|
||
None,
|
||
None,
|
||
"gpt-5.1".to_string(),
|
||
Some("Broad world knowledge with strong general reasoning.".to_string()),
|
||
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(60, 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()),
|
||
None,
|
||
None,
|
||
"gpt-5.1-codex-max".to_string(),
|
||
Some("Codex-optimized flagship for deep and fast reasoning.".to_string()),
|
||
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(60, 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()),
|
||
None,
|
||
None,
|
||
"gpt-5.1-codex-mini".to_string(),
|
||
Some("Optimized for codex. Cheaper, faster, but less capable.".to_string()),
|
||
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()),
|
||
None,
|
||
None,
|
||
"gpt-new".to_string(),
|
||
Some("Latest recommended model for better performance.".to_string()),
|
||
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()),
|
||
None,
|
||
None,
|
||
"gpt-new".to_string(),
|
||
Some("Latest recommended model for better performance.".to_string()),
|
||
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(40, 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}"
|
||
);
|
||
}
|
||
}
|