diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfc8ad5..d6bb1d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## [Unreleased]
+
+### Added
+
+- Added the Unity 6+ `ucp ui` family for agent-driven UI Toolkit work: target discovery, importer- and clone-backed UXML/USS/TSS linting, bounded resolved-tree and binding inspection including physical control children and realized ListView rows, DPI-correct Editor-panel PNG capture, and an end-to-end multi-state `ui check` workflow.
+- Added strict `.ucp-ui.json` scenarios with JSON data overlays, allowlisted fixture mutations, eager `repeat` templates, and harness-owned virtualized `ListView` binding for reproducible dynamic layouts.
+- Added conservative UI audits with complete diagnostic totals, error-prioritized bounded details, and exemptions for Unity internal focus targets; lint inspects immutable package artifacts without forced reimport.
+- Added UI operation interruption reporting, status recovery on CLI timeouts, and a render timeout default that accommodates the bridge's overall duration limit.
+- Added focused Rust, bridge, fixture, lint, inspector, and audit coverage plus agent-facing UI Toolkit workflow documentation.
+
## [0.6.2] - 2026-08-31
### Added
@@ -576,4 +586,3 @@
- JSON-RPC 2.0 protocol
- Lock file discovery mechanism
- Per-session token authentication
-
diff --git a/README.md b/README.md
index 8501041..84361e0 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,10 @@ A few things that are awkward or impossible without it:
- **Profile programmatically.** Run a session, then read the hierarchy sorted by self time, with
`--fields` to keep the payload small and truncation reported as "showing 50 of 4,312".
+### UI Toolkit authoring loops
+
+Lint UXML and USS with Unity's own importers, populate repeaters and virtualized lists from strict JSON scenarios, inspect resolved layout and bindings, and capture deterministic panel screenshots. `ucp ui check` runs the complete lint-to-visual-verification pass without loading or dirtying a scene.
+
## Install
diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs
index 3618c8c..4a6e125 100644
--- a/cli/src/commands/mod.rs
+++ b/cli/src/commands/mod.rs
@@ -28,6 +28,7 @@ pub mod snapshot;
pub mod spatial;
pub mod tests;
pub mod transform;
+pub mod ui;
pub mod vcs;
pub mod view;
@@ -389,6 +390,11 @@ pub enum Command {
#[command(subcommand)]
action: view::ViewAction,
},
+ /// Inspect, lint, and render UI Toolkit UXML and USS
+ Ui {
+ #[command(subcommand)]
+ action: ui::UiAction,
+ },
}
/// Run editor-side automation scripts (Playwright-like). A script is an editor class implementing
@@ -971,5 +977,6 @@ pub async fn run(cmd: Command, ctx: Context) -> anyhow::Result<()> {
Command::Transform { action } => transform::run(action, &ctx).await,
Command::Spatial { action } => spatial::run(action, &ctx).await,
Command::View { action } => view::run(action, &ctx).await,
+ Command::Ui { action } => ui::run(action, &ctx).await,
}
}
diff --git a/cli/src/commands/ui.rs b/cli/src/commands/ui.rs
new file mode 100644
index 0000000..20bc4f7
--- /dev/null
+++ b/cli/src/commands/ui.rs
@@ -0,0 +1,1281 @@
+use crate::client::BridgeClient;
+use crate::output;
+use clap::{Args, Subcommand, ValueEnum};
+use serde_json::{Map, Value};
+use std::collections::HashSet;
+use std::fmt;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use super::{Context, UnityLifecyclePolicy};
+
+const MAX_VIEWPORT_DIMENSION: u32 = 8192;
+const MAX_VIEWPORT_PIXELS: u64 = 8_388_608;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum UiDetailArg {
+ Summary,
+ Normal,
+ Verbose,
+}
+
+impl UiDetailArg {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Summary => "summary",
+ Self::Normal => "normal",
+ Self::Verbose => "verbose",
+ }
+ }
+}
+
+/// A UXML document or UI scenario plus the state used to render it.
+#[derive(Args, Debug, Clone)]
+pub struct UiTargetArgs {
+ /// UXML asset or .ucp-ui.json scenario to load
+ pub target: String,
+ /// Named scenario state to render
+ #[arg(long)]
+ pub state: Option,
+ /// Inline JSON object merged into the fixture data
+ #[arg(long, value_name = "JSON", conflicts_with = "data_file")]
+ pub data_json: Option,
+ /// JSON file whose object is merged into the fixture data
+ #[arg(long, value_name = "PATH", conflicts_with = "data_json")]
+ pub data_file: Option,
+ /// Logical viewport width in pixels
+ #[arg(long, requires = "height")]
+ pub width: Option,
+ /// Logical viewport height in pixels
+ #[arg(long, requires = "width")]
+ pub height: Option,
+}
+
+/// Inspect, lint, and render UI Toolkit UXML and USS in an isolated Editor harness.
+#[derive(Subcommand)]
+pub enum UiAction {
+ /// Discover UXML documents and .ucp-ui.json scenarios
+ List {
+ /// Asset folder to scan
+ #[arg(long, default_value = "Assets")]
+ root: String,
+ /// Include UI targets from installed packages
+ #[arg(long)]
+ include_packages: bool,
+ /// Maximum targets to return
+ #[arg(long, default_value_t = 100)]
+ limit: usize,
+ },
+ /// Import and validate UXML, USS, and TSS assets
+ Lint {
+ /// UXML, USS, TSS, scenario, or folder paths to lint
+ #[arg(required = true, num_args = 1..)]
+ paths: Vec,
+ /// Treat warnings as a failed lint result
+ #[arg(long)]
+ fail_on_warnings: bool,
+ /// Maximum diagnostics to return
+ #[arg(long, default_value_t = 100)]
+ max_diagnostics: usize,
+ },
+ /// Instantiate a UI and return its bounded visual tree
+ Inspect {
+ #[command(flatten)]
+ target: UiTargetArgs,
+ /// Restrict the snapshot to elements matching a UI Toolkit query
+ #[arg(long)]
+ query: Option,
+ /// Maximum visual-tree depth
+ #[arg(long, default_value_t = 6)]
+ depth: usize,
+ /// Maximum elements to return
+ #[arg(long, default_value_t = 200)]
+ max_elements: usize,
+ /// Snapshot detail level
+ #[arg(long, value_enum, default_value_t = UiDetailArg::Normal)]
+ detail: UiDetailArg,
+ },
+ /// Render a UI to a deterministic PNG
+ Screenshot {
+ #[command(flatten)]
+ target: UiTargetArgs,
+ /// Copy the PNG artifact to this path
+ #[arg(short = 'o', long, value_name = "PATH")]
+ out: Option,
+ /// Replace an existing output file
+ #[arg(long, requires = "out")]
+ force: bool,
+ },
+ /// Lint, instantiate, inspect, audit, and capture a UI
+ Check {
+ #[command(flatten)]
+ target: UiTargetArgs,
+ /// Check every state declared by a scenario
+ #[arg(long, conflicts_with = "state")]
+ all_states: bool,
+ /// Restrict snapshots to elements matching a UI Toolkit query
+ #[arg(long)]
+ query: Option,
+ /// Maximum visual-tree depth
+ #[arg(long, default_value_t = 4)]
+ depth: usize,
+ /// Maximum elements per snapshot
+ #[arg(long, default_value_t = 100)]
+ max_elements: usize,
+ /// Snapshot detail level
+ #[arg(long, value_enum, default_value_t = UiDetailArg::Normal)]
+ detail: UiDetailArg,
+ /// Copy capture artifacts into this directory
+ #[arg(long, value_name = "DIR")]
+ out_dir: Option,
+ /// Treat warnings as a failed check result
+ #[arg(long)]
+ fail_on_warnings: bool,
+ /// Replace existing files in --out-dir
+ #[arg(long, requires = "out_dir")]
+ force: bool,
+ },
+}
+
+#[derive(Debug)]
+pub struct UiCommandFailure {
+ pub message: String,
+ pub result: Value,
+}
+
+impl fmt::Display for UiCommandFailure {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.message)
+ }
+}
+
+impl std::error::Error for UiCommandFailure {}
+
+struct UiOperationOutcome {
+ result: Value,
+ failure: Option,
+}
+
+pub async fn run(action: UiAction, ctx: &Context) -> anyhow::Result<()> {
+ match action {
+ UiAction::List {
+ root,
+ include_packages,
+ limit,
+ } => run_list(root, include_packages, limit, ctx).await,
+ UiAction::Lint {
+ paths,
+ fail_on_warnings,
+ max_diagnostics,
+ } => run_lint(paths, fail_on_warnings, max_diagnostics, ctx).await,
+ UiAction::Inspect {
+ target,
+ query,
+ depth,
+ max_elements,
+ detail,
+ } => {
+ validate_range("--depth", depth, 0, 64)?;
+ validate_range("--max-elements", max_elements, 1, 5000)?;
+ let mut params = target_params(&target)?;
+ insert_optional(&mut params, "query", query);
+ params.insert("depth".into(), serde_json::json!(depth));
+ params.insert("max".into(), serde_json::json!(max_elements));
+ params.insert("detail".into(), serde_json::json!(detail.as_str()));
+ run_async("ui/inspect", "inspect", params.into(), ctx, |_, _| Ok(())).await
+ }
+ UiAction::Screenshot { target, out, force } => {
+ let params = target_params(&target)?;
+ run_async(
+ "ui/screenshot",
+ "screenshot",
+ params.into(),
+ ctx,
+ move |result, project| {
+ if let Some(destination) = out {
+ let source = first_artifact_path(result).ok_or_else(|| {
+ anyhow::anyhow!("UI screenshot completed without an artifact path")
+ })?;
+ let copied =
+ copy_artifact(project, Path::new(source), &destination, force)?;
+ insert_result_field(
+ result,
+ "outputPath",
+ Value::String(display_path(&copied)),
+ );
+ }
+ Ok(())
+ },
+ )
+ .await
+ }
+ UiAction::Check {
+ target,
+ all_states,
+ query,
+ depth,
+ max_elements,
+ detail,
+ out_dir,
+ fail_on_warnings,
+ force,
+ } => {
+ validate_range("--depth", depth, 0, 64)?;
+ validate_range("--max-elements", max_elements, 1, 5000)?;
+ let mut params = target_params(&target)?;
+ params.insert("allStates".into(), serde_json::json!(all_states));
+ insert_optional(&mut params, "query", query);
+ params.insert("depth".into(), serde_json::json!(depth));
+ params.insert("max".into(), serde_json::json!(max_elements));
+ params.insert("detail".into(), serde_json::json!(detail.as_str()));
+ params.insert("failOnWarnings".into(), serde_json::json!(fail_on_warnings));
+ run_async(
+ "ui/check",
+ "check",
+ params.into(),
+ ctx,
+ move |result, project| {
+ if let Some(directory) = out_dir {
+ let copied = copy_result_artifacts(project, result, &directory, force)?;
+ insert_result_field(
+ result,
+ "outputPaths",
+ serde_json::json!(
+ copied.iter().map(|p| display_path(p)).collect::>()
+ ),
+ );
+ }
+ Ok(())
+ },
+ )
+ .await
+ }
+ }
+}
+
+async fn run_list(
+ root: String,
+ include_packages: bool,
+ limit: usize,
+ ctx: &Context,
+) -> anyhow::Result<()> {
+ validate_range("--limit", limit, 1, 5000)?;
+ let (_, _, mut client) = super::connect_client(ctx).await?;
+ let result = client
+ .call(
+ "ui/list",
+ serde_json::json!({
+ "path": root,
+ "includePackages": include_packages,
+ "limit": limit,
+ }),
+ )
+ .await
+ .map_err(|err| super::map_bridge_method_error(err, "ui/list", "UI discovery"))?;
+ client.close().await;
+
+ if ctx.json {
+ output::print_json(&output::success_json(result));
+ } else {
+ print_list_result(&result);
+ }
+ Ok(())
+}
+
+async fn run_lint(
+ paths: Vec,
+ fail_on_warnings: bool,
+ max_diagnostics: usize,
+ ctx: &Context,
+) -> anyhow::Result<()> {
+ validate_range("--max-diagnostics", max_diagnostics, 1, 1000)?;
+ let (project, lock, mut client) = super::connect_client(ctx).await?;
+ let result = client
+ .call(
+ "ui/lint",
+ serde_json::json!({
+ "paths": paths,
+ "failOnWarnings": fail_on_warnings,
+ "maxDiagnostics": max_diagnostics,
+ }),
+ )
+ .await
+ .map_err(|err| super::map_bridge_method_error(err, "ui/lint", "UI linting"))?;
+ client.close().await;
+
+ super::await_unity_lifecycle(
+ &project,
+ Some(&lock),
+ UnityLifecyclePolicy::editor_settle(
+ "Waiting for Unity to finish UI asset work...",
+ "UI linting",
+ ),
+ ctx,
+ )
+ .await?;
+
+ let failure = failed_result_message(&result, "UI lint");
+ if ctx.json {
+ if let Some(message) = failure {
+ return Err(UiCommandFailure { message, result }.into());
+ }
+ output::print_json(&output::success_json(result));
+ } else {
+ print_lint_result(&result);
+ if let Some(message) = failure {
+ return Err(UiCommandFailure { message, result }.into());
+ }
+ }
+ Ok(())
+}
+
+async fn run_async(
+ method: &'static str,
+ operation: &'static str,
+ params: Value,
+ ctx: &Context,
+ post_process: F,
+) -> anyhow::Result<()>
+where
+ F: FnOnce(&mut Value, &Path) -> anyhow::Result<()>,
+{
+ let (project, _, mut client) = super::connect_client(ctx).await?;
+ if !ctx.json {
+ output::print_info(&format!("Starting UI {operation}..."));
+ }
+
+ let start = client
+ .call(method, params)
+ .await
+ .map_err(|err| super::map_bridge_method_error(err, method, "UI harness support"))?;
+ let operation_id = start
+ .get("operationId")
+ .cloned()
+ .ok_or_else(|| anyhow::anyhow!("{method} returned no operationId"))?;
+
+ if !ctx.json {
+ output::print_info(&format!(
+ "UI {operation} started; waiting for the result..."
+ ));
+ }
+
+ let outcome = wait_for_ui_result(&mut client, operation_id, operation, ctx.timeout).await?;
+ client.close().await;
+ let (result, operation_failure) = if outcome.failure.is_some() {
+ (outcome.result, outcome.failure)
+ } else {
+ (
+ apply_post_process(outcome.result, &project, post_process)
+ .map_err(anyhow::Error::new)?,
+ None,
+ )
+ };
+ let failure =
+ operation_failure.or_else(|| failed_result_message(&result, &format!("UI {operation}")));
+
+ if ctx.json {
+ if let Some(message) = failure {
+ return Err(UiCommandFailure { message, result }.into());
+ }
+ output::print_json(&output::success_json(result));
+ } else {
+ if let Some(message) = failure {
+ if operation == "check" && result.get("passed").and_then(Value::as_bool) == Some(false)
+ {
+ print_async_result(operation, &result);
+ }
+ return Err(UiCommandFailure { message, result }.into());
+ }
+ print_async_result(operation, &result);
+ }
+ Ok(())
+}
+
+fn apply_post_process(
+ mut result: Value,
+ project: &Path,
+ post_process: F,
+) -> Result
+where
+ F: FnOnce(&mut Value, &Path) -> anyhow::Result<()>,
+{
+ if let Err(error) = post_process(&mut result, project) {
+ return Err(UiCommandFailure {
+ message: format!("{error:#}"),
+ result,
+ });
+ }
+ Ok(result)
+}
+
+async fn wait_for_ui_result(
+ client: &mut BridgeClient,
+ operation_id: Value,
+ operation: &str,
+ timeout_secs: u64,
+) -> anyhow::Result {
+ let wait = async {
+ loop {
+ let notification = client.next_notification().await?;
+ if notification.method == "ui/result"
+ && notification.params.get("operationId") == Some(&operation_id)
+ {
+ return Some(notification.params);
+ }
+ }
+ };
+
+ let received = if timeout_secs == 0 {
+ Ok(wait.await)
+ } else {
+ tokio::time::timeout(Duration::from_secs(timeout_secs), wait).await
+ };
+ match received {
+ Ok(Some(envelope)) => Ok(ui_operation_outcome(envelope, operation)),
+ Ok(None) => Ok(UiOperationOutcome {
+ result: serde_json::json!({
+ "operationId": operation_id,
+ "operation": operation,
+ "status": "unknown",
+ "error": { "code": "connection_closed" }
+ }),
+ failure: Some(format!(
+ "Connection closed before UI {operation} ({operation_id}) completed; \
+ the Editor may have reloaded or quit. Completion could not be confirmed."
+ )),
+ }),
+ Err(_) => {
+ // A notification can be lost while a request is being read. Recover
+ // terminal results, or report the bridge's last known state.
+ let status = client
+ .call_with_timeout(
+ "ui/status",
+ serde_json::json!({ "operationId": operation_id }),
+ Some(Duration::from_secs(5)),
+ )
+ .await;
+ match status {
+ Ok(envelope)
+ if matches!(
+ envelope.get("status").and_then(Value::as_str),
+ Some("completed" | "failed")
+ ) =>
+ {
+ Ok(ui_operation_outcome(envelope, operation))
+ }
+ Ok(envelope) => {
+ let state = envelope
+ .get("status")
+ .and_then(Value::as_str)
+ .unwrap_or("not found");
+ let message = format!(
+ "Timed out after {timeout_secs}s waiting for UI {operation} ({operation_id}); \
+ bridge status: {state}. The operation was not cancelled and may still be running."
+ );
+ Ok(UiOperationOutcome {
+ result: envelope,
+ failure: Some(message),
+ })
+ }
+ Err(error) => Ok(UiOperationOutcome {
+ result: serde_json::json!({
+ "operationId": operation_id,
+ "operation": operation,
+ "status": "unknown",
+ "error": { "code": "status_unavailable", "message": error.to_string() }
+ }),
+ failure: Some(format!(
+ "Timed out after {timeout_secs}s waiting for UI {operation} ({operation_id}); \
+ status lookup failed: {error}. The operation was not cancelled."
+ )),
+ }),
+ }
+ }
+ }
+}
+
+fn ui_operation_outcome(envelope: Value, operation: &str) -> UiOperationOutcome {
+ if envelope.get("status").and_then(Value::as_str) == Some("completed") {
+ UiOperationOutcome {
+ result: envelope.get("result").cloned().unwrap_or(Value::Null),
+ failure: None,
+ }
+ } else {
+ let message =
+ operation_error_message(&envelope).unwrap_or_else(|| format!("UI {operation} failed"));
+ UiOperationOutcome {
+ result: envelope,
+ failure: Some(message),
+ }
+ }
+}
+
+fn target_params(args: &UiTargetArgs) -> anyhow::Result