From c2a65edb6ec70dc89bd728882e2139e69b99b463 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 13 May 2026 09:11:47 +0000 Subject: [PATCH] Add presentation hint for quiet managed hooks --- .../schema/json/ServerNotification.json | 15 +++ .../codex_app_server_protocol.schemas.json | 15 +++ .../codex_app_server_protocol.v2.schemas.json | 15 +++ .../json/v2/HookCompletedNotification.json | 15 +++ .../json/v2/HookStartedNotification.json | 15 +++ .../schema/typescript/v2/HookRunSummary.ts | 3 +- .../typescript/v2/HookVisibilityHint.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/hook.rs | 12 ++ codex-rs/app-server/README.md | 2 + .../request_processors/config_processor.rs | 1 + .../app-server/tests/suite/v2/hooks_list.rs | 1 + codex-rs/config/src/hook_config.rs | 8 ++ codex-rs/config/src/hooks_tests.rs | 8 ++ .../core/src/config/config_loader_tests.rs | 1 + codex-rs/core/src/hook_runtime.rs | 57 +++++++++ codex-rs/core/src/session/tests.rs | 1 + codex-rs/hooks/src/declarations.rs | 1 + codex-rs/hooks/src/engine/discovery.rs | 118 ++++++++++++++++++ codex-rs/hooks/src/engine/dispatcher.rs | 3 + codex-rs/hooks/src/engine/mod.rs | 2 + codex-rs/hooks/src/engine/mod_tests.rs | 14 +++ codex-rs/hooks/src/events/compact.rs | 1 + codex-rs/hooks/src/events/post_tool_use.rs | 1 + codex-rs/hooks/src/events/pre_tool_use.rs | 1 + codex-rs/hooks/src/events/session_start.rs | 1 + codex-rs/hooks/src/events/stop.rs | 1 + .../hooks/src/events/user_prompt_submit.rs | 1 + codex-rs/protocol/src/protocol.rs | 16 +++ codex-rs/tui/src/app/thread_events.rs | 2 + codex-rs/tui/src/chatwidget.rs | 6 + ...ine_rows_but_render_failures_snapshot.snap | 22 ++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 55 ++++++++ codex-rs/tui/src/debug_config.rs | 1 + codex-rs/tui/src/history_cell.rs | 1 + codex-rs/tui/src/history_cell/hook_cell.rs | 43 +++++++ 37 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookVisibilityHint.ts create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot.snap diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6af19f89e2..dd7f04d96a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1873,6 +1873,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -1932,6 +1940,13 @@ ], "type": "object" }, + "HookVisibilityHint": { + "enum": [ + "default", + "hidden" + ], + "type": "string" + }, "ItemCompletedNotification": { "properties": { "completedAtMs": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 667607d43e..d833b5e14e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9787,6 +9787,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/v2/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -9857,6 +9865,13 @@ ], "type": "string" }, + "HookVisibilityHint": { + "enum": [ + "default", + "hidden" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c1a99eddda..4df7402c8c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6287,6 +6287,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -6357,6 +6365,13 @@ ], "type": "string" }, + "HookVisibilityHint": { + "enum": [ + "default", + "hidden" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index f63e6a5cfe..24f0d35e0d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -132,6 +132,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -169,6 +177,13 @@ "unknown" ], "type": "string" + }, + "HookVisibilityHint": { + "enum": [ + "default", + "hidden" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index f8eeecfe4d..64b99592a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -132,6 +132,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -169,6 +177,13 @@ "unknown" ], "type": "string" + }, + "HookVisibilityHint": { + "enum": [ + "default", + "hidden" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts index 75ab780b93..efbae22302 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts @@ -9,5 +9,6 @@ import type { HookOutputEntry } from "./HookOutputEntry"; import type { HookRunStatus } from "./HookRunStatus"; import type { HookScope } from "./HookScope"; import type { HookSource } from "./HookSource"; +import type { HookVisibilityHint } from "./HookVisibilityHint"; -export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array, }; +export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, visibilityHint: HookVisibilityHint, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookVisibilityHint.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookVisibilityHint.ts new file mode 100644 index 0000000000..add4477a8e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookVisibilityHint.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookVisibilityHint = "default" | "hidden"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index ab6eaefb5a..53f426af07 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -161,6 +161,7 @@ export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; export type { HookTrustStatus } from "./HookTrustStatus"; +export type { HookVisibilityHint } from "./HookVisibilityHint"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 4a07bd495b..5f247d2532 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::HookSource as CoreHookSource; use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus; +use codex_protocol::protocol::HookVisibilityHint as CoreHookVisibilityHint; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -70,6 +71,14 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + #[derive(Default)] + pub enum HookVisibilityHint from CoreHookVisibilityHint { + #[default] + Default, Hidden + } +); + v2_enum_from_core!( pub enum HookOutputEntryKind from CoreHookOutputEntryKind { Warning, Stop, Feedback, Context, Error @@ -108,6 +117,8 @@ pub struct HookRunSummary { pub display_order: i64, pub status: HookRunStatus, pub status_message: Option, + #[serde(default)] + pub visibility_hint: HookVisibilityHint, pub started_at: i64, pub completed_at: Option, pub duration_ms: Option, @@ -127,6 +138,7 @@ impl From for HookRunSummary { display_order: value.display_order, status: value.status.into(), status_message: value.status_message, + visibility_hint: value.visibility_hint.into(), started_at: value.started_at, completed_at: value.completed_at, duration_ms: value.duration_ms, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 788fc9e7c3..8cfc5efe52 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1550,6 +1550,8 @@ For linked Git worktrees, project hook declarations come from the matching `.cod Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading. +Hook execution notifications still report every started and completed run. `HookRunSummary.visibilityHint` is a presentation-only hint (`default` or `hidden`) that clients may use to quiet routine managed-hook rendering without suppressing consequential outcomes. + For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. ```json diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b7f973b076..f4157ec3a6 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -500,6 +500,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand timeout_sec, r#async, status_message, + .. } => ConfiguredHookHandler::Command { command, command_windows, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 15675257fd..19f1929aa5 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -57,6 +57,7 @@ fn command_hook_hash( timeout_sec: Some(timeout_sec), r#async: false, status_message: status_message.map(ToOwned::to_owned), + visibility_hint: Default::default(), }], }, }; diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 869761bdaa..636b6bfc17 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::path::PathBuf; use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookVisibilityHint; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -134,6 +135,13 @@ pub enum HookHandlerConfig { r#async: bool, #[serde(default, rename = "statusMessage")] status_message: Option, + #[schemars(skip)] + #[serde( + default, + rename = "visibilityHint", + skip_serializing_if = "HookVisibilityHint::is_default" + )] + visibility_hint: HookVisibilityHint, }, #[serde(rename = "prompt")] Prompt {}, diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 915bec7d0c..c40d7ea2f6 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -8,6 +8,7 @@ use super::HooksFile; use super::HooksToml; use super::ManagedHooksRequirementsToml; use super::MatcherGroup; +use codex_protocol::protocol::HookVisibilityHint; #[test] fn hooks_file_deserializes_existing_json_shape() { @@ -44,6 +45,7 @@ fn hooks_file_deserializes_existing_json_shape() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -79,6 +81,7 @@ statusMessage = "checking" timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -116,6 +119,7 @@ command = "python3 /tmp/pre.py" timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -143,6 +147,7 @@ matcher = "^Bash$" [[PreToolUse.hooks]] type = "command" command = "python3 /enterprise/place/pre.py" +visibilityHint = "hidden" "#, ) .expect("requirements hooks TOML should deserialize"); @@ -161,6 +166,7 @@ command = "python3 /enterprise/place/pre.py" timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Hidden, }], }], ..Default::default() @@ -197,6 +203,7 @@ command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -232,6 +239,7 @@ commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 9eec97773d..edccff9c0c 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1267,6 +1267,7 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: Default::default(), }], }], ..Default::default() diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 3d72e4a7c6..4744724a5c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -598,18 +598,24 @@ fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { #[cfg(test)] mod tests { use codex_protocol::models::ContentItem; + use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookExecutionMode; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookScope; use codex_protocol::protocol::HookSource; + use codex_protocol::protocol::HookStartedEvent; + use codex_protocol::protocol::HookVisibilityHint; use pretty_assertions::assert_eq; use super::additional_context_messages; + use super::emit_hook_completed_events; + use super::emit_hook_started_events; use super::hook_run_analytics_payload; use super::hook_run_metric_tags; use crate::session::tests::make_session_and_context; + use crate::session::tests::make_session_and_context_with_rx; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::test_support::PathBufExt; @@ -684,6 +690,56 @@ mod tests { assert_eq!(hook.status, HookRunStatus::Failed); } + #[tokio::test] + async fn hidden_hook_lifecycle_events_still_reach_clients() { + let (session, turn_context, rx) = make_session_and_context_with_rx().await; + let hidden_run = HookRunSummary { + visibility_hint: HookVisibilityHint::Hidden, + ..sample_hook_run(HookRunStatus::Running, HookSource::CloudRequirements) + }; + let completed_run = HookRunSummary { + status: HookRunStatus::Completed, + completed_at: Some(37), + duration_ms: Some(27), + ..hidden_run.clone() + }; + + emit_hook_started_events(&session, &turn_context, vec![hidden_run.clone()]).await; + emit_hook_completed_events( + &session, + &turn_context, + vec![HookCompletedEvent { + turn_id: Some(turn_context.sub_id.clone()), + run: completed_run.clone(), + }], + ) + .await; + + let started = rx.recv().await.expect("hook started event"); + match started.msg { + EventMsg::HookStarted(event) => assert_eq!( + event, + HookStartedEvent { + turn_id: Some(turn_context.sub_id.clone()), + run: hidden_run, + } + ), + other => panic!("unexpected event: {other:?}"), + } + + let completed = rx.recv().await.expect("hook completed event"); + match completed.msg { + EventMsg::HookCompleted(event) => assert_eq!( + event, + HookCompletedEvent { + turn_id: Some(turn_context.sub_id.clone()), + run: completed_run, + } + ), + other => panic!("unexpected event: {other:?}"), + } + } + #[test] fn hook_run_metric_tags_match_analytics_shape() { let run = sample_hook_run(HookRunStatus::Blocked, HookSource::Project); @@ -736,6 +792,7 @@ mod tests { display_order: 0, status, status_message: None, + visibility_hint: Default::default(), started_at: 10, completed_at: Some(37), duration_ms: Some(27), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c5602ddaae..a3f6cb20b5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1315,6 +1315,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { timeout_sec: Some(600), r#async: false, status_message: None, + visibility_hint: Default::default(), }], }, }; diff --git a/codex-rs/hooks/src/declarations.rs b/codex-rs/hooks/src/declarations.rs index 468fbc947a..2a830df34e 100644 --- a/codex-rs/hooks/src/declarations.rs +++ b/codex-rs/hooks/src/declarations.rs @@ -69,6 +69,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + visibility_hint: Default::default(), }, ], }], diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 51d242efdd..bd831ae966 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -29,6 +29,7 @@ use crate::events::common::validate_matcher_pattern; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; +use codex_protocol::protocol::HookVisibilityHint; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -434,7 +435,19 @@ fn append_matcher_groups( timeout_sec, r#async, status_message, + visibility_hint, } => { + let visibility_hint = if source.is_managed { + visibility_hint + } else if visibility_hint == HookVisibilityHint::Hidden { + warnings.push(format!( + "ignoring visibilityHint in {}: hidden hooks are only supported for managed hook sources", + source.path.display() + )); + HookVisibilityHint::Default + } else { + HookVisibilityHint::Default + }; let command = if cfg!(windows) { command_windows.unwrap_or(command) } else { @@ -461,6 +474,7 @@ fn append_matcher_groups( timeout_sec: Some(timeout_sec), r#async, status_message: status_message.clone(), + visibility_hint: HookVisibilityHint::Default, }; let current_hash = command_hook_hash(event_name, matcher, &group, normalized_handler); @@ -509,6 +523,7 @@ fn append_matcher_groups( source: source.source, display_order: *display_order, env: source.env.clone(), + visibility_hint, }); } *display_order += 1; @@ -630,6 +645,7 @@ mod tests { use codex_config::MatcherGroup; use codex_config::TomlValue; use codex_protocol::protocol::HookTrustStatus; + use codex_protocol::protocol::HookVisibilityHint; fn source_path() -> AbsolutePathBuf { test_path_buf("/tmp/hooks.json").abs() @@ -681,6 +697,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], } } @@ -716,6 +733,7 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: HookVisibilityHint::Default, }] ); } @@ -751,6 +769,7 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: HookVisibilityHint::Default, }] ); } @@ -868,6 +887,103 @@ mod tests { assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write")); } + #[test] + fn visibility_hint_does_not_change_command_trust_hash() { + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + let mut default_entries = Vec::new(); + let mut hidden_entries = Vec::new(); + let mut default_handlers = Vec::new(); + let mut hidden_handlers = Vec::new(); + let mut default_warnings = Vec::new(); + let mut hidden_warnings = Vec::new(); + let mut default_display_order = 0; + let mut hidden_display_order = 0; + let default_group = command_group(Some("^Bash$")); + let mut hidden_group = command_group(Some("^Bash$")); + let HookHandlerConfig::Command { + visibility_hint, .. + } = &mut hidden_group.hooks[0] + else { + unreachable!("command_group should create a command hook"); + }; + *visibility_hint = HookVisibilityHint::Hidden; + + append_matcher_groups( + &mut default_handlers, + &mut default_entries, + &mut default_warnings, + &mut default_display_order, + &hook_handler_source(&source_path, &hook_states), + HookEventName::PreToolUse, + vec![default_group], + ); + append_matcher_groups( + &mut hidden_handlers, + &mut hidden_entries, + &mut hidden_warnings, + &mut hidden_display_order, + &hook_handler_source(&source_path, &hook_states), + HookEventName::PreToolUse, + vec![hidden_group], + ); + + assert_eq!(default_warnings, Vec::::new()); + assert_eq!(hidden_warnings, Vec::::new()); + assert_eq!( + default_entries[0].current_hash, + hidden_entries[0].current_hash + ); + } + + #[test] + fn unmanaged_visibility_hint_is_ignored_with_warning() { + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + let mut handlers = Vec::new(); + let mut entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let mut hidden_group = command_group(Some("^Bash$")); + let HookHandlerConfig::Command { + visibility_hint, .. + } = &mut hidden_group.hooks[0] + else { + unreachable!("command_group should create a command hook"); + }; + *visibility_hint = HookVisibilityHint::Hidden; + let unmanaged_source = super::HookHandlerSource { + path: &source_path, + key_source: source_path.display().to_string(), + source: HookSource::User, + is_managed: false, + bypass_hook_trust: false, + hook_states: &hook_states, + env: std::collections::HashMap::new(), + plugin_id: None, + }; + + append_matcher_groups( + &mut handlers, + &mut entries, + &mut warnings, + &mut display_order, + &unmanaged_source, + HookEventName::PreToolUse, + vec![hidden_group], + ); + + assert_eq!(handlers, Vec::::new()); + assert_eq!(entries.len(), 1); + assert_eq!( + warnings, + vec![format!( + "ignoring visibilityHint in {}: hidden hooks are only supported for managed hook sources", + source_path.display() + )] + ); + } + #[test] fn toml_hook_discovery_ignores_malformed_state_entries() { let layer = ConfigLayerEntry::new( @@ -893,6 +1009,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -923,6 +1040,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 7243fc6eab..ac40c1b931 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -77,6 +77,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { display_order: handler.display_order, status: HookRunStatus::Running, status_message: handler.status_message.clone(), + visibility_hint: handler.visibility_hint, started_at: chrono::Utc::now().timestamp(), completed_at: None, duration_ms: None, @@ -130,6 +131,7 @@ pub(crate) fn completed_summary( display_order: handler.display_order, status, status_message: handler.status_message.clone(), + visibility_hint: handler.visibility_hint, started_at: run_result.started_at, completed_at: Some(run_result.completed_at), duration_ms: Some(run_result.duration_ms), @@ -177,6 +179,7 @@ mod tests { source: HookSource::User, display_order, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 441c24314c..401e8e1fa9 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -31,6 +31,7 @@ use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; +use codex_protocol::protocol::HookVisibilityHint; use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Debug, Clone)] @@ -46,6 +47,7 @@ pub(crate) struct ConfiguredHandler { pub command: String, pub timeout_sec: u64, pub status_message: Option, + pub visibility_hint: HookVisibilityHint, pub source_path: AbsolutePathBuf, pub source: HookSource, pub display_order: i64, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index b7877b6a80..d65d82c174 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -25,6 +25,7 @@ use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; +use codex_protocol::protocol::HookVisibilityHint; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -66,6 +67,7 @@ fn pre_tool_use_hook_events(command: impl Into) -> HookEventsToml { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -173,6 +175,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Hidden, }], }], ..Default::default() @@ -235,6 +238,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: }); assert_eq!(preview.len(), 1); assert_eq!(preview[0].source_path, managed_dir); + assert_eq!(preview[0].visibility_hint, HookVisibilityHint::Hidden); let outcome = engine .run_pre_tool_use(PreToolUseRequest { @@ -252,6 +256,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: .await; assert!(!outcome.should_block); + assert_eq!( + outcome.hook_events[0].run.visibility_hint, + HookVisibilityHint::Hidden + ); let log_contents = fs::read_to_string(log_path).expect("read managed hook log"); assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\"")); } @@ -274,6 +282,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -352,6 +361,7 @@ fn unknown_requirement_source_hooks_stay_managed() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -420,6 +430,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -651,6 +662,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -1146,6 +1158,7 @@ print(json.dumps({ timeout_sec: Some(10), r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() @@ -1258,6 +1271,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { timeout_sec: Some(5), r#async: false, status_message: None, + visibility_hint: HookVisibilityHint::Default, }], }], ..Default::default() diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 469fdda232..06c18206b0 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -589,6 +589,7 @@ mod tests { command: "python3 compact_hook.py".to_string(), timeout_sec: 5, status_message: Some("running compact hook".to_string()), + visibility_hint: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 801c5f09e9..98bdb8788a 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -548,6 +548,7 @@ mod tests { command: "python3 post_tool_use_hook.py".to_string(), timeout_sec: 5, status_message: Some("running post tool use hook".to_string()), + visibility_hint: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index b21daf063b..5cfb574a7e 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -740,6 +740,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 5, status_message: None, + visibility_hint: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 88bd9c00a0..f8f8b08693 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -361,6 +361,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 600, status_message: None, + visibility_hint: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 3cd2d44270..ee44175963 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -528,6 +528,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 600, status_message: None, + visibility_hint: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index eb152a1f48..114cb8a20d 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -419,6 +419,7 @@ mod tests { command: "echo hook".to_string(), timeout_sec: 5, status_message: None, + visibility_hint: Default::default(), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d858..22b2099f6b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1518,6 +1518,20 @@ pub enum HookRunStatus { Stopped, } +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HookVisibilityHint { + #[default] + Default, + Hidden, +} + +impl HookVisibilityHint { + pub fn is_default(&self) -> bool { + *self == Self::Default + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookOutputEntryKind { @@ -1549,6 +1563,8 @@ pub struct HookRunSummary { pub display_order: i64, pub status: HookRunStatus, pub status_message: Option, + #[serde(default)] + pub visibility_hint: HookVisibilityHint, #[ts(type = "number")] pub started_at: i64, #[ts(type = "number | null")] diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 431bf5f804..1ab1edc951 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -413,6 +413,7 @@ mod tests { display_order: 0, status: AppServerHookRunStatus::Running, status_message: Some("checking go-workflow input policy".to_string()), + visibility_hint: Default::default(), started_at: 1, completed_at: None, duration_ms: None, @@ -436,6 +437,7 @@ mod tests { display_order: 0, status: AppServerHookRunStatus::Stopped, status_message: Some("checking go-workflow input policy".to_string()), + visibility_hint: Default::default(), started_at: 1, completed_at: Some(11), duration_ms: Some(10), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b73459c6ff..1bfc447efd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2917,6 +2917,9 @@ impl ChatWidget { } fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { + if history_cell::hook_run_should_skip_render(&run) { + return; + } self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { @@ -2936,6 +2939,9 @@ impl ChatWidget { } fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) { + if history_cell::hook_run_should_skip_render(&completed) { + return; + } let completed_existing_run = self .active_hook_cell .as_mut() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot.snap new file mode 100644 index 0000000000..e37be48219 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "format!(\"{routine_running_snapshot}\\n\\n{routine_completed_snapshot}\\n\\n{failed_snapshot}\")" +--- +routine running +live hooks: + +history: + + +routine completed +live hooks: + +history: + + +failed +live hooks: + +history: +• PostToolUse hook (failed) + error: hook exited with code 7 diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 28ed69139b..0d72a2b8c5 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1483,6 +1483,7 @@ pub(super) fn hook_run( display_order: 0, status, status_message: Some(status_message.to_string()), + visibility_hint: Default::default(), started_at: 1, completed_at: matches!( status, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4cc01d7591..af763e3d30 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2545,6 +2545,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { display_order: 0, status: AppServerHookRunStatus::Running, status_message: Some("checking go-workflow input policy".to_string()), + visibility_hint: Default::default(), started_at: 1, completed_at: None, duration_ms: None, @@ -2568,6 +2569,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { display_order: 0, status: AppServerHookRunStatus::Stopped, status_message: Some("checking go-workflow input policy".to_string()), + visibility_hint: Default::default(), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -2776,6 +2778,58 @@ async fn completed_hook_with_output_flushes_immediately() { ); } +#[tokio::test] +async fn visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let mut routine_started = hook_started_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + Some("checking output policy"), + ); + routine_started.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden; + handle_hook_started(&mut chat, routine_started); + reveal_running_hooks(&mut chat); + let routine_running_snapshot = hook_live_and_history_snapshot(&chat, "routine running", ""); + + let mut routine_completed = hook_completed_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + Vec::new(), + ); + routine_completed.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden; + handle_hook_completed(&mut chat, routine_completed); + let routine_history = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + let routine_completed_snapshot = + hook_live_and_history_snapshot(&chat, "routine completed", &routine_history); + + let mut failed_completed = hook_completed_run( + "post-tool-use:1:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Failed, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Error, + text: "hook exited with code 7".to_string(), + }], + ); + failed_completed.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden; + handle_hook_completed(&mut chat, failed_completed); + let failed_history = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + let failed_snapshot = hook_live_and_history_snapshot(&chat, "failed", &failed_history); + + assert_chatwidget_snapshot!( + "visibility_hidden_hooks_skip_routine_rows_but_render_failures_snapshot", + format!("{routine_running_snapshot}\n\n{routine_completed_snapshot}\n\n{failed_snapshot}") + ); +} + #[tokio::test] async fn completed_hook_output_precedes_following_assistant_message() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -3165,6 +3219,7 @@ fn hook_run_summary( display_order: 0, status, status_message: status_message.map(str::to_string), + visibility_hint: Default::default(), started_at: 1, completed_at: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(2), duration_ms: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(1), diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 3dd2d8235f..d007986131 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -952,6 +952,7 @@ approval_policy = "never" timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: Default::default(), }], }], ..Default::default() diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4521fe9a7b..c37afc12f2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -106,6 +106,7 @@ const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; mod hook_cell; pub(crate) use hook_cell::HookCell; +pub(crate) use hook_cell::hook_run_should_skip_render; pub(crate) use hook_cell::new_active_hook_cell; pub(crate) use hook_cell::new_completed_hook_cell; diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index e78ce27fb0..6a0f639e05 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::HookOutputEntry; use codex_app_server_protocol::HookOutputEntryKind; use codex_app_server_protocol::HookRunStatus; use codex_app_server_protocol::HookRunSummary; +use codex_app_server_protocol::HookVisibilityHint; use ratatui::prelude::*; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; @@ -679,6 +680,16 @@ pub(crate) fn new_completed_hook_cell(run: HookRunSummary, animations_enabled: b HookCell::new_completed(run, animations_enabled) } +/// Returns true when a hook marked as hidden has no user-relevant consequence to render. +pub(crate) fn hook_run_should_skip_render(run: &HookRunSummary) -> bool { + run.visibility_hint == HookVisibilityHint::Hidden + && match run.status { + HookRunStatus::Running => true, + HookRunStatus::Completed => run.entries.is_empty(), + HookRunStatus::Blocked | HookRunStatus::Failed | HookRunStatus::Stopped => false, + } +} + /// Returns true for hook completions that should be invisible in history. fn hook_run_is_quiet_success(run: &HookRunSummary) -> bool { run.status == HookRunStatus::Completed && run.entries.is_empty() @@ -802,6 +813,37 @@ mod tests { ); } + #[test] + fn hidden_hook_skips_routine_running_and_empty_completion_rows() { + let mut run = hook_run_summary("hook-1"); + run.visibility_hint = HookVisibilityHint::Hidden; + assert!(hook_run_should_skip_render(&run)); + + run.status = HookRunStatus::Completed; + assert!(hook_run_should_skip_render(&run)); + } + + #[test] + fn hidden_hook_still_surfaces_user_relevant_outcomes() { + let mut run = hook_run_summary("hook-1"); + run.visibility_hint = HookVisibilityHint::Hidden; + run.status = HookRunStatus::Failed; + assert!(!hook_run_should_skip_render(&run)); + + run.status = HookRunStatus::Blocked; + assert!(!hook_run_should_skip_render(&run)); + + run.status = HookRunStatus::Stopped; + assert!(!hook_run_should_skip_render(&run)); + + run.status = HookRunStatus::Completed; + run.entries = vec![HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: "Review this hook result".to_string(), + }]; + assert!(!hook_run_should_skip_render(&run)); + } + fn hook_run_summary(id: &str) -> HookRunSummary { HookRunSummary { id: id.to_string(), @@ -814,6 +856,7 @@ mod tests { display_order: 0, status: HookRunStatus::Running, status_message: Some("checking output policy".to_string()), + visibility_hint: HookVisibilityHint::Default, started_at: 1, completed_at: None, duration_ms: None,