diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3570491d1..b4dfb93fb 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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); } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index e923db177..4cb5072da 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -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; diff --git a/codex-rs/tui/src/app/history_ui_tests.rs b/codex-rs/tui/src/app/history_ui_tests.rs new file mode 100644 index 000000000..750c66535 --- /dev/null +++ b/codex-rs/tui/src/app/history_ui_tests.rs @@ -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::>() + .join("\n") +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap new file mode 100644 index 000000000..256dd9693 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap @@ -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. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap new file mode 100644 index 000000000..a1f526451 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/history_ui_tests.rs +expression: render_cell(&cell) +--- +• Opened this session in Codex Desktop. diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 228ee0c41..c2f9ee3f3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 9d38d4033..9e0e1b649 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -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()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap new file mode 100644 index 000000000..95e28a39c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap @@ -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, + ] +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 99833da8a..08c6db56d 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap new file mode 100644 index 000000000..f7ff40ea5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_app_without_thread_id_shows_starting_error.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 4f425c25c..099ae1d76 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 3fccdf596..805f266e0 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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]