Move TUI on top of app server (parallel code) (#14717)

This PR replicates the `tui` code directory and creates a temporary
parallel `tui_app_server` directory. It also implements a new feature
flag `tui_app_server` to select between the two tui implementations.

Once the new app-server-based TUI is stabilized, we'll delete the old
`tui` directory and feature flag.
This commit is contained in:
Eric Traut
2026-03-16 10:49:19 -06:00
committed by GitHub
parent c04a0a7454
commit db89b73a9c
1109 changed files with 134253 additions and 17 deletions

View File

@@ -0,0 +1,6 @@
// Aggregates all former standalone integration tests as modules.
mod model_availability_nux;
mod no_panic_on_startup;
mod status_indicator;
mod vt100_history;
mod vt100_live_commit;

View File

@@ -0,0 +1,197 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use serde_json::Value as JsonValue;
use tempfile::tempdir;
use tokio::select;
use tokio::time::sleep;
use tokio::time::timeout;
#[tokio::test]
async fn resume_startup_does_not_consume_model_availability_nux_count() -> Result<()> {
// run_codex_cli() does not work on Windows due to PTY limitations.
if cfg!(windows) {
return Ok(());
}
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex_home = tempdir()?;
let source_catalog_path = codex_utils_cargo_bin::find_resource!("../core/models.json")?;
let source_catalog = std::fs::read_to_string(&source_catalog_path)?;
let mut source_catalog: JsonValue = serde_json::from_str(&source_catalog)?;
let models = source_catalog
.get_mut("models")
.and_then(JsonValue::as_array_mut)
.context("models array missing")?;
for model in models.iter_mut() {
if let Some(object) = model.as_object_mut() {
object.remove("availability_nux");
}
}
let first_model = models.first_mut().context("models array is empty")?;
let first_model_object = first_model
.as_object_mut()
.context("first model was not a JSON object")?;
let model_slug = first_model_object
.get("slug")
.and_then(JsonValue::as_str)
.context("first model missing slug")?
.to_string();
first_model_object.insert(
"availability_nux".to_string(),
serde_json::json!({
"message": "Model now available",
}),
);
let custom_catalog_path = codex_home.path().join("catalog.json");
std::fs::write(
&custom_catalog_path,
serde_json::to_string(&source_catalog)?,
)?;
let repo_root_display = repo_root.display();
let catalog_display = custom_catalog_path.display();
let config_contents = format!(
r#"model = "{model_slug}"
model_provider = "openai"
model_catalog_json = "{catalog_display}"
[projects."{repo_root_display}"]
trust_level = "trusted"
[tui.model_availability_nux]
"{model_slug}" = 1
"#
);
std::fs::write(codex_home.path().join("config.toml"), config_contents)?;
let fixture_path =
codex_utils_cargo_bin::find_resource!("../core/tests/cli_responses_fixture.sse")?;
let codex = if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
path
} else {
let fallback = repo_root.join("codex-rs/target/debug/codex");
if fallback.is_file() {
fallback
} else {
eprintln!("skipping integration test because codex binary is unavailable");
return Ok(());
}
};
let exec_output = std::process::Command::new(&codex)
.arg("exec")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(&repo_root)
.arg("seed session for resume")
.env("CODEX_HOME", codex_home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", fixture_path)
.env("OPENAI_BASE_URL", "http://unused.local")
.output()
.context("failed to execute codex exec")?;
anyhow::ensure!(
exec_output.status.success(),
"codex exec failed: {}",
String::from_utf8_lossy(&exec_output.stderr)
);
let mut env = HashMap::new();
env.insert(
"CODEX_HOME".to_string(),
codex_home.path().display().to_string(),
);
env.insert("OPENAI_API_KEY".to_string(), "dummy".to_string());
let args = vec![
"resume".to_string(),
"--last".to_string(),
"--no-alt-screen".to_string(),
"-C".to_string(),
repo_root.display().to_string(),
"-c".to_string(),
"analytics.enabled=false".to_string(),
];
let spawned = codex_utils_pty::spawn_pty_process(
codex.to_string_lossy().as_ref(),
&args,
&repo_root,
&env,
&None,
codex_utils_pty::TerminalSize::default(),
)
.await?;
let mut output = Vec::new();
let codex_utils_pty::SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut exit_rx = exit_rx;
let writer_tx = session.writer_sender();
let interrupt_writer = writer_tx.clone();
let interrupt_task = tokio::spawn(async move {
sleep(Duration::from_secs(2)).await;
for _ in 0..4 {
let _ = interrupt_writer.send(vec![3]).await;
sleep(Duration::from_millis(500)).await;
}
});
let exit_code_result = timeout(Duration::from_secs(15), async {
loop {
select! {
result = output_rx.recv() => match result {
Ok(chunk) => {
if chunk.windows(4).any(|window| window == b"\x1b[6n") {
let _ = writer_tx.send(b"\x1b[1;1R".to_vec()).await;
}
output.extend_from_slice(&chunk);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break exit_rx.await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
},
result = &mut exit_rx => break result,
}
}
})
.await;
interrupt_task.abort();
let exit_code = match exit_code_result {
Ok(Ok(code)) => code,
Ok(Err(err)) => return Err(err.into()),
Err(_) => {
session.terminate();
anyhow::bail!("timed out waiting for codex resume to exit");
}
};
anyhow::ensure!(
exit_code == 0 || exit_code == 130,
"unexpected exit code from codex resume: {exit_code}; output: {}",
String::from_utf8_lossy(&output)
);
let config_contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let config: toml::Value = toml::from_str(&config_contents)?;
let shown_count = config
.get("tui")
.and_then(|tui| tui.get("model_availability_nux"))
.and_then(|nux| nux.get(&model_slug))
.and_then(toml::Value::as_integer)
.context("missing tui.model_availability_nux count")?;
assert_eq!(shown_count, 1);
Ok(())
}

View File

@@ -0,0 +1,127 @@
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tokio::select;
use tokio::time::timeout;
/// Regression test for https://github.com/openai/codex/issues/8803.
#[tokio::test]
#[ignore = "TODO(mbolin): flaky"]
async fn malformed_rules_should_not_panic() -> anyhow::Result<()> {
// run_codex_cli() does not work on Windows due to PTY limitations.
if cfg!(windows) {
return Ok(());
}
let tmp = tempfile::tempdir()?;
let codex_home = tmp.path();
std::fs::write(
codex_home.join("rules"),
"rules should be a directory not a file",
)?;
// TODO(mbolin): Figure out why using a temp dir as the cwd causes this test
// to hang.
let cwd = std::env::current_dir()?;
let config_contents = format!(
r#"
# Pick a local provider so the CLI doesn't prompt for OpenAI auth in this test.
model_provider = "ollama"
[projects]
"{cwd}" = {{ trust_level = "trusted" }}
"#,
cwd = cwd.display()
);
std::fs::write(codex_home.join("config.toml"), config_contents)?;
let CodexCliOutput { exit_code, output } = run_codex_cli(codex_home, cwd).await?;
assert_ne!(0, exit_code, "Codex CLI should exit nonzero.");
assert!(
output.contains("ERROR: Failed to initialize codex:"),
"expected startup error in output, got: {output}"
);
assert!(
output.contains("failed to read rules files"),
"expected rules read error in output, got: {output}"
);
Ok(())
}
struct CodexCliOutput {
exit_code: i32,
output: String,
}
async fn run_codex_cli(
codex_home: impl AsRef<Path>,
cwd: impl AsRef<Path>,
) -> anyhow::Result<CodexCliOutput> {
let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?;
let mut env = HashMap::new();
env.insert(
"CODEX_HOME".to_string(),
codex_home.as_ref().display().to_string(),
);
let args = vec!["-c".to_string(), "analytics.enabled=false".to_string()];
let spawned = codex_utils_pty::spawn_pty_process(
codex_cli.to_string_lossy().as_ref(),
&args,
cwd.as_ref(),
&env,
&None,
codex_utils_pty::TerminalSize::default(),
)
.await?;
let mut output = Vec::new();
let codex_utils_pty::SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut exit_rx = exit_rx;
let writer_tx = session.writer_sender();
let exit_code_result = timeout(Duration::from_secs(10), async {
// Read PTY output until the process exits while replying to cursor
// position queries so the TUI can initialize without a real terminal.
loop {
select! {
result = output_rx.recv() => match result {
Ok(chunk) => {
// The TUI asks for the cursor position via ESC[6n.
// Respond with a valid position to unblock startup.
if chunk.windows(4).any(|window| window == b"\x1b[6n") {
let _ = writer_tx.send(b"\x1b[1;1R".to_vec()).await;
}
output.extend_from_slice(&chunk);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break exit_rx.await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
},
result = &mut exit_rx => break result,
}
}
})
.await;
let exit_code = match exit_code_result {
Ok(Ok(code)) => code,
Ok(Err(err)) => return Err(err.into()),
Err(_) => {
session.terminate();
anyhow::bail!("timed out waiting for codex CLI to exit");
}
};
// Drain any output that raced with the exit notification.
while let Ok(chunk) = output_rx.try_recv() {
output.extend_from_slice(&chunk);
}
let output = String::from_utf8_lossy(&output);
Ok(CodexCliOutput {
exit_code,
output: output.to_string(),
})
}

View File

@@ -0,0 +1,24 @@
//! Regression test: ensure that `StatusIndicatorWidget` sanitises ANSI escape
//! sequences so that no raw `\x1b` bytes are written into the backing
//! buffer. Rendering logic is tricky to unittest endtoend, therefore we
//! verify the *public* contract of `ansi_escape_line()` which the widget now
//! relies on.
use codex_ansi_escape::ansi_escape_line;
#[test]
fn ansi_escape_line_strips_escape_sequences() {
let text_in_ansi_red = "\x1b[31mRED\x1b[0m";
// The returned line must contain three printable glyphs and **no** raw
// escape bytes.
let line = ansi_escape_line(text_in_ansi_red);
let combined: String = line
.spans
.iter()
.map(|span| span.content.to_string())
.collect();
assert_eq!(combined, "RED");
}

View File

@@ -0,0 +1,153 @@
#![cfg(feature = "vt100-tests")]
#![expect(clippy::expect_used)]
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
// Small helper macro to assert a collection contains an item with a clearer
// failure message.
macro_rules! assert_contains {
($collection:expr, $item:expr $(,)?) => {
assert!(
$collection.contains(&$item),
"Expected {:?} to contain {:?}",
$collection,
$item
);
};
($collection:expr, $item:expr, $($arg:tt)+) => {
assert!($collection.contains(&$item), $($arg)+);
};
}
struct TestScenario {
term: codex_tui_app_server::custom_terminal::Terminal<VT100Backend>,
}
impl TestScenario {
fn new(width: u16, height: u16, viewport: Rect) -> Self {
let backend = VT100Backend::new(width, height);
let mut term = codex_tui_app_server::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
term.set_viewport_area(viewport);
Self { term }
}
fn run_insert(&mut self, lines: Vec<Line<'static>>) {
codex_tui_app_server::insert_history::insert_history_lines(&mut self.term, lines)
.expect("Failed to insert history lines in test");
}
}
#[test]
fn basic_insertion_no_wrap() {
// Screen of 20x6; viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec!["first".into(), "second".into()];
scenario.run_insert(lines);
let rows = scenario.term.backend().vt100().screen().contents();
assert_contains!(rows, String::from("first"));
assert_contains!(rows, String::from("second"));
}
#[test]
fn long_token_wraps() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let long = "A".repeat(45); // > 2 lines at width 20
let lines = vec![long.clone().into()];
scenario.run_insert(lines);
let screen = scenario.term.backend().vt100().screen();
// Count total A's on the screen
let mut count_a = 0usize;
for row in 0..6 {
for col in 0..20 {
if let Some(cell) = screen.cell(row, col)
&& let Some(ch) = cell.contents().chars().next()
&& ch == 'A'
{
count_a += 1;
}
}
}
assert_eq!(
count_a,
long.len(),
"wrapped content did not preserve all characters"
);
}
#[test]
fn emoji_and_cjk() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let text = String::from("😀😀😀😀😀 你好世界");
let lines = vec![text.clone().into()];
scenario.run_insert(lines);
let rows = scenario.term.backend().vt100().screen().contents();
for ch in text.chars().filter(|c| !c.is_whitespace()) {
assert!(
rows.contains(ch),
"missing character {ch:?} in reconstructed screen"
);
}
}
#[test]
fn mixed_ansi_spans() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let line = vec!["red".red(), "+plain".into()].into();
scenario.run_insert(vec![line]);
let rows = scenario.term.backend().vt100().screen().contents();
assert_contains!(rows, String::from("red+plain"));
}
#[test]
fn cursor_restoration() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec!["x".into()];
scenario.run_insert(lines);
assert_eq!(scenario.term.last_known_cursor_pos, (0, 0).into());
}
#[test]
fn word_wrap_no_mid_word_split() {
// Screen of 40x10; viewport is the last row
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Years passed, and Willowmere thrived in peace and friendship. Miras herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
scenario.run_insert(vec![sample.into()]);
let joined = scenario.term.backend().vt100().screen().contents();
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
}
#[test]
fn em_dash_and_space_word_wrap() {
// Repro from report: ensure we break before "inside", not mid-word.
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed.";
scenario.run_insert(vec![sample.into()]);
let joined = scenario.term.backend().vt100().screen().contents();
assert!(
!joined.contains("insi\nde"),
"word 'inside' should not be split across lines:\n{joined}"
);
}

View File

@@ -0,0 +1,45 @@
#![cfg(feature = "vt100-tests")]
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::text::Line;
#[test]
fn live_001_commit_on_overflow() {
let backend = VT100Backend::new(20, 6);
let mut term = match codex_tui_app_server::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
// Build 5 explicit rows at width 20.
let mut rb = codex_tui_app_server::live_wrap::RowBuilder::new(20);
rb.push_fragment("one\n");
rb.push_fragment("two\n");
rb.push_fragment("three\n");
rb.push_fragment("four\n");
rb.push_fragment("five\n");
// Keep the last 3 in the live ring; commit the first 2.
let commit_rows = rb.drain_commit_ready(3);
let lines: Vec<Line<'static>> = commit_rows.into_iter().map(|r| r.text.into()).collect();
codex_tui_app_server::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
let screen = term.backend().vt100().screen();
// The words "one" and "two" should appear above the viewport.
let joined = screen.contents();
assert!(
joined.contains("one"),
"expected committed 'one' to be visible\n{joined}"
);
assert!(
joined.contains("two"),
"expected committed 'two' to be visible\n{joined}"
);
// The last three (three,four,five) remain in the live ring, not committed here.
}