From 012d0aa0abcf56e83fb89c8c0f1d23647649c0c3 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sat, 9 May 2026 17:35:40 -0400 Subject: [PATCH] Add hook visibility hints --- .../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 | 3 + codex-rs/config/src/hooks_tests.rs | 17 +++-- codex-rs/core/config.schema.json | 15 ++++ .../core/src/config/config_loader_tests.rs | 1 + codex-rs/core/src/hook_runtime.rs | 1 + codex-rs/core/src/session/tests.rs | 1 + codex-rs/hooks/src/declarations.rs | 1 + codex-rs/hooks/src/engine/discovery.rs | 7 ++ codex-rs/hooks/src/engine/dispatcher.rs | 3 + codex-rs/hooks/src/engine/mod.rs | 1 + codex-rs/hooks/src/engine/mod_tests.rs | 73 +++++++++++++++++++ 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 | 10 +++ codex-rs/tui/src/app/thread_events.rs | 2 + codex-rs/tui/src/chatwidget.rs | 6 ++ ..._hook_background_and_failure_snapshot.snap | 13 ++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 52 +++++++++++++ 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 | 15 ++++ 38 files changed, 323 insertions(+), 7 deletions(-) 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__hidden_hook_background_and_failure_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 215842d929..6f948a1457 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 @@ -9751,6 +9751,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/v2/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -9821,6 +9829,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 085744e819..3919383ab5 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 @@ -6251,6 +6251,14 @@ "string", "null" ] + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -6321,6 +6329,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 a6b961366e..7739debd4d 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..0080fd5388 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; @@ -76,6 +77,14 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + #[derive(Default)] + pub enum HookVisibilityHint from CoreHookVisibilityHint { + #[default] + Default, Hidden + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -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 9ff7d890ff..2a612c02e5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1560,6 +1560,8 @@ Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result i 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 lifecycle notifications include each run's `visibilityHint`. Clients may use `"hidden"` to suppress routine background rendering while still surfacing runs that fail, block, stop, or otherwise produce user-relevant output. + 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 cda2bbe61d..b960c98a10 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -517,6 +517,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand timeout_sec, r#async, status_message, + visibility_hint: _, } => ConfiguredHookHandler::Command { command, timeout_sec, 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 623896626c..7c09fbad09 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -56,6 +56,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 630d18c569..77bad55832 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; @@ -132,6 +133,8 @@ pub enum HookHandlerConfig { r#async: bool, #[serde(default, rename = "statusMessage")] status_message: Option, + #[serde(default, rename = "visibilityHint")] + 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 69fcd3fe95..ac2703147d 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -18,12 +18,13 @@ fn hooks_file_deserializes_existing_json_shape() { { "matcher": "^Bash$", "hooks": [ - { - "type": "command", - "command": "python3 /tmp/pre.py", - "timeout": 10, - "statusMessage": "checking" - } + { + "type": "command", + "command": "python3 /tmp/pre.py", + "timeout": 10, + "statusMessage": "checking", + "visibilityHint": "hidden" + } ] } ] @@ -43,6 +44,7 @@ fn hooks_file_deserializes_existing_json_shape() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: codex_protocol::protocol::HookVisibilityHint::Hidden, }], }], ..Default::default() @@ -77,6 +79,7 @@ statusMessage = "checking" timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: Default::default(), }], }], ..Default::default() @@ -113,6 +116,7 @@ command = "python3 /tmp/pre.py" timeout_sec: None, r#async: false, status_message: None, + visibility_hint: Default::default(), }], }], ..Default::default() @@ -157,6 +161,7 @@ command = "python3 /enterprise/place/pre.py" timeout_sec: None, r#async: false, status_message: None, + visibility_hint: Default::default(), }], }], ..Default::default() diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index edbf45694f..c23d088866 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -971,6 +971,14 @@ "command" ], "type": "string" + }, + "visibilityHint": { + "allOf": [ + { + "$ref": "#/definitions/HookVisibilityHint" + } + ], + "default": "default" } }, "required": [ @@ -1020,6 +1028,13 @@ }, "type": "object" }, + "HookVisibilityHint": { + "enum": [ + "default", + "hidden" + ], + "type": "string" + }, "HooksToml": { "properties": { "PermissionRequest": { diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 4a7a33b7e6..cf776f9b12 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1160,6 +1160,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 2bea729339..e6fe6bfc03 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -724,6 +724,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 6a5b08c124..4831c61680 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 6c414eaf81..da16e91a34 100644 --- a/codex-rs/hooks/src/declarations.rs +++ b/codex-rs/hooks/src/declarations.rs @@ -68,6 +68,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 cc180325b6..a5a0625609 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -391,6 +391,7 @@ fn append_matcher_groups( timeout_sec, r#async, status_message, + visibility_hint, } => { if r#async { warnings.push(format!( @@ -412,6 +413,7 @@ fn append_matcher_groups( timeout_sec: Some(timeout_sec), r#async, status_message: status_message.clone(), + visibility_hint, }; let current_hash = command_hook_hash(event_name, matcher, &group, normalized_handler); @@ -455,6 +457,7 @@ fn append_matcher_groups( command, timeout_sec, status_message, + visibility_hint, source_path: source.path.clone(), source: source.source, display_order: *display_order, @@ -611,6 +614,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + visibility_hint: Default::default(), }], } } @@ -642,6 +646,7 @@ mod tests { command: "echo hello".to_string(), timeout_sec: 600, status_message: None, + visibility_hint: Default::default(), source_path: source_path.clone(), source: hook_source(), display_order: 0, @@ -677,6 +682,7 @@ mod tests { command: "echo hello".to_string(), timeout_sec: 600, status_message: None, + visibility_hint: Default::default(), source_path: source_path.clone(), source: hook_source(), display_order: 0, @@ -756,6 +762,7 @@ mod tests { timeout_sec: None, r#async: false, status_message: None, + visibility_hint: Default::default(), }], }], ..Default::default() diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 9c71bf49b5..023cda0ad6 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -75,6 +75,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, @@ -121,6 +122,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), @@ -168,6 +170,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 579f7594d4..673da33195 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -46,6 +46,7 @@ pub(crate) struct ConfiguredHandler { pub command: String, pub timeout_sec: u64, pub status_message: Option, + pub visibility_hint: codex_protocol::protocol::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 32739165f1..e2069fe688 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -88,6 +88,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: Default::default(), }], }], ..Default::default() @@ -169,6 +170,73 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\"")); } +#[test] +fn preview_runs_preserve_configured_visibility_hint() { + let temp = tempdir().expect("create temp dir"); + let managed_dir = + AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); + fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); + let managed_hooks = managed_hooks_for_current_platform( + managed_dir, + HookEventsToml { + user_prompt_submit: vec![MatcherGroup { + matcher: None, + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/managed.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + visibility_hint: codex_protocol::protocol::HookVisibilityHint::Hidden, + }], + }], + ..Default::default() + }, + ); + let config_layer_stack = ConfigLayerStack::new( + Vec::new(), + ConfigRequirements { + managed_hooks: Some(ConstrainedWithSource::new( + Constrained::allow_any(managed_hooks.clone()), + Some(RequirementSource::CloudRequirements), + )), + ..ConfigRequirements::default() + }, + ConfigRequirementsToml { + hooks: Some(managed_hooks), + ..ConfigRequirementsToml::default() + }, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + let preview = engine.preview_user_prompt_submit( + &crate::events::user_prompt_submit::UserPromptSubmitRequest { + session_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + cwd: cwd(), + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + prompt: "hello".to_string(), + }, + ); + + assert_eq!(preview.len(), 1); + assert_eq!( + preview[0].visibility_hint, + codex_protocol::protocol::HookVisibilityHint::Hidden + ); +} + #[test] fn unknown_requirement_source_hooks_stay_managed() { let temp = tempdir().expect("create temp dir"); @@ -185,6 +253,7 @@ fn unknown_requirement_source_hooks_stay_managed() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: Default::default(), }], }], ..Default::default() @@ -247,6 +316,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: Default::default(), }], }], ..Default::default() @@ -466,6 +536,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), + visibility_hint: Default::default(), }], }], ..Default::default() @@ -677,6 +748,7 @@ print(json.dumps({ timeout_sec: Some(10), r#async: false, status_message: None, + visibility_hint: Default::default(), }], }], ..Default::default() @@ -785,6 +857,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { timeout_sec: Some(5), r#async: false, status_message: None, + visibility_hint: Default::default(), }], }], ..Default::default() diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 67c13c34eb..0dfbce8e85 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -591,6 +591,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 223efa7260..0b740f642b 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -551,6 +551,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 77e6d3f3fa..2bab8e0987 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -613,6 +613,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 195bb11257..effd19354f 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -364,6 +364,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 8fc176a474..3c9502ed2e 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -531,6 +531,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index a10798ea62..110c9163d9 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -422,6 +422,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + visibility_hint: Default::default(), } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d858..444848db63 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1518,6 +1518,14 @@ 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, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookOutputEntryKind { @@ -1549,6 +1557,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 85a9c3a8f3..de4641339b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4073,6 +4073,9 @@ impl ChatWidget { } fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { + if history_cell::hook_run_is_hidden(&run) { + return; + } self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { @@ -4092,6 +4095,9 @@ impl ChatWidget { } fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) { + if history_cell::hook_run_is_hidden(&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__hidden_hook_background_and_failure_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hidden_hook_background_and_failure_snapshot.snap new file mode 100644 index 0000000000..a856a10101 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hidden_hook_background_and_failure_snapshot.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "format!(\"{quiet_snapshot}\\n\\nvisible history:\\n{rendered}\")" +--- +hidden background hook +live hooks: + +history: + + +visible 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 05f967b5a4..0f622105d5 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1608,6 +1608,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 df88bcec93..f1e54c385e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2206,6 +2206,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, @@ -2229,6 +2230,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), @@ -2352,6 +2354,55 @@ async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() { assert_eq!(active_hook_blob(&chat), "\n"); } +#[tokio::test] +async fn hidden_hooks_skip_background_rendering_but_keep_failures_visible() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let mut hidden_running = hook_started_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + Some("injecting background context"), + ); + hidden_running.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden; + handle_hook_started(&mut chat, hidden_running); + + let mut hidden_context_only = hook_completed_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, + text: "time is 12:34".to_string(), + }], + ); + hidden_context_only.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden; + handle_hook_completed(&mut chat, hidden_context_only); + + let quiet_snapshot = hook_live_and_history_snapshot(&chat, "hidden background hook", ""); + assert!(drain_insert_history(&mut rx).is_empty()); + + let mut hidden_failed = 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(), + }], + ); + hidden_failed.visibility_hint = codex_app_server_protocol::HookVisibilityHint::Hidden; + handle_hook_completed(&mut chat, hidden_failed); + + let rendered = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_chatwidget_snapshot!( + "hidden_hook_background_and_failure_snapshot", + format!("{quiet_snapshot}\n\nvisible history:\n{rendered}") + ); +} + #[tokio::test] async fn blocked_and_failed_hooks_render_feedback_and_errors() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -2826,6 +2877,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 1d4bf2b858..787637f3aa 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -933,6 +933,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 9920bf63a8..88d58e563b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -105,6 +105,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_is_hidden; 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..1b27953068 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; @@ -684,6 +685,19 @@ fn hook_run_is_quiet_success(run: &HookRunSummary) -> bool { run.status == HookRunStatus::Completed && run.entries.is_empty() } +/// Returns true when a hook marked as background has no user-relevant consequence to render. +pub(crate) fn hook_run_is_hidden(run: &HookRunSummary) -> bool { + run.visibility_hint == HookVisibilityHint::Hidden + && match run.status { + HookRunStatus::Running => true, + HookRunStatus::Completed => run + .entries + .iter() + .all(|entry| entry.kind == HookOutputEntryKind::Context), + HookRunStatus::Blocked | HookRunStatus::Failed | HookRunStatus::Stopped => false, + } +} + fn hook_completed_bullet(status: HookRunStatus, entries: &[HookOutputEntry]) -> Span<'static> { match status { HookRunStatus::Completed => { @@ -814,6 +828,7 @@ mod tests { display_order: 0, status: HookRunStatus::Running, status_message: Some("checking output policy".to_string()), + visibility_hint: Default::default(), started_at: 1, completed_at: None, duration_ms: None,