feat(tui): add /app desktop handoff (#25638)

This commit is contained in:
Felipe Coury
2026-06-03 20:30:15 -03:00
committed by GitHub
Unverified
parent 8285cd278b
commit 80b65e9945
12 changed files with 244 additions and 1 deletions
+3
View File
@@ -403,6 +403,9 @@ impl App {
AppEvent::OpenUrlInBrowser { url } => {
self.open_url_in_browser(url);
}
AppEvent::OpenDesktopThread { thread_id } => {
self.open_desktop_thread(thread_id);
}
AppEvent::PetSelected { pet_id } => {
self.handle_pet_selected(tui, pet_id);
}
+107 -1
View File
@@ -1,10 +1,12 @@
//! Terminal history and clear-screen UI helpers for the TUI app.
//! Terminal history, desktop handoff, and clear-screen UI helpers for the TUI app.
//!
//! This module owns rendering the fresh session header, clearing inline or alternate-screen UI
//! state, and resetting transcript-related app state after `/clear` or Ctrl-L.
use super::*;
const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in Codex Desktop.";
impl App {
pub(super) fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
@@ -17,6 +19,20 @@ impl App {
.add_info_message(format!("Opened {url} in your browser."), /*hint*/ None);
}
pub(super) fn open_desktop_thread(&mut self, thread_id: ThreadId) {
let url = format!("codex://threads/{thread_id}");
if let Err(err) = open_desktop_thread_url(&url) {
self.chat_widget
.add_error_message(desktop_thread_open_error_message(&err));
return;
}
self.chat_widget.add_info_message(
DESKTOP_THREAD_OPENED_MESSAGE.to_string(),
/*hint*/ None,
);
}
pub(super) fn clear_ui_header_lines_with_version(
&self,
width: u16,
@@ -99,3 +115,93 @@ impl App {
self.backtrack_render_pending = false;
}
}
fn desktop_thread_open_error_message(err: &str) -> String {
format!(
"Failed to open this session in Codex Desktop: {err}. Install or launch Codex Desktop and try again."
)
}
#[cfg(target_os = "macos")]
fn open_desktop_thread_url(url: &str) -> Result<(), String> {
let status = std::process::Command::new("open")
.arg(url)
.status()
.map_err(|err| format!("failed to invoke `open`: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("`open {url}` exited with {status}"))
}
}
#[cfg(target_os = "windows")]
fn open_desktop_thread_url(url: &str) -> Result<(), String> {
let script = windows_desktop_app_launch_script(url);
let output = std::process::Command::new("powershell.exe")
.arg("-NoProfile")
.arg("-Command")
.arg(&script)
.output()
.map_err(|err| format!("failed to launch Codex Desktop through PowerShell: {err}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!(
"failed to launch Codex Desktop through PowerShell with {}",
output.status
))
} else {
Err(stderr)
}
}
#[cfg(target_os = "windows")]
fn windows_desktop_app_launch_script(url: &str) -> String {
let url = powershell_single_quoted_string(url);
format!(
r#"
$ErrorActionPreference = 'Stop'
$url = {url}
$installLocation = (Get-AppxPackage -Name OpenAI.Codex -ErrorAction SilentlyContinue).InstallLocation
if ([string]::IsNullOrWhiteSpace($installLocation)) {{
Write-Error 'Codex Desktop package is not installed'
exit 1
}}
$appDir = Join-Path $installLocation 'app'
$exe = Join-Path $appDir 'Codex.exe'
$app = Join-Path $appDir 'resources\app.asar'
if (-not (Test-Path $exe)) {{
Write-Error "Codex Desktop executable not found at $exe"
exit 1
}}
if (-not (Test-Path $app)) {{
Write-Error "Codex Desktop app bundle not found at $app"
exit 1
}}
Start-Process -FilePath $exe -WorkingDirectory $appDir -ArgumentList @('resources\app.asar', $url)
"#
)
}
#[cfg(target_os = "windows")]
fn powershell_single_quoted_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn open_desktop_thread_url(_url: &str) -> Result<(), String> {
Err("Codex Desktop is only available on macOS and Windows".to_string())
}
#[cfg(test)]
#[path = "history_ui_tests.rs"]
mod tests;
+29
View File
@@ -0,0 +1,29 @@
use super::*;
use crate::history_cell;
use crate::history_cell::HistoryCell;
#[test]
fn desktop_thread_opened_history_snapshot() {
let cell = history_cell::new_info_event(
DESKTOP_THREAD_OPENED_MESSAGE.to_string(),
/*hint*/ None,
);
insta::assert_snapshot!("desktop_thread_opened_history", render_cell(&cell));
}
#[test]
fn desktop_thread_open_error_history_snapshot() {
let cell = history_cell::new_error_event(desktop_thread_open_error_message("launch failed"));
insta::assert_snapshot!("desktop_thread_open_error_history", render_cell(&cell));
}
fn render_cell(cell: &impl HistoryCell) -> String {
let lines = cell.display_lines(/*width*/ 80);
lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
}
@@ -0,0 +1,5 @@
---
source: tui/src/app/history_ui_tests.rs
expression: render_cell(&cell)
---
■ Failed to open this session in Codex Desktop: launch failed. Install or launch Codex Desktop and try again.
@@ -0,0 +1,5 @@
---
source: tui/src/app/history_ui_tests.rs
expression: render_cell(&cell)
---
• Opened this session in Codex Desktop.
+5
View File
@@ -330,6 +330,11 @@ pub(crate) enum AppEvent {
url: String,
},
/// Open the current thread in Codex Desktop.
OpenDesktopThread {
thread_id: ThreadId,
},
/// Persist a pet selection and reload the ambient pet.
PetSelected {
pet_id: String,
@@ -393,6 +393,25 @@ mod tests {
);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn app_command_popup_snapshot() {
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/app".to_string());
let width = 72;
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
popup.calculate_required_height(width),
);
let mut buf = Buffer::empty(area);
popup.render_ref(area, &mut buf);
insta::assert_snapshot!("command_popup_app", format!("{buf:?}"));
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/command_popup.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 2 },
content: [
" /app continue this session in Codex Desktop ",
" /approve approve one retry of a recent auto-review denial ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 50, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 6, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 60, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -202,6 +202,16 @@ impl ChatWidget {
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::App => {
let Some(thread_id) = self.thread_id else {
self.add_error_message(
"Session is still starting; try /app again in a moment.".to_string(),
);
return;
};
self.app_event_tx
.send(AppEvent::OpenDesktopThread { thread_id });
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
if init_target.exists() {
@@ -974,6 +984,7 @@ impl ChatWidget {
| SlashCommand::Raw
| SlashCommand::Vim
| SlashCommand::Diff
| SlashCommand::App
| SlashCommand::Rename
| SlashCommand::TestApproval => QueueDrain::Continue,
SlashCommand::Feedback
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: "lines_to_single_string(&cells[0])"
---
■ Session is still starting; try /app again in a moment.
@@ -2052,6 +2052,36 @@ async fn slash_fork_requests_current_fork() {
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
}
#[tokio::test]
async fn slash_app_requests_desktop_handoff() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.dispatch_command(SlashCommand::App);
assert_matches!(
rx.try_recv(),
Ok(AppEvent::OpenDesktopThread {
thread_id: actual_thread_id,
}) if actual_thread_id == thread_id
);
}
#[tokio::test]
async fn slash_app_without_thread_id_shows_starting_error() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::App);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected app startup error");
assert_chatwidget_snapshot!(
"slash_app_without_thread_id_shows_starting_error",
lines_to_single_string(&cells[0])
);
}
#[tokio::test]
async fn slash_rollout_displays_current_path() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+5
View File
@@ -33,6 +33,7 @@ pub enum SlashCommand {
Archive,
Resume,
Fork,
App,
Init,
Compact,
Plan,
@@ -90,6 +91,7 @@ impl SlashCommand {
SlashCommand::Archive => "archive this session and exit",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
SlashCommand::App => "continue this session in Codex Desktop",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
@@ -213,6 +215,7 @@ impl SlashCommand {
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::App
| SlashCommand::Goal
| SlashCommand::Mcp
| SlashCommand::Apps
@@ -239,6 +242,7 @@ impl SlashCommand {
match self {
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
SlashCommand::Copy => !cfg!(target_os = "android"),
SlashCommand::App => cfg!(any(target_os = "macos", target_os = "windows")),
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
_ => true,
}
@@ -285,6 +289,7 @@ mod tests {
assert!(SlashCommand::Raw.available_during_task());
assert!(SlashCommand::Raw.available_in_side_conversation());
assert!(SlashCommand::Raw.supports_inline_args());
assert!(SlashCommand::App.available_during_task());
}
#[test]