Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
108 changes: 107 additions & 1 deletion codex-rs/tui/src/app/history_ui.rs
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
fcoury-oai marked this conversation as resolved.
.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)]
Comment thread
fcoury-oai marked this conversation as resolved.
#[path = "history_ui_tests.rs"]
mod tests;
29 changes: 29 additions & 0 deletions codex-rs/tui/src/app/history_ui_tests.rs
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: tui/src/app/history_ui_tests.rs
expression: render_cell(&cell)
---
• Opened this session in Codex Desktop.
5 changes: 5 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
]
}
11 changes: 11 additions & 0 deletions codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
fcoury-oai marked this conversation as resolved.
);
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() {
Expand Down Expand Up @@ -974,6 +984,7 @@ impl ChatWidget {
| SlashCommand::Raw
| SlashCommand::Vim
| SlashCommand::Diff
| SlashCommand::App
| SlashCommand::Rename
| SlashCommand::TestApproval => QueueDrain::Continue,
SlashCommand::Feedback
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/tui/src/slash_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub enum SlashCommand {
Archive,
Resume,
Fork,
App,
Init,
Compact,
Plan,
Expand Down Expand Up @@ -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",
Comment thread
fcoury-oai marked this conversation as resolved.
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
Expand Down Expand Up @@ -213,6 +215,7 @@ impl SlashCommand {
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::App
| SlashCommand::Goal
| SlashCommand::Mcp
| SlashCommand::Apps
Expand All @@ -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,
}
Expand Down Expand Up @@ -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]
Expand Down
Loading