mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Add an experimental UI tree renderer for the status line (#57970)
## What changed - Add the disabled-by-default `tui_extensions_statusline` feature to render configured status items and their picker preview through a shared UI tree renderer. - Support text, HTTP(S) links, rows, spacers, dividers, and gaps with rendering budgets, text sanitization, and width-aware truncation that keeps the ellipsis outside hyperlinks. - Use plain text for the extension status line and hide the picker's theme color toggle while preserving the saved color preference. ## Testing Add coverage for tree serialization and URL validation, Unicode widths, hyperlink clipping, picker previews, color preference preservation, and token usage updates across renderer switches. GitOrigin-RevId: a6b87c2b1096765d61296d73e142834fcd53ceee
This commit is contained in:
@@ -1395,6 +1395,9 @@
|
||||
"tui_app_server": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tui_extensions_statusline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tui_multi_backend": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -8050,6 +8053,9 @@
|
||||
"tui_app_server": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tui_extensions_statusline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tui_multi_backend": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -123,6 +123,8 @@ pub enum Feature {
|
||||
DaemonAutoStart,
|
||||
/// Allow the TUI to discover and select among multiple conversation backends.
|
||||
TuiMultiBackend,
|
||||
/// Render the configured status line through the experimental TUI Extensions renderer.
|
||||
TuiExtensionsStatusLine,
|
||||
/// Enable negotiated zstd compression for app-server WebSocket and UDS connections.
|
||||
AppServerWebSocketCompression,
|
||||
|
||||
@@ -1098,6 +1100,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::TuiExtensionsStatusLine,
|
||||
key: "tui_extensions_statusline",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::AppServerWebSocketCompression,
|
||||
key: "app_server_websocket_compression",
|
||||
|
||||
@@ -2174,12 +2174,17 @@ async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_usage_update_refreshes_status_line_with_runtime_context_window() {
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::footer_content::FooterContent;
|
||||
|
||||
let mut app = make_test_app().await;
|
||||
app.chat_widget.setup_status_line(
|
||||
vec![crate::bottom_pane::StatusLineItem::ContextWindowSize],
|
||||
vec![
|
||||
StatusLineItem::ContextWindowSize,
|
||||
StatusLineItem::UsedTokens,
|
||||
],
|
||||
/*use_theme_colors*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget.config_panel_tui.status_line,
|
||||
app.chat_widget.local_settings.tui.status_line
|
||||
@@ -2187,14 +2192,31 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window()
|
||||
assert!(app.chat_widget.config_panel_tui.status_line_use_colors);
|
||||
assert_eq!(app.chat_widget.status_line_text(), None);
|
||||
|
||||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||||
token_usage_notification(ThreadId::new(), "turn-1", Some(950_000)),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget.status_line_text(),
|
||||
Some("950K window".into())
|
||||
);
|
||||
let thread_id = ThreadId::new();
|
||||
for (extensions, window, expected) in [
|
||||
(false, 950_000, "950K window · 8 used"),
|
||||
(true, 800_000, "800K window · 8 used"),
|
||||
(false, 900_000, "900K window · 8 used"),
|
||||
] {
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 60);
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::TuiExtensionsStatusLine, extensions);
|
||||
app.refresh_status_line();
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 60), before);
|
||||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||||
token_usage_notification(thread_id, "turn-1", Some(window)),
|
||||
)));
|
||||
assert!(matches!(
|
||||
(extensions, app.chat_widget.status_line_content()),
|
||||
(false, Some(FooterContent::HyperlinkLine(_)))
|
||||
| (true, Some(FooterContent::Extension { .. }))
|
||||
));
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 60);
|
||||
assert!(
|
||||
rendered.lines().any(|row| row.trim() == expected),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1843,8 +1843,8 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_line_text(&self) -> Option<String> {
|
||||
self.footer.status_line_text()
|
||||
pub(crate) fn status_line_content(&self) -> Option<&FooterContent> {
|
||||
self.footer.status_line_value.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn local_images(&self) -> Vec<LocalImageAttachment> {
|
||||
|
||||
@@ -398,16 +398,6 @@ impl FooterState {
|
||||
.unwrap_or_else(Instant::now);
|
||||
self.flash = Some(FooterFlash { line, expires_at });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn status_line_text(&self) -> Option<String> {
|
||||
self.status_line_value
|
||||
.as_ref()
|
||||
.map(|content| match content {
|
||||
FooterContent::Line(line) => line.to_string(),
|
||||
FooterContent::HyperlinkLine(line) => line.line.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -22,6 +22,54 @@ fn composer() -> ChatComposer {
|
||||
composer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_status_line_clips_links_to_footer_layout() {
|
||||
let mut composer = composer();
|
||||
let tree = serde_json::from_value(serde_json::json!({
|
||||
"type": "row", "props": {}, "children": [
|
||||
{ "type": "text", "props": { "text": "plain" } },
|
||||
{ "type": "row", "props": {}, "children": [
|
||||
{ "type": "text", "props": { "text": " · " } },
|
||||
{ "type": "link", "props": { "href": "https://example.com/pr/42" }, "children": [
|
||||
{ "type": "text", "props": { "text": "PR #42" } }
|
||||
] }
|
||||
] }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
composer.set_status_line(Some(FooterContent::Extension { content: tree }));
|
||||
|
||||
for separate_status_line in [false, true] {
|
||||
for width in [12, 40] {
|
||||
let options = composer.resolve_render_options(ComposerRenderOptions {
|
||||
separate_status_line,
|
||||
..ComposerRenderOptions::default()
|
||||
});
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
composer.desired_height_with_options(width, options),
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
|
||||
let linked = buffer
|
||||
.content
|
||||
.iter()
|
||||
.filter(|cell| cell.symbol().contains("https://example.com/pr/42"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(linked.len(), if width == 12 { 1 } else { 6 }, "{buffer:?}");
|
||||
assert!(linked.iter().all(|cell| !cell.symbol().contains('…')));
|
||||
assert!(buffer.content.iter().any(|cell| cell.symbol() == "p"));
|
||||
}
|
||||
}
|
||||
|
||||
composer.set_active_agent_label(Some("bot".to_string()));
|
||||
let (output, _) = render(&composer, /*width*/ 40, /*footer*/ None);
|
||||
assert!(output.contains("plain"), "{output}");
|
||||
assert!(!output.contains("bot"), "{output}");
|
||||
}
|
||||
|
||||
fn render(
|
||||
composer: &ChatComposer,
|
||||
width: u16,
|
||||
|
||||
@@ -818,15 +818,16 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<FooterCo
|
||||
.status_line_value
|
||||
.as_ref()
|
||||
.filter(|_| props.status_line_enabled);
|
||||
let mut line = match status {
|
||||
Some(FooterContent::Line(line)) => HyperlinkLine::from(line.clone()),
|
||||
Some(FooterContent::HyperlinkLine(line)) => line.clone(),
|
||||
Some(content @ FooterContent::Extension { .. }) => return Some(content.clone()),
|
||||
None => HyperlinkLine::default(),
|
||||
};
|
||||
let suffix = passive_footer_suffix(props, status.is_some());
|
||||
if status.is_none() && suffix.spans.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut line = match status {
|
||||
Some(FooterContent::Line(line)) => HyperlinkLine::from(line.clone()),
|
||||
Some(FooterContent::HyperlinkLine(line)) => line.clone(),
|
||||
None => HyperlinkLine::default(),
|
||||
};
|
||||
if line.width() > 0 && suffix.width() > 0 {
|
||||
line.line
|
||||
.push_span(STATUS_LINE_SEPARATOR.set_style(secondary_text_style()));
|
||||
|
||||
@@ -8,12 +8,14 @@ use ratatui::widgets::Widget;
|
||||
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::ui_tree::UiNode;
|
||||
|
||||
/// Owned content for the footer's negotiated left-hand area.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum FooterContent {
|
||||
Line(Line<'static>),
|
||||
HyperlinkLine(HyperlinkLine),
|
||||
Extension { content: UiNode },
|
||||
}
|
||||
|
||||
impl FooterContent {
|
||||
@@ -22,6 +24,7 @@ impl FooterContent {
|
||||
let width = match self {
|
||||
Self::Line(line) => line.width(),
|
||||
Self::HyperlinkLine(line) => line.width(),
|
||||
Self::Extension { content } => content.render_line(u16::MAX).width(),
|
||||
};
|
||||
u16::try_from(width).unwrap_or(u16::MAX)
|
||||
}
|
||||
@@ -30,6 +33,7 @@ impl FooterContent {
|
||||
pub(crate) fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
match self {
|
||||
Self::Line(line) => Widget::render(line, area, buf),
|
||||
Self::Extension { content } => content.render_line(area.width).render_fitted(area, buf),
|
||||
Self::HyperlinkLine(mut line) => {
|
||||
let area = Rect {
|
||||
height: area.height.min(1),
|
||||
@@ -51,6 +55,7 @@ impl FooterContent {
|
||||
match self {
|
||||
Self::Line(line) => line.into(),
|
||||
Self::HyperlinkLine(line) => line,
|
||||
Self::Extension { content } => content.render_line(u16::MAX),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ pub(crate) use multi_select_picker::MultiSelectPicker;
|
||||
mod question_tests;
|
||||
mod questions;
|
||||
mod request_user_input;
|
||||
pub(crate) mod status_line_content;
|
||||
mod status_line_setup;
|
||||
mod status_line_style;
|
||||
mod status_surface_preview;
|
||||
@@ -120,6 +121,7 @@ pub(crate) use footer::footer_content;
|
||||
pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;
|
||||
pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
|
||||
pub(crate) use request_user_input::RequestUserInputOverlay;
|
||||
pub(crate) use status_line_style::STATUS_LINE_SEPARATOR;
|
||||
pub(crate) use status_line_style::status_line_from_segments;
|
||||
pub(crate) use voice_strip::VoiceStripPhase;
|
||||
pub(crate) use voice_strip::VoiceStripState;
|
||||
@@ -1375,8 +1377,8 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_line_text(&self) -> Option<String> {
|
||||
self.composer.status_line_text()
|
||||
pub(crate) fn status_line_content(&self) -> Option<&footer_content::FooterContent> {
|
||||
self.composer.status_line_content()
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
|
||||
@@ -58,6 +58,7 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use super::footer_content::FooterContent;
|
||||
use super::picker_rows;
|
||||
use super::picker_style;
|
||||
use super::selection_popup_common::ColumnWidthConfig;
|
||||
@@ -75,7 +76,6 @@ use crate::key_hint::is_plain_text_key_event;
|
||||
use crate::keymap::ListAction;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
@@ -110,7 +110,7 @@ pub type CancelCallback = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
/// Callback to generate an optional preview line based on current item states.
|
||||
/// Returns `None` to hide the preview area.
|
||||
pub type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync>;
|
||||
type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<FooterContent> + Send + Sync>;
|
||||
|
||||
/// A single selectable item in the multi-select picker.
|
||||
///
|
||||
@@ -201,7 +201,7 @@ pub(crate) struct MultiSelectPicker {
|
||||
preview_builder: Option<PreviewCallback>,
|
||||
|
||||
/// Cached preview line (updated on item changes).
|
||||
preview_line: Option<Line<'static>>,
|
||||
preview_line: Option<FooterContent>,
|
||||
|
||||
/// Callback invoked when items change (toggle or reorder).
|
||||
on_change: Option<ChangeCallBack>,
|
||||
@@ -620,16 +620,8 @@ impl Renderable for MultiSelectPicker {
|
||||
let [preview_area, hint_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)])
|
||||
.areas(footer_area);
|
||||
let preview_area = Rect {
|
||||
x: preview_area.x + 2,
|
||||
y: preview_area.y,
|
||||
width: preview_area.width.saturating_sub(2),
|
||||
height: preview_area.height,
|
||||
};
|
||||
let max_preview_width = preview_area.width.saturating_sub(2) as usize;
|
||||
let preview_line =
|
||||
truncate_line_with_ellipsis_if_overflow(preview_line.clone(), max_preview_width);
|
||||
preview_line.render(preview_area, buf);
|
||||
let preview_area = preview_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2));
|
||||
preview_line.clone().render(preview_area, buf);
|
||||
hint_area
|
||||
} else {
|
||||
footer_area
|
||||
@@ -715,10 +707,19 @@ impl MultiSelectPickerBuilder {
|
||||
///
|
||||
/// The callback receives all items and should return a [`Line`] to display,
|
||||
/// or `None` to hide the preview area.
|
||||
pub fn on_preview<F>(mut self, callback: F) -> Self
|
||||
pub fn on_preview<F>(self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync + 'static,
|
||||
{
|
||||
self.on_preview_content(move |items| {
|
||||
callback(items).map(|line| FooterContent::HyperlinkLine(line.into()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn on_preview_content(
|
||||
mut self,
|
||||
callback: impl Fn(&[MultiSelectItem]) -> Option<FooterContent> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.preview_builder = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Builds the configured status line for both the footer and its live picker preview.
|
||||
|
||||
use super::STATUS_LINE_SEPARATOR;
|
||||
use super::footer_content::FooterContent;
|
||||
use super::status_line_from_segments;
|
||||
use super::status_line_setup::StatusLineItem;
|
||||
use crate::ui_tree::Key;
|
||||
use crate::ui_tree::LinkHref;
|
||||
use crate::ui_tree::LinkProps;
|
||||
use crate::ui_tree::StackProps;
|
||||
use crate::ui_tree::UiNode;
|
||||
use codex_protocol::ThreadId;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum StatusLineRenderer {
|
||||
Native,
|
||||
Extension,
|
||||
}
|
||||
|
||||
impl StatusLineRenderer {
|
||||
pub(crate) fn content(
|
||||
self,
|
||||
segments: impl IntoIterator<Item = (StatusLineItem, String)>,
|
||||
use_theme_colors: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
pull_request_url: Option<&str>,
|
||||
) -> Option<FooterContent> {
|
||||
if self == Self::Native {
|
||||
return status_line_from_segments(
|
||||
segments,
|
||||
use_theme_colors,
|
||||
thread_id,
|
||||
pull_request_url,
|
||||
)
|
||||
.map(FooterContent::HyperlinkLine);
|
||||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
for (item, text) in segments {
|
||||
if !children.is_empty() {
|
||||
children.push(Some(UiNode::text(/*key*/ None, STATUS_LINE_SEPARATOR)));
|
||||
}
|
||||
let key = Some(Key::String(item.to_string()));
|
||||
let node = if item == StatusLineItem::PullRequestNumber
|
||||
&& let Some(href) = pull_request_url.and_then(|url| LinkHref::try_from(url).ok())
|
||||
{
|
||||
UiNode::Link {
|
||||
key,
|
||||
props: LinkProps { href, title: None },
|
||||
children: vec![Some(UiNode::text(/*key*/ None, text))],
|
||||
}
|
||||
} else {
|
||||
UiNode::text(key, text)
|
||||
};
|
||||
children.push(Some(node));
|
||||
}
|
||||
let tree = UiNode::Row {
|
||||
key: Some(Key::String("status-line".to_string())),
|
||||
props: StackProps::default(),
|
||||
children,
|
||||
};
|
||||
Some(FooterContent::Extension { content: tree })
|
||||
}
|
||||
}
|
||||
@@ -306,14 +306,19 @@ impl StatusLineSetupView {
|
||||
list_keymap: ListKeymap,
|
||||
) -> Self {
|
||||
let mut used_ids = HashSet::new();
|
||||
let mut items = vec![MultiSelectItem {
|
||||
id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(),
|
||||
name: "Use theme colors".to_string(),
|
||||
description: Some("Apply colors from the active /theme".to_string()),
|
||||
enabled: use_theme_colors,
|
||||
orderable: false,
|
||||
section_break_after: true,
|
||||
}];
|
||||
let show_theme_colors =
|
||||
preview_data.renderer == super::status_line_content::StatusLineRenderer::Native;
|
||||
let mut items = Vec::new();
|
||||
if show_theme_colors {
|
||||
items.push(MultiSelectItem {
|
||||
id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(),
|
||||
name: "Use theme colors".to_string(),
|
||||
description: Some("Apply colors from the active /theme".to_string()),
|
||||
enabled: use_theme_colors,
|
||||
orderable: false,
|
||||
section_break_after: true,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(selected_items) = status_line_items.as_ref() {
|
||||
for id in *selected_items {
|
||||
@@ -347,18 +352,25 @@ impl StatusLineSetupView {
|
||||
Self {
|
||||
picker: MultiSelectPicker::builder(
|
||||
"Configure Status Line".to_string(),
|
||||
Some("Select which items to display in the status line.".to_string()),
|
||||
Some(
|
||||
if show_theme_colors {
|
||||
"Select which items to display in the status line."
|
||||
} else {
|
||||
"Select items to display. The extension renderer uses plain text."
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
app_event_tx,
|
||||
)
|
||||
.list_keymap(list_keymap)
|
||||
.items(items)
|
||||
.enable_ordering()
|
||||
.on_preview(move |items| {
|
||||
.on_preview_content(move |items| {
|
||||
let use_theme_colors = items
|
||||
.iter()
|
||||
.find(|item| item.id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID)
|
||||
.map(|item| item.enabled)
|
||||
.unwrap_or(true);
|
||||
.unwrap_or(use_theme_colors);
|
||||
preview_data.status_line_for_items(
|
||||
items
|
||||
.iter()
|
||||
@@ -367,10 +379,13 @@ impl StatusLineSetupView {
|
||||
use_theme_colors,
|
||||
)
|
||||
})
|
||||
.on_confirm(|ids, app_event| {
|
||||
let use_theme_colors = ids
|
||||
.iter()
|
||||
.any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID);
|
||||
.on_confirm(move |ids, app_event| {
|
||||
let use_theme_colors = if show_theme_colors {
|
||||
ids.iter()
|
||||
.any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID)
|
||||
} else {
|
||||
use_theme_colors
|
||||
};
|
||||
let items = ids
|
||||
.iter()
|
||||
.filter_map(|id| id.parse::<StatusLineItem>().ok())
|
||||
@@ -451,11 +466,11 @@ impl Renderable for StatusLineSetupView {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::footer_content::FooterContent;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
@@ -699,6 +714,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_picker_previews_the_pr_link_and_preserves_the_color_preference() {
|
||||
let url = "https://example.com/pr/42";
|
||||
for use_theme_colors in [false, true] {
|
||||
let (tx, mut rx) = unbounded_channel();
|
||||
let mut preview = StatusSurfacePreviewData::from_iter([(
|
||||
StatusLineItem::PullRequestNumber.preview_item(),
|
||||
"PR #42",
|
||||
)]);
|
||||
preview.renderer =
|
||||
crate::bottom_pane::status_line_content::StatusLineRenderer::Extension;
|
||||
preview.pull_request_url = Some(url.to_owned());
|
||||
let mut view = StatusLineSetupView::new(
|
||||
Some(&[StatusLineItem::PullRequestNumber.to_string()]),
|
||||
use_theme_colors,
|
||||
preview,
|
||||
AppEventSender::new(tx),
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let output = render_lines(&view, /*width*/ 80);
|
||||
assert!(output.contains(&crate::terminal_hyperlinks::osc8_hyperlink(url, "P")));
|
||||
assert!(!output.contains("Use theme colors"));
|
||||
view.handle_key_event(crossterm::event::KeyCode::Enter.into());
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::StatusLineSetup { use_theme_colors: saved, .. }) if saved == use_theme_colors
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_view_snapshot_uses_runtime_preview_values() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -815,12 +860,7 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn line_text(line: Option<Line<'static>>) -> Option<String> {
|
||||
line.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
fn line_text(content: Option<FooterContent>) -> Option<String> {
|
||||
content.map(|content| content.into_hyperlink_line().line.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::footer_content::FooterContent;
|
||||
use codex_protocol::ThreadId;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::status_line_from_segments;
|
||||
use super::status_line_content::StatusLineRenderer;
|
||||
use super::status_line_setup::StatusLineItem;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||
@@ -137,6 +137,8 @@ struct PreviewValue {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StatusSurfacePreviewData {
|
||||
pub(crate) thread_id: Option<ThreadId>,
|
||||
pub(crate) renderer: StatusLineRenderer,
|
||||
pub(crate) pull_request_url: Option<String>,
|
||||
values: BTreeMap<StatusSurfacePreviewItem, PreviewValue>,
|
||||
}
|
||||
|
||||
@@ -144,6 +146,8 @@ impl Default for StatusSurfacePreviewData {
|
||||
fn default() -> Self {
|
||||
let mut data = Self {
|
||||
thread_id: None,
|
||||
renderer: StatusLineRenderer::Native,
|
||||
pull_request_url: None,
|
||||
values: BTreeMap::new(),
|
||||
};
|
||||
for item in StatusSurfacePreviewItem::iter() {
|
||||
@@ -246,21 +250,25 @@ impl StatusSurfacePreviewData {
|
||||
&self,
|
||||
items: I,
|
||||
use_theme_colors: bool,
|
||||
) -> Option<Line<'static>>
|
||||
) -> Option<FooterContent>
|
||||
where
|
||||
I: IntoIterator<Item = StatusLineItem>,
|
||||
{
|
||||
let segments = items.into_iter().filter_map(|item| {
|
||||
self.value_for(item.preview_item())
|
||||
.map(|value| (item, value.to_string()))
|
||||
});
|
||||
status_line_from_segments(
|
||||
let mut segments = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
self.value_for(item.preview_item())
|
||||
.map(|value| (item, value.to_string()))
|
||||
})
|
||||
.peekable();
|
||||
segments.peek()?;
|
||||
self.renderer.content(
|
||||
segments,
|
||||
use_theme_colors,
|
||||
self.thread_id,
|
||||
/*pull_request_url*/ None,
|
||||
self.live_value_for(StatusSurfacePreviewItem::PullRequestNumber)
|
||||
.and(self.pull_request_url.as_deref()),
|
||||
)
|
||||
.map(|line| line.line)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2073,7 +2073,16 @@ impl ChatWidget {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_line_text(&self) -> Option<String> {
|
||||
self.bottom_pane.status_line_text()
|
||||
self.status_line_content()
|
||||
.cloned()
|
||||
.map(|content| content.into_hyperlink_line().line.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_line_content(
|
||||
&self,
|
||||
) -> Option<&crate::bottom_pane::footer_content::FooterContent> {
|
||||
self.bottom_pane.status_line_content()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_token_usage(&mut self) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! history-facing `/status` surface.
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::footer_content::FooterContent;
|
||||
use crate::status::thread_cost_micros;
|
||||
|
||||
impl ChatWidget {
|
||||
@@ -83,9 +84,8 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
/// Sets the currently rendered footer status-line value.
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<HyperlinkLine>) {
|
||||
self.bottom_pane
|
||||
.set_status_line(status_line.map(Into::into));
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<FooterContent>) {
|
||||
self.bottom_pane.set_status_line(status_line);
|
||||
}
|
||||
|
||||
/// Forwards the contextual active-agent label into the bottom-pane footer pipeline.
|
||||
@@ -368,6 +368,8 @@ impl ChatWidget {
|
||||
}),
|
||||
);
|
||||
preview_data.thread_id = self.thread_id;
|
||||
preview_data.renderer = self.status_line_renderer();
|
||||
preview_data.pull_request_url = self.status_line_pull_request_url().map(str::to_owned);
|
||||
|
||||
if self.rate_limit_snapshots_by_limit_id.contains_key("codex") {
|
||||
for item in [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::status_line_from_segments;
|
||||
use crate::bottom_pane::status_line_content::StatusLineRenderer;
|
||||
use crate::branch_summary;
|
||||
use crate::chatwidget::limit_label_for_window;
|
||||
use crate::chatwidget::rate_limits::get_limits_duration;
|
||||
@@ -216,12 +216,25 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
self.set_status_line(status_line_from_segments(
|
||||
let content = self.status_line_renderer().content(
|
||||
segments,
|
||||
self.local_settings.tui.status_line_use_colors,
|
||||
self.thread_id,
|
||||
self.status_line_pull_request_url(),
|
||||
));
|
||||
);
|
||||
self.set_status_line(content);
|
||||
}
|
||||
|
||||
pub(super) fn status_line_renderer(&self) -> StatusLineRenderer {
|
||||
if self
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::TuiExtensionsStatusLine)
|
||||
{
|
||||
StatusLineRenderer::Extension
|
||||
} else {
|
||||
StatusLineRenderer::Native
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the terminal title Codex most recently wrote, if any.
|
||||
@@ -846,7 +859,7 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_line_pull_request_url(&self) -> Option<&str> {
|
||||
pub(super) fn status_line_pull_request_url(&self) -> Option<&str> {
|
||||
self.status_line_git_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.pull_request.as_ref())
|
||||
|
||||
@@ -28,7 +28,11 @@ async fn thread_color_preview_matches_footer() {
|
||||
let mut snapshot = Vec::new();
|
||||
let preview = chat.status_surface_preview_data();
|
||||
for use_colors in [true, false] {
|
||||
let line = preview.status_line_for_items(items, use_colors).unwrap();
|
||||
let line = preview
|
||||
.status_line_for_items(items, use_colors)
|
||||
.unwrap()
|
||||
.into_hyperlink_line()
|
||||
.line;
|
||||
let footer = crate::bottom_pane::status_line_from_segments(
|
||||
items
|
||||
.into_iter()
|
||||
@@ -50,7 +54,7 @@ fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -
|
||||
let preview_data = chat.status_surface_preview_data();
|
||||
preview_data
|
||||
.status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true)
|
||||
.map(line_text)
|
||||
.map(|content| line_text(content.into_hyperlink_line().line))
|
||||
}
|
||||
|
||||
fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String {
|
||||
|
||||
@@ -248,6 +248,7 @@ mod transcript_view;
|
||||
mod tui;
|
||||
mod turn_tip;
|
||||
mod ui_consts;
|
||||
mod ui_tree;
|
||||
mod unarchive_prompt;
|
||||
pub(crate) mod update_action;
|
||||
mod worktree_startup;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/ui_tree_tests.rs
|
||||
assertion_line: 42
|
||||
expression: "format!(\"{buffer:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 16, height: 1 },
|
||||
content: [
|
||||
"界 ]8;;https://example.com/pr/42P]8;;]8;;https://example.com/pr/42R]8;;]8;;https://example.com/pr/42 ]8;;]8;;https://example.com/pr/42#]8;;]8;;https://example.com/pr/424]8;;]8;;https://example.com/pr/422]8;; ", // hidden by multi-width symbols: [(1, " ")]
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 3, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED,
|
||||
x: 9, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Bounded, display-only subset of the `codex-tui/1` tree contract.
|
||||
//!
|
||||
//! The wire tree is independent of its producer. Producers normalize text into leaf nodes and
|
||||
//! arrays into composite nodes; unsupported primitives and properties fail deserialization. This
|
||||
//! first slice renders one horizontal line of plain text, links, rows, spacers, dividers, and
|
||||
//! numeric gaps. Links underline their text. Keys are preserved for future reconciliation.
|
||||
|
||||
mod render;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ui_tree_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A single display node, or no content.
|
||||
pub(crate) type UiTree = Option<UiNode>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub(crate) enum UiNode {
|
||||
Text {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<Key>,
|
||||
props: TextProps,
|
||||
},
|
||||
Link {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<Key>,
|
||||
props: LinkProps,
|
||||
children: Vec<UiTree>,
|
||||
},
|
||||
Row {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<Key>,
|
||||
props: StackProps,
|
||||
children: Vec<UiTree>,
|
||||
},
|
||||
Spacer {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<Key>,
|
||||
},
|
||||
Divider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<Key>,
|
||||
},
|
||||
}
|
||||
|
||||
impl UiNode {
|
||||
pub(crate) fn text(key: Option<Key>, text: impl Into<String>) -> Self {
|
||||
Self::Text {
|
||||
key,
|
||||
props: TextProps { text: text.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct LinkProps {
|
||||
pub(crate) href: LinkHref,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) title: Option<String>,
|
||||
}
|
||||
|
||||
/// An absolute HTTP(S) destination safe to embed in a terminal hyperlink.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
pub(crate) struct LinkHref(String);
|
||||
|
||||
impl TryFrom<String> for LinkHref {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for LinkHref {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
crate::terminal_hyperlinks::web_destination(value)
|
||||
.filter(|destination| destination == value)
|
||||
.map(Self)
|
||||
.ok_or("link href must be an absolute HTTP(S) URL without control characters")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum Key {
|
||||
String(String),
|
||||
Number(serde_json::Number),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TextProps {
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct StackProps {
|
||||
/// Numeric spacing uses the profile's four-point scale.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) gap: Option<f64>,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Render the visible prefix of a horizontal display tree into one terminal line.
|
||||
|
||||
use super::UiNode;
|
||||
use super::UiTree;
|
||||
use crate::history_cell::sanitize_user_text;
|
||||
use crate::line_truncation::truncate_line_to_width;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
const MAX_DEPTH: usize = 16;
|
||||
const MAX_NODES: usize = 256;
|
||||
const MAX_TEXT_BYTES: usize = 16 * 1024;
|
||||
|
||||
struct Budget {
|
||||
nodes: usize,
|
||||
text_bytes: usize,
|
||||
}
|
||||
|
||||
impl UiNode {
|
||||
/// Render one row. On overflow, fit an unlinked ellipsis inside the supplied width.
|
||||
pub(crate) fn render_line(&self, width: u16) -> HyperlinkLine {
|
||||
let width = usize::from(width);
|
||||
if width == 0 {
|
||||
return HyperlinkLine::default();
|
||||
}
|
||||
let mut budget = Budget {
|
||||
nodes: MAX_NODES,
|
||||
text_bytes: MAX_TEXT_BYTES,
|
||||
};
|
||||
let mut line = node_line(Some(self), /*depth*/ 0, &mut budget).unwrap_or_default();
|
||||
if line.width() > width {
|
||||
line.line = truncate_line_to_width(line.line, width - 1);
|
||||
line.clip_hyperlinks_to_width(line.width());
|
||||
line.line.push_span("…");
|
||||
}
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
fn node_line(node: Option<&UiNode>, depth: usize, budget: &mut Budget) -> Option<HyperlinkLine> {
|
||||
if budget.nodes == 0 {
|
||||
return None;
|
||||
}
|
||||
budget.nodes -= 1;
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
match node? {
|
||||
UiNode::Text { props, .. } => {
|
||||
let raw = &props.text;
|
||||
if budget.text_bytes == 0 && !raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = raw.floor_char_boundary(raw.len().min(budget.text_bytes));
|
||||
let first = raw[..end].split('\n').next().unwrap_or_default();
|
||||
budget.text_bytes -= first.len();
|
||||
let visible = sanitize_user_text(first.into()).replace('\t', " ");
|
||||
Some(visible.into())
|
||||
}
|
||||
UiNode::Link {
|
||||
props, children, ..
|
||||
} => {
|
||||
let mut line = row_line(children, /*gap*/ 0, depth, budget)?;
|
||||
for span in &mut line.line.spans {
|
||||
span.style = span.style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
let width = line.width();
|
||||
if width > 0 {
|
||||
line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())];
|
||||
}
|
||||
Some(line)
|
||||
}
|
||||
UiNode::Row {
|
||||
props, children, ..
|
||||
} => {
|
||||
// A spacing unit is four points; a terminal cell is eight points across.
|
||||
let gap = (props.gap.unwrap_or(0.0).clamp(0.0, 32.0) / 2.0).ceil() as usize;
|
||||
row_line(children, gap, depth, budget)
|
||||
}
|
||||
UiNode::Spacer { .. } => Some(" ".into()),
|
||||
UiNode::Divider { .. } => Some("│".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_line(
|
||||
children: &[UiTree],
|
||||
gap: usize,
|
||||
depth: usize,
|
||||
budget: &mut Budget,
|
||||
) -> Option<HyperlinkLine> {
|
||||
let mut result: Option<HyperlinkLine> = None;
|
||||
let mut used = 0;
|
||||
for child in children {
|
||||
if budget.nodes == 0 {
|
||||
break;
|
||||
}
|
||||
let Some(child) = node_line(child.as_ref(), depth + 1, budget) else {
|
||||
continue;
|
||||
};
|
||||
let spacing = if result.is_some() { gap } else { 0 };
|
||||
let line = result.get_or_insert_default();
|
||||
if spacing > 0 {
|
||||
line.line.push_span(" ".repeat(spacing));
|
||||
}
|
||||
let offset = used + spacing;
|
||||
used = offset + child.width();
|
||||
line.line.spans.extend(child.line.spans);
|
||||
line.hyperlinks
|
||||
.extend(child.hyperlinks.into_iter().map(|mut link| {
|
||||
link.columns = link.columns.start + offset..link.columns.end + offset;
|
||||
link
|
||||
}));
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn tree(value: serde_json::Value) -> UiNode {
|
||||
serde_json::from_value::<UiTree>(value).unwrap().unwrap()
|
||||
}
|
||||
|
||||
fn text(key: &str, value: &str) -> serde_json::Value {
|
||||
json!({"type":"text","key":key,"props":{"text":value}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_tree_round_trips_and_enforces_leaf_shape() {
|
||||
let value = json!({"type":"row","key":7,"props":{"gap":2.0},"children":[{"type":"link","props":{"href":"https://example.com/pr/42"},"children":[text("usage", "12%")]}, null, {"type":"spacer"}]});
|
||||
assert_eq!(serde_json::to_value(tree(value.clone())).unwrap(), value);
|
||||
assert!(
|
||||
serde_json::from_value::<UiTree>(
|
||||
json!({"type":"text","props":{"text":"hello"},"children":[]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<UiTree>(json!(null)).unwrap(), None);
|
||||
for href in [
|
||||
"relative/path",
|
||||
"file:///tmp/example",
|
||||
"https://example.com/\u{001b}",
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<UiTree>(
|
||||
json!({"type":"link","props":{"href":href},"children":[]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_only_annotate_their_own_cells() {
|
||||
let tree = tree(
|
||||
json!({"type":"row","props":{"gap":2},"children":[text("prefix", "界"),{"type":"link","props":{"href":"https://example.com/pr/42"},"children":[{"type":"row","props":{},"children":[text("pr", "PR #42")]}]}]}),
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 16, /*height*/ 1,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
tree.render_line(area.width)
|
||||
.render_fitted(area, &mut buffer);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "界");
|
||||
assert!(
|
||||
buffer[(3, 0)]
|
||||
.symbol()
|
||||
.contains("https://example.com/pr/42")
|
||||
);
|
||||
insta::assert_snapshot!(format!("{buffer:?}"));
|
||||
|
||||
assert_eq!(tree.render_line(/*width*/ 1).line.to_string(), "…");
|
||||
let narrow = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 6, /*height*/ 2,
|
||||
);
|
||||
let mut buffer = Buffer::empty(narrow);
|
||||
tree.render_line(narrow.width)
|
||||
.render_fitted(narrow, &mut buffer);
|
||||
assert!(
|
||||
buffer[(4, 0)]
|
||||
.symbol()
|
||||
.contains("https://example.com/pr/42")
|
||||
);
|
||||
assert_eq!(buffer[(5, 0)].symbol(), "…");
|
||||
assert!((0..narrow.width).all(|x| buffer[(x, 1)].symbol() == " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_status_line_keeps_exactly_fitting_halfwidth_voiced_katakana() {
|
||||
let status = tree(text("name", "ガ"));
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 2, /*height*/ 1,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
status
|
||||
.render_line(area.width)
|
||||
.render_fitted(area, &mut buffer);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "ガ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipsis_counts_toward_width_only_when_there_is_overflow() {
|
||||
let status =
|
||||
tree(json!({"type":"row","props":{"gap":2},"children":[text("label","abcde"), null]}));
|
||||
for (width, expected) in [(0, ""), (1, "…"), (4, "abc…"), (5, "abcde")] {
|
||||
let line = status.render_line(width);
|
||||
assert_eq!(line.line.to_string(), expected);
|
||||
assert!(line.width() <= usize::from(width));
|
||||
}
|
||||
let wide = tree(text("label", "a界界"));
|
||||
assert_eq!(wide.render_line(/*width*/ 4).line.to_string(), "a界…");
|
||||
let long = "x".repeat(513);
|
||||
assert_eq!(
|
||||
tree(text("label", &long))
|
||||
.render_line(/*width*/ 513)
|
||||
.line
|
||||
.to_string(),
|
||||
long
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_skips_hidden_text_before_rendering_its_sibling() {
|
||||
let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(16 * 1024));
|
||||
let tree = tree(json!({"type":"row","props":{},"children":[
|
||||
text("first", &multiline), text("sibling", "!")
|
||||
]}));
|
||||
assert_eq!(tree.render_line(/*width*/ 4).line.to_string(), "ok!");
|
||||
}
|
||||
Reference in New Issue
Block a user