mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
feat(tui): own terminal browser lifecycle
This commit is contained in:
@@ -161,6 +161,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_rollout::StateDbHandle;
|
||||
use codex_terminal_browser::TerminalBrowser;
|
||||
use codex_terminal_detection::user_agent;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::builtin_permission_profile_for_active_permission_profile;
|
||||
@@ -226,6 +227,7 @@ mod resize_reflow;
|
||||
mod session_lifecycle;
|
||||
mod side;
|
||||
mod startup_prompts;
|
||||
mod terminal_browser;
|
||||
mod thread_events;
|
||||
mod thread_goal_actions;
|
||||
mod thread_routing;
|
||||
@@ -531,6 +533,12 @@ pub(crate) struct App {
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
/// Carbonyl runtime shared by the active conversation and the browser panel.
|
||||
pub(crate) terminal_browser: Option<Arc<TerminalBrowser>>,
|
||||
/// Conversation currently allowed to drive `terminal_browser`.
|
||||
pub(crate) terminal_browser_owner_thread_id: Option<ThreadId>,
|
||||
/// Invalidates delayed browser-profile approvals when the owned runtime changes.
|
||||
pub(crate) terminal_browser_generation: u64,
|
||||
pub(crate) deferred_history_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
|
||||
has_emitted_history_lines: bool,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
@@ -1048,6 +1056,9 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
enhanced_keys_supported,
|
||||
keymap: runtime_keymap,
|
||||
overlay: None,
|
||||
terminal_browser: None,
|
||||
terminal_browser_owner_thread_id: None,
|
||||
terminal_browser_generation: 0,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
||||
@@ -1231,6 +1242,8 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
}
|
||||
};
|
||||
app.reset_terminal_browser_for_thread_change().await;
|
||||
tui.set_raw_mouse_events(/*raw_mouse_events*/ false);
|
||||
if let Err(err) = app_server.shutdown().await {
|
||||
tracing::warn!(error = %err, "failed to shut down embedded app server");
|
||||
}
|
||||
@@ -1290,6 +1303,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
| TuiEvent::Paste(_)
|
||||
| TuiEvent::MouseScroll(_)
|
||||
| TuiEvent::MousePrimary(_)
|
||||
| TuiEvent::MouseOther(_)
|
||||
| TuiEvent::Draw => {}
|
||||
}
|
||||
if matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
|
||||
@@ -1339,6 +1353,9 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
TuiEvent::MousePrimary(event) => {
|
||||
self.handle_owned_screen_mouse_primary(tui, event);
|
||||
}
|
||||
// Raw browser input is routed after the browser panel is integrated into the
|
||||
// owned-screen frame. Other app surfaces intentionally ignore these events.
|
||||
TuiEvent::MouseOther(_) => {}
|
||||
TuiEvent::FocusLost => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if self.backtrack_render_pending {
|
||||
@@ -1420,6 +1437,9 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
|
||||
impl Drop for App {
|
||||
fn drop(&mut self) {
|
||||
if let Some(browser) = self.terminal_browser.as_ref() {
|
||||
browser.terminate();
|
||||
}
|
||||
if let Err(err) = self.chat_widget.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title on app drop");
|
||||
}
|
||||
|
||||
@@ -519,6 +519,13 @@ impl App {
|
||||
&feature_updates_to_apply,
|
||||
)
|
||||
.await;
|
||||
if feature_updates_to_apply
|
||||
.iter()
|
||||
.any(|(feature, _)| *feature == Feature::TerminalBrowser)
|
||||
&& !self.config.features.enabled(Feature::TerminalBrowser)
|
||||
{
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
}
|
||||
if windows_sandbox_changed {
|
||||
self.propagate_windows_sandbox_turn_context();
|
||||
}
|
||||
@@ -528,6 +535,9 @@ impl App {
|
||||
|
||||
let memory_tool_was_enabled = self.config.features.enabled(Feature::MemoryTool);
|
||||
self.config = next_config;
|
||||
let terminal_browser_disabled = feature_updates_to_apply
|
||||
.iter()
|
||||
.any(|(feature, enabled)| *feature == Feature::TerminalBrowser && !enabled);
|
||||
let show_memory_enable_notice =
|
||||
feature_updates_to_apply.iter().any(|(feature, enabled)| {
|
||||
*feature == Feature::MemoryTool && *enabled && !memory_tool_was_enabled
|
||||
@@ -538,6 +548,9 @@ impl App {
|
||||
.set_feature_enabled(feature, effective_enabled);
|
||||
});
|
||||
}
|
||||
if terminal_browser_disabled {
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
}
|
||||
if show_memory_enable_notice {
|
||||
self.chat_widget.add_memories_enable_notice();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ impl App {
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
self.focus_installed_conversation_pane(PaneSlot::Side);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +428,102 @@ impl App {
|
||||
self.chat_widget.prepare_local_op_submission(&op);
|
||||
self.submit_active_thread_op(app_server, op).await?;
|
||||
}
|
||||
AppEvent::ToggleTerminalBrowser => {
|
||||
self.toggle_terminal_browser().await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::CloseTerminalBrowser => {
|
||||
self.close_terminal_browser();
|
||||
}
|
||||
AppEvent::DoctorTerminalBrowser => {
|
||||
self.doctor_terminal_browser().await;
|
||||
}
|
||||
AppEvent::TerminalBrowserDoctorCompleted { healthy, summary } => {
|
||||
if healthy {
|
||||
self.chat_widget.add_info_message(summary, /*hint*/ None);
|
||||
} else {
|
||||
self.chat_widget.add_error_message(summary);
|
||||
}
|
||||
}
|
||||
AppEvent::ManageTerminalBrowserProfile(command) => {
|
||||
self.manage_terminal_browser_profile(command).await;
|
||||
}
|
||||
AppEvent::ApproveTerminalBrowserProfile(approval) => {
|
||||
self.approve_terminal_browser_profile(approval).await;
|
||||
}
|
||||
AppEvent::ToggleTerminalBrowserControl => {
|
||||
self.toggle_terminal_browser_control().await;
|
||||
}
|
||||
AppEvent::TerminalBrowserControlCompleted { error } => {
|
||||
let active = self.terminal_browser_owned_by_current_thread()
|
||||
&& self
|
||||
.terminal_browser
|
||||
.as_ref()
|
||||
.is_some_and(|browser| browser.is_human_control_active());
|
||||
tui.set_raw_mouse_events(/*raw_mouse_events*/ active);
|
||||
if let Some(error) = error {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Browser control failed: {error}"));
|
||||
} else {
|
||||
let message = if active {
|
||||
"You control the browser now. Press Ctrl+] to return control to Codex."
|
||||
} else {
|
||||
"Browser control returned to Codex; previous node handles were invalidated."
|
||||
};
|
||||
self.chat_widget
|
||||
.add_info_message(message.to_string(), /*hint*/ None);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::TerminalBrowserClosed => {
|
||||
let raw_mouse_events = self.terminal_browser_owned_by_current_thread()
|
||||
&& self
|
||||
.terminal_browser
|
||||
.as_ref()
|
||||
.is_some_and(|browser| browser.is_human_control_active());
|
||||
tui.set_raw_mouse_events(raw_mouse_events);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::TerminalBrowserUpdated => {
|
||||
let raw_mouse_events = self.terminal_browser_owned_by_current_thread()
|
||||
&& self
|
||||
.terminal_browser
|
||||
.as_ref()
|
||||
.is_some_and(|browser| browser.is_human_control_active());
|
||||
tui.set_raw_mouse_events(raw_mouse_events);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::TerminalBrowserToolCompleted {
|
||||
request_id,
|
||||
response,
|
||||
profile_approval,
|
||||
} => {
|
||||
match serde_json::to_value(response) {
|
||||
Ok(result) => {
|
||||
if let Err(err) =
|
||||
app_server.resolve_server_request(request_id, result).await
|
||||
{
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to resolve terminal browser tool request: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let message =
|
||||
format!("Failed to serialize terminal browser response: {err}");
|
||||
self.chat_widget.add_error_message(message.clone());
|
||||
if let Err(reject_err) = self
|
||||
.reject_app_server_request(app_server, request_id, message)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%reject_err, "failed to reject terminal-browser request");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(approval) = profile_approval {
|
||||
self.show_terminal_browser_profile_approval(approval);
|
||||
}
|
||||
}
|
||||
AppEvent::ConversationOp { target, op } => {
|
||||
self.submit_thread_op(app_server, target.thread_id, op)
|
||||
.await?;
|
||||
|
||||
@@ -309,6 +309,16 @@ impl App {
|
||||
if self.chat_widget.focused_slot() == target {
|
||||
return true;
|
||||
}
|
||||
self.focus_installed_conversation_pane(target)
|
||||
}
|
||||
|
||||
pub(super) fn focus_installed_conversation_pane(&mut self, target: PaneSlot) -> bool {
|
||||
if self.chat_widget.by_slot(target).is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.chat_widget.focused_slot() != target {
|
||||
self.reset_terminal_browser_for_focus_change();
|
||||
}
|
||||
self.chat_widget.focus(target)
|
||||
}
|
||||
|
||||
@@ -444,6 +454,26 @@ mod tests {
|
||||
assert!(!app.backtrack.primed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pane_focus_change_detaches_the_previous_terminal_browser() {
|
||||
let mut app = make_test_app().await;
|
||||
install_side_pane(&mut app).await;
|
||||
let browser = Arc::new(codex_terminal_browser::TerminalBrowser::discover());
|
||||
app.terminal_browser = Some(browser);
|
||||
app.terminal_browser_owner_thread_id = Some(ThreadId::new());
|
||||
app.terminal_browser_generation = 7;
|
||||
|
||||
assert!(app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Char('2'),
|
||||
KeyModifiers::ALT,
|
||||
)));
|
||||
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
assert!(app.terminal_browser.is_none());
|
||||
assert_eq!(app.terminal_browser_owner_thread_id, None);
|
||||
assert_eq!(app.terminal_browser_generation, 8);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pane_focus_does_not_steal_word_motion_from_nonempty_draft() {
|
||||
|
||||
@@ -429,7 +429,7 @@ impl App {
|
||||
);
|
||||
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
|
||||
self.reset_for_thread_switch(tui)?;
|
||||
self.reset_for_thread_switch(tui).await?;
|
||||
self.replay_thread_snapshot(snapshot, !is_replay_only);
|
||||
if is_replay_only {
|
||||
let message = if attached_replay_only {
|
||||
@@ -455,7 +455,8 @@ impl App {
|
||||
.is_none_or(|entry| !entry.is_closed)
|
||||
}
|
||||
|
||||
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
pub(super) async fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
self.reset_app_ui_state_after_clear(tui);
|
||||
tui.clear_pending_history_lines();
|
||||
if !self.has_owned_screen() {
|
||||
@@ -544,6 +545,7 @@ impl App {
|
||||
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
||||
// history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it
|
||||
// until the new session is configured and any replayed turns have been rendered.
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
self.refresh_in_memory_config_from_disk_best_effort("starting a new thread")
|
||||
.await;
|
||||
let model = self.chat_widget.current_model().to_string();
|
||||
@@ -618,6 +620,7 @@ impl App {
|
||||
// Initial messages are for freshly attached primary threads only. Thread switches and
|
||||
// resume/fork flows pass `None` so they cannot replay old history and then auto-submit a new
|
||||
// user turn by accident.
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
self.reset_thread_event_state();
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
||||
tui,
|
||||
|
||||
@@ -393,6 +393,9 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) async fn discard_thread_local_state(&mut self, thread_id: ThreadId) {
|
||||
if self.terminal_browser_owner_thread_id == Some(thread_id) {
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
}
|
||||
let remove_side_pane = self.installed_side_thread_id() == Some(thread_id);
|
||||
if remove_side_pane || self.side_threads.contains_key(&thread_id) {
|
||||
self.retire_thread(thread_id);
|
||||
@@ -435,7 +438,7 @@ impl App {
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
if self.installed_side_thread_id() == Some(thread_id) {
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
self.focus_installed_conversation_pane(PaneSlot::Side);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
@@ -560,7 +563,7 @@ impl App {
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
if self.installed_side_thread_id() == Some(thread_id) {
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
self.focus_installed_conversation_pane(PaneSlot::Side);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(());
|
||||
}
|
||||
@@ -569,7 +572,7 @@ impl App {
|
||||
.prepare_agent_thread_selection(app_server, thread_id)
|
||||
.await
|
||||
else {
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
self.focus_installed_conversation_pane(PaneSlot::Side);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(());
|
||||
};
|
||||
@@ -581,7 +584,7 @@ impl App {
|
||||
self.keep_side_thread_visible_after_cleanup_failure(tui, side_thread_id);
|
||||
return Ok(());
|
||||
}
|
||||
self.chat_widget.focus(PaneSlot::Parent);
|
||||
self.focus_installed_conversation_pane(PaneSlot::Parent);
|
||||
self.apply_prepared_agent_thread_selection(tui, app_server, selection)
|
||||
.await?;
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
@@ -698,7 +701,7 @@ impl App {
|
||||
.await;
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
self.focus_installed_conversation_pane(PaneSlot::Side);
|
||||
self.sync_side_thread_ui();
|
||||
|
||||
if let Err(err) = app_server
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! App-owned lifecycle for the Carbonyl-backed terminal browser.
|
||||
//!
|
||||
//! Rendering and input adaptation live in `crate::terminal_browser`. This module keeps the
|
||||
//! process, permission policy, and app-server tool surface bound to one displayed conversation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_terminal_browser::BrowserLaunchContext;
|
||||
use codex_terminal_browser::BrowserNetworkPolicy;
|
||||
use codex_terminal_browser::BrowserStatus;
|
||||
use codex_terminal_browser::TerminalBrowser;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use super::App;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::TerminalBrowserProfileApproval;
|
||||
use crate::app_event::TerminalBrowserProfileCommand;
|
||||
use crate::terminal_browser::profile_approval_view_params;
|
||||
|
||||
impl App {
|
||||
fn terminal_browser_network_policy(&self) -> BrowserNetworkPolicy {
|
||||
let config = self.chat_widget.config_ref();
|
||||
if config.permissions.network_sandbox_policy().is_enabled()
|
||||
&& config.permissions.network.is_none()
|
||||
{
|
||||
BrowserNetworkPolicy::Direct
|
||||
} else {
|
||||
BrowserNetworkPolicy::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_browser_enabled(&self) -> bool {
|
||||
self.chat_widget
|
||||
.config_ref()
|
||||
.features
|
||||
.enabled(Feature::TerminalBrowser)
|
||||
&& !self.app_server_target.uses_remote_workspace()
|
||||
}
|
||||
|
||||
fn discover_terminal_browser(&self) -> Arc<TerminalBrowser> {
|
||||
let config = self.chat_widget.config_ref();
|
||||
let codex_linux_sandbox_exe = config.codex_linux_sandbox_exe.as_ref().and_then(|path| {
|
||||
match AbsolutePathBuf::from_absolute_path_checked(path) {
|
||||
Ok(path) => Some(path),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
%error,
|
||||
path = %path.display(),
|
||||
"invalid Linux sandbox helper path for terminal browser"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
Arc::new(TerminalBrowser::discover_with_launch_context(
|
||||
BrowserLaunchContext {
|
||||
codex_home: Some(config.codex_home.clone()),
|
||||
workspace_root: Some(config.cwd.clone()),
|
||||
codex_linux_sandbox_exe,
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn terminal_browser_for_thread(
|
||||
&mut self,
|
||||
owner_thread_id: ThreadId,
|
||||
) -> Option<Arc<TerminalBrowser>> {
|
||||
if !self.terminal_browser_enabled() {
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.terminal_browser_owner_thread_id != Some(owner_thread_id) {
|
||||
self.reset_terminal_browser_for_thread_change().await;
|
||||
}
|
||||
|
||||
if self.terminal_browser.is_none() {
|
||||
let browser = self.discover_terminal_browser();
|
||||
let mut updates = browser.subscribe();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while updates.changed().await.is_ok() {
|
||||
app_event_tx.send(AppEvent::TerminalBrowserUpdated);
|
||||
}
|
||||
});
|
||||
self.terminal_browser_generation =
|
||||
self.terminal_browser_generation.wrapping_add(/*rhs*/ 1);
|
||||
self.terminal_browser = Some(browser);
|
||||
self.terminal_browser_owner_thread_id = Some(owner_thread_id);
|
||||
}
|
||||
|
||||
let network_policy = self.terminal_browser_network_policy();
|
||||
let browser = self.terminal_browser.as_ref()?.clone();
|
||||
browser.set_network_policy(network_policy).await;
|
||||
Some(browser)
|
||||
}
|
||||
|
||||
async fn terminal_browser_for_active_thread(&mut self) -> Option<Arc<TerminalBrowser>> {
|
||||
let owner_thread_id = self.current_displayed_thread_id()?;
|
||||
self.terminal_browser_for_thread(owner_thread_id).await
|
||||
}
|
||||
|
||||
pub(super) fn terminal_browser_request_matches_active_thread(
|
||||
&self,
|
||||
request_thread_id: &str,
|
||||
) -> bool {
|
||||
terminal_browser_request_matches_thread(
|
||||
self.current_displayed_thread_id(),
|
||||
request_thread_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_browser_owned_by_current_thread(&self) -> bool {
|
||||
self.terminal_browser.is_some()
|
||||
&& self
|
||||
.terminal_browser_owner_thread_id
|
||||
.is_some_and(|owner| self.current_displayed_thread_id() == Some(owner))
|
||||
}
|
||||
|
||||
pub(super) async fn terminal_browser_for_active_request(
|
||||
&mut self,
|
||||
) -> Option<Arc<TerminalBrowser>> {
|
||||
self.terminal_browser_for_active_thread().await
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_terminal_browser_network_policy(&mut self) {
|
||||
let Some(browser) = self.terminal_browser.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let network_policy = self.terminal_browser_network_policy();
|
||||
let network_disabled = matches!(network_policy, BrowserNetworkPolicy::Disabled);
|
||||
browser.set_network_policy(network_policy).await;
|
||||
if network_disabled {
|
||||
browser.set_visibility(/*visible*/ false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn toggle_terminal_browser(&mut self) {
|
||||
let Some(browser) = self.terminal_browser_for_active_thread().await else {
|
||||
self.chat_widget.add_info_message(
|
||||
"Terminal browser is disabled for this session.".to_string(),
|
||||
Some(
|
||||
"Enable the Terminal browser experiment, then start a new session.".to_string(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
};
|
||||
if !browser.is_available() {
|
||||
let message = match browser.view().status {
|
||||
BrowserStatus::Unavailable { reason } => reason,
|
||||
_ => "Carbonyl could not be discovered on this machine.".to_string(),
|
||||
};
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Terminal browser is unavailable: {message}"));
|
||||
return;
|
||||
}
|
||||
|
||||
browser.set_visibility(!browser.view().visible);
|
||||
self.app_event_tx.send(AppEvent::TerminalBrowserUpdated);
|
||||
}
|
||||
|
||||
pub(super) fn close_terminal_browser(&mut self) {
|
||||
let Some(browser) = self.terminal_browser.as_ref().cloned() else {
|
||||
self.chat_widget.add_info_message(
|
||||
"Terminal browser is not enabled.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
browser.close().await;
|
||||
app_event_tx.send(AppEvent::TerminalBrowserClosed);
|
||||
});
|
||||
}
|
||||
|
||||
fn take_terminal_browser_for_thread_change(&mut self) -> Option<Arc<TerminalBrowser>> {
|
||||
self.terminal_browser_owner_thread_id = None;
|
||||
self.terminal_browser_generation =
|
||||
self.terminal_browser_generation.wrapping_add(/*rhs*/ 1);
|
||||
let browser = self.terminal_browser.take()?;
|
||||
browser.set_visibility(/*visible*/ false);
|
||||
Some(browser)
|
||||
}
|
||||
|
||||
pub(super) async fn reset_terminal_browser_for_thread_change(&mut self) {
|
||||
let Some(browser) = self.take_terminal_browser_for_thread_change() else {
|
||||
return;
|
||||
};
|
||||
browser.close().await;
|
||||
self.app_event_tx.send(AppEvent::TerminalBrowserClosed);
|
||||
}
|
||||
|
||||
pub(super) fn reset_terminal_browser_for_focus_change(&mut self) {
|
||||
let Some(browser) = self.take_terminal_browser_for_thread_change() else {
|
||||
return;
|
||||
};
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
app_event_tx.send(AppEvent::TerminalBrowserClosed);
|
||||
tokio::spawn(async move {
|
||||
browser.close().await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) async fn doctor_terminal_browser(&mut self) {
|
||||
let Some(browser) = self.terminal_browser_for_active_thread().await else {
|
||||
self.chat_widget
|
||||
.add_error_message("Terminal browser is not enabled.".to_string());
|
||||
return;
|
||||
};
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let report = browser.doctor().await;
|
||||
app_event_tx.send(AppEvent::TerminalBrowserDoctorCompleted {
|
||||
healthy: report.healthy,
|
||||
summary: report.summary,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) async fn manage_terminal_browser_profile(
|
||||
&mut self,
|
||||
command: TerminalBrowserProfileCommand,
|
||||
) {
|
||||
let Some(browser) = self.terminal_browser_for_active_thread().await else {
|
||||
self.chat_widget
|
||||
.add_error_message("Terminal browser is not enabled.".to_string());
|
||||
return;
|
||||
};
|
||||
let result = match command {
|
||||
TerminalBrowserProfileCommand::List => browser.profiles().map(|profiles| {
|
||||
let selected = browser
|
||||
.selected_profile()
|
||||
.map_or_else(|| "ephemeral".to_string(), |name| format!("named `{name}`"));
|
||||
format!(
|
||||
"Terminal-browser profiles: {}. Current profile: {selected}.",
|
||||
if profiles.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
profiles.join(", ")
|
||||
}
|
||||
)
|
||||
}),
|
||||
TerminalBrowserProfileCommand::Create(name) => browser
|
||||
.create_profile(&name)
|
||||
.await
|
||||
.map(|()| format!("Created and selected browser profile `{name}`.")),
|
||||
TerminalBrowserProfileCommand::Use(name) => browser
|
||||
.select_profile(&name)
|
||||
.await
|
||||
.map(|()| format!("Selected browser profile `{name}`.")),
|
||||
TerminalBrowserProfileCommand::Ephemeral => browser
|
||||
.select_ephemeral_profile()
|
||||
.await
|
||||
.map(|()| "Selected a new ephemeral browser profile.".to_string()),
|
||||
TerminalBrowserProfileCommand::Forget(name) => browser
|
||||
.forget_profile(&name)
|
||||
.await
|
||||
.map(|()| format!("Permanently deleted browser profile `{name}`.")),
|
||||
};
|
||||
match result {
|
||||
Ok(message) => self.chat_widget.add_info_message(message, /*hint*/ None),
|
||||
Err(error) => self
|
||||
.chat_widget
|
||||
.add_error_message(format!("Browser profile command failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn terminal_browser_profile_approval(
|
||||
&self,
|
||||
command: TerminalBrowserProfileCommand,
|
||||
) -> Option<TerminalBrowserProfileApproval> {
|
||||
let thread_id = self.terminal_browser_owner_thread_id?;
|
||||
self.terminal_browser_owned_by_current_thread()
|
||||
.then_some(TerminalBrowserProfileApproval {
|
||||
command,
|
||||
thread_id,
|
||||
generation: self.terminal_browser_generation,
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_browser_profile_approval_is_current(
|
||||
&self,
|
||||
approval: &TerminalBrowserProfileApproval,
|
||||
) -> bool {
|
||||
self.terminal_browser.is_some()
|
||||
&& self.current_displayed_thread_id() == Some(approval.thread_id)
|
||||
&& self.terminal_browser_owner_thread_id == Some(approval.thread_id)
|
||||
&& self.terminal_browser_generation == approval.generation
|
||||
}
|
||||
|
||||
pub(super) fn show_terminal_browser_profile_approval(
|
||||
&mut self,
|
||||
approval: TerminalBrowserProfileApproval,
|
||||
) {
|
||||
if !self.terminal_browser_profile_approval_is_current(&approval) {
|
||||
return;
|
||||
}
|
||||
self.chat_widget
|
||||
.show_selection_view(profile_approval_view_params(approval));
|
||||
}
|
||||
|
||||
pub(super) async fn approve_terminal_browser_profile(
|
||||
&mut self,
|
||||
approval: TerminalBrowserProfileApproval,
|
||||
) {
|
||||
if !self.terminal_browser_profile_approval_is_current(&approval) {
|
||||
self.chat_widget.add_info_message(
|
||||
"Browser profile request expired after the active conversation changed."
|
||||
.to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.manage_terminal_browser_profile(approval.command).await;
|
||||
}
|
||||
|
||||
pub(super) async fn toggle_terminal_browser_control(&mut self) {
|
||||
let Some(browser) = self.terminal_browser_for_active_thread().await else {
|
||||
self.chat_widget
|
||||
.add_error_message("Terminal browser is not enabled.".to_string());
|
||||
return;
|
||||
};
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = browser.toggle_human_control().await;
|
||||
app_event_tx.send(AppEvent::TerminalBrowserControlCompleted {
|
||||
error: result.err().map(|error| error.to_string()),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_browser_request_matches_thread(
|
||||
active_thread_id: Option<ThreadId>,
|
||||
request_thread_id: &str,
|
||||
) -> bool {
|
||||
active_thread_id.is_some_and(|thread_id| thread_id.to_string() == request_thread_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "terminal_browser_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::*;
|
||||
use crate::app::test_support::make_test_app;
|
||||
use crate::app_event::PaneSlot;
|
||||
|
||||
#[test]
|
||||
fn terminal_browser_requests_require_the_displayed_thread() {
|
||||
let displayed = ThreadId::new();
|
||||
let inactive = ThreadId::new();
|
||||
|
||||
assert!(terminal_browser_request_matches_thread(
|
||||
Some(displayed),
|
||||
&displayed.to_string(),
|
||||
));
|
||||
assert!(!terminal_browser_request_matches_thread(
|
||||
Some(displayed),
|
||||
&inactive.to_string(),
|
||||
));
|
||||
assert!(!terminal_browser_request_matches_thread(
|
||||
/*active_thread_id*/ None,
|
||||
&displayed.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_approval_expires_when_the_browser_generation_changes() {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.chat_widget
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.attach_thread(thread_id, /*receiver*/ None);
|
||||
app.terminal_browser = Some(Arc::new(TerminalBrowser::discover()));
|
||||
app.terminal_browser_owner_thread_id = Some(thread_id);
|
||||
app.terminal_browser_generation = 3;
|
||||
let approval = app
|
||||
.terminal_browser_profile_approval(TerminalBrowserProfileCommand::Ephemeral)
|
||||
.expect("current browser should produce an approval token");
|
||||
|
||||
assert!(app.terminal_browser_profile_approval_is_current(&approval));
|
||||
app.terminal_browser_generation += 1;
|
||||
assert!(!app.terminal_browser_profile_approval_is_current(&approval));
|
||||
}
|
||||
@@ -45,6 +45,9 @@ pub(super) async fn make_test_app_with_channels() -> (
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
overlay: None,
|
||||
terminal_browser: None,
|
||||
terminal_browser_owner_thread_id: None,
|
||||
terminal_browser_generation: 0,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
enhanced_keys_supported: false,
|
||||
|
||||
@@ -4552,6 +4552,9 @@ async fn make_test_app() -> App {
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
overlay: None,
|
||||
terminal_browser: None,
|
||||
terminal_browser_owner_thread_id: None,
|
||||
terminal_browser_generation: 0,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
enhanced_keys_supported: false,
|
||||
@@ -4619,6 +4622,9 @@ async fn make_test_app_with_channels() -> (
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
overlay: None,
|
||||
terminal_browser: None,
|
||||
terminal_browser_owner_thread_id: None,
|
||||
terminal_browser_generation: 0,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
enhanced_keys_supported: false,
|
||||
|
||||
@@ -33,6 +33,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) {
|
||||
self.reconcile_terminal_browser_network_policy().await;
|
||||
let Some(active_thread_id) = self.chat_widget.active_thread_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::atomic::Ordering;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::DynamicToolCallResponse;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::GetAccountTokenUsageResponse;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
@@ -28,6 +29,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use codex_connectors::AppInfo;
|
||||
@@ -55,6 +57,22 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TerminalBrowserProfileCommand {
|
||||
List,
|
||||
Create(String),
|
||||
Use(String),
|
||||
Ephemeral,
|
||||
Forget(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TerminalBrowserProfileApproval {
|
||||
pub(crate) command: TerminalBrowserProfileCommand,
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ThreadGoalSetMode {
|
||||
ConfirmIfExists,
|
||||
@@ -340,6 +358,48 @@ pub(crate) enum AppEvent {
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(AppCommand),
|
||||
|
||||
/// Show or hide the terminal-browser panel.
|
||||
ToggleTerminalBrowser,
|
||||
|
||||
/// Close the terminal-browser process without forgetting a named profile.
|
||||
CloseTerminalBrowser,
|
||||
|
||||
/// Validate the configured Carbonyl executable and report its compatibility.
|
||||
DoctorTerminalBrowser,
|
||||
|
||||
/// The asynchronous Carbonyl validation probe completed.
|
||||
TerminalBrowserDoctorCompleted {
|
||||
healthy: bool,
|
||||
summary: String,
|
||||
},
|
||||
|
||||
/// Apply a user-entered or user-approved named-profile command.
|
||||
ManageTerminalBrowserProfile(TerminalBrowserProfileCommand),
|
||||
|
||||
/// Apply a model-requested profile command after confirming it still targets this browser.
|
||||
ApproveTerminalBrowserProfile(TerminalBrowserProfileApproval),
|
||||
|
||||
/// Enter or leave exclusive keyboard-and-mouse browser control.
|
||||
ToggleTerminalBrowserControl,
|
||||
|
||||
/// A human-control transition completed.
|
||||
TerminalBrowserControlCompleted {
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// The terminal-browser process finished closing.
|
||||
TerminalBrowserClosed,
|
||||
|
||||
/// The browser's terminal frame, status, or visibility changed.
|
||||
TerminalBrowserUpdated,
|
||||
|
||||
/// Complete an app-server dynamic-tool request after browser work finishes.
|
||||
TerminalBrowserToolCompleted {
|
||||
request_id: AppServerRequestId,
|
||||
response: DynamicToolCallResponse,
|
||||
profile_approval: Option<TerminalBrowserProfileApproval>,
|
||||
},
|
||||
|
||||
/// Restore an output-free interrupted turn into the composer and roll it back.
|
||||
RestoreCancelledTurn(UserMessage),
|
||||
|
||||
|
||||
@@ -252,6 +252,19 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::OpenDesktopThread { thread_id });
|
||||
}
|
||||
SlashCommand::Browser => {
|
||||
if !self.config.features.enabled(Feature::TerminalBrowser) {
|
||||
self.add_info_message(
|
||||
"Terminal browser is disabled.".to_string(),
|
||||
Some(
|
||||
"Enable the Terminal browser experiment, then start a new session."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::ToggleTerminalBrowser);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md");
|
||||
self.submit_user_message(INIT_PROMPT.to_string().into());
|
||||
@@ -696,6 +709,83 @@ impl ChatWidget {
|
||||
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
|
||||
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
|
||||
},
|
||||
SlashCommand::Browser => {
|
||||
let parts = trimmed.split_whitespace().collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[command] if command.eq_ignore_ascii_case("close") => {
|
||||
self.app_event_tx.send(AppEvent::CloseTerminalBrowser);
|
||||
}
|
||||
[command] if command.eq_ignore_ascii_case("doctor") => {
|
||||
self.app_event_tx.send(AppEvent::DoctorTerminalBrowser);
|
||||
}
|
||||
[command] if command.eq_ignore_ascii_case("control") => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ToggleTerminalBrowserControl);
|
||||
}
|
||||
[profile, command]
|
||||
if profile.eq_ignore_ascii_case("profile")
|
||||
&& command.eq_ignore_ascii_case("list") =>
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile(
|
||||
crate::app_event::TerminalBrowserProfileCommand::List,
|
||||
));
|
||||
}
|
||||
[profile, command]
|
||||
if profile.eq_ignore_ascii_case("profile")
|
||||
&& command.eq_ignore_ascii_case("ephemeral") =>
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile(
|
||||
crate::app_event::TerminalBrowserProfileCommand::Ephemeral,
|
||||
));
|
||||
}
|
||||
[profile, command, name]
|
||||
if profile.eq_ignore_ascii_case("profile")
|
||||
&& command.eq_ignore_ascii_case("create") =>
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile(
|
||||
crate::app_event::TerminalBrowserProfileCommand::Create(
|
||||
(*name).to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
[profile, command, name]
|
||||
if profile.eq_ignore_ascii_case("profile")
|
||||
&& command.eq_ignore_ascii_case("use") =>
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile(
|
||||
crate::app_event::TerminalBrowserProfileCommand::Use(
|
||||
(*name).to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
[profile, command, name, confirm]
|
||||
if profile.eq_ignore_ascii_case("profile")
|
||||
&& command.eq_ignore_ascii_case("forget")
|
||||
&& *confirm == "--confirm" =>
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile(
|
||||
crate::app_event::TerminalBrowserProfileCommand::Forget(
|
||||
(*name).to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
[profile, command, name]
|
||||
if profile.eq_ignore_ascii_case("profile")
|
||||
&& command.eq_ignore_ascii_case("forget") =>
|
||||
{
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Profile `{name}` was not deleted. Run `/browser profile forget {name} --confirm` to confirm permanent deletion."
|
||||
),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
_ => self.add_error_message(
|
||||
"Usage: /browser [close|doctor|control|profile list|profile ephemeral|profile create <name>|profile use <name>|profile forget <name> --confirm]"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() {
|
||||
"" => self.open_keymap_picker(),
|
||||
"debug" => {
|
||||
@@ -1091,6 +1181,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::App
|
||||
| SlashCommand::Browser
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::TestApproval => QueueDrain::Continue,
|
||||
SlashCommand::Feedback
|
||||
|
||||
@@ -177,6 +177,7 @@ mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod style;
|
||||
mod terminal_browser;
|
||||
mod terminal_hyperlinks;
|
||||
mod terminal_palette;
|
||||
mod terminal_probe;
|
||||
|
||||
@@ -36,6 +36,7 @@ pub enum SlashCommand {
|
||||
Resume,
|
||||
Fork,
|
||||
App,
|
||||
Browser,
|
||||
Init,
|
||||
Compact,
|
||||
Plan,
|
||||
@@ -96,6 +97,7 @@ impl SlashCommand {
|
||||
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::Browser => "show, hide, or control the terminal browser",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Copy => "copy last response as markdown",
|
||||
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
||||
@@ -173,6 +175,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Summary
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Browser
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,6 +192,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Sidebar
|
||||
| SlashCommand::Summary
|
||||
| SlashCommand::Browser
|
||||
)
|
||||
}
|
||||
|
||||
@@ -231,6 +235,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Stop
|
||||
| SlashCommand::App
|
||||
| SlashCommand::Browser
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Apps
|
||||
@@ -257,6 +262,7 @@ impl SlashCommand {
|
||||
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
|
||||
SlashCommand::Copy => !cfg!(target_os = "android"),
|
||||
SlashCommand::App => cfg!(any(target_os = "macos", target_os = "windows")),
|
||||
SlashCommand::Browser => cfg!(any(target_os = "macos", target_os = "linux")),
|
||||
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
|
||||
_ => true,
|
||||
}
|
||||
@@ -304,6 +310,9 @@ mod tests {
|
||||
assert!(SlashCommand::Raw.available_in_side_conversation());
|
||||
assert!(SlashCommand::Raw.supports_inline_args());
|
||||
assert!(SlashCommand::App.available_during_task());
|
||||
assert!(SlashCommand::Browser.available_during_task());
|
||||
assert!(SlashCommand::Browser.available_in_side_conversation());
|
||||
assert!(SlashCommand::Browser.supports_inline_args());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::app_event::TerminalBrowserProfileApproval;
|
||||
use crate::app_event::TerminalBrowserProfileCommand;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
@@ -37,9 +38,9 @@ fn valid_profile_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn profile_approval_view_params(
|
||||
command: TerminalBrowserProfileCommand,
|
||||
approval: TerminalBrowserProfileApproval,
|
||||
) -> SelectionViewParams {
|
||||
let (subtitle, approve_name, approve_description) = match &command {
|
||||
let (subtitle, approve_name, approve_description) = match &approval.command {
|
||||
TerminalBrowserProfileCommand::Create(name) => (
|
||||
format!("The model wants to create and select browser profile `{name}`."),
|
||||
"Create and select profile",
|
||||
@@ -69,8 +70,8 @@ pub(crate) fn profile_approval_view_params(
|
||||
),
|
||||
};
|
||||
let approve_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(crate::app_event::AppEvent::ManageTerminalBrowserProfile(
|
||||
command.clone(),
|
||||
tx.send(crate::app_event::AppEvent::ApproveTerminalBrowserProfile(
|
||||
approval.clone(),
|
||||
));
|
||||
})];
|
||||
SelectionViewParams {
|
||||
|
||||
@@ -30,6 +30,7 @@ use ratatui::style::Stylize;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::TerminalBrowserProfileApproval;
|
||||
use crate::app_event::TerminalBrowserProfileCommand;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ListSelectionView;
|
||||
@@ -309,6 +310,19 @@ fn human_control_maps_keyboard_and_mouse_input() {
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
browser_key_input(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)),
|
||||
Some(BrowserKeyInput {
|
||||
key: "?".to_string(),
|
||||
code: "Slash".to_string(),
|
||||
text: Some("?".to_string()),
|
||||
modifiers: BrowserInputModifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
browser_mouse_input(
|
||||
MouseEvent {
|
||||
@@ -381,7 +395,11 @@ fn terminal_browser_unavailable_panel_wraps_reason_snapshot() {
|
||||
fn terminal_browser_profile_forget_approval_snapshot() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let view = ListSelectionView::new(
|
||||
profile_approval_view_params(TerminalBrowserProfileCommand::Forget("work".to_string())),
|
||||
profile_approval_view_params(TerminalBrowserProfileApproval {
|
||||
command: TerminalBrowserProfileCommand::Forget("work".to_string()),
|
||||
thread_id: codex_protocol::ThreadId::new(),
|
||||
generation: 1,
|
||||
}),
|
||||
AppEventSender::new(tx),
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user