Files
codex/codex-rs/tui/src/ui_tree.rs
T
Joe Savona 47172f064f Validate UI trees at decode time and simplify display elements (#59518)
## Why

UI tree limits previously applied during rendering, silently dropping content
beyond the budgets. Validate the whole JSON tree before rendering so oversized
inputs are rejected, including content outside the visible line.

## What changed

- Add a `UiTree` string parser that limits JSON to 64 KiB, nesting depth to 16,
  non-null nodes to 256, and total string content to 16 KiB of UTF-8.
- Represent text as string leaves or `text` elements with children. Remove node
  keys, `spacer`, and `divider` elements.
- Require an explicit `row` gap as a non-negative JavaScript-safe integer measured
  in terminal columns. Bound rendered spacing by the viewport and skip empty
  children when inserting gaps.
- Update footer content and built-in status lines to use the revised tree format.

## Testing

Add coverage for malformed elements and gaps, whole-tree size boundaries, URL
length limits, large-gap rendering, and bounded UTF-8 prefixes for directly
constructed text. Update hyperlink and footer clipping tests for the new format.

GitOrigin-RevId: 3c00881d3e7f2d79d11aa188f64f0725cd47ee68
2026-11-04 23:31:55 +00:00

83 lines
2.3 KiB
Rust

//! Display elements for the terminal UI and their JSON representation.
mod decode;
mod render;
#[cfg(test)]
#[path = "ui_tree_tests.rs"]
mod tests;
use serde::Deserialize;
use serde::Serialize;
const MAX_GAP: u64 = (1 << 53) - 1;
const MAX_TEXT_BYTES: usize = 16 * 1024;
/// A nullable root with bounded depth, node count, and total UTF-8 leaf bytes.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub(crate) struct UiTree(pub(crate) Option<UiNode>);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) enum UiNode {
Text {
#[serde(default, skip_serializing)]
props: TextProps,
children: Vec<Option<UiNode>>,
},
Link {
props: LinkProps,
children: Vec<Option<UiNode>>,
},
Row {
props: RowProps,
children: Vec<Option<UiNode>>,
},
#[serde(untagged)]
String(String),
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TextProps {}
#[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) URL without control characters, at most 8 KiB in UTF-8.
#[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 of at most 8 KiB without control characters")
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RowProps {
/// Horizontal spacing in terminal columns, expressed as a non-negative JavaScript-safe integer.
#[serde(deserialize_with = "decode::gap")]
pub(crate) gap: u64,
}