mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add /hooks browser for lifecycle hooks (#19882)
## Why `hooks/list` and `hooks/config/write` give us read/write access to hooks and their state. This hooks up the TUI as a client so users can inspect and manage that state directly. ## What - add a two-page `/hooks` browser in the TUI: an event overview with installed/active counts, followed by a per-event handler page with toggle controls and detail rendering - thread managed-state metadata through hook discovery and `hooks/list` so the UI can label admin-managed hooks and suppress toggles for them - persist hook toggles through the existing config-write path and add snapshot coverage for the event list, handler list, managed-hook, and empty states ## Stack 1. openai/codex#19705 2. openai/codex#19778 3. openai/codex#19840 4. This PR - openai/codex#19882 ## Reviewer Notes - Main UI logic is in `codex-rs/tui/src/bottom_pane/hooks_browser_view.rs`; most of the diff is the new view plus its snapshot coverage - Request / write plumbing for opening the browser and persisting toggles is in `codex-rs/tui/src/app/background_requests.rs` and `codex-rs/tui/src/chatwidget/hooks.rs` - Outside the TUI, the only behavioral change in this PR is threading `is_managed` through hook discovery and `hooks/list` so managed hooks render as non-toggleable - The `codex-rs/tui/src/status/snapshots/` churn is unrelated merge fallout from the stacked base branch's newer permission-label rendering --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
719431da6e
commit
93d53f655b
@@ -122,6 +122,16 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
assert!(engine.warnings().is_empty());
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
let listed = crate::list_hooks(crate::HooksConfig {
|
||||
legacy_notify_argv: None,
|
||||
feature_enabled: true,
|
||||
config_layer_stack: Some(config_layer_stack.clone()),
|
||||
plugin_hook_sources: Vec::new(),
|
||||
plugin_hook_load_warnings: Vec::new(),
|
||||
shell_program: None,
|
||||
shell_args: Vec::new(),
|
||||
});
|
||||
assert!(listed.hooks[0].is_managed);
|
||||
let cwd = cwd();
|
||||
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
@@ -560,7 +570,7 @@ print(json.dumps({
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
/*config_layer_stack*/ None,
|
||||
plugin_hook_sources,
|
||||
plugin_hook_sources.clone(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
@@ -583,6 +593,19 @@ print(json.dumps({
|
||||
assert_eq!(preview.len(), 1);
|
||||
assert_eq!(preview[0].source, HookSource::Plugin);
|
||||
assert_eq!(preview[0].source_path, source_path);
|
||||
let listed = crate::list_hooks(crate::HooksConfig {
|
||||
legacy_notify_argv: None,
|
||||
feature_enabled: true,
|
||||
config_layer_stack: None,
|
||||
plugin_hook_sources,
|
||||
plugin_hook_load_warnings: Vec::new(),
|
||||
shell_program: None,
|
||||
shell_args: Vec::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
listed.hooks[0].plugin_id.as_deref(),
|
||||
Some("demo-plugin@test-marketplace")
|
||||
);
|
||||
|
||||
let outcome = engine
|
||||
.run_pre_tool_use(PreToolUseRequest {
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
use crate::AgentPath;
|
||||
use crate::ThreadId;
|
||||
use crate::approvals::ElicitationRequestEvent;
|
||||
@@ -1529,7 +1531,7 @@ pub enum EventMsg {
|
||||
CollabResumeEnd(CollabResumeEndEvent),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
PreToolUse,
|
||||
|
||||
@@ -84,12 +84,15 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConfigWriteResponse;
|
||||
use codex_app_server_protocol::FeedbackUploadParams;
|
||||
use codex_app_server_protocol::FeedbackUploadResponse;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::HooksListParams;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
#[cfg(test)]
|
||||
@@ -496,6 +499,9 @@ pub(crate) struct App {
|
||||
// overwrite a newer toggle, even if the plugin is toggled from different
|
||||
// cwd contexts.
|
||||
pending_plugin_enabled_writes: HashMap<String, Option<bool>>,
|
||||
// Serialize hook enablement writes per hook so stale completions cannot
|
||||
// persist an older toggle after a newer one.
|
||||
pending_hook_enabled_writes: HashMap<String, Option<bool>>,
|
||||
}
|
||||
|
||||
fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
|
||||
@@ -858,6 +864,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
};
|
||||
if let Some(started) = initial_started_thread {
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
|
||||
@@ -95,6 +95,17 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fetch_hooks_list(request_handle, cwd.clone())
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::HooksLoaded { cwd, result });
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_plugin_detail(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
@@ -250,6 +261,43 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn set_hook_enabled(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
key: String,
|
||||
enabled: bool,
|
||||
) {
|
||||
if let Some(queued_enabled) = self.pending_hook_enabled_writes.get_mut(&key) {
|
||||
*queued_enabled = Some(enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_hook_enabled_writes.insert(key.clone(), None);
|
||||
self.spawn_hook_enabled_write(app_server, key, enabled);
|
||||
}
|
||||
|
||||
pub(super) fn spawn_hook_enabled_write(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
key: String,
|
||||
enabled: bool,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let key_for_event = key.clone();
|
||||
let result = write_hook_enabled(request_handle, key, enabled)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| format!("Failed to update hook config: {err}"));
|
||||
app_event_tx.send(AppEvent::HookEnabledSet {
|
||||
key: key_for_event,
|
||||
enabled,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn refresh_plugin_mentions(&mut self) {
|
||||
let config = self.config.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
@@ -542,6 +590,20 @@ pub(super) async fn fetch_plugins_list(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_hooks_list(
|
||||
request_handle: AppServerRequestHandle,
|
||||
cwd: PathBuf,
|
||||
) -> Result<HooksListResponse> {
|
||||
let request_id = RequestId::String(format!("hooks-list-{}", Uuid::new_v4()));
|
||||
request_handle
|
||||
.request_typed(ClientRequest::HooksList {
|
||||
request_id,
|
||||
params: HooksListParams { cwds: vec![cwd] },
|
||||
})
|
||||
.await
|
||||
.wrap_err("hooks/list failed in TUI")
|
||||
}
|
||||
|
||||
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];
|
||||
|
||||
pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) {
|
||||
@@ -676,6 +738,34 @@ pub(super) async fn write_plugin_enabled(
|
||||
.wrap_err("config/value/write failed while updating plugin enablement in TUI")
|
||||
}
|
||||
|
||||
pub(super) async fn write_hook_enabled(
|
||||
request_handle: AppServerRequestHandle,
|
||||
key: String,
|
||||
enabled: bool,
|
||||
) -> Result<ConfigWriteResponse> {
|
||||
let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4()));
|
||||
request_handle
|
||||
.request_typed(ClientRequest::ConfigBatchWrite {
|
||||
request_id,
|
||||
params: ConfigBatchWriteParams {
|
||||
edits: vec![codex_app_server_protocol::ConfigEdit {
|
||||
key_path: "hooks.state".to_string(),
|
||||
value: serde_json::json!({
|
||||
key: {
|
||||
"enabled": enabled,
|
||||
}
|
||||
}),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
}],
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: true,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("config/batchWrite failed while updating hook enablement in TUI")
|
||||
}
|
||||
|
||||
pub(super) fn build_feedback_upload_params(
|
||||
origin_thread_id: Option<ThreadId>,
|
||||
rollout_path: Option<PathBuf>,
|
||||
|
||||
@@ -384,6 +384,9 @@ impl App {
|
||||
AppEvent::FetchPluginsList { cwd } => {
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
AppEvent::FetchHooksList { cwd } => {
|
||||
self.fetch_hooks_list(app_server, cwd);
|
||||
}
|
||||
AppEvent::OpenMarketplaceAddPrompt => {
|
||||
self.chat_widget.open_marketplace_add_prompt();
|
||||
}
|
||||
@@ -426,6 +429,9 @@ impl App {
|
||||
AppEvent::PluginsLoaded { cwd, result } => {
|
||||
self.chat_widget.on_plugins_loaded(cwd, result);
|
||||
}
|
||||
AppEvent::HooksLoaded { cwd, result } => {
|
||||
self.chat_widget.on_hooks_loaded(cwd, result);
|
||||
}
|
||||
AppEvent::FetchMarketplaceAdd { cwd, source } => {
|
||||
self.fetch_marketplace_add(app_server, cwd, source);
|
||||
}
|
||||
@@ -1654,6 +1660,33 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SetHookEnabled { key, enabled } => {
|
||||
self.set_hook_enabled(app_server, key, enabled);
|
||||
}
|
||||
AppEvent::HookEnabledSet {
|
||||
key,
|
||||
enabled,
|
||||
result,
|
||||
} => {
|
||||
let queued_enabled = self
|
||||
.pending_hook_enabled_writes
|
||||
.get_mut(&key)
|
||||
.and_then(Option::take);
|
||||
let should_apply_result = if let Some(queued_enabled) = queued_enabled
|
||||
&& (result.is_err() || queued_enabled != enabled)
|
||||
{
|
||||
self.spawn_hook_enabled_write(app_server, key.clone(), queued_enabled);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if should_apply_result {
|
||||
self.pending_hook_enabled_writes.remove(&key);
|
||||
if let Err(err) = result {
|
||||
self.chat_widget.add_error_message(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenPermissionsPopup => {
|
||||
self.chat_widget.open_permissions_popup();
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3770,6 +3770,7 @@ async fn make_test_app() -> App {
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3830,6 +3831,7 @@ async fn make_test_app_with_channels() -> (
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
},
|
||||
rx,
|
||||
op_rx,
|
||||
|
||||
@@ -290,12 +290,23 @@ pub(crate) enum AppEvent {
|
||||
cwd: PathBuf,
|
||||
},
|
||||
|
||||
/// Fetch lifecycle hook inventory for the provided working directory.
|
||||
FetchHooksList {
|
||||
cwd: PathBuf,
|
||||
},
|
||||
|
||||
/// Result of fetching plugin marketplace state.
|
||||
PluginsLoaded {
|
||||
cwd: PathBuf,
|
||||
result: Result<PluginListResponse, String>,
|
||||
},
|
||||
|
||||
/// Result of fetching lifecycle hook inventory.
|
||||
HooksLoaded {
|
||||
cwd: PathBuf,
|
||||
result: Result<codex_app_server_protocol::HooksListResponse, String>,
|
||||
},
|
||||
|
||||
/// Open the prompt for adding a marketplace source.
|
||||
OpenMarketplaceAddPrompt,
|
||||
|
||||
@@ -714,6 +725,19 @@ pub(crate) enum AppEvent {
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Enable or disable a hook by stable hook key.
|
||||
SetHookEnabled {
|
||||
key: String,
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Result of persisting hook enabled state.
|
||||
HookEnabledSet {
|
||||
key: String,
|
||||
enabled: bool,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
|
||||
/// Notify that the manage skills popup was closed.
|
||||
ManageSkillsClosed,
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,7 @@ pub(crate) use list_selection_view::popup_content_width;
|
||||
pub(crate) use list_selection_view::side_by_side_layout_widths;
|
||||
pub(crate) use memories_settings_view::MemoriesSettingsView;
|
||||
mod feedback_view;
|
||||
mod hooks_browser_view;
|
||||
pub(crate) use feedback_view::FeedbackAudience;
|
||||
pub(crate) use feedback_view::feedback_classification;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
@@ -136,6 +137,7 @@ mod selection_tabs;
|
||||
mod textarea;
|
||||
mod unified_exec_footer;
|
||||
pub(crate) use feedback_view::FeedbackNoteView;
|
||||
pub(crate) use hooks_browser_view::HooksBrowserView;
|
||||
pub(crate) use selection_tabs::SelectionTab;
|
||||
|
||||
/// How long the "press again to quit" hint stays visible.
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 44)"
|
||||
---
|
||||
|
||||
PreToolUse hooks
|
||||
Turn hooks on or off. Your changes are s
|
||||
|
||||
[x] Hook 1
|
||||
|
||||
Event PreToolUse
|
||||
Matcher Bash
|
||||
Source User config - /tmp/h.json
|
||||
Command one two three four five six
|
||||
seven eight nine ten eleven
|
||||
twelve thirteen fourteen…
|
||||
Timeout 30s
|
||||
|
||||
Press space or enter to toggle; esc to go
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
PermissionRequest hooks
|
||||
Turn hooks on or off. Your changes are saved automatically.
|
||||
|
||||
No hooks installed for this event.
|
||||
|
||||
Press esc to go back
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
Hooks
|
||||
Lifecycle hooks from config and enabled plugins.
|
||||
|
||||
Event Installed Active Description
|
||||
PreToolUse 2 1 Before a tool executes
|
||||
PermissionRequest 1 1 When permission is requested
|
||||
PostToolUse 0 0 After a tool executes
|
||||
SessionStart 0 0 When a new session starts
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
Stop 0 0 Right before Codex ends its turn
|
||||
|
||||
Press enter to view hooks; esc to close
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
Hooks
|
||||
Lifecycle hooks from config and enabled plugins.
|
||||
|
||||
Issues
|
||||
⚠ skipped invalid matcher for PreToolUse
|
||||
■ /tmp/hooks.json: failed to parse hooks config
|
||||
|
||||
Event Installed Active Description
|
||||
PreToolUse 0 0 Before a tool executes
|
||||
PermissionRequest 0 0 When permission is requested
|
||||
PostToolUse 0 0 After a tool executes
|
||||
SessionStart 0 0 When a new session starts
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
Stop 0 0 Right before Codex ends its turn
|
||||
|
||||
Press enter to view hooks; esc to close
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
PreToolUse hooks
|
||||
Turn hooks on or off. Your changes are saved automatically.
|
||||
|
||||
[x] Hook 1
|
||||
[ ] Hook 2
|
||||
|
||||
Event PreToolUse
|
||||
Matcher Bash
|
||||
Source Plugin - superpowers@openai-curated
|
||||
Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh
|
||||
Timeout 30s
|
||||
|
||||
Press space or enter to toggle; esc to go back
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
PermissionRequest hooks
|
||||
Turn hooks on or off. Your changes are saved automatically.
|
||||
|
||||
[x] Hook 1
|
||||
|
||||
Event PermissionRequest
|
||||
Matcher Bash
|
||||
Source Admin config
|
||||
Command /enterprise/hooks/permission-check.sh
|
||||
Timeout 30s
|
||||
|
||||
Managed hooks are always on; press esc to go back
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
PreToolUse hooks
|
||||
Turn hooks on or off. Your changes are saved automatically.
|
||||
|
||||
[x] Hook 2
|
||||
[x] Hook 3
|
||||
[x] Hook 4
|
||||
[x] Hook 5
|
||||
[x] Hook 6
|
||||
[x] Hook 7
|
||||
[x] Hook 8
|
||||
[x] Hook 9
|
||||
|
||||
Event PreToolUse
|
||||
Matcher Bash
|
||||
Source User config - /tmp/hooks.json
|
||||
Command /tmp/hook-8.sh
|
||||
Timeout 30s
|
||||
|
||||
Press space or enter to toggle; esc to go back
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
PreToolUse hooks
|
||||
Turn hooks on or off. Your changes are saved automatically.
|
||||
|
||||
[x] Hook 1
|
||||
[x] Hook 2
|
||||
|
||||
Event PreToolUse
|
||||
Matcher Bash
|
||||
Source Admin config
|
||||
Command /enterprise/hooks/pre-tool-use-2.sh
|
||||
Timeout 30s
|
||||
|
||||
Managed hooks are always on; press esc to go back
|
||||
@@ -327,6 +327,7 @@ mod mcp_startup;
|
||||
use self::mcp_startup::McpStartupStatus;
|
||||
mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
mod hooks;
|
||||
mod skills;
|
||||
mod slash_dispatch;
|
||||
use self::skills::collect_tool_mentions;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::HooksBrowserView;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn add_hooks_output(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::FetchHooksList {
|
||||
cwd: self.config.cwd.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn on_hooks_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
result: Result<HooksListResponse, String>,
|
||||
) {
|
||||
if self.config.cwd.as_path() != cwd.as_path() {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let (hooks, warnings, errors) = response
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd.as_path())
|
||||
.map(|entry| (entry.hooks, entry.warnings, entry.errors))
|
||||
.unwrap_or_default();
|
||||
self.bottom_pane.show_view(Box::new(HooksBrowserView::new(
|
||||
hooks,
|
||||
warnings,
|
||||
errors,
|
||||
self.app_event_tx.clone(),
|
||||
)));
|
||||
self.request_redraw();
|
||||
}
|
||||
Err(err) => self.add_error_message(format!("Failed to load hooks: {err}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,9 @@ impl ChatWidget {
|
||||
SlashCommand::Skills => {
|
||||
self.open_skills_menu();
|
||||
}
|
||||
SlashCommand::Hooks => {
|
||||
self.add_hooks_output();
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
if self.should_prefetch_rate_limits() {
|
||||
let request_id = self.next_status_refresh_request_id;
|
||||
@@ -877,6 +880,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Hooks
|
||||
| SlashCommand::Title
|
||||
| SlashCommand::Statusline
|
||||
| SlashCommand::Theme => QueueDrain::Stop,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Hooks
|
||||
Lifecycle hooks from config and enabled plugins.
|
||||
|
||||
Issues
|
||||
⚠ skipped invalid matcher for PreToolUse
|
||||
■ /tmp/hooks.json: failed to parse hooks config
|
||||
|
||||
Event Installed Active Description
|
||||
PreToolUse 0 0 Before a tool executes
|
||||
PermissionRequest 0 0 When permission is requested
|
||||
PostToolUse 0 0 After a tool executes
|
||||
SessionStart 0 0 When a new session starts
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
Stop 0 0 Right before Codex ends its turn
|
||||
|
||||
Press enter to view hooks; esc to close
|
||||
@@ -35,12 +35,18 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
|
||||
|
||||
pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
|
||||
let mut text = text.into();
|
||||
|
||||
for unix_path in ["/tmp/project", "/tmp/hooks.json"] {
|
||||
let platform_path = test_path_display(unix_path);
|
||||
if platform_path != unix_path {
|
||||
text = text.replace(&platform_path, unix_path);
|
||||
}
|
||||
}
|
||||
|
||||
let platform_test_cwd = test_path_display("/tmp/project");
|
||||
if platform_test_cwd == "/tmp/project" {
|
||||
text
|
||||
} else {
|
||||
text = text.replace(&platform_test_cwd, "/tmp/project");
|
||||
|
||||
for platform_prefix in truncated_path_variants(&platform_test_cwd)
|
||||
.into_iter()
|
||||
.rev()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::HookErrorInfo;
|
||||
use codex_app_server_protocol::HooksListEntry;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -104,6 +107,30 @@ async fn plugins_popup_loading_state_snapshot() {
|
||||
assert_chatwidget_snapshot!("plugins_popup_loading_state", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_popup_shows_list_diagnostics() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let cwd = chat.config.cwd.clone();
|
||||
|
||||
chat.on_hooks_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(HooksListResponse {
|
||||
data: vec![HooksListEntry {
|
||||
cwd: cwd.to_path_buf(),
|
||||
hooks: Vec::new(),
|
||||
warnings: vec!["skipped invalid matcher for PreToolUse".to_string()],
|
||||
errors: vec![HookErrorInfo {
|
||||
path: test_path_buf("/tmp/hooks.json"),
|
||||
message: "failed to parse hooks config".to_string(),
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 112));
|
||||
assert_chatwidget_snapshot!("hooks_popup_shows_list_diagnostics", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub enum SlashCommand {
|
||||
AutoReview,
|
||||
Memories,
|
||||
Skills,
|
||||
Hooks,
|
||||
Review,
|
||||
Rename,
|
||||
New,
|
||||
@@ -91,6 +92,7 @@ impl SlashCommand {
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
SlashCommand::Hooks => "view and manage lifecycle hooks",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
|
||||
SlashCommand::Title => "configure which items appear in the terminal title",
|
||||
@@ -191,6 +193,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Hooks
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::DebugConfig
|
||||
| SlashCommand::Ps
|
||||
|
||||
Reference in New Issue
Block a user