use color_eyre::eyre::Result;use crossterm::event::KeyEventKind;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::widgets::Clear;use ratatui::widgets::Paragraph;use ratatui::widgets::WidgetRef;use ratatui::widgets::Wrap;use tokio::sync::mpsc::unbounded_channel;use tokio_stream::StreamExt;use crate::app_event::AppEvent;use crate::app_event_sender::AppEventSender;use crate::app_server_session::AppServerSession;use crate::bottom_pane::BottomPaneView;use crate::bottom_pane::ListSelectionView;use crate::bottom_pane::SelectionItem;use crate::bottom_pane::SelectionViewParams;use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;use crate::config_update::format_config_error;use crate::hooks_rpc::HookTrustUpdate;use crate::hooks_rpc::fetch_hooks_list;use crate::hooks_rpc::hook_needs_review;use crate::hooks_rpc::hooks_list_entry_for_cwd;use crate::hooks_rpc::write_hook_trusts;use crate::keymap::RuntimeKeymap;use crate::legacy_core::config::Config;use crate::render::renderable::ColumnRenderable;use crate::render::renderable::Renderable;use crate::tui::Tui;use crate::tui::TuiEvent;use codex_app_server_client::AppServerRequestHandle;use codex_app_server_protocol::HooksListEntry;use std::path::PathBuf;pub(crate) enum StartupHooksReviewOutcome { Continue, OpenHooksBrowser(HooksListEntry),}#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum StartupHooksReviewSelection { ReviewHooks, TrustAllAndContinue, ContinueWithoutTrusting,}pub(crate) async fn load_startup_hooks_review_entry( request_handle: AppServerRequestHandle, cwd: PathBuf,) -> HooksListEntry { let response = match fetch_hooks_list(request_handle, cwd.clone()).await { Ok(response) => response, Err(err) => { tracing::warn!("failed to load startup hook review state: {err:#}"); return HooksListEntry { cwd, hooks: Vec::new(), warnings: Vec::new(), errors: Vec::new(), }; } }; hooks_list_entry_for_cwd(response, &cwd)}pub(crate) async fn maybe_run_startup_hooks_review( app_server: &mut AppServerSession, tui: &mut Tui, config: &Config, bypass_hook_trust: bool, entry: HooksListEntry,) -> Result<StartupHooksReviewOutcome> { if !review_is_needed(bypass_hook_trust, &entry) { return Ok(StartupHooksReviewOutcome::Continue); } run_startup_hooks_review_app(app_server, tui, config, entry).await}async fn run_startup_hooks_review_app( app_server: &mut AppServerSession, tui: &mut Tui, config: &Config, entry: HooksListEntry,) -> Result<StartupHooksReviewOutcome> { let keymap = RuntimeKeymap::from_config(&config.tui_keymap) .map_err(|err| color_eyre::eyre::eyre!(err))?; let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let app_event_tx = AppEventSender::new(tx_raw); let mut trust_all_error = None; let mut view = selection_view( &entry, trust_all_error.as_deref(), /*trusting_all*/ false, app_event_tx.clone(), &keymap, ); draw_view(tui, &view)?; let tui_events = tui.event_stream(); tokio::pin!(tui_events); loop { let Some(event) = tui_events.next().await else { return Ok(StartupHooksReviewOutcome::Continue); }; match event { TuiEvent::Key(key_event) => { if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { view.handle_key_event(key_event); } let Some(selection) = selected_choice(&mut view) else { draw_view(tui, &view)?; continue; }; match selection { StartupHooksReviewSelection::ReviewHooks => { return Ok(StartupHooksReviewOutcome::OpenHooksBrowser(entry)); } StartupHooksReviewSelection::ContinueWithoutTrusting => { return Ok(StartupHooksReviewOutcome::Continue); } StartupHooksReviewSelection::TrustAllAndContinue => { view = selection_view( &entry, trust_all_error.as_deref(), /*trusting_all*/ true, app_event_tx.clone(), &keymap, ); draw_view(tui, &view)?; let result = write_hook_trusts( app_server.request_handle(), entry .hooks .iter() .filter(|hook| hook_needs_review(hook)) .map(|hook| HookTrustUpdate { key: hook.key.clone(), current_hash: hook.current_hash.clone(), }) .collect(), ) .await .map(|_| ()) .map_err(|err| { format!("Failed to trust hooks: {}", format_config_error(&err)) }); match result { Ok(()) => return Ok(StartupHooksReviewOutcome::Continue), Err(err) => { trust_all_error = Some(err); view = selection_view( &entry, trust_all_error.as_deref(), /*trusting_all*/ false, app_event_tx.clone(), &keymap, ); draw_view(tui, &view)?; } } } } } TuiEvent::Paste(_) => {} TuiEvent::Draw | TuiEvent::Resize => draw_view(tui, &view)?, } }}fn selected_choice(view: &mut ListSelectionView) -> Option<StartupHooksReviewSelection> { if !view.is_complete() { return None; } match view.take_last_selected_index() { Some(0) => Some(StartupHooksReviewSelection::ReviewHooks), Some(1) => Some(StartupHooksReviewSelection::TrustAllAndContinue), Some(2) | None => Some(StartupHooksReviewSelection::ContinueWithoutTrusting), Some(_) => None, }}fn selection_view( entry: &HooksListEntry, trust_all_error: Option<&str>, trusting_all: bool, app_event_tx: AppEventSender, keymap: &RuntimeKeymap,) -> ListSelectionView { ListSelectionView::new( selection_view_params(entry, trust_all_error, trusting_all, keymap), app_event_tx, keymap.list.clone(), )}#[allow(clippy::disallowed_methods)]fn selection_view_params( entry: &HooksListEntry, trust_all_error: Option<&str>, trusting_all: bool, keymap: &RuntimeKeymap,) -> SelectionViewParams { let count = review_needed_count(entry); let count_line = match count { 1 => "1 hook is new or changed.".to_string(), count => format!("{count} hooks are new or changed."), }; let mut header = ColumnRenderable::new(); header.push(Line::from("Hooks need review".bold())); header.push(Line::from(count_line).yellow()); header.push(Line::from( "Hooks can run outside the sandbox after you trust them.".dim(), )); if let Some(error) = trust_all_error { header.push(Paragraph::new(Line::from(error.to_string()).red()).wrap(Wrap { trim: false })); } else if trusting_all { header.push(Line::from("Trusting hooks...".dim())); } SelectionViewParams { footer_hint: Some(standard_popup_hint_line_for_keymap(&keymap.list)), items: vec![ selection_item("Review hooks", trusting_all), selection_item("Trust all and continue", trusting_all), selection_item("Continue without trusting (hooks won't run)", trusting_all), ], header: Box::new(header), ..Default::default() }}fn review_needed_count(entry: &HooksListEntry) -> usize { entry .hooks .iter() .filter(|hook| hook_needs_review(hook)) .count()}fn review_is_needed(bypass_hook_trust: bool, entry: &HooksListEntry) -> bool { !bypass_hook_trust && review_needed_count(entry) > 0}fn selection_item(name: &str, is_disabled: bool) -> SelectionItem { SelectionItem { name: name.to_string(), dismiss_on_select: true, is_disabled, ..Default::default() }}fn draw_view(tui: &mut Tui, view: &ListSelectionView) -> Result<()> { tui.draw(u16::MAX, |frame| { let area = frame.area(); frame.render_widget_ref(Clear, area); let view_area = Rect::new( area.x, area.y, area.width, view.desired_height(area.width).min(area.height), ); frame.render_widget_ref(&StandaloneSelectionView { view }, view_area); })?; Ok(())}struct StandaloneSelectionView<'a> { view: &'a ListSelectionView,}impl WidgetRef for &StandaloneSelectionView<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { self.view.render(area, buf); }}#[cfg(test)]mod tests { use super::review_is_needed; use super::selection_view; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; 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 codex_app_server_protocol::HooksListEntry; use insta::assert_snapshot; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; fn hook(key: &str, trust_status: HookTrustStatus) -> HookMetadata { HookMetadata { key: key.to_string(), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, is_managed: false, matcher: Some("Bash".to_string()), command: Some("/tmp/hook.sh".to_string()), timeout_sec: 30, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: HookSource::User, plugin_id: None, display_order: 0, enabled: false, current_hash: format!("sha256:{key}"), trust_status, } } fn entry() -> HooksListEntry { HooksListEntry { cwd: test_path_buf("/tmp"), hooks: vec![ hook("path:new", HookTrustStatus::Untrusted), hook("path:changed", HookTrustStatus::Modified), ], warnings: Vec::new(), errors: Vec::new(), } } fn render_lines(view: &crate::bottom_pane::ListSelectionView, 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>(); rendered.trim_end().to_string() }) .collect::<Vec<_>>() .join("\n") } #[test] fn bypass_hook_trust_suppresses_startup_review() { assert!(!review_is_needed(/*bypass_hook_trust*/ true, &entry())); } #[test] fn untrusted_hooks_need_review_without_bypass() { assert!(review_is_needed(/*bypass_hook_trust*/ false, &entry())); } #[test] fn renders_prompt() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let keymap = RuntimeKeymap::defaults(); let view = selection_view( &entry(), /*trust_all_error*/ None, /*trusting_all*/ false, AppEventSender::new(tx_raw), &keymap, ); assert_snapshot!( "startup_hooks_review_prompt", render_lines(&view, /*width*/ 80) ); } #[test] fn renders_prompt_with_trust_error() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let keymap = RuntimeKeymap::defaults(); let view = selection_view( &entry(), Some( "Failed to trust hooks: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]", ), /*trusting_all*/ false, AppEventSender::new(tx_raw), &keymap, ); assert_snapshot!( "startup_hooks_review_prompt_with_trust_error", render_lines(&view, /*width*/ 62) ); }}