use codex_app_server_protocol::HookEventName;use codex_app_server_protocol::HookMetadata;use codex_app_server_protocol::HookSource;use codex_app_server_protocol::HookTrustStatus;use codex_app_server_protocol::HooksListEntry;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::Styled;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::hooks_rpc::HookTrustUpdate;use crate::hooks_rpc::hook_needs_review;use crate::key_hint;use crate::key_hint::KeyBindingListExt;use crate::keymap::ListKeymap;use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;use crate::render::renderable::Renderable;use crate::status::format_directory_display;use crate::style::accent_style;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 { entry: HooksListEntry, page: HooksBrowserPage, state: ScrollState, complete: bool, app_event_tx: AppEventSender, keymap: ListKeymap,}impl HooksBrowserView { #[cfg(test)] pub(crate) fn new( hooks: Vec<HookMetadata>, warnings: Vec<String>, errors: Vec<codex_app_server_protocol::HookErrorInfo>, app_event_tx: AppEventSender, ) -> Self { Self::from_entry( HooksListEntry { cwd: std::path::PathBuf::new(), hooks, warnings, errors, }, app_event_tx, crate::keymap::RuntimeKeymap::defaults().list, ) } pub(crate) fn from_entry( mut entry: HooksListEntry, app_event_tx: AppEventSender, keymap: ListKeymap, ) -> Self { entry.hooks.sort_by_key(|hook| hook.display_order); let mut view = Self { entry, page: HooksBrowserPage::Events, state: ScrollState::new(), complete: false, app_event_tx, keymap, }; if view.page_len() > 0 { view.state.selected_idx = Some( view.event_rows() .iter() .position(|row| row.needs_review > 0) .unwrap_or(0), ); } view } fn event_rows(&self) -> Vec<EventRow> { codex_protocol::protocol::HookEventName::iter() .map(|event_name| { let event_name: HookEventName = event_name.into(); let installed = self .entry .hooks .iter() .filter(|hook| hook.event_name == event_name) .count(); let active = self .entry .hooks .iter() .filter(|hook| hook.event_name == event_name && hook_is_active(hook)) .count(); let needs_review = self .entry .hooks .iter() .filter(|hook| hook.event_name == event_name && hook_needs_review(hook)) .count(); EventRow { event_name, installed, active, needs_review, } }) .collect() } fn handlers_for_event(&self, event_name: HookEventName) -> impl Iterator<Item = &HookMetadata> { self.entry .hooks .iter() .filter(move |hook| hook.event_name == event_name) } fn selected_event(&self) -> Option<HookEventName> { 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<usize> { let selected_visible_idx = self.state.selected_idx?; self.entry .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.entry.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_up(&mut self) { let len = self.page_len(); self.state.page_up_clamped(len, self.max_visible_rows()); } fn page_down(&mut self) { let len = self.page_len(); self.state.page_down_clamped(len, self.max_visible_rows()); } fn jump_top(&mut self) { let len = self.page_len(); self.state.jump_top(len, self.max_visible_rows()); } fn jump_bottom(&mut self) { let len = self.page_len(); self.state.jump_bottom(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.entry.hooks.get_mut(idx) else { return; }; if hook.is_managed { return; } if hook_needs_review(hook) { return; } hook.enabled = !hook.enabled; self.app_event_tx.send(AppEvent::SetHookEnabled { key: hook.key.clone(), enabled: hook.enabled, }); } fn trust_selected_hook(&mut self, event_name: HookEventName) { let Some(idx) = self.selected_hook_index(event_name) else { return; }; let Some(hook) = self.entry.hooks.get_mut(idx) else { return; }; if !hook_needs_review(hook) { return; } hook.trust_status = HookTrustStatus::Trusted; self.app_event_tx.send(AppEvent::TrustHook { key: hook.key.clone(), current_hash: hook.current_hash.clone(), }); } fn trust_all_hooks(&mut self) { let mut updates = Vec::new(); for hook in &mut self.entry.hooks { if !hook_needs_review(hook) { continue; } hook.trust_status = HookTrustStatus::Trusted; updates.push(HookTrustUpdate { key: hook.key.clone(), current_hash: hook.current_hash.clone(), }); } if !updates.is_empty() { self.app_event_tx.send(AppEvent::TrustHooks { updates }); } } 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<Line<'static>> { vec![ "Hooks".bold().into(), "Lifecycle hooks from config and enabled plugins." .dim() .into(), ] } fn review_needed_total_count(&self) -> usize { self.entry .hooks .iter() .filter(|hook| hook_needs_review(hook)) .count() } #[allow(clippy::disallowed_methods)] fn handler_header_lines( event_name: HookEventName, review_needed_count: usize, ) -> Vec<Line<'static>> { let mut lines = vec![format!("{} hooks", event_label(event_name)).bold().into()]; match review_needed_message(review_needed_count) { None => lines.push( "Turn hooks on or off. Your changes are saved automatically." .dim() .into(), ), Some(message) => lines.push(message.yellow().into()), } lines } fn review_needed_count(&self, event_name: HookEventName) -> usize { self.handlers_for_event(event_name) .filter(|hook| hook_needs_review(hook)) .count() } #[allow(clippy::disallowed_methods)] fn event_table_lines(&self) -> Vec<Line<'static>> { let rows = self.event_rows(); let show_review = rows.iter().any(|row| row.needs_review > 0); let mut lines = Vec::new(); let mut header = vec![ format!("{:<EVENT_COLUMN_WIDTH$}", "Event").into(), format!("{:<COUNT_COLUMN_WIDTH$}", "Installed").into(), format!("{:<COUNT_COLUMN_WIDTH$}", "Active").into(), ]; if show_review { header.push(format!("{:<COUNT_COLUMN_WIDTH$}", "Review").into()); } header.push("Description".into()); lines.push(Line::from(header)); for (idx, row) in rows.into_iter().enumerate() { let selected = self.state.selected_idx == Some(idx); let needs_review = row.needs_review > 0; let mut row_line = vec![ Span::from(format!( "{:<EVENT_COLUMN_WIDTH$}", event_label(row.event_name) )), Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.installed)), Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.active)), ]; if show_review { let review_count = Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.needs_review)); row_line.push(if needs_review { review_count.yellow() } else { review_count }); } row_line.push(Span::from(event_description(row.event_name))); if selected { let style = accent_style(); for span in &mut row_line { *span = span.clone().set_style(style); } } else { row_line[1] = row_line[1].clone().dim(); row_line[2] = row_line[2].clone().dim(); if show_review && !needs_review { row_line[3] = row_line[3].clone().dim(); } let description_idx = row_line.len() - 1; row_line[description_idx] = row_line[description_idx].clone().dim(); } lines.push(Line::from(row_line)); } lines } fn event_issue_lines(&self) -> Vec<Line<'static>> { let mut lines = Vec::new(); if self.entry.warnings.is_empty() && self.entry.errors.is_empty() { return lines; } lines.push("Issues".bold().into()); lines.extend( self.entry .warnings .iter() .map(|warning| format!("⚠ {warning}").into()), ); lines.extend(self.entry.errors.iter().map(|error| { format!("■ {}: {}", error.path.display(), error.message) .red() .into() })); lines } #[allow(clippy::disallowed_methods)] fn event_page_lines(&self) -> Vec<Line<'static>> { let mut lines = Self::event_header_lines(); lines.push(Line::default()); if let Some(message) = review_needed_message(self.review_needed_total_count()) { lines.push(format!("⚠ {message}").yellow().into()); 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 } #[allow(clippy::disallowed_methods)] fn handler_row_lines(&self, event_name: HookEventName, width: usize) -> Vec<Line<'static>> { self.handlers_for_event(event_name) .enumerate() .map(|(idx, hook)| { let marker = if hook_needs_review(hook) { '!' } else if hook_is_active(hook) { 'x' } else { ' ' }; let row = match hook.trust_status { HookTrustStatus::Modified => { format!("[{marker}] {} · modified", hook_title(idx)) } HookTrustStatus::Untrusted => format!("[{marker}] {} · new", hook_title(idx)), HookTrustStatus::Managed | HookTrustStatus::Trusted => { format!("[{marker}] {}", hook_title(idx)) } }; let mut line = Line::from(row); line = truncate_line_with_ellipsis_if_overflow(line, width); let needs_review = hook_needs_review(hook); if self.state.selected_idx == Some(idx) { if needs_review { line = line.yellow().bold(); } else { line = line.patch_style(accent_style()); } } else if needs_review { line = line.yellow(); } else if hook.is_managed { line = line.dim(); } line }) .collect() } fn detail_lines(&self, event_name: HookEventName, width: usize) -> Vec<Line<'static>> { 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.push(detail_line("Trust", hook_trust_label(hook.trust_status))); 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 if self.review_needed_total_count() > 0 => Line::from(vec![ "Press ".into(), key_hint::plain(KeyCode::Char('t')).into(), " to trust all; ".into(), key_hint::plain(KeyCode::Enter).into(), " to review hooks; ".into(), key_hint::plain(KeyCode::Esc).into(), " to close".into(), ]), 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 if selected_hook.is_some_and(hook_needs_review) { Line::from(vec![ "Press ".into(), key_hint::plain(KeyCode::Char('t')).into(), " to trust; ".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 { _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), _ if self.keymap.accept.is_pressed(key_event) && self.page == HooksBrowserPage::Events => { self.open_selected_event() } _ if self.keymap.accept.is_pressed(key_event) => { 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::Char('t'), modifiers: KeyModifiers::NONE, .. } => match self.page { HooksBrowserPage::Events => self.trust_all_hooks(), HooksBrowserPage::Handlers(event_name) => self.trust_selected_hook(event_name), }, _ if self.keymap.cancel.is_pressed(key_event) => 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(); let header_line_count = Self::handler_header_lines(event_name, self.review_needed_count(event_name)) .len(); if row_count == 0 { header_line_count + 2 } else { let visible_row_count = row_count.min(MAX_POPUP_ROWS); header_line_count + 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, self.review_needed_count(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::<Vec<_>>(); 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); }}fn hook_is_active(hook: &HookMetadata) -> bool { hook.enabled && matches!( hook.trust_status, HookTrustStatus::Managed | HookTrustStatus::Trusted )}fn review_needed_message(count: usize) -> Option<String> { match count { 0 => None, 1 => Some("1 hook needs review before it can run.".to_string()), count => Some(format!("{count} hooks need review before they can run.")), }}struct EventRow { event_name: HookEventName, installed: usize, active: usize, needs_review: usize,}fn hook_trust_label(status: HookTrustStatus) -> &'static str { match status { HookTrustStatus::Managed => "Managed", HookTrustStatus::Trusted => "Trusted", HookTrustStatus::Untrusted => "New hook - review required", HookTrustStatus::Modified => "Modified since last trusted - review required", }}fn event_label(event_name: HookEventName) -> &'static str { match event_name { HookEventName::PreToolUse => "PreToolUse", HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", 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::PreCompact => "Before context compaction", HookEventName::PostCompact => "After context compaction", HookEventName::SessionStart => "When a new session starts", HookEventName::UserPromptSubmit => "When the user submits a prompt", HookEventName::SubagentStart => "When a subagent is created", HookEventName::SubagentStop => "Right before a subagent ends its turn", 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::CloudManagedConfig | 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::CloudManagedConfig => "Cloud-managed 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<usize>,) -> Vec<Line<'static>> { 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::<usize>(); 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::<String>(); 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::HookErrorInfo; 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 codex_app_server_protocol::HookTrustStatus; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use insta::assert_snapshot; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Modifier; 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::<String>(); 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::<Vec<_>>() .join("\n") } fn render_buffer(view: &HooksBrowserView, width: u16) -> Buffer { 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); buf } #[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 { let current_hash = "sha256:current".to_string(); 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, current_hash, trust_status: if is_managed { HookTrustStatus::Managed } else { HookTrustStatus::Trusted }, } } fn view() -> HooksBrowserView { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); 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 selected_event_rows_use_the_shared_accent_style() { let view = view(); let buf = render_buffer(&view, /*width*/ 112); let expected = accent_style(); let selected_cell = buf .content .iter() .find(|cell| { let style = cell.style(); cell.symbol() == "P" && style.fg == expected.fg && style.add_modifier.contains(Modifier::BOLD) }) .expect("selected event row should use the shared accent style"); assert_eq!(selected_cell.style().fg, expected.fg); } #[test] fn renders_event_browser_with_review_column_when_needed() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/pre-tool-use-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let view = HooksBrowserView::new( vec![untrusted_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); assert_snapshot!( "hooks_browser_events_with_review_column", render_lines(&view, /*width*/ 112) ); assert_eq!( view.event_table_lines()[1].spans[3].style.fg, Some(Color::Cyan) ); assert!( view.event_table_lines()[1].spans[3] .style .add_modifier .contains(ratatui::style::Modifier::BOLD) ); } #[test] fn renders_event_browser_with_issues() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); 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_untrusted_enabled_handler_as_inactive() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "~/bin/untrusted.sh", /*enabled*/ true, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let mut view = HooksBrowserView::new( vec![untrusted_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_snapshot!( "hooks_browser_untrusted_enabled_handler", render_lines(&view, /*width*/ 112) ); } #[test] fn review_needed_handler_rows_use_warning_color() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "~/bin/untrusted.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 1, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let mut view = HooksBrowserView::new( vec![ hook( "path:trusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "~/bin/trusted.sh", /*enabled*/ true, /*is_managed*/ false, /*display_order*/ 0, ), untrusted_hook, ], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_eq!( view.handler_row_lines(HookEventName::PreToolUse, /*width*/ 112)[1] .style .fg, Some(Color::Yellow) ); } #[test] fn review_needed_handler_header_uses_warning_color() { assert_eq!( HooksBrowserView::handler_header_lines( HookEventName::PreToolUse, /*review_needed_count*/ 1, )[1] .spans[0] .style .fg, Some(Color::Yellow) ); } #[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::<AppEvent>(); 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::<AppEvent>(); 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::<AppEvent>(); 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::<AppEvent>(); 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::<AppEvent>(); let 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), ); 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); } #[test] fn review_needed_hooks_are_not_active() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/pre-tool-use-check.sh", /*enabled*/ true, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let view = HooksBrowserView::new( vec![untrusted_hook], 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, 0); assert_eq!(pre_tool_use.needs_review, 1); } #[test] fn review_needed_event_is_selected_by_default() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PermissionRequest, HookSource::User, /*plugin_id*/ None, "/tmp/permission-request-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let view = HooksBrowserView::new( vec![untrusted_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); assert_eq!( view.selected_event(), Some(HookEventName::PermissionRequest) ); } #[test] fn renders_review_needed_handler() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/pre-tool-use-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let mut view = HooksBrowserView::new( vec![untrusted_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_snapshot!( "hooks_browser_review_needed_handler", render_lines(&view, /*width*/ 112) ); } fn assert_unmanaged_toggle_key(key_code: KeyCode) { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); 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::<AppEvent>(); 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 trust_key_trusts_review_needed_handler_without_changing_enablement() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/pre-tool-use-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let current_hash = untrusted_hook.current_hash.clone(); let mut view = HooksBrowserView::new( vec![untrusted_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); match rx.try_recv().expect("trust event") { AppEvent::TrustHook { key, current_hash: hash_to_trust, } => { assert_eq!(key, "path:untrusted"); assert_eq!(hash_to_trust, current_hash); } other => panic!("expected hook trust event, got {other:?}"), } } #[test] fn trust_key_preserves_disabled_modified_handler() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let mut modified_hook = hook( "path:modified", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/pre-tool-use-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 0, ); modified_hook.trust_status = HookTrustStatus::Modified; let current_hash = modified_hook.current_hash.clone(); let mut view = HooksBrowserView::new( vec![modified_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); let hook = view.entry.hooks.first().expect("trusted hook"); assert!(!hook.enabled); assert_eq!(hook.trust_status, HookTrustStatus::Trusted); match rx.try_recv().expect("trust event") { AppEvent::TrustHook { key, current_hash: hash_to_trust, } => { assert_eq!(key, "path:modified"); assert_eq!(hash_to_trust, current_hash); } other => panic!("expected hook trust event, got {other:?}"), } } #[test] fn trust_key_on_event_page_trusts_all_review_needed_hooks() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let mut untrusted_hook = hook( "path:untrusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/pre-tool-use-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 0, ); untrusted_hook.trust_status = HookTrustStatus::Untrusted; let mut modified_hook = hook( "path:modified", HookEventName::Stop, HookSource::User, /*plugin_id*/ None, "/tmp/stop-check.sh", /*enabled*/ false, /*is_managed*/ false, /*display_order*/ 1, ); modified_hook.trust_status = HookTrustStatus::Modified; let mut view = HooksBrowserView::new( vec![ untrusted_hook, modified_hook, hook( "path:trusted", HookEventName::PreToolUse, HookSource::User, /*plugin_id*/ None, "/tmp/trusted.sh", /*enabled*/ true, /*is_managed*/ false, /*display_order*/ 2, ), ], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); assert_eq!( view.entry .hooks .iter() .map(|hook| hook.trust_status) .collect::<Vec<_>>(), vec![ HookTrustStatus::Trusted, HookTrustStatus::Trusted, HookTrustStatus::Trusted, ] ); match rx.try_recv().expect("trust event") { AppEvent::TrustHooks { updates } => assert_eq!( updates, vec![ HookTrustUpdate { key: "path:untrusted".to_string(), current_hash: "sha256:current".to_string(), }, HookTrustUpdate { key: "path:modified".to_string(), current_hash: "sha256:current".to_string(), }, ] ), other => panic!("expected hook trust event, got {other:?}"), } 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()); }}