mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Upgrade Ratatui to 0.30.2 (#35959)
## What changed - Upgrade `ratatui`, `ratatui-macros`, `crossterm`, and `ansi-to-tui`, and adapt the TUI to their updated rendering, backend, color-conversion, and cell APIs. - Use Ratatui's cell-width and continuation-cell metadata when mapping OSC 8 hyperlinks so links remain aligned across wrapped wide glyphs and halfwidth characters with dakuten. - Preserve explicit hyperlinks in the sign-in success screen and empty MCP state without embedding escape sequences in the text passed to Ratatui. ## Testing - Add regression coverage for hyperlink placement across wide glyphs and halfwidth dakuten, hyperlink preservation in onboarding and MCP output, and display-width handling. GitOrigin-RevId: 8460873e5a266923acd0417bc46264845eab0134
This commit is contained in:
Generated
+60
-11
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
ignore = [
|
||||
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained
|
||||
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained
|
||||
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
|
||||
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/v8; upstream crate is unmaintained
|
||||
"RUSTSEC-2023-0089", # atomic-polyfill via postcard/heapless/pagable; upstream crate is unmaintained
|
||||
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
|
||||
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
|
||||
|
||||
Generated
+652
-123
File diff suppressed because it is too large
Load Diff
+9
-6
@@ -269,7 +269,7 @@ mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
|
||||
# External
|
||||
age = "0.11.1"
|
||||
ansi-to-tui = "7.0.0"
|
||||
ansi-to-tui = "8.0.1"
|
||||
anyhow = "1"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
arc-swap = "1.9.0"
|
||||
@@ -302,7 +302,7 @@ color-eyre = "0.6.3"
|
||||
constant_time_eq = "0.3.1"
|
||||
crossbeam-channel = "0.5.15"
|
||||
crypto_box = { version = "0.9.1", features = ["seal"] }
|
||||
crossterm = "0.28.1"
|
||||
crossterm = "0.29.0"
|
||||
ctor = "0.6.3"
|
||||
deno_core_icudata = "0.77.0"
|
||||
derive_more = "2"
|
||||
@@ -368,8 +368,12 @@ pretty_assertions = "1.4.1"
|
||||
pulldown-cmark = { version = "0.10", default-features = false }
|
||||
quick-xml = "0.41.0"
|
||||
rand = "0.9"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-macros = "0.6.0"
|
||||
ratatui = { version = "0.30.2", default-features = false, features = [
|
||||
"crossterm",
|
||||
"layout-cache",
|
||||
"underline-color",
|
||||
] }
|
||||
ratatui-macros = "0.7.2"
|
||||
rcgen = { version = "0.14.7", default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"pem",
|
||||
@@ -564,8 +568,7 @@ opt-level = 0
|
||||
[patch.crates-io]
|
||||
# Uncomment to debug local changes.
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" }
|
||||
ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" }
|
||||
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5" }
|
||||
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
|
||||
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
|
||||
|
||||
|
||||
@@ -15,8 +15,5 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
ansi-to-tui = { workspace = true }
|
||||
ratatui = { workspace = true, features = [
|
||||
"unstable-rendered-line-info",
|
||||
"unstable-widget-ref",
|
||||
] }
|
||||
ratatui = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
|
||||
@@ -38,8 +38,7 @@ rustPlatform.buildRustPackage (_: {
|
||||
];
|
||||
|
||||
cargoLock.outputHashes = {
|
||||
"ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho=";
|
||||
"crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728=";
|
||||
"crossterm-0.29.0" = "sha256-ewiWWQPEU1lSUHzmZTiO5yes5luIaQ9TrvCNnTWhxpE=";
|
||||
"nucleo-0.5.0" = "sha256-Hm4SxtTSBrcWpXrtSqeO0TACbUxq3gizg1zD/6Yw/sI=";
|
||||
"nucleo-matcher-0.3.1" = "sha256-Hm4SxtTSBrcWpXrtSqeO0TACbUxq3gizg1zD/6Yw/sI=";
|
||||
"runfiles-0.1.0" = "sha256-uJpVLcQh8wWZA3GPv9D8Nt43EOirajfDJ7eq/FB+tek=";
|
||||
|
||||
+1
-2
@@ -74,7 +74,7 @@ ignore = [
|
||||
# Each exception must identify the dependency path and removal condition.
|
||||
{ id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark/starlark_syntax v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" },
|
||||
{ id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map under starlark v0.14.2 used by execpolicy/cli/core and bm25 used by core; remove when upstream dependencies drop fxhash" },
|
||||
{ id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via starlark/v8 used by execpolicy/code-mode; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is unmaintained; pulled in via postcard/heapless/pagable under starlark v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" },
|
||||
# TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities.
|
||||
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
|
||||
@@ -314,7 +314,6 @@ allow-git = [
|
||||
"https://github.com/dzbarsky/rules_rust",
|
||||
"https://github.com/helix-editor/nucleo.git",
|
||||
"https://github.com/juberti-oai/rust-sdks.git",
|
||||
"https://github.com/nornagon/ratatui",
|
||||
"https://github.com/openai-oss-forks/crossterm",
|
||||
"https://github.com/openai-oss-forks/tokio-tungstenite",
|
||||
"https://github.com/openai-oss-forks/tungstenite-rs",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! resuming/forking saved sessions, replacing ChatWidget instances, and maintaining the agent picker
|
||||
//! cache used for multi-agent navigation.
|
||||
|
||||
use std::io;
|
||||
|
||||
use super::agent_picker::AGENT_PICKER_VIEW_ID;
|
||||
use super::*;
|
||||
use crate::app_server_session::source_agent_path;
|
||||
@@ -569,7 +571,7 @@ impl App {
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
) -> Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
terminal.clear_scrollback_and_visible_screen_ansi()?;
|
||||
let mut area = terminal.viewport_area;
|
||||
|
||||
@@ -192,6 +192,7 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
@@ -4665,7 +4666,7 @@ impl ChatComposer {
|
||||
}
|
||||
} else if self.footer.flash_visible() {
|
||||
if let Some(flash) = self.footer.flash.as_ref() {
|
||||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||||
Widget::render(&flash.line, inset_footer_hint_area(hint_rect), buf);
|
||||
}
|
||||
} else if let Some(items) = active_footer_hint_override {
|
||||
render_footer_hint_items(hint_rect, buf, items);
|
||||
@@ -4703,11 +4704,11 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
let style = user_message_style();
|
||||
Block::default().style(style).render_ref(composer_rect, buf);
|
||||
Block::default().style(style).render(composer_rect, buf);
|
||||
if !remote_images_rect.is_empty() {
|
||||
Paragraph::new(self.attachments.remote_image_lines())
|
||||
.style(style)
|
||||
.render_ref(remote_images_rect, buf);
|
||||
.render(remote_images_rect, buf);
|
||||
}
|
||||
if !textarea_rect.is_empty() {
|
||||
let prompt = if self.draft.input_enabled {
|
||||
@@ -4780,8 +4781,7 @@ impl ChatComposer {
|
||||
};
|
||||
if !textarea_rect.is_empty() {
|
||||
let placeholder = Span::from(text).dim();
|
||||
Line::from(vec![placeholder])
|
||||
.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
Line::from(vec![placeholder]).render(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
}
|
||||
}
|
||||
if matches!(self.popups.active, ActivePopup::None)
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::path::PathBuf;
|
||||
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Span;
|
||||
|
||||
const TAG_WIDTH: usize = "Plugin".len();
|
||||
|
||||
@@ -54,7 +54,6 @@ use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -309,7 +308,7 @@ impl Renderable for DelayedLoadingHeader {
|
||||
lines.push(Line::from(note.as_str().dim()));
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render_ref(area, buf);
|
||||
Paragraph::new(lines).render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
|
||||
@@ -281,7 +281,6 @@ async fn unified_exec_begin_restores_working_status_snapshot() {
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
|
||||
.expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw chatwidget");
|
||||
@@ -1220,7 +1219,6 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw approval modal (no reason)");
|
||||
@@ -1265,7 +1263,6 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw approval modal (multiline prefix)");
|
||||
@@ -1309,7 +1306,6 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
||||
let height = chat.desired_height(/*width*/ 80);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(/*width*/ 80, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw patch approval modal");
|
||||
|
||||
@@ -635,7 +635,6 @@ async fn approvals_popup_shows_disabled_presets() {
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("render approvals popup");
|
||||
|
||||
@@ -2542,7 +2542,6 @@ async fn status_widget_and_approval_modal_snapshot() {
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
|
||||
.expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw status + approval modal");
|
||||
@@ -3941,7 +3940,6 @@ async fn reasoning_delta_restores_recreated_status_indicator_header() {
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
|
||||
.expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
|
||||
terminal
|
||||
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw restored reasoning status");
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
use super::TokenActivityView;
|
||||
use crate::color::blend;
|
||||
|
||||
@@ -37,27 +37,29 @@ use crossterm::terminal::Clear;
|
||||
use derive_more::IsVariant;
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::backend::ClearType;
|
||||
use ratatui::backend::IntoCrossterm;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::CellDiffOption;
|
||||
use ratatui::buffer::CellWidth;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
|
||||
///
|
||||
/// OSC sequences (e.g. OSC 8 hyperlinks: `\x1B]8;;URL\x07`) are terminal
|
||||
/// control sequences that don't consume display columns. The standard
|
||||
/// `UnicodeWidthStr::width()` method incorrectly counts the printable
|
||||
/// `CellWidth::cell_width()` method incorrectly counts the printable
|
||||
/// characters inside OSC payloads (like `]`, `8`, `;`, and URL characters).
|
||||
/// This function strips them first so that only visible characters contribute
|
||||
/// to the width.
|
||||
fn display_width(s: &str) -> usize {
|
||||
// Fast path: no escape sequences present.
|
||||
if !s.contains('\x1B') {
|
||||
return s.width();
|
||||
return usize::from(s.cell_width());
|
||||
}
|
||||
|
||||
// Strip OSC sequences: ESC ] ... BEL
|
||||
@@ -76,7 +78,7 @@ fn display_width(s: &str) -> usize {
|
||||
}
|
||||
visible.push(ch);
|
||||
}
|
||||
visible.width()
|
||||
usize::from(visible.as_str().cell_width())
|
||||
}
|
||||
|
||||
fn osc8_hyperlink_parts(symbol: &str) -> Option<(&str, &str)> {
|
||||
@@ -156,7 +158,7 @@ impl Frame<'_> {
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Terminal<B>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
/// The backend used to interface with the terminal
|
||||
backend: B,
|
||||
@@ -182,7 +184,7 @@ where
|
||||
|
||||
impl<B> Drop for Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
B: Backend<Error = io::Error>,
|
||||
B: Write,
|
||||
{
|
||||
#[allow(clippy::print_stderr)]
|
||||
@@ -202,7 +204,7 @@ where
|
||||
|
||||
impl<B> Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
B: Backend<Error = io::Error>,
|
||||
B: Write,
|
||||
{
|
||||
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
|
||||
@@ -626,7 +628,10 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec<DrawCommand> {
|
||||
// their place (the skipped cells should be blank anyway), or due to per-cell-skipping:
|
||||
let mut to_skip: usize = 0;
|
||||
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
|
||||
if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
|
||||
if current.diff_option != CellDiffOption::Skip
|
||||
&& (current != previous || invalidated > 0)
|
||||
&& to_skip == 0
|
||||
{
|
||||
let (x, y) = a.pos_of(i);
|
||||
let row = i / a.area.width as usize;
|
||||
if x <= last_nonblank_columns[row] {
|
||||
@@ -690,7 +695,10 @@ where
|
||||
if cell.fg != fg || cell.bg != bg {
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(cell.fg.into(), cell.bg.into()))
|
||||
SetColors(Colors::new(
|
||||
cell.fg.into_crossterm(),
|
||||
cell.bg.into_crossterm()
|
||||
))
|
||||
)?;
|
||||
fg = cell.fg;
|
||||
bg = cell.bg;
|
||||
@@ -705,7 +713,7 @@ where
|
||||
DrawCommand::ClearToEnd { bg: clear_bg, .. } => {
|
||||
queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?;
|
||||
modifier = Modifier::empty();
|
||||
queue!(writer, SetBackgroundColor((*clear_bg).into()))?;
|
||||
queue!(writer, SetBackgroundColor((*clear_bg).into_crossterm()))?;
|
||||
bg = *clear_bg;
|
||||
queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?;
|
||||
}
|
||||
@@ -840,6 +848,8 @@ mod tests {
|
||||
}
|
||||
|
||||
impl Backend for CaptureBackend {
|
||||
type Error = io::Error;
|
||||
|
||||
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
@@ -989,6 +999,15 @@ mod tests {
|
||||
assert!(output.find(close).expect("hyperlink close") < footer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_width_handles_halfwidth_dakuten() {
|
||||
assert_eq!(display_width("ガ"), 2);
|
||||
assert_eq!(
|
||||
display_width("\x1b]8;;https://example.com\x07ガ\x1b]8;;\x07"),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_draw_applies_requested_cursor_style() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
@@ -353,6 +353,7 @@ mod tests {
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::widgets::FrameExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn new_prompt() -> CwdPromptScreen {
|
||||
|
||||
@@ -1324,7 +1324,6 @@ mod tests {
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
#[test]
|
||||
@@ -1377,7 +1376,7 @@ mod tests {
|
||||
.draw(|f| {
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render_ref(f.area(), f.buffer_mut())
|
||||
.render(f.area(), f.buffer_mut())
|
||||
})
|
||||
.expect("draw");
|
||||
assert!(
|
||||
|
||||
@@ -4,6 +4,7 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::widgets::FrameExt;
|
||||
|
||||
fn new_screen() -> ExternalAgentConfigSourceScreen {
|
||||
ExternalAgentConfigSourceScreen::new(
|
||||
|
||||
@@ -25,22 +25,28 @@ impl HistoryCell for PlainHistoryCell {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebHyperlinkHistoryCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl WebHyperlinkHistoryCell {
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
|
||||
Self {
|
||||
lines: crate::terminal_hyperlinks::annotate_web_urls(lines),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_hyperlink_lines(lines: Vec<HyperlinkLine>) -> Self {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for WebHyperlinkHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
self.lines.iter().map(|line| line.line.clone()).collect()
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
|
||||
crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone())
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
@@ -48,7 +54,7 @@ impl HistoryCell for WebHyperlinkHistoryCell {
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
plain_lines(self.lines.iter().map(|line| line.line.clone()))
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -317,26 +317,24 @@ fn decode_mcp_image(block: &serde_json::Value) -> Option<DynamicImage> {
|
||||
.ok()
|
||||
}
|
||||
/// Render a summary of configured MCP servers from the current `Config`.
|
||||
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
"/mcp".magenta().into(),
|
||||
"".into(),
|
||||
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
|
||||
"".into(),
|
||||
" • No MCP servers configured.".italic().into(),
|
||||
Line::from(vec![
|
||||
" See the ".into(),
|
||||
crate::terminal_hyperlinks::osc8_hyperlink(
|
||||
"https://developers.openai.com/codex/mcp",
|
||||
"MCP docs",
|
||||
)
|
||||
.underlined(),
|
||||
" to configure them.".into(),
|
||||
])
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
pub(crate) fn empty_mcp_output() -> WebHyperlinkHistoryCell {
|
||||
let mut docs_line = HyperlinkLine::new(Line::from(" See the "));
|
||||
docs_line.push_span(
|
||||
"MCP docs".underlined(),
|
||||
Some("https://developers.openai.com/codex/mcp"),
|
||||
);
|
||||
docs_line.push_span(" to configure them.".into(), /*destination*/ None);
|
||||
|
||||
let lines = vec![
|
||||
HyperlinkLine::new("/mcp".magenta().into()),
|
||||
HyperlinkLine::from(""),
|
||||
HyperlinkLine::new(vec!["🔌 ".into(), "MCP Tools".bold()].into()),
|
||||
HyperlinkLine::from(""),
|
||||
HyperlinkLine::new(" • No MCP servers configured.".italic().into()),
|
||||
docs_line.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
];
|
||||
|
||||
PlainHistoryCell::new(lines)
|
||||
WebHyperlinkHistoryCell::new_hyperlink_lines(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -257,22 +257,9 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
|
||||
/// Returns the number of viewport rows for the transcript overlay.
|
||||
///
|
||||
/// Uses the same `Paragraph::line_count` measurement as
|
||||
/// `desired_height`. Contains a workaround for a ratatui bug where
|
||||
/// a single whitespace-only line reports 2 rows instead of 1.
|
||||
/// Uses the same `Paragraph::line_count` measurement as `desired_height`.
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
let lines = visible_lines(self.transcript_hyperlink_lines(width));
|
||||
// Workaround: ratatui's line_count returns 2 for a single
|
||||
// whitespace-only line. Clamp to 1 in that case.
|
||||
if let [line] = &lines[..]
|
||||
&& line
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.chars().all(char::is_whitespace))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
|
||||
@@ -6,7 +6,7 @@ fn finalized_plan_reuses_lines_primed_by_transcript_height() {
|
||||
let cell = new_proposed_plan("1. Inspect **markdown**".to_string(), Path::new("/tmp"));
|
||||
let width = 48;
|
||||
|
||||
assert_eq!(cell.desired_transcript_height(width), 8);
|
||||
assert_eq!(cell.desired_transcript_height(width), 5);
|
||||
cell.rendered_lines
|
||||
.cached
|
||||
.lock()
|
||||
|
||||
@@ -352,6 +352,57 @@ fn composite_cell_preserves_child_web_links() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mcp_output_preserves_docs_hyperlink() {
|
||||
let destination = "https://developers.openai.com/codex/mcp";
|
||||
let cell: Box<dyn HistoryCell> = Box::new(empty_mcp_output());
|
||||
|
||||
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 80)).join("\n"), @r"
|
||||
/mcp
|
||||
|
||||
🔌 MCP Tools
|
||||
|
||||
• No MCP servers configured.
|
||||
See the MCP docs to configure them.
|
||||
");
|
||||
|
||||
let expected_link = vec![crate::terminal_hyperlinks::TerminalHyperlink::web(
|
||||
/*columns*/ 12..20,
|
||||
destination.to_string(),
|
||||
)];
|
||||
assert_eq!(
|
||||
cell.display_hyperlink_lines(/*width*/ 80)[5].hyperlinks,
|
||||
expected_link
|
||||
);
|
||||
assert_eq!(
|
||||
cell.transcript_hyperlink_lines(/*width*/ 80)[5].hyperlinks,
|
||||
expected_link
|
||||
);
|
||||
|
||||
let area = Rect::new(0, 0, 80, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
cell.render(area, &mut buf);
|
||||
assert_eq!(
|
||||
(0..39).map(|x| buf[(x, 5)].modifier).collect::<Vec<_>>(),
|
||||
[
|
||||
vec![Modifier::DIM; 12],
|
||||
vec![Modifier::DIM | Modifier::UNDERLINED; 8],
|
||||
vec![Modifier::DIM; 19],
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
let linked_text = area
|
||||
.positions()
|
||||
.filter_map(|position| {
|
||||
let symbol = buf[position].symbol();
|
||||
symbol
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
.then(|| crate::terminal_hyperlinks::strip_osc8(symbol))
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(linked_text, "MCP docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposed_plan_cell_unwraps_markdown_fenced_table() {
|
||||
let plan = new_proposed_plan(
|
||||
|
||||
@@ -9,7 +9,6 @@ use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
use crossterm::terminal::Clear;
|
||||
use crossterm::terminal::ClearType;
|
||||
use ratatui::backend::IntoCrossterm;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
@@ -63,7 +64,7 @@ pub fn insert_history_lines<B>(
|
||||
lines: Vec<Line>,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
insert_history_lines_with_wrap_policy(terminal, lines, HistoryLineWrapPolicy::PreWrap)
|
||||
}
|
||||
@@ -74,7 +75,7 @@ pub fn insert_history_lines_with_wrap_policy<B>(
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
@@ -91,7 +92,7 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy<B>(
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
insert_history_hyperlink_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
@@ -108,7 +109,7 @@ pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy<B>(
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
|
||||
@@ -299,12 +300,12 @@ fn write_history_line<W: Write>(
|
||||
line.line
|
||||
.style
|
||||
.fg
|
||||
.map(std::convert::Into::into)
|
||||
.map(IntoCrossterm::into_crossterm)
|
||||
.unwrap_or(CColor::Reset),
|
||||
line.line
|
||||
.style
|
||||
.bg
|
||||
.map(std::convert::Into::into)
|
||||
.map(IntoCrossterm::into_crossterm)
|
||||
.unwrap_or(CColor::Reset)
|
||||
))
|
||||
)?;
|
||||
@@ -459,7 +460,10 @@ where
|
||||
if next_fg != fg || next_bg != bg {
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(next_fg.into(), next_bg.into()))
|
||||
SetColors(Colors::new(
|
||||
next_fg.into_crossterm(),
|
||||
next_bg.into_crossterm()
|
||||
))
|
||||
)?;
|
||||
fg = next_fg;
|
||||
bg = next_bg;
|
||||
|
||||
@@ -19,7 +19,6 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Span;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -64,7 +64,6 @@ use pulldown_cmark::Parser;
|
||||
use pulldown_cmark::Tag;
|
||||
use pulldown_cmark::TagEnd;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
|
||||
@@ -52,6 +52,9 @@ use crate::motion::shimmer_text;
|
||||
use crate::onboarding::keys;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink.
|
||||
@@ -544,50 +547,51 @@ impl AuthModeWidget {
|
||||
}
|
||||
|
||||
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut docs_line = HyperlinkLine::new(Line::from(" For more details see the ").dim());
|
||||
docs_line.push_span(
|
||||
"Codex docs".underlined(),
|
||||
Some("https://developers.openai.com/codex/security"),
|
||||
);
|
||||
let mut preferences_line =
|
||||
HyperlinkLine::new(Line::from(" Uses your plan's rate limits and ").dim());
|
||||
preferences_line.push_span(
|
||||
"training data preferences".underlined(),
|
||||
Some("https://chatgpt.com/#settings"),
|
||||
);
|
||||
|
||||
let lines = vec![
|
||||
"✓ Signed in with your ChatGPT account"
|
||||
.fg(Color::Green)
|
||||
.into(),
|
||||
HyperlinkLine::new(
|
||||
"✓ Signed in with your ChatGPT account"
|
||||
.fg(Color::Green)
|
||||
.into(),
|
||||
),
|
||||
"".into(),
|
||||
" Before you start:".into(),
|
||||
"".into(),
|
||||
" Decide how much autonomy you want to grant Codex".into(),
|
||||
Line::from(vec![
|
||||
" For more details see the ".into(),
|
||||
crate::terminal_hyperlinks::osc8_hyperlink(
|
||||
"https://developers.openai.com/codex/security",
|
||||
"Codex docs",
|
||||
)
|
||||
.underlined(),
|
||||
])
|
||||
.dim(),
|
||||
docs_line,
|
||||
"".into(),
|
||||
" Codex can make mistakes".into(),
|
||||
" Review the code it writes and commands it runs"
|
||||
.dim()
|
||||
.into(),
|
||||
HyperlinkLine::new(
|
||||
" Review the code it writes and commands it runs"
|
||||
.dim()
|
||||
.into(),
|
||||
),
|
||||
"".into(),
|
||||
" Powered by your ChatGPT account".into(),
|
||||
Line::from(vec![
|
||||
" Uses your plan's rate limits and ".into(),
|
||||
crate::terminal_hyperlinks::osc8_hyperlink(
|
||||
"https://chatgpt.com/#settings",
|
||||
"training data preferences",
|
||||
)
|
||||
.underlined(),
|
||||
])
|
||||
.dim(),
|
||||
preferences_line,
|
||||
"".into(),
|
||||
Line::from(vec![
|
||||
HyperlinkLine::new(Line::from(vec![
|
||||
" Press ".fg(Color::Cyan),
|
||||
self.confirm_binding().into(),
|
||||
" to continue".fg(Color::Cyan),
|
||||
]),
|
||||
])),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
Paragraph::new(visible_lines(lines.clone()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &lines, /*scroll_rows*/ 0);
|
||||
}
|
||||
|
||||
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -1251,6 +1255,69 @@ mod tests {
|
||||
assert!(contents.contains("Press esc to cancel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_success_message_renders_osc8_hyperlinks() {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
|
||||
let area = Rect::new(0, 0, 80, 14);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
widget.render_chatgpt_success_message(area, &mut buf);
|
||||
|
||||
assert_eq!(
|
||||
collect_osc8_chars(&buf, area, "https://developers.openai.com/codex/security"),
|
||||
"Codex docs"
|
||||
);
|
||||
assert_eq!(
|
||||
collect_osc8_chars(&buf, area, "https://chatgpt.com/#settings"),
|
||||
"training data preferences"
|
||||
);
|
||||
assert_eq!(
|
||||
(0..37).map(|x| buf[(x, 5)].modifier).collect::<Vec<_>>(),
|
||||
[
|
||||
vec![Modifier::DIM; 27],
|
||||
vec![Modifier::DIM | Modifier::UNDERLINED; 10],
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
assert_eq!(
|
||||
(0..60).map(|x| buf[(x, 11)].modifier).collect::<Vec<_>>(),
|
||||
[
|
||||
vec![Modifier::DIM; 35],
|
||||
vec![Modifier::DIM | Modifier::UNDERLINED; 25],
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
|
||||
let visible = (area.top()..area.bottom())
|
||||
.map(|y| {
|
||||
let row = (area.left()..area.right())
|
||||
.map(|x| buf[(x, y)].symbol())
|
||||
.collect::<String>();
|
||||
crate::terminal_hyperlinks::strip_osc8(&row)
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(visible, @r###"
|
||||
✓ Signed in with your ChatGPT account
|
||||
|
||||
Before you start:
|
||||
|
||||
Decide how much autonomy you want to grant Codex
|
||||
For more details see the Codex docs
|
||||
|
||||
Codex can make mistakes
|
||||
Review the code it writes and commands it runs
|
||||
|
||||
Powered by your ChatGPT account
|
||||
Uses your plan's rate limits and training data preferences
|
||||
|
||||
Press enter to continue
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_widget_suppresses_animations_when_device_code_is_visible() {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
@@ -146,7 +146,7 @@ mod tests {
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let frame_lines = widget.animation.current_frame().lines().count() as u16;
|
||||
(&widget).render(area, &mut buf);
|
||||
(&widget).render_ref(area, &mut buf);
|
||||
|
||||
let welcome_row = row_containing(&buf, "Welcome");
|
||||
assert_eq!(welcome_row, Some(frame_lines + 1));
|
||||
@@ -161,7 +161,7 @@ mod tests {
|
||||
);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&widget).render(area, &mut buf);
|
||||
(&widget).render_ref(area, &mut buf);
|
||||
|
||||
let welcome_row = row_containing(&buf, "Welcome");
|
||||
assert_eq!(welcome_row, Some(0));
|
||||
|
||||
@@ -24,9 +24,9 @@ use crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Direction;
|
||||
use ratatui::layout::HorizontalAlignment;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -270,7 +270,10 @@ impl WidgetRef for &OssSelectionWidget<'_> {
|
||||
} else {
|
||||
Style::new().bg(Color::DarkGray)
|
||||
};
|
||||
opt.label.clone().alignment(Alignment::Center).style(style)
|
||||
opt.label
|
||||
.clone()
|
||||
.alignment(HorizontalAlignment::Center)
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ use ratatui::text::Text;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
pub(crate) enum Overlay {
|
||||
@@ -113,7 +112,7 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &st
|
||||
spans.push(Span::from(desc.to_string()));
|
||||
first = false;
|
||||
}
|
||||
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
|
||||
Paragraph::new(vec![Line::from(spans).dim()]).render(area, buf);
|
||||
}
|
||||
|
||||
/// Generic widget for rendering a pager view.
|
||||
@@ -177,9 +176,9 @@ impl PagerView {
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
||||
Span::from("/ ".repeat(area.width as usize / 2))
|
||||
.dim()
|
||||
.render_ref(area, buf);
|
||||
.render(area, buf);
|
||||
let header = format!("/ {}", self.title);
|
||||
header.dim().render_ref(area, buf);
|
||||
header.dim().render(area, buf);
|
||||
}
|
||||
|
||||
fn render_content(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -230,7 +229,7 @@ impl PagerView {
|
||||
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render_ref(sep_rect, buf);
|
||||
.render(sep_rect, buf);
|
||||
let percent = if total_len == 0 {
|
||||
100
|
||||
} else {
|
||||
@@ -247,7 +246,7 @@ impl PagerView {
|
||||
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
|
||||
Span::from(pct_text)
|
||||
.dim()
|
||||
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
||||
.render(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::HorizontalAlignment;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
@@ -128,7 +128,7 @@ impl Renderable for PetPickerPreviewRenderable {
|
||||
lines.push(Line::from(body.dim()));
|
||||
}
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Center)
|
||||
.alignment(HorizontalAlignment::Center)
|
||||
.render(text_area, buf);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::render::Insets;
|
||||
@@ -109,7 +110,7 @@ impl<'a> Renderable for Span<'a> {
|
||||
|
||||
impl<'a> Renderable for Line<'a> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
WidgetRef::render_ref(self, area, buf);
|
||||
Widget::render(self, area, buf);
|
||||
}
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
1
|
||||
|
||||
@@ -1902,11 +1902,11 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
state.action.title().bold().cyan()
|
||||
};
|
||||
let header_line: Line = vec![header_title].into();
|
||||
frame.render_widget_ref(header_line, chrome(header));
|
||||
frame.render_widget_ref(&header_line, chrome(header));
|
||||
|
||||
// Search line
|
||||
let search = chrome(search);
|
||||
frame.render_widget_ref(search_line(state, search.width), search);
|
||||
frame.render_widget_ref(&search_line(state, search.width), search);
|
||||
|
||||
let list = Rect::new(
|
||||
list.x.saturating_add(2),
|
||||
@@ -2076,7 +2076,7 @@ fn render_picker_footer(
|
||||
if y >= area.bottom() {
|
||||
break;
|
||||
}
|
||||
frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1));
|
||||
frame.render_widget_ref(&line, Rect::new(area.x, y, area.width, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2090,7 +2090,7 @@ fn render_picker_footer_separator(
|
||||
}
|
||||
|
||||
let separator = "─".repeat(area.width as usize);
|
||||
frame.render_widget_ref(Line::from(separator.dim()), area);
|
||||
frame.render_widget_ref(&Line::from(separator.dim()), area);
|
||||
|
||||
let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16;
|
||||
if progress_width < area.width {
|
||||
@@ -2100,7 +2100,7 @@ fn render_picker_footer_separator(
|
||||
progress_width,
|
||||
1,
|
||||
);
|
||||
frame.render_widget_ref(Line::from(progress_label.dim()), percent_area);
|
||||
frame.render_widget_ref(&Line::from(progress_label.dim()), percent_area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2339,7 +2339,7 @@ fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame,
|
||||
message_width.min(overlay.width),
|
||||
1,
|
||||
);
|
||||
frame.render_widget_ref(Line::from(message.bold()), line);
|
||||
frame.render_widget_ref(&Line::from(message.bold()), line);
|
||||
}
|
||||
|
||||
fn transcript_loading_overlay_style() -> Style {
|
||||
@@ -2456,7 +2456,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
let rows = &state.filtered_rows;
|
||||
if rows.is_empty() {
|
||||
let message = render_empty_state_line(state);
|
||||
frame.render_widget_ref(message, area);
|
||||
frame.render_widget_ref(&message, area);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2472,7 +2472,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
);
|
||||
if show_more_above {
|
||||
frame.render_widget_ref(
|
||||
more_line("↑ more"),
|
||||
&more_line("↑ more"),
|
||||
Rect::new(area.x, area.y, area.width, 1),
|
||||
);
|
||||
}
|
||||
@@ -2493,7 +2493,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
if y >= content_area.y.saturating_add(content_area.height) {
|
||||
break;
|
||||
}
|
||||
frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1));
|
||||
frame.render_widget_ref(&line, Rect::new(area.x, y, area.width, 1));
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
if state.density == SessionListDensity::Comfortable
|
||||
@@ -2509,7 +2509,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
{
|
||||
let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into();
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
frame.render_widget_ref(loading_line, rect);
|
||||
frame.render_widget_ref(&loading_line, rect);
|
||||
}
|
||||
if show_more_below {
|
||||
let label = if state.pagination.loading.is_pending() {
|
||||
@@ -2518,7 +2518,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
"↓ more"
|
||||
};
|
||||
frame.render_widget_ref(
|
||||
more_line(label),
|
||||
&more_line(label),
|
||||
Rect::new(
|
||||
area.x,
|
||||
area.y.saturating_add(area.height.saturating_sub(1)),
|
||||
@@ -3764,7 +3764,7 @@ mod tests {
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
let line = search_line(&state, frame.area().width);
|
||||
frame.render_widget_ref(line, frame.area());
|
||||
frame.render_widget_ref(&line, frame.area());
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
||||
@@ -4663,7 +4663,7 @@ session_picker_view = "dense"
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
let line = search_line(&state, frame.area().width);
|
||||
frame.render_widget_ref(line, frame.area());
|
||||
frame.render_widget_ref(&line, frame.area());
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RowRenderable;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled as _;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -258,7 +258,7 @@ fn selection_item(name: &str, is_disabled: bool) -> SelectionItem {
|
||||
fn draw_view(tui: &mut Tui, view: &ListSelectionView) -> Result<()> {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
let area = frame.area();
|
||||
frame.render_widget_ref(Clear, area);
|
||||
frame.render_widget_ref(&Clear, area);
|
||||
let view_area = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
|
||||
@@ -15,7 +15,7 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -293,7 +293,7 @@ impl Renderable for StatusIndicatorWidget {
|
||||
lines.extend(details.into_iter().take(max_details));
|
||||
}
|
||||
|
||||
Paragraph::new(Text::from(lines)).render_ref(area, buf);
|
||||
Paragraph::new(Text::from(lines)).render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::terminal_palette::rgb_color;
|
||||
use crate::terminal_palette::stdout_color_level;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
const LIGHT_BG_ACCENT_RGB: (u8, u8, u8) = (0, 95, 135);
|
||||
// Decorative table rules should remain visible without competing with cell content.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::CellDiffOption;
|
||||
use ratatui::buffer::CellWidth;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
@@ -15,8 +17,6 @@ use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
use crate::render::line_utils::line_to_borrowed;
|
||||
@@ -85,12 +85,16 @@ impl HyperlinkLine {
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.line.width()
|
||||
self.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| usize::from(span.content.as_ref().cell_width()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
|
||||
let start = self.width();
|
||||
let end = start + span.content.width();
|
||||
let end = start + usize::from(span.content.as_ref().cell_width());
|
||||
self.line.push_span(span);
|
||||
if end > start
|
||||
&& let Some(destination) = destination.and_then(web_destination)
|
||||
@@ -153,7 +157,7 @@ pub(crate) fn prefix_hyperlink_lines(
|
||||
} else {
|
||||
subsequent_prefix.clone()
|
||||
};
|
||||
let shift = prefix.content.width();
|
||||
let shift = usize::from(prefix.content.as_ref().cell_width());
|
||||
let mut spans = Vec::with_capacity(line.line.spans.len() + 1);
|
||||
spans.push(prefix);
|
||||
spans.extend(line.line.spans);
|
||||
@@ -227,7 +231,10 @@ pub(crate) fn remap_wrapped_line(
|
||||
if index > 0 {
|
||||
let trimmed = source_text[source_byte..].trim_start_matches(char::is_whitespace);
|
||||
let skipped = source_text[source_byte..].len() - trimmed.len();
|
||||
source_column += source_text[source_byte..source_byte + skipped].width();
|
||||
source_column += source_text[source_byte..source_byte + skipped]
|
||||
.chars()
|
||||
.map(char_cell_width)
|
||||
.sum::<usize>();
|
||||
source_byte += skipped;
|
||||
}
|
||||
|
||||
@@ -237,9 +244,9 @@ pub(crate) fn remap_wrapped_line(
|
||||
continue;
|
||||
};
|
||||
let mapped = &rendered[rendered_start..];
|
||||
let mut output_column = rendered[..rendered_start].width();
|
||||
let mut output_column = usize::from(rendered[..rendered_start].cell_width());
|
||||
for ch in mapped.chars() {
|
||||
let width = ch.width().unwrap_or(/*default*/ 0);
|
||||
let width = char_cell_width(ch);
|
||||
while source
|
||||
.hyperlinks
|
||||
.get(link_index)
|
||||
@@ -312,8 +319,8 @@ pub(crate) fn web_links_in_text(text: &str) -> Vec<TerminalHyperlink> {
|
||||
let Some(destination) = web_destination(candidate) else {
|
||||
continue;
|
||||
};
|
||||
let start = text[..raw_start + trimmed_start].width();
|
||||
let end = start + candidate.width();
|
||||
let start = usize::from(text[..raw_start + trimmed_start].cell_width());
|
||||
let end = start + usize::from(candidate.cell_width());
|
||||
links.push(TerminalHyperlink::web(start..end, destination));
|
||||
}
|
||||
links
|
||||
@@ -427,7 +434,7 @@ pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
|
||||
let mut active_destination: Option<String> = None;
|
||||
for span in &line.line.spans {
|
||||
for ch in span.content.chars() {
|
||||
let width = ch.width().unwrap_or(/*default*/ 0);
|
||||
let width = char_cell_width(ch);
|
||||
while line
|
||||
.hyperlinks
|
||||
.get(link_index)
|
||||
@@ -480,6 +487,14 @@ fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn char_cell_width(ch: char) -> usize {
|
||||
if ch.is_control() {
|
||||
return 0;
|
||||
}
|
||||
let mut encoded = [0; 4];
|
||||
usize::from(ch.encode_utf8(&mut encoded).cell_width())
|
||||
}
|
||||
|
||||
pub(crate) fn mark_buffer_hyperlinks(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -509,10 +524,19 @@ pub(crate) fn mark_buffer_hyperlinks(
|
||||
paragraph.render(layout_area, &mut layout);
|
||||
let rendered_lines = (0..layout_area.height)
|
||||
.map(|row| {
|
||||
let mut trailing_columns = 0usize;
|
||||
let text = (0..layout_area.width)
|
||||
.filter_map(|column| {
|
||||
if trailing_columns > 0 {
|
||||
trailing_columns -= 1;
|
||||
return None;
|
||||
}
|
||||
let cell = &layout[(column, row)];
|
||||
(!cell.skip).then(|| cell.symbol())
|
||||
if cell.diff_option == CellDiffOption::Skip {
|
||||
return None;
|
||||
}
|
||||
trailing_columns = usize::from(cell.cell_width()).saturating_sub(1);
|
||||
Some(cell.symbol())
|
||||
})
|
||||
.collect::<String>();
|
||||
Line::from(text.trim_end().to_string())
|
||||
@@ -520,7 +544,12 @@ pub(crate) fn mark_buffer_hyperlinks(
|
||||
.collect();
|
||||
for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
|
||||
for link in &rendered.hyperlinks {
|
||||
let mut trailing_columns = 0usize;
|
||||
for column in link.columns.clone() {
|
||||
if trailing_columns > 0 {
|
||||
trailing_columns -= 1;
|
||||
continue;
|
||||
}
|
||||
let row = logical_row + row;
|
||||
if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
|
||||
continue;
|
||||
@@ -528,9 +557,10 @@ pub(crate) fn mark_buffer_hyperlinks(
|
||||
let x = area.x + column as u16;
|
||||
let y = area.y + (row - scroll_rows) as u16;
|
||||
let cell = &mut buf[(x, y)];
|
||||
if cell.skip || cell.symbol().trim().is_empty() {
|
||||
if cell.diff_option == CellDiffOption::Skip {
|
||||
continue;
|
||||
}
|
||||
trailing_columns = usize::from(cell.cell_width()).saturating_sub(1);
|
||||
let symbol = link.terminal_destination().map_or_else(
|
||||
|| cell.symbol().to_string(),
|
||||
|destination| {
|
||||
@@ -568,7 +598,10 @@ fn mark_matching_cells(
|
||||
}
|
||||
for position in area.positions() {
|
||||
let cell = &mut buf[position];
|
||||
if !cell.skip && !cell.symbol().trim().is_empty() && matches(cell) {
|
||||
if cell.diff_option != CellDiffOption::Skip
|
||||
&& !cell.symbol().trim().is_empty()
|
||||
&& matches(cell)
|
||||
{
|
||||
let symbol = osc8_hyperlink(destination, cell.symbol());
|
||||
cell.set_symbol(&symbol);
|
||||
}
|
||||
@@ -611,7 +644,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
web_links_in_text(&format!("See ({destination}).")),
|
||||
vec![TerminalHyperlink::web(
|
||||
/*columns*/ 5..5 + destination.width(),
|
||||
/*columns*/ 5..5 + usize::from(destination.cell_width()),
|
||||
destination.to_string(),
|
||||
)]
|
||||
);
|
||||
@@ -623,7 +656,7 @@ mod tests {
|
||||
let line = HyperlinkLine {
|
||||
line: Line::from(destination),
|
||||
hyperlinks: vec![TerminalHyperlink::web(
|
||||
/*columns*/ 0..destination.width(),
|
||||
/*columns*/ 0..usize::from(destination.cell_width()),
|
||||
destination.to_string(),
|
||||
)],
|
||||
};
|
||||
@@ -662,13 +695,15 @@ mod tests {
|
||||
let text = "alpha 😀here middle there end";
|
||||
let first_start = text.find("here").expect("first link");
|
||||
let second_start = text.find("there").expect("second link");
|
||||
let first_column = usize::from(text[..first_start].cell_width());
|
||||
let second_column = usize::from(text[..second_start].cell_width());
|
||||
let mut source = HyperlinkLine::new(Line::from(text));
|
||||
source.hyperlinks.push(TerminalHyperlink::web(
|
||||
text[..first_start].width()..text[..first_start].width() + "here".width(),
|
||||
first_column..first_column + usize::from("here".cell_width()),
|
||||
"https://example.com/first".to_string(),
|
||||
));
|
||||
source.hyperlinks.push(TerminalHyperlink::web(
|
||||
text[..second_start].width()..text[..second_start].width() + "there".width(),
|
||||
second_column..second_column + usize::from("there".cell_width()),
|
||||
"https://example.com/second".to_string(),
|
||||
));
|
||||
|
||||
@@ -706,7 +741,7 @@ mod tests {
|
||||
let destination = "https://example.com/path";
|
||||
let mut line = HyperlinkLine::new(Line::from(format!("See {destination} now")));
|
||||
line.hyperlinks.push(TerminalHyperlink::web(
|
||||
/*columns*/ 4..4 + destination.width(),
|
||||
/*columns*/ 4..4 + usize::from(destination.cell_width()),
|
||||
destination.to_string(),
|
||||
));
|
||||
let area = Rect::new(
|
||||
@@ -731,6 +766,62 @@ mod tests {
|
||||
assert_eq!(linked_text, destination);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_hyperlinks_follow_wrapped_wide_glyphs() {
|
||||
let destination = "https://example.com/wide";
|
||||
let mut line = HyperlinkLine::new(Line::from("前文 "));
|
||||
line.push_span("漢字漢字".into(), Some(destination));
|
||||
line.push_span(" 後文".into(), /*destination*/ None);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 6, /*height*/ 4,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
Paragraph::new(Text::from(line.line.clone()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, &mut buf);
|
||||
mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
|
||||
|
||||
let linked_text = area
|
||||
.positions()
|
||||
.filter_map(|position| {
|
||||
let symbol = buf[position].symbol();
|
||||
symbol
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
.then(|| strip_osc8(symbol))
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(linked_text, "漢字漢字");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_hyperlinks_follow_wrapped_halfwidth_dakuten() {
|
||||
let destination = "https://example.com/dakuten";
|
||||
let mut line = HyperlinkLine::new(Line::from("ガ "));
|
||||
line.push_span("パlink".into(), Some(destination));
|
||||
line.push_span(" tail".into(), /*destination*/ None);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 5, /*height*/ 4,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
Paragraph::new(Text::from(line.line.clone()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, &mut buf);
|
||||
mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
|
||||
|
||||
let linked_text = area
|
||||
.positions()
|
||||
.filter_map(|position| {
|
||||
let symbol = buf[position].symbol();
|
||||
symbol
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
.then(|| strip_osc8(symbol))
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(linked_text, "パlink");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_file_destination_receives_osc8_without_enabling_plain_file_links() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp directory");
|
||||
|
||||
@@ -53,6 +53,8 @@ impl fmt::Display for VT100Backend {
|
||||
}
|
||||
|
||||
impl Backend for VT100Backend {
|
||||
type Error = io::Error;
|
||||
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
@@ -589,7 +590,7 @@ struct PendingHistoryLines {
|
||||
|
||||
fn clear_for_viewport_change<B>(terminal: &mut CustomTerminal<B>, new_area: Rect) -> Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
let clear_position = if terminal.viewport_area.is_empty() {
|
||||
new_area.as_position()
|
||||
|
||||
@@ -248,6 +248,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::widgets::FrameExt;
|
||||
|
||||
fn new_prompt() -> UpdatePromptScreen {
|
||||
UpdatePromptScreen::new(
|
||||
|
||||
Reference in New Issue
Block a user