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:
Joe Savona
2026-10-29 03:07:15 +00:00
committed by copyberry
parent 6c776cfc8c
commit 797bab4746
22 changed files with 670 additions and 86 deletions
+6
View File
@@ -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"
},
+8
View File
@@ -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",
+32 -10
View File
@@ -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,
+6 -5
View File
@@ -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),
}
}
}
+4 -2
View File
@@ -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)
}
}
+10 -1
View File
@@ -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 [
+17 -4
View File
@@ -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 {
+1
View File
@@ -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;
@@ -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,
]
}
+110
View File
@@ -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>,
}
+116
View File
@@ -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
}
+118
View File
@@ -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!");
}