mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add /app desktop handoff (#25638)
This commit is contained in:
committed by
GitHub
Unverified
parent
8285cd278b
commit
80b65e9945
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+5
@@ -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.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/app/history_ui_tests.rs
|
||||
expression: render_cell(&cell)
|
||||
---
|
||||
• Opened this session in Codex Desktop.
|
||||
@@ -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());
|
||||
|
||||
+20
@@ -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
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user