Sync tui2 with tui and keep dual-run glue (#7965)

- Copy latest tui sources into tui2
- Restore notifications, tests, and styles
- Keep codex-tui interop conversions and snapshots

The expected changes that are necessary to make this work are still in
place:

diff -ru codex-rs/tui codex-rs/tui2 --exclude='*.snap'
--exclude='*.snap.new'

```diff
diff -ru --ex codex-rs/tui/Cargo.toml codex-rs/tui2/Cargo.toml
--- codex-rs/tui/Cargo.toml	2025-12-12 16:39:12
+++ codex-rs/tui2/Cargo.toml	2025-12-12 17:31:01
@@ -1,15 +1,15 @@
 [package]
-name = "codex-tui"
+name = "codex-tui2"
 version.workspace = true
 edition.workspace = true
 license.workspace = true
 
 [[bin]]
-name = "codex-tui"
+name = "codex-tui2"
 path = "src/main.rs"
 
 [lib]
-name = "codex_tui"
+name = "codex_tui2"
 path = "src/lib.rs"
 
 [features]
@@ -42,6 +42,7 @@
 codex-login = { workspace = true }
 codex-protocol = { workspace = true }
 codex-utils-absolute-path = { workspace = true }
+codex-tui = { workspace = true }
 color-eyre = { workspace = true }
 crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
 derive_more = { workspace = true, features = ["is_variant"] }
diff -ru --ex codex-rs/tui/src/app.rs codex-rs/tui2/src/app.rs
--- codex-rs/tui/src/app.rs	2025-12-12 16:39:05
+++ codex-rs/tui2/src/app.rs	2025-12-12 17:30:36
@@ -69,6 +69,16 @@
     pub update_action: Option<UpdateAction>,
 }
 
+impl From<AppExitInfo> for codex_tui::AppExitInfo {
+    fn from(info: AppExitInfo) -> Self {
+        codex_tui::AppExitInfo {
+            token_usage: info.token_usage,
+            conversation_id: info.conversation_id,
+            update_action: info.update_action.map(Into::into),
+        }
+    }
+}
+
 fn session_summary(
     token_usage: TokenUsage,
     conversation_id: Option<ConversationId>,
Only in codex-rs/tui/src/bin: md-events.rs
Only in codex-rs/tui2/src/bin: md-events2.rs
diff -ru --ex codex-rs/tui/src/cli.rs codex-rs/tui2/src/cli.rs
--- codex-rs/tui/src/cli.rs	2025-11-19 13:40:42
+++ codex-rs/tui2/src/cli.rs	2025-12-12 17:30:43
@@ -88,3 +88,28 @@
     #[clap(skip)]
     pub config_overrides: CliConfigOverrides,
 }
+
+impl From<codex_tui::Cli> for Cli {
+    fn from(cli: codex_tui::Cli) -> Self {
+        Self {
+            prompt: cli.prompt,
+            images: cli.images,
+            resume_picker: cli.resume_picker,
+            resume_last: cli.resume_last,
+            resume_session_id: cli.resume_session_id,
+            resume_show_all: cli.resume_show_all,
+            model: cli.model,
+            oss: cli.oss,
+            oss_provider: cli.oss_provider,
+            config_profile: cli.config_profile,
+            sandbox_mode: cli.sandbox_mode,
+            approval_policy: cli.approval_policy,
+            full_auto: cli.full_auto,
+            dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox,
+            cwd: cli.cwd,
+            web_search: cli.web_search,
+            add_dir: cli.add_dir,
+            config_overrides: cli.config_overrides,
+        }
+    }
+}
diff -ru --ex codex-rs/tui/src/main.rs codex-rs/tui2/src/main.rs
--- codex-rs/tui/src/main.rs	2025-12-12 16:39:05
+++ codex-rs/tui2/src/main.rs	2025-12-12 16:39:06
@@ -1,8 +1,8 @@
 use clap::Parser;
 use codex_arg0::arg0_dispatch_or_else;
 use codex_common::CliConfigOverrides;
-use codex_tui::Cli;
-use codex_tui::run_main;
+use codex_tui2::Cli;
+use codex_tui2::run_main;
 
 #[derive(Parser, Debug)]
 struct TopCli {
diff -ru --ex codex-rs/tui/src/update_action.rs codex-rs/tui2/src/update_action.rs
--- codex-rs/tui/src/update_action.rs	2025-11-19 11:11:47
+++ codex-rs/tui2/src/update_action.rs	2025-12-12 17:30:48
@@ -9,6 +9,20 @@
     BrewUpgrade,
 }
 
+impl From<UpdateAction> for codex_tui::update_action::UpdateAction {
+    fn from(action: UpdateAction) -> Self {
+        match action {
+            UpdateAction::NpmGlobalLatest => {
+                codex_tui::update_action::UpdateAction::NpmGlobalLatest
+            }
+            UpdateAction::BunGlobalLatest => {
+                codex_tui::update_action::UpdateAction::BunGlobalLatest
+            }
+            UpdateAction::BrewUpgrade => codex_tui::update_action::UpdateAction::BrewUpgrade,
+        }
+    }
+}
+
 impl UpdateAction {
     /// Returns the list of command-line arguments for invoking the update.
     pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
```
This commit is contained in:
Josh McKinney
2025-12-12 20:46:18 -08:00
committed by GitHub
Unverified
parent ad7b9d63c3
commit 6ec2831b91
31 changed files with 9206 additions and 415 deletions
+139
View File
@@ -0,0 +1,139 @@
mod osc9;
mod windows_toast;
use std::env;
use std::io;
use codex_core::env::is_wsl;
use osc9::Osc9Backend;
use windows_toast::WindowsToastBackend;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationBackendKind {
Osc9,
WindowsToast,
}
#[derive(Debug)]
pub enum DesktopNotificationBackend {
Osc9(Osc9Backend),
WindowsToast(WindowsToastBackend),
}
impl DesktopNotificationBackend {
pub fn osc9() -> Self {
Self::Osc9(Osc9Backend)
}
pub fn windows_toast() -> Self {
Self::WindowsToast(WindowsToastBackend::default())
}
pub fn kind(&self) -> NotificationBackendKind {
match self {
DesktopNotificationBackend::Osc9(_) => NotificationBackendKind::Osc9,
DesktopNotificationBackend::WindowsToast(_) => NotificationBackendKind::WindowsToast,
}
}
pub fn notify(&mut self, message: &str) -> io::Result<()> {
match self {
DesktopNotificationBackend::Osc9(backend) => backend.notify(message),
DesktopNotificationBackend::WindowsToast(backend) => backend.notify(message),
}
}
}
pub fn detect_backend() -> DesktopNotificationBackend {
if should_use_windows_toasts() {
tracing::info!(
"Windows Terminal session detected under WSL; using Windows toast notifications"
);
DesktopNotificationBackend::windows_toast()
} else {
DesktopNotificationBackend::osc9()
}
}
fn should_use_windows_toasts() -> bool {
is_wsl() && env::var_os("WT_SESSION").is_some()
}
#[cfg(test)]
mod tests {
use super::NotificationBackendKind;
use super::detect_backend;
use serial_test::serial;
use std::ffi::OsString;
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
fn remove(key: &'static str) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::remove_var(key);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
#[serial]
fn defaults_to_osc9_outside_wsl() {
let _wsl_guard = EnvVarGuard::remove("WSL_DISTRO_NAME");
let _wt_guard = EnvVarGuard::remove("WT_SESSION");
assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9);
}
#[test]
#[serial]
fn waits_for_windows_terminal() {
let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu");
let _wt_guard = EnvVarGuard::remove("WT_SESSION");
assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9);
}
#[cfg(target_os = "linux")]
#[test]
#[serial]
fn selects_windows_toast_in_wsl_windows_terminal() {
let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu");
let _wt_guard = EnvVarGuard::set("WT_SESSION", "abc");
assert_eq!(
detect_backend().kind(),
NotificationBackendKind::WindowsToast
);
}
#[cfg(not(target_os = "linux"))]
#[test]
#[serial]
fn stays_on_osc9_outside_linux_even_with_wsl_env() {
let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu");
let _wt_guard = EnvVarGuard::set("WT_SESSION", "abc");
assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9);
}
}
+37
View File
@@ -0,0 +1,37 @@
use std::fmt;
use std::io;
use std::io::stdout;
use crossterm::Command;
use ratatui::crossterm::execute;
#[derive(Debug, Default)]
pub struct Osc9Backend;
impl Osc9Backend {
pub fn notify(&mut self, message: &str) -> io::Result<()> {
execute!(stdout(), PostNotification(message.to_string()))
}
}
/// Command that emits an OSC 9 desktop notification with a message.
#[derive(Debug, Clone)]
pub struct PostNotification(pub String);
impl Command for PostNotification {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
write!(f, "\x1b]9;{}\x07", self.0)
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Err(std::io::Error::other(
"tried to execute PostNotification using WinAPI; use ANSI instead",
))
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
true
}
}
@@ -0,0 +1,128 @@
use std::io;
use std::process::Command;
use std::process::Stdio;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
const APP_ID: &str = "Codex";
const POWERSHELL_EXE: &str = "powershell.exe";
#[derive(Debug)]
pub struct WindowsToastBackend {
encoded_title: String,
}
impl WindowsToastBackend {
pub fn notify(&mut self, message: &str) -> io::Result<()> {
let encoded_body = encode_argument(message);
let encoded_command = build_encoded_command(&self.encoded_title, &encoded_body);
spawn_powershell(encoded_command)
}
}
impl Default for WindowsToastBackend {
fn default() -> Self {
WindowsToastBackend {
encoded_title: encode_argument(APP_ID),
}
}
}
fn spawn_powershell(encoded_command: String) -> io::Result<()> {
let mut command = Command::new(POWERSHELL_EXE);
command
.arg("-NoProfile")
.arg("-NoLogo")
.arg("-EncodedCommand")
.arg(encoded_command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let status = command.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"{POWERSHELL_EXE} exited with status {status}"
)))
}
}
fn build_encoded_command(encoded_title: &str, encoded_body: &str) -> String {
let script = build_ps_script(encoded_title, encoded_body);
encode_script_for_powershell(&script)
}
fn build_ps_script(encoded_title: &str, encoded_body: &str) -> String {
format!(
r#"
$encoding = [System.Text.Encoding]::UTF8
$titleText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_title}"))
$bodyText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_body}"))
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
$doc = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$textNodes = $doc.GetElementsByTagName("text")
$textNodes.Item(0).AppendChild($doc.CreateTextNode($titleText)) | Out-Null
$textNodes.Item(1).AppendChild($doc.CreateTextNode($bodyText)) | Out-Null
$toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Codex').Show($toast)
"#,
)
}
fn encode_script_for_powershell(script: &str) -> String {
let mut wide: Vec<u8> = Vec::with_capacity((script.len() + 1) * 2);
for unit in script.encode_utf16() {
let bytes = unit.to_le_bytes();
wide.extend_from_slice(&bytes);
}
BASE64.encode(wide)
}
fn encode_argument(value: &str) -> String {
BASE64.encode(escape_for_xml(value))
}
pub fn escape_for_xml(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::encode_script_for_powershell;
use super::escape_for_xml;
use pretty_assertions::assert_eq;
#[test]
fn escapes_xml_entities() {
assert_eq!(escape_for_xml("5 > 3"), "5 &gt; 3");
assert_eq!(escape_for_xml("a & b"), "a &amp; b");
assert_eq!(escape_for_xml("<tag>"), "&lt;tag&gt;");
assert_eq!(escape_for_xml("\"quoted\""), "&quot;quoted&quot;");
assert_eq!(escape_for_xml("single 'quote'"), "single &apos;quote&apos;");
}
#[test]
fn leaves_safe_text_unmodified() {
assert_eq!(escape_for_xml("codex"), "codex");
assert_eq!(escape_for_xml("multi word text"), "multi word text");
}
#[test]
fn encodes_utf16le_for_powershell() {
assert_eq!(encode_script_for_powershell("A"), "QQA=");
}
}