mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Picker-style UI in the TUI has accumulated a mix of hardcoded navigation keys. Some lists supported page movement, some did not; some accepted Vim-like keys, while others only accepted arrows; and tabbed or horizontally adjustable pickers had no shared keymap action for left/right movement. This PR makes picker/list navigation consistent and configurable so users can rely on the same defaults across the TUI. ## What Changed - Adds shared list keymap actions for: - vertical movement: `move_up`, `move_down` - horizontal movement: `move_left`, `move_right` - paging and jumps: `page_up`, `page_down`, `jump_top`, `jump_bottom` - Adds defaults: - Up/down: arrows, `Ctrl+P/N`, `Ctrl+K/J`, and plain `k/j` where text input is not active - Page up/down: `PageUp/PageDown` and `Ctrl+B/F` - First/last: `Home/End` - Left/right: `Left/Right` and `Ctrl+H/L` - Wires the shared list keymap through picker and list surfaces including session resume, multi-select, tabbed selection lists, settings-style lists, app-link selection, MCP elicitation, request-user-input, and the OSS selection wizard. - Keeps search behavior intact by reserving printable characters for query text in searchable pickers. - Updates keymap setup actions, config schema, snapshots, and focused coverage for the new list actions. ## How to Test 1. Start Codex from this branch and open the session picker, for example with an existing session history. 2. In the session list, verify that `Ctrl+J/K` moves the selection down/up. 3. Verify that `Ctrl+F/B` pages down/up and `Home/End` jumps to the first/last visible session. 4. Type printable search text such as `j` or `k` and confirm it updates the query instead of navigating. 5. Focus a picker control that changes values horizontally, such as a session picker toolbar control, and verify `Ctrl+H/L` changes the focused value like left/right arrows. Targeted tests run: - `cargo test -p codex-tui keymap::tests::` - `cargo test -p codex-tui keymap_setup::tests::` - `cargo test -p codex-tui horizontal_list_keys` - `cargo test -p codex-tui page_and_jump_navigation_use_list_keymap` - `cargo test -p codex-tui ctrl_h_l_move_provider_selection` - `cargo test -p codex-tui scroll_state::tests` - `cargo test -p codex-tui switching_tabs_changes_visible_items_and_clears_search` - `cargo test -p codex-tui toggle_sort_key_reloads_with_new_sort` Also ran `just write-config-schema`, `just fmt`, `just fix -p codex-tui`, `just argument-comment-lint`, and `git diff --check`. Note: `cargo test -p codex-tui` was attempted and still aborts in the pre-existing `tests::fork_last_filters_latest_session_by_cwd_unless_show_all` stack overflow, which is unrelated to this branch.
1629 lines
55 KiB
Rust
1629 lines
55 KiB
Rust
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::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::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<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());
|
|
}
|
|
}
|