diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index be9fdad7b19..c37539bb1d0 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -122,6 +122,16 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); assert!(engine.handlers[0].source.is_managed()); + let listed = crate::list_hooks(crate::HooksConfig { + legacy_notify_argv: None, + feature_enabled: true, + config_layer_stack: Some(config_layer_stack.clone()), + plugin_hook_sources: Vec::new(), + plugin_hook_load_warnings: Vec::new(), + shell_program: None, + shell_args: Vec::new(), + }); + assert!(listed.hooks[0].is_managed); let cwd = cwd(); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), @@ -560,7 +570,7 @@ print(json.dumps({ let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*config_layer_stack*/ None, - plugin_hook_sources, + plugin_hook_sources.clone(), Vec::new(), CommandShell { program: String::new(), @@ -583,6 +593,19 @@ print(json.dumps({ assert_eq!(preview.len(), 1); assert_eq!(preview[0].source, HookSource::Plugin); assert_eq!(preview[0].source_path, source_path); + let listed = crate::list_hooks(crate::HooksConfig { + legacy_notify_argv: None, + feature_enabled: true, + config_layer_stack: None, + plugin_hook_sources, + plugin_hook_load_warnings: Vec::new(), + shell_program: None, + shell_args: Vec::new(), + }); + assert_eq!( + listed.hooks[0].plugin_id.as_deref(), + Some("demo-plugin@test-marketplace") + ); let outcome = engine .run_pre_tool_use(PreToolUseRequest { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1eae3213fe9..9445c1994e5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -11,6 +11,8 @@ use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; +use strum_macros::EnumIter; + use crate::AgentPath; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; @@ -1529,7 +1531,7 @@ pub enum EventMsg { CollabResumeEnd(CollabResumeEndEvent), } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)] #[serde(rename_all = "snake_case")] pub enum HookEventName { PreToolUse, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 86e40ac5e99..18c02133ce7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -84,12 +84,15 @@ use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; @@ -583,6 +586,9 @@ pub(crate) struct App { // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. pending_plugin_enabled_writes: HashMap>, + // Serialize hook enablement writes per hook so stale completions cannot + // persist an older toggle after a newer one. + pending_hook_enabled_writes: HashMap>, } fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { @@ -944,6 +950,7 @@ See the Codex keymap documentation for supported actions and examples." pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 45d7247e9f0..afed77fa1cf 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -94,6 +94,17 @@ impl App { }); } + pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_hooks_list(request_handle, cwd.clone()) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::HooksLoaded { cwd, result }); + }); + } + pub(super) fn fetch_plugin_detail( &mut self, app_server: &AppServerSession, @@ -249,6 +260,43 @@ impl App { }); } + pub(super) fn set_hook_enabled( + &mut self, + app_server: &AppServerSession, + key: String, + enabled: bool, + ) { + if let Some(queued_enabled) = self.pending_hook_enabled_writes.get_mut(&key) { + *queued_enabled = Some(enabled); + return; + } + + self.pending_hook_enabled_writes.insert(key.clone(), None); + self.spawn_hook_enabled_write(app_server, key, enabled); + } + + pub(super) fn spawn_hook_enabled_write( + &mut self, + app_server: &AppServerSession, + key: String, + enabled: bool, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let key_for_event = key.clone(); + let result = write_hook_enabled(request_handle, key, enabled) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to update hook config: {err}")); + app_event_tx.send(AppEvent::HookEnabledSet { + key: key_for_event, + enabled, + result, + }); + }); + } + pub(super) fn refresh_plugin_mentions(&mut self) { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); @@ -541,6 +589,20 @@ pub(super) async fn fetch_plugins_list( Ok(response) } +pub(super) async fn fetch_hooks_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + let request_id = RequestId::String(format!("hooks-list-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::HooksList { + request_id, + params: HooksListParams { cwds: vec![cwd] }, + }) + .await + .wrap_err("hooks/list failed in TUI") +} + const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"]; pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) { @@ -675,6 +737,34 @@ pub(super) async fn write_plugin_enabled( .wrap_err("config/value/write failed while updating plugin enablement in TUI") } +pub(super) async fn write_hook_enabled( + request_handle: AppServerRequestHandle, + key: String, + enabled: bool, +) -> Result { + let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConfigBatchWrite { + request_id, + params: ConfigBatchWriteParams { + edits: vec![codex_app_server_protocol::ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + key: { + "enabled": enabled, + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }, + }) + .await + .wrap_err("config/batchWrite failed while updating hook enablement in TUI") +} + pub(super) fn build_feedback_upload_params( origin_thread_id: Option, rollout_path: Option, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ca17daed7d8..c239f908125 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -384,6 +384,9 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::FetchHooksList { cwd } => { + self.fetch_hooks_list(app_server, cwd); + } AppEvent::OpenMarketplaceAddPrompt => { self.chat_widget.open_marketplace_add_prompt(); } @@ -426,6 +429,9 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::HooksLoaded { cwd, result } => { + self.chat_widget.on_hooks_loaded(cwd, result); + } AppEvent::FetchMarketplaceAdd { cwd, source } => { self.fetch_marketplace_add(app_server, cwd, source); } @@ -1653,6 +1659,33 @@ impl App { } } } + AppEvent::SetHookEnabled { key, enabled } => { + self.set_hook_enabled(app_server, key, enabled); + } + AppEvent::HookEnabledSet { + key, + enabled, + result, + } => { + let queued_enabled = self + .pending_hook_enabled_writes + .get_mut(&key) + .and_then(Option::take); + let should_apply_result = if let Some(queued_enabled) = queued_enabled + && (result.is_err() || queued_enabled != enabled) + { + self.spawn_hook_enabled_write(app_server, key.clone(), queued_enabled); + false + } else { + true + }; + if should_apply_result { + self.pending_hook_enabled_writes.remove(&key); + if let Err(err) = result { + self.chat_widget.add_error_message(err); + } + } + } AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index fd88161cade..23f48a4b877 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -59,6 +59,7 @@ pub(super) async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 756c50869dc..0acdec67074 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3733,6 +3733,7 @@ async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), } } @@ -3793,6 +3794,7 @@ async fn make_test_app_with_channels() -> ( pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 87d7484b9cd..33fd1587fbe 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -283,12 +283,23 @@ pub(crate) enum AppEvent { cwd: PathBuf, }, + /// Fetch lifecycle hook inventory for the provided working directory. + FetchHooksList { + cwd: PathBuf, + }, + /// Result of fetching plugin marketplace state. PluginsLoaded { cwd: PathBuf, result: Result, }, + /// Result of fetching lifecycle hook inventory. + HooksLoaded { + cwd: PathBuf, + result: Result, + }, + /// Open the prompt for adding a marketplace source. OpenMarketplaceAddPrompt, @@ -707,6 +718,19 @@ pub(crate) enum AppEvent { enabled: bool, }, + /// Enable or disable a hook by stable hook key. + SetHookEnabled { + key: String, + enabled: bool, + }, + + /// Result of persisting hook enabled state. + HookEnabledSet { + key: String, + enabled: bool, + result: Result<(), String>, + }, + /// Notify that the manage skills popup was closed. ManageSkillsClosed, diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs new file mode 100644 index 00000000000..2f4c6a8a0d4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -0,0 +1,1028 @@ +use codex_app_server_protocol::HookErrorInfo; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HookSource; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use strum::IntoEnumIterator; +use unicode_width::UnicodeWidthStr; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::render_menu_surface; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; +use crate::status::format_directory_display; + +const EVENT_COLUMN_WIDTH: usize = 22; +const COUNT_COLUMN_WIDTH: usize = 12; +const MAX_COMMAND_DETAIL_LINES: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HooksBrowserPage { + Events, + Handlers(HookEventName), +} + +pub(crate) struct HooksBrowserView { + hooks: Vec, + warnings: Vec, + errors: Vec, + page: HooksBrowserPage, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, +} + +impl HooksBrowserView { + pub(crate) fn new( + mut hooks: Vec, + warnings: Vec, + errors: Vec, + app_event_tx: AppEventSender, + ) -> Self { + hooks.sort_by_key(|hook| hook.display_order); + let mut view = Self { + hooks, + warnings, + errors, + page: HooksBrowserPage::Events, + state: ScrollState::new(), + complete: false, + app_event_tx, + }; + if view.page_len() > 0 { + view.state.selected_idx = Some(0); + } + view + } + + fn event_rows(&self) -> Vec { + codex_protocol::protocol::HookEventName::iter() + .map(|event_name| { + let event_name: HookEventName = event_name.into(); + let installed = self + .hooks + .iter() + .filter(|hook| hook.event_name == event_name) + .count(); + let active = self + .hooks + .iter() + .filter(|hook| { + hook.event_name == event_name && (hook.enabled || hook.is_managed) + }) + .count(); + EventRow { + event_name, + installed, + active, + } + }) + .collect() + } + + fn handlers_for_event(&self, event_name: HookEventName) -> impl Iterator { + self.hooks + .iter() + .filter(move |hook| hook.event_name == event_name) + } + + fn selected_event(&self) -> Option { + self.state + .selected_idx + .and_then(|idx| codex_protocol::protocol::HookEventName::iter().nth(idx)) + .map(Into::into) + } + + fn selected_hook_index(&self, event_name: HookEventName) -> Option { + let selected_visible_idx = self.state.selected_idx?; + self.hooks + .iter() + .enumerate() + .filter(|(_, hook)| hook.event_name == event_name) + .nth(selected_visible_idx) + .map(|(idx, _)| idx) + } + + fn selected_hook(&self, event_name: HookEventName) -> Option<&HookMetadata> { + self.selected_hook_index(event_name) + .and_then(|idx| self.hooks.get(idx)) + } + + fn move_up(&mut self) { + let len = self.page_len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, self.max_visible_rows()); + } + + fn move_down(&mut self) { + let len = self.page_len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, self.max_visible_rows()); + } + + fn page_len(&self) -> usize { + match self.page { + HooksBrowserPage::Events => codex_protocol::protocol::HookEventName::iter().count(), + HooksBrowserPage::Handlers(event_name) => self.handlers_for_event(event_name).count(), + } + } + + fn max_visible_rows(&self) -> usize { + MAX_POPUP_ROWS.min(self.page_len().max(1)) + } + + fn open_selected_event(&mut self) { + let Some(event_name) = self.selected_event() else { + return; + }; + self.page = HooksBrowserPage::Handlers(event_name); + self.state = ScrollState::new(); + if self.page_len() > 0 { + self.state.selected_idx = Some(0); + } + } + + fn toggle_selected_hook(&mut self, event_name: HookEventName) { + let Some(idx) = self.selected_hook_index(event_name) else { + return; + }; + let Some(hook) = self.hooks.get_mut(idx) else { + return; + }; + if hook.is_managed { + return; + } + + hook.enabled = !hook.enabled; + self.app_event_tx.send(AppEvent::SetHookEnabled { + key: hook.key.clone(), + enabled: hook.enabled, + }); + } + + fn close(&mut self) { + self.complete = true; + } + + fn return_to_events(&mut self) { + let selected_event_name = match self.page { + HooksBrowserPage::Events => None, + HooksBrowserPage::Handlers(event_name) => Some(event_name), + }; + self.page = HooksBrowserPage::Events; + self.state = ScrollState::new(); + self.state.selected_idx = selected_event_name + .and_then(|event_name| { + codex_protocol::protocol::HookEventName::iter() + .position(|candidate| HookEventName::from(candidate) == event_name) + }) + .or_else(|| (self.page_len() > 0).then_some(0)); + } + + fn event_header_lines() -> Vec> { + vec![ + "Hooks".bold().into(), + "Lifecycle hooks from config and enabled plugins." + .dim() + .into(), + ] + } + + fn handler_header_lines(event_name: HookEventName) -> Vec> { + vec![ + format!("{} hooks", event_label(event_name)).bold().into(), + "Turn hooks on or off. Your changes are saved automatically." + .dim() + .into(), + ] + } + + fn event_table_lines(&self) -> Vec> { + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + format!("{: Vec> { + let mut lines = Vec::new(); + if self.warnings.is_empty() && self.errors.is_empty() { + return lines; + } + + lines.push("Issues".bold().into()); + lines.extend( + self.warnings + .iter() + .map(|warning| format!("⚠ {warning}").into()), + ); + lines.extend(self.errors.iter().map(|error| { + format!("■ {}: {}", error.path.display(), error.message) + .red() + .into() + })); + lines + } + + fn event_page_lines(&self) -> Vec> { + let mut lines = Self::event_header_lines(); + lines.push(Line::default()); + + let issue_lines = self.event_issue_lines(); + if !issue_lines.is_empty() { + lines.extend(issue_lines); + lines.push(Line::default()); + } + + lines.extend(self.event_table_lines()); + lines + } + + fn handler_row_lines(&self, event_name: HookEventName, width: usize) -> Vec> { + self.handlers_for_event(event_name) + .enumerate() + .map(|(idx, hook)| { + let marker = if hook.enabled || hook.is_managed { + 'x' + } else { + ' ' + }; + let row = format!("[{marker}] {}", hook_title(idx)); + let mut line = Line::from(row); + line = truncate_line_with_ellipsis_if_overflow(line, width); + if hook.is_managed { + line = line.dim(); + } + if self.state.selected_idx == Some(idx) { + line = line.cyan().bold(); + } + line + }) + .collect() + } + + fn detail_lines(&self, event_name: HookEventName, width: usize) -> Vec> { + let Some(hook) = self.selected_hook(event_name) else { + return vec!["No hooks installed for this event.".dim().into()]; + }; + + let mut lines = vec![detail_line("Event", event_label(event_name))]; + if let Some(matcher) = hook.matcher.as_deref() { + lines.extend(detail_wrapped_lines( + "Matcher", matcher, width, /*max_lines*/ None, + )); + } + lines.extend(detail_wrapped_lines( + "Source", + &detail_source_value(hook), + width, + /*max_lines*/ None, + )); + lines.extend(detail_wrapped_lines( + "Command", + hook.command.as_deref().unwrap_or("-"), + width, + Some(MAX_COMMAND_DETAIL_LINES), + )); + lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); + lines + } + + fn render_footer(&self, area: Rect, buf: &mut Buffer) { + let hint_area = Rect { + x: area.x + 2, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + let footer = match self.page { + HooksBrowserPage::Events => Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to view hooks; ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ]), + HooksBrowserPage::Handlers(event_name) => { + let selected_hook = self.selected_hook(event_name); + if selected_hook.is_none() { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) + } else if selected_hook.is_some_and(|hook| hook.is_managed) { + Line::from(vec![ + "Managed hooks are always on; press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) + } else { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char(' ')).into(), + " or ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to toggle; ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) + } + } + }; + footer.dim().render(hint_area, buf); + } +} + +impl BottomPaneView for HooksBrowserView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } if self.page == HooksBrowserPage::Events => self.open_selected_event(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.toggle_selected_hook(event_name); + } + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.toggle_selected_hook(event_name); + } + } + KeyEvent { + code: KeyCode::Esc, .. + } => match self.page { + HooksBrowserPage::Events => self.close(), + HooksBrowserPage::Handlers(_) => self.return_to_events(), + }, + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.close(); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +impl Renderable for HooksBrowserView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4) as usize; + let height = match self.page { + HooksBrowserPage::Events => self.event_page_lines().len(), + HooksBrowserPage::Handlers(event_name) => { + let row_count = self.handler_row_lines(event_name, content_width).len(); + if row_count == 0 { + Self::handler_header_lines(event_name).len() + 2 + } else { + let visible_row_count = row_count.min(MAX_POPUP_ROWS); + Self::handler_header_lines(event_name).len() + + 1 + + visible_row_count + + 1 + + self.detail_lines(event_name, content_width).len() + } + } + }; + (height + 3).try_into().unwrap_or(u16::MAX) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let content_area = render_menu_surface(content_area, buf); + let width = content_area.width as usize; + let lines = match self.page { + HooksBrowserPage::Events => self.event_page_lines(), + HooksBrowserPage::Handlers(event_name) => { + let mut lines = Self::handler_header_lines(event_name); + let rows = self.handler_row_lines(event_name, width); + if rows.is_empty() { + lines.push(Line::default()); + lines.push(Line::from( + "No hooks installed for this event.".dim().italic(), + )); + lines.push(Line::default()); + Paragraph::new(lines).render(content_area, buf); + self.render_footer(footer_area, buf); + return; + } + let list_height = rows.len().clamp(1, MAX_POPUP_ROWS) as u16; + lines.push(Line::default()); + let header_height = lines.len() as u16; + let [header_area, list_area, detail_area] = Layout::vertical([ + Constraint::Length(header_height), + Constraint::Length(list_height), + Constraint::Fill(1), + ]) + .areas(content_area); + Paragraph::new(lines.clone()).render(header_area, buf); + let visible_rows = rows + .into_iter() + .skip(self.state.scroll_top) + .take(list_height as usize) + .collect::>(); + Paragraph::new(visible_rows).render(list_area, buf); + let mut detail_lines = vec![Line::default()]; + detail_lines.extend(self.detail_lines(event_name, width)); + Paragraph::new(detail_lines).render(detail_area, buf); + self.render_footer(footer_area, buf); + return; + } + }; + Paragraph::new(lines).render(content_area, buf); + self.render_footer(footer_area, buf); + } +} + +struct EventRow { + event_name: HookEventName, + installed: usize, + active: usize, +} + +fn event_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::Stop => "Stop", + } +} + +fn event_description(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "Before a tool executes", + HookEventName::PermissionRequest => "When permission is requested", + HookEventName::PostToolUse => "After a tool executes", + HookEventName::SessionStart => "When a new session starts", + HookEventName::UserPromptSubmit => "When the user submits a prompt", + HookEventName::Stop => "Right before Codex ends its turn", + } +} + +fn hook_title(idx: usize) -> String { + format!("Hook {}", idx + 1) +} + +fn hook_source_summary(hook: &HookMetadata) -> String { + match hook.source { + HookSource::Plugin => hook + .plugin_id + .as_deref() + .map(|plugin_id| format!("Plugin - {plugin_id}")) + .unwrap_or_else(|| "Plugin".to_string()), + _ => config_source_label(hook.source).to_string(), + } +} + +fn detail_source_value(hook: &HookMetadata) -> String { + match hook.source { + HookSource::Plugin => hook_source_summary(hook), + HookSource::System + | HookSource::Mdm + | HookSource::CloudRequirements + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm => config_source_label(hook.source).to_string(), + _ => format!( + "{} - {}", + config_source_label(hook.source), + format_directory_display(&hook.source_path, /*max_width*/ None) + ), + } +} + +fn config_source_label(source: HookSource) -> &'static str { + match source { + HookSource::System => "Admin config", + HookSource::User => "User config", + HookSource::Project => "Project config", + HookSource::Mdm => "Admin config", + HookSource::SessionFlags => "Session flags", + HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"), + HookSource::CloudRequirements => "Admin config", + HookSource::LegacyManagedConfigFile => "Admin config", + HookSource::LegacyManagedConfigMdm => "Admin config", + HookSource::Unknown => "Unknown source", + } +} + +fn detail_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![format!("{label:<10}").into(), value.to_string().dim()]) +} + +fn detail_wrapped_lines( + label: &str, + value: &str, + width: usize, + max_lines: Option, +) -> Vec> { + let prefix = format!("{label:<10}"); + let available = width.saturating_sub(prefix.width()).max(1); + let mut wrapped = textwrap::wrap(value, available).into_iter(); + let first = wrapped.next().unwrap_or_default().into_owned(); + let mut lines = vec![Line::from(vec![prefix.into(), first.dim()])]; + lines + .extend(wrapped.map(|line| Line::from(vec![" ".into(), line.into_owned().dim()]))); + let Some(max_lines) = max_lines else { + return lines; + }; + if lines.len() <= max_lines { + return lines; + } + + lines.truncate(max_lines); + if let Some(last_line) = lines.last_mut() { + let prefix_width = last_line.spans[..last_line.spans.len().saturating_sub(1)] + .iter() + .map(ratatui::prelude::Span::width) + .sum::(); + let max_width = width.saturating_sub(prefix_width); + let Some(last_span) = last_line.spans.last_mut() else { + return lines; + }; + let truncated = truncate_line_with_ellipsis_if_overflow( + Line::from(format!("{}…", last_span.content)), + max_width, + ); + let content = truncated + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::(); + last_span.content = content.into(); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::bottom_pane_view::BottomPaneView; + use crate::render::renderable::Renderable; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use crate::test_support::test_path_display; + use codex_app_server_protocol::HookEventName; + use codex_app_server_protocol::HookHandlerType; + use codex_app_server_protocol::HookMetadata; + use codex_app_server_protocol::HookSource; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn render_lines(view: &HooksBrowserView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + (0..area.height) + .map(|row| { + let rendered = (0..area.width) + .map(|col| { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + " ".to_string() + } else { + symbol.to_string() + } + }) + .collect::(); + let normalized = rendered + .replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json") + .replace(&test_path_display("/tmp/h.json"), "/tmp/h.json"); + format!("{normalized:width$}", width = area.width as usize) + }) + .collect::>() + .join("\n") + } + + #[allow(clippy::too_many_arguments)] + fn hook( + key: &str, + event_name: HookEventName, + source: HookSource, + plugin_id: Option<&str>, + command: &str, + enabled: bool, + is_managed: bool, + display_order: i64, + ) -> HookMetadata { + HookMetadata { + key: key.to_string(), + event_name, + handler_type: HookHandlerType::Command, + is_managed, + matcher: Some("Bash".to_string()), + command: Some(command.to_string()), + timeout_sec: 30, + status_message: None, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source, + plugin_id: plugin_id.map(str::to_string), + display_order, + enabled, + } + } + + fn view() -> HooksBrowserView { + let (tx_raw, _rx) = unbounded_channel::(); + HooksBrowserView::new( + vec![ + hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + "${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ), + hook( + "path:user-config", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "~/bin/check-shell-with-a-command-that-is-way-too-long-for-the-summary-column.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 1, + ), + hook( + "path:managed", + HookEventName::PermissionRequest, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/permission-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 2, + ), + ], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ) + } + + #[test] + fn renders_event_browser() { + let view = view(); + assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112)); + } + + #[test] + fn renders_event_browser_with_issues() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = HooksBrowserView::new( + Vec::new(), + vec!["skipped invalid matcher for PreToolUse".to_string()], + vec![HookErrorInfo { + path: test_path_buf("/tmp/hooks.json"), + message: "failed to parse hooks config".to_string(), + }], + AppEventSender::new(tx_raw), + ); + + assert_snapshot!( + "hooks_browser_events_with_issues", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_handler_browser_with_details() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112)); + } + + #[test] + fn renders_managed_handler_without_toggle_hint() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_managed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_selected_managed_handler() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![ + hook( + "path:managed-1", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-1.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + ), + hook( + "path:managed-2", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-2.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 1, + ), + ], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_snapshot!( + "hooks_browser_selected_managed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_scrolled_handler_window() { + let (tx_raw, _rx) = unbounded_channel::(); + let hooks = (0..=MAX_POPUP_ROWS) + .map(|idx| { + hook( + &format!("path:hook-{idx}"), + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + &format!("/tmp/hook-{idx}.sh"), + /*enabled*/ true, + /*is_managed*/ false, + idx as i64, + ) + }) + .collect(); + let mut view = + HooksBrowserView::new(hooks, Vec::new(), Vec::new(), AppEventSender::new(tx_raw)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + for _ in 0..MAX_POPUP_ROWS { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + assert_snapshot!( + "hooks_browser_scrolled_handlers", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_command_details_with_three_line_cap() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut capped_command_hook = hook( + "path:long-command", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + capped_command_hook.source_path = test_path_buf("/tmp/h.json").abs(); + let mut view = HooksBrowserView::new( + vec![capped_command_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_capped_command_details", + render_lines(&view, /*width*/ 44) + ); + } + + #[test] + fn renders_empty_handler_browser_message() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + Vec::new(), + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_empty_handlers", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn managed_hooks_count_as_active() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = HooksBrowserView::new( + vec![hook( + "path:managed", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ true, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + let rows = view.event_rows(); + let pre_tool_use = rows + .into_iter() + .find(|row| row.event_name == HookEventName::PreToolUse) + .expect("pre tool use row"); + + assert_eq!(pre_tool_use.installed, 1); + assert_eq!(pre_tool_use.active, 1); + } + + fn assert_unmanaged_toggle_key(key_code: KeyCode) { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + "hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(key_code)); + + match rx.try_recv().expect("toggle event") { + AppEvent::SetHookEnabled { key, enabled } => { + assert_eq!(key, "plugin:superpowers"); + assert!(!enabled); + } + other => panic!("expected hook toggle event, got {other:?}"), + } + } + + #[test] + fn toggle_keys_toggle_unmanaged_handler() { + for key_code in [KeyCode::Char(' '), KeyCode::Enter] { + assert_unmanaged_toggle_key(key_code); + } + } + + #[test] + fn space_does_not_toggle_managed_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "path:managed", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); + + assert!(rx.try_recv().is_err()); + } + + #[test] + fn escape_returns_to_the_selected_event() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert_eq!(view.page, HooksBrowserPage::Events); + assert_eq!( + view.selected_event(), + Some(HookEventName::PermissionRequest) + ); + } + + #[test] + fn esc_routes_through_the_view() { + assert!(view().prefer_esc_to_handle_key_event()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 02275755b78..f87e096c221 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -110,6 +110,7 @@ pub(crate) use list_selection_view::popup_content_width; pub(crate) use list_selection_view::side_by_side_layout_widths; pub(crate) use memories_settings_view::MemoriesSettingsView; mod feedback_view; +mod hooks_browser_view; pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_classification; pub(crate) use feedback_view::feedback_disabled_params; @@ -136,6 +137,7 @@ mod selection_tabs; mod textarea; mod unified_exec_footer; pub(crate) use feedback_view::FeedbackNoteView; +pub(crate) use hooks_browser_view::HooksBrowserView; pub(crate) use selection_tabs::SelectionTab; /// How long the "press again to quit" hint stays visible. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap new file mode 100644 index 00000000000..7af93e3c5a8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 44)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are s + + [x] Hook 1 + + Event PreToolUse + Matcher Bash + Source User config - /tmp/h.json + Command one two three four five six + seven eight nine ten eleven + twelve thirteen fourteen… + Timeout 30s + + Press space or enter to toggle; esc to go diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap new file mode 100644 index 00000000000..33321eec2be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PermissionRequest hooks + Turn hooks on or off. Your changes are saved automatically. + + No hooks installed for this event. + + Press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap new file mode 100644 index 00000000000..522105c30d9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Event Installed Active Description + PreToolUse 2 1 Before a tool executes + PermissionRequest 1 1 When permission is requested + PostToolUse 0 0 After a tool executes + SessionStart 0 0 When a new session starts + UserPromptSubmit 0 0 When the user submits a prompt + Stop 0 0 Right before Codex ends its turn + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap new file mode 100644 index 00000000000..18e3b9f849a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Issues + ⚠ skipped invalid matcher for PreToolUse + ■ /tmp/hooks.json: failed to parse hooks config + + Event Installed Active Description + PreToolUse 0 0 Before a tool executes + PermissionRequest 0 0 When permission is requested + PostToolUse 0 0 After a tool executes + SessionStart 0 0 When a new session starts + UserPromptSubmit 0 0 When the user submits a prompt + Stop 0 0 Right before Codex ends its turn + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap new file mode 100644 index 00000000000..c44f4b866a3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + [ ] Hook 2 + + Event PreToolUse + Matcher Bash + Source Plugin - superpowers@openai-curated + Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh + Timeout 30s + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap new file mode 100644 index 00000000000..21c59065f5d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PermissionRequest hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + + Event PermissionRequest + Matcher Bash + Source Admin config + Command /enterprise/hooks/permission-check.sh + Timeout 30s + + Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap new file mode 100644 index 00000000000..4f4a4377c6a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 2 + [x] Hook 3 + [x] Hook 4 + [x] Hook 5 + [x] Hook 6 + [x] Hook 7 + [x] Hook 8 + [x] Hook 9 + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command /tmp/hook-8.sh + Timeout 30s + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap new file mode 100644 index 00000000000..9a53b95d6d2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + [x] Hook 2 + + Event PreToolUse + Matcher Bash + Source Admin config + Command /enterprise/hooks/pre-tool-use-2.sh + Timeout 30s + + Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bebdbf12480..32cfb781777 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -391,6 +391,7 @@ use self::interrupts::InterruptManager; mod keymap_picker; mod session_header; use self::session_header::SessionHeader; +mod hooks; mod skills; mod slash_dispatch; use self::skills::collect_tool_mentions; diff --git a/codex-rs/tui/src/chatwidget/hooks.rs b/codex-rs/tui/src/chatwidget/hooks.rs new file mode 100644 index 00000000000..5e2748d3aa7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/hooks.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::bottom_pane::HooksBrowserView; +use codex_app_server_protocol::HooksListResponse; + +impl ChatWidget { + pub(crate) fn add_hooks_output(&mut self) { + self.app_event_tx.send(AppEvent::FetchHooksList { + cwd: self.config.cwd.to_path_buf(), + }); + } + + pub(crate) fn on_hooks_loaded( + &mut self, + cwd: PathBuf, + result: Result, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + match result { + Ok(response) => { + let (hooks, warnings, errors) = response + .data + .into_iter() + .find(|entry| entry.cwd.as_path() == cwd.as_path()) + .map(|entry| (entry.hooks, entry.warnings, entry.errors)) + .unwrap_or_default(); + self.bottom_pane.show_view(Box::new(HooksBrowserView::new( + hooks, + warnings, + errors, + self.app_event_tx.clone(), + ))); + self.request_redraw(); + } + Err(err) => self.add_error_message(format!("Failed to load hooks: {err}")), + } + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 82f366fbb71..87daec746bd 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -345,6 +345,9 @@ impl ChatWidget { SlashCommand::Skills => { self.open_skills_menu(); } + SlashCommand::Hooks => { + self.add_hooks_output(); + } SlashCommand::Status => { if self.should_prefetch_rate_limits() { let request_id = self.next_status_refresh_request_id; @@ -878,6 +881,7 @@ impl ChatWidget { | SlashCommand::Logout | SlashCommand::Mention | SlashCommand::Skills + | SlashCommand::Hooks | SlashCommand::Title | SlashCommand::Statusline | SlashCommand::Theme => QueueDrain::Stop, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap new file mode 100644 index 00000000000..865d19031fb --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Hooks + Lifecycle hooks from config and enabled plugins. + + Issues + ⚠ skipped invalid matcher for PreToolUse + ■ /tmp/hooks.json: failed to parse hooks config + + Event Installed Active Description + PreToolUse 0 0 Before a tool executes + PermissionRequest 0 0 When permission is requested + PostToolUse 0 0 After a tool executes + SessionStart 0 0 When a new session starts + UserPromptSubmit 0 0 When the user submits a prompt + Stop 0 0 Right before Codex ends its turn + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 015340bbb2a..547742f80d8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -35,12 +35,18 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec { pub(super) fn normalize_snapshot_paths(text: impl Into) -> String { let mut text = text.into(); + + for unix_path in ["/tmp/project", "/tmp/hooks.json"] { + let platform_path = test_path_display(unix_path); + if platform_path != unix_path { + text = text.replace(&platform_path, unix_path); + } + } + let platform_test_cwd = test_path_display("/tmp/project"); if platform_test_cwd == "/tmp/project" { text } else { - text = text.replace(&platform_test_cwd, "/tmp/project"); - for platform_prefix in truncated_path_variants(&platform_test_cwd) .into_iter() .rev() diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index dd318db133a..29f92f1e7bd 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,5 +1,8 @@ use super::*; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::HookErrorInfo; +use codex_app_server_protocol::HooksListEntry; +use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -102,6 +105,30 @@ async fn plugins_popup_loading_state_snapshot() { assert_chatwidget_snapshot!("plugins_popup_loading_state", popup); } +#[tokio::test] +async fn hooks_popup_shows_list_diagnostics() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let cwd = chat.config.cwd.clone(); + + chat.on_hooks_loaded( + cwd.to_path_buf(), + Ok(HooksListResponse { + data: vec![HooksListEntry { + cwd: cwd.to_path_buf(), + hooks: Vec::new(), + warnings: vec!["skipped invalid matcher for PreToolUse".to_string()], + errors: vec![HookErrorInfo { + path: test_path_buf("/tmp/hooks.json"), + message: "failed to parse hooks config".to_string(), + }], + }], + }), + ); + + let popup = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 112)); + assert_chatwidget_snapshot!("hooks_popup_shows_list_diagnostics", popup); +} + #[tokio::test] async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name() { let (mut chat, _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 4f7714505c4..a1878433235 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -26,6 +26,7 @@ pub enum SlashCommand { AutoReview, Memories, Skills, + Hooks, Review, Rename, New, @@ -91,6 +92,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", + SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", @@ -191,6 +193,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills + | SlashCommand::Hooks | SlashCommand::Status | SlashCommand::DebugConfig | SlashCommand::Ps