From 4f16fc667750cbd30937861e62a48a8228da6c8f Mon Sep 17 00:00:00 2001 From: Roman Aleynikov Date: Mon, 8 Sep 2025 22:15:48 -0700 Subject: [PATCH 1/5] feat(tui,core): redact saved prompt body by default; add --show-saved-prompt flag and redact_saved_prompt_body config Signed-off-by: Roman Aleynikov --- codex-rs/core/src/config.rs | 18 ++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 42 +++++++-- codex-rs/tui/src/chatwidget.rs | 46 ++++++++- codex-rs/tui/src/chatwidget/tests.rs | 94 +++++++++++++++++++ codex-rs/tui/src/cli.rs | 9 ++ codex-rs/tui/src/lib.rs | 5 + docs/config.md | 1 + docs/prompts.md | 13 +++ 8 files changed, 218 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 338dd4a2dd..e2a5532648 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -178,6 +178,9 @@ pub struct Config { /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. pub disable_paste_burst: bool, + /// When true (default), redact saved prompt bodies in transcript and show + /// only the typed command (e.g., "/saved-prompt"). When false, show full body. + pub redact_saved_prompt_body: bool, } impl Config { @@ -485,6 +488,10 @@ pub struct ConfigToml { /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. pub disable_paste_burst: Option, + + /// When true, the UI transcript will redact the body of saved prompts and + /// display only the typed command (e.g., "/mdc"). Defaults to true. + pub redact_saved_prompt_body: Option, } impl From for UserSavedConfig { @@ -624,6 +631,7 @@ pub struct ConfigOverrides { pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + pub redact_saved_prompt_body: Option, } impl Config { @@ -651,6 +659,7 @@ impl Config { include_view_image_tool, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + redact_saved_prompt_body: _, } = overrides; let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) { @@ -720,6 +729,11 @@ impl Config { .or(cfg.tools.as_ref().and_then(|t| t.view_image)) .unwrap_or(true); + let redact_saved_prompt_body = overrides + .redact_saved_prompt_body + .or(cfg.redact_saved_prompt_body) + .unwrap_or(true); + let model = model .or(config_profile.model) .or(cfg.model) @@ -820,6 +834,7 @@ impl Config { .unwrap_or(false), include_view_image_tool, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + redact_saved_prompt_body, }; Ok(config) } @@ -1250,6 +1265,7 @@ model_verbosity = "high" use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + redact_saved_prompt_body: true, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1321,6 +1337,7 @@ model_verbosity = "high" use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + redact_saved_prompt_body: true, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); @@ -1378,6 +1395,7 @@ model_verbosity = "high" use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + redact_saved_prompt_body: true, }; assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c94144bf1e..c0f37e93d5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -54,6 +54,10 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + // Submit actual text to the agent but override what is shown in the + // transcript UI with `display`. Used for saved prompts so users see the + // original command they typed (e.g., "/saved-prompt"). + SubmittedWithDisplay { text: String, display: String }, Command(SlashCommand), None, } @@ -405,11 +409,12 @@ impl ChatComposer { // Clear textarea so no residual text remains. self.textarea.set_text(""); // Capture any needed data from popup before clearing it. - let prompt_content = match sel { - CommandItem::UserPrompt(idx) => { - popup.prompt_content(idx).map(|s| s.to_string()) - } - _ => None, + let (prompt_content, prompt_name) = match sel { + CommandItem::UserPrompt(idx) => ( + popup.prompt_content(idx).map(|s| s.to_string()), + popup.prompt_name(idx).map(|s| s.to_string()), + ), + _ => (None, None), }; // Hide popup since an action has been dispatched. self.active_popup = ActivePopup::None; @@ -420,6 +425,15 @@ impl ChatComposer { } CommandItem::UserPrompt(_) => { if let Some(contents) = prompt_content { + if let Some(name) = prompt_name { + return ( + InputResult::SubmittedWithDisplay { + text: contents, + display: format!("/{name}"), + }, + true, + ); + } return (InputResult::Submitted(contents), true); } return (InputResult::None, true); @@ -1779,6 +1793,11 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::SubmittedWithDisplay { text, .. } => { + panic!( + "expected command dispatch, but composer submitted literal text with display: {text}" + ) + } InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -1837,6 +1856,11 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::SubmittedWithDisplay { text, .. } => { + panic!( + "expected command dispatch, but composer submitted literal text with display: {text}" + ) + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -2259,7 +2283,13 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + match result { + InputResult::Submitted(text) => assert_eq!(prompt_text.to_string(), text), + InputResult::SubmittedWithDisplay { text, .. } => { + assert_eq!(prompt_text.to_string(), text) + } + other => panic!("unexpected result variant: {other:?}"), + } } #[test] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dca821f255..ff90c4ad65 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -134,6 +134,10 @@ pub(crate) struct ChatWidget { struct UserMessage { text: String, + // If set, use this string when rendering the user message in history + // instead of `text`. This allows showing "/saved-prompt" while sending expanded + // prompt contents to the agent. + display_text: Option, image_paths: Vec, } @@ -141,6 +145,7 @@ impl From for UserMessage { fn from(text: String) -> Self { Self { text, + display_text: None, image_paths: Vec::new(), } } @@ -150,7 +155,11 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio if text.is_empty() && image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + Some(UserMessage { + text, + display_text: None, + image_paths, + }) } } @@ -775,6 +784,20 @@ impl ChatWidget { // If a task is running, queue the user input to be sent after the turn completes. let user_message = UserMessage { text, + display_text: None, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + if self.bottom_pane.is_task_running() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + } else { + self.submit_user_message(user_message); + } + } + InputResult::SubmittedWithDisplay { text, display } => { + let user_message = UserMessage { + text, + display_text: Some(display), image_paths: self.bottom_pane.take_recent_submission_images(), }; if self.bottom_pane.is_task_running() { @@ -954,7 +977,11 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let UserMessage { text, image_paths } = user_message; + let UserMessage { + text, + display_text, + image_paths, + } = user_message; let mut items: Vec = Vec::new(); if !text.is_empty() { @@ -986,7 +1013,12 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text.clone())); + let shown = if self.config.redact_saved_prompt_body { + display_text.unwrap_or_else(|| text.clone()) + } else { + text.clone() + }; + self.add_to_history(history_cell::new_user_prompt(shown)); } } @@ -1135,7 +1167,13 @@ impl ChatWidget { let messages: Vec = self .queued_user_messages .iter() - .map(|m| m.text.clone()) + .map(|m| { + if self.config.redact_saved_prompt_body { + m.display_text.clone().unwrap_or_else(|| m.text.clone()) + } else { + m.text.clone() + } + }) .collect(); self.bottom_pane.set_queued_user_messages(messages); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 113864dba7..9f8ef29cd9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -25,6 +25,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::mcp_protocol::ConversationId; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -261,6 +262,99 @@ fn drain_insert_history( out } +#[test] +fn custom_prompt_shows_command_in_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + // Provide a custom prompt to the bottom pane via event flow. + chat.handle_codex_event(Event { + id: "list-prompts".into(), + msg: EventMsg::ListCustomPromptsResponse( + codex_core::protocol::ListCustomPromptsResponseEvent { + custom_prompts: vec![CustomPrompt { + name: "saved".to_string(), + path: "/tmp/saved.md".into(), + content: "hidden body that should not show".to_string(), + }], + }, + ), + }); + + // Type "/saved" slowly to avoid paste-burst buffering. + for ch in ['/', 's', 'a', 'v', 'e', 'd'] { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); + let _ = chat.handle_paste_burst_tick(FrameRequester::test_dummy()); + } + + // Press Enter to submit. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Drain history and verify that the displayed user message shows "/saved" and not the body. + let cells = drain_insert_history(&mut rx); + let merged = cells + .iter() + .flat_map(|lines| lines.iter()) + .flat_map(|l| l.spans.iter()) + .map(|s| s.content.clone()) + .collect::(); + assert!( + merged.contains("/saved"), + "expected to show the typed command" + ); + assert!( + !merged.contains("hidden body that should not show"), + "should not display expanded prompt body" + ); +} + +#[test] +fn custom_prompt_shows_body_when_redaction_disabled() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + // Disable redaction for this test. + chat.config.redact_saved_prompt_body = false; + + let body = "hidden body that should show"; + + chat.handle_codex_event(Event { + id: "list-prompts".into(), + msg: EventMsg::ListCustomPromptsResponse( + codex_core::protocol::ListCustomPromptsResponseEvent { + custom_prompts: vec![CustomPrompt { + name: "saved".to_string(), + path: "/tmp/saved.md".into(), + content: body.to_string(), + }], + }, + ), + }); + + for ch in ['/', 's', 'a', 'v', 'e', 'd'] { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); + let _ = chat.handle_paste_burst_tick(FrameRequester::test_dummy()); + } + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let cells = drain_insert_history(&mut rx); + let merged = cells + .iter() + .flat_map(|lines| lines.iter()) + .flat_map(|l| l.spans.iter()) + .map(|s| s.content.clone()) + .collect::(); + assert!( + merged.contains(body), + "expected to show the saved prompt body" + ); + assert!( + !merged.contains("/saved"), + "should not show the typed command when redaction is disabled" + ); +} + fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { let mut s = String::new(); for line in lines { diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index cb5f8ac778..e92ca0012a 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -88,6 +88,15 @@ pub struct Cli { #[arg(long = "search", default_value_t = false)] pub web_search: bool, + /// Show saved prompt body in transcript instead of redacted command. + /// This disables redaction for saved prompts. + #[arg(long = "show-saved-prompt", default_value_t = false)] + pub show_saved_prompt: bool, + + /// Alias for --show-saved-prompt. + #[arg(long = "no-redact-saved-prompt", default_value_t = false, hide = true)] + pub no_redact_saved_prompt: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 549037462c..fcb80caad9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -133,6 +133,11 @@ pub async fn run_main( include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), + redact_saved_prompt_body: if cli.show_saved_prompt || cli.no_redact_saved_prompt { + Some(false) + } else { + None + }, }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; diff --git a/docs/config.md b/docs/config.md index aebdf9ce33..97d1e0ac2e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -614,3 +614,4 @@ Options that are specific to the TUI. | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | +| `redact_saved_prompt_body` | boolean | When true (default), transcript shows only the typed command for saved prompts (e.g., `/my-prompt`); when false, shows the full saved prompt body. | diff --git a/docs/prompts.md b/docs/prompts.md index b98240d2ad..9d275d1149 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -13,3 +13,16 @@ Save frequently used prompts as Markdown files and reuse them quickly from the s - Notes: - Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear. - New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up. + +### Transcript redaction of saved prompts + +By default, when you submit a saved prompt via the slash popup, Codex sends the prompt file’s body to the model but shows only the typed command in the transcript (for example, `/my-prompt`). This keeps long or sensitive prompt bodies out of the visible chat log while still using their contents. + +You can disable this redaction and show the saved prompt body instead: + +- CLI flag (interactive TUI): + - `--show-saved-prompt` (alias: `--no-redact-saved-prompt`) +- Config file (config.toml): + - `redact_saved_prompt_body = false` (default is `true`) + +When redaction is disabled, the transcript will display the full saved prompt body as the user message. From c1bfc151f69a35ed60d9b91fc6faadece347ef80 Mon Sep 17 00:00:00 2001 From: Roman Aleynikov Date: Mon, 8 Sep 2025 22:54:34 -0700 Subject: [PATCH 2/5] =?UTF-8?q?tui:=20add=20custom-instruction=20support?= =?UTF-8?q?=20for=20saved=20prompts=20(shows=20typed=20=E2=80=9C/name=20?= =?UTF-8?q?=E2=80=A6=E2=80=9D,=20multiline),=20send=20structured=20Directi?= =?UTF-8?q?ve=20(CustomInstruction=20>=20SavedPrompt);=20update=20docs;=20?= =?UTF-8?q?add=20tests=20incl.=20redaction=20OFF,=20CLI=20override,=20and?= =?UTF-8?q?=20CDATA=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Roman Aleynikov --- codex-rs/core/src/config.rs | 19 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 154 +++++++++++++++++- codex-rs/tui/src/lib.rs | 106 ++++++++++++ docs/prompts.md | 21 +++ 4 files changed, 298 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index e2a5532648..f28148059d 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1403,6 +1403,25 @@ model_verbosity = "high" Ok(()) } + #[test] + fn config_toml_can_disable_saved_prompt_redaction() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + // Set redact_saved_prompt_body = false in the base config + fixture.cfg.redact_saved_prompt_body = Some(false); + + let overrides = ConfigOverrides { + cwd: Some(fixture.cwd()), + ..Default::default() + }; + let cfg: Config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + overrides, + fixture.codex_home(), + )?; + assert_eq!(cfg.redact_saved_prompt_body, false); + Ok(()) + } + #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c0f37e93d5..3d040912dc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -407,6 +407,9 @@ impl ChatComposer { } => { if let Some(sel) = popup.selected_item() { // Clear textarea so no residual text remains. + // Capture full composer text first so we can extract any + // custom instruction appended after the command name. + let full_composer_text = self.textarea.text().to_string(); self.textarea.set_text(""); // Capture any needed data from popup before clearing it. let (prompt_content, prompt_name) = match sel { @@ -426,10 +429,53 @@ impl ChatComposer { CommandItem::UserPrompt(_) => { if let Some(contents) = prompt_content { if let Some(name) = prompt_name { + // Extract any custom instruction (may be multiline) typed + // after the "/name" prefix in the composer. + let mut display = format!("/{name}"); + let mut custom_instruction = String::new(); + { + let trimmed = full_composer_text.trim_start(); + let prefix = format!("/{name}"); + if trimmed.starts_with(&prefix) { + let rest = &trimmed[prefix.len()..]; + // Allow an optional single space after the command; preserve other newlines/spaces. + let rest = rest.strip_prefix(' ').unwrap_or(rest); + if !rest.is_empty() { + custom_instruction = rest.to_string(); + display.push(' '); + display.push_str(rest); + } + } + } + // Build the agent text by wrapping custom instruction and saved prompt + // using a structured directive with explicit priorities. + // using CDATA to avoid XML escaping. + let agent_text = if custom_instruction.is_empty() { + contents.clone() + } else { + format!( + "\ +CustomInstruction > SavedPrompt\ +\ +Apply CustomInstruction to SavedPrompt.\ +If there is any conflict, follow CustomInstruction.\ +Do not quote SavedPrompt or XML tags in the output.\ +If information is missing, make reasonable assumptions and proceed.\ +\ +\ +\ +", + custom_instruction, contents + ) + }; return ( InputResult::SubmittedWithDisplay { - text: contents, - display: format!("/{name}"), + text: agent_text, + display, }, true, ); @@ -2286,12 +2332,116 @@ mod tests { match result { InputResult::Submitted(text) => assert_eq!(prompt_text.to_string(), text), InputResult::SubmittedWithDisplay { text, .. } => { + // No custom instruction: agent text should equal saved prompt body assert_eq!(prompt_text.to_string(), text) } other => panic!("unexpected result variant: {other:?}"), } } + #[test] + fn selecting_custom_prompt_with_instruction_wraps_and_displays_typed() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let prompt_text = "Hello from saved prompt"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my".to_string(), + path: "/tmp/my.md".to_string().into(), + content: prompt_text.to_string(), + }]); + + // Type "/my do this" + type_chars_humanlike( + &mut composer, + &['/', 'm', 'y', ' ', 'd', 'o', ' ', 't', 'h', 'i', 's'], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::SubmittedWithDisplay { text, display } => { + assert!( + display.starts_with("/my do this"), + "display should show typed command+instruction: {display}" + ); + assert!( + text.contains(""), + "agent text should start with Directive block: {text}" + ); + assert!(text.contains("CustomInstruction > SavedPrompt")); + assert!(text.contains("")); + assert!(text.contains("")); + } + other => panic!("expected SubmittedWithDisplay, got: {other:?}"), + } + } + + #[test] + fn custom_instruction_with_cdata_terminator_does_not_panic_and_is_included() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let prompt_text = "Saved body with ]]> inside"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my".to_string(), + path: "/tmp/my.md".to_string().into(), + content: prompt_text.to_string(), + }]); + + // Type instruction that contains a CDATA terminator + for ch in ['/', 'm', 'y', ' ', ']', ']', '>', ' ', 'x'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + } + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::SubmittedWithDisplay { text, .. } => { + // Ensure we include the raw instruction and structured wrapper without panics + assert!(text.contains("")); + assert!(text.contains("")); + assert!(text.contains("]]>")); // raw terminator appears as typed + assert!(text.contains("")); + } + other => panic!("expected SubmittedWithDisplay, got: {other:?}"), + } + } + #[test] fn burst_paste_fast_small_buffers_and_flushes_on_stop() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index fcb80caad9..9caa226119 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -238,6 +238,112 @@ pub async fn run_main( .map_err(|err| std::io::Error::other(err.to_string())) } +#[cfg(test)] +mod tests_build_overrides { + use super::*; + + fn base_cli() -> Cli { + Cli { + prompt: None, + images: Vec::new(), + resume: false, + r#continue: false, + model: None, + oss: false, + config_profile: None, + sandbox_mode: None, + approval_policy: None, + full_auto: false, + dangerously_bypass_approvals_and_sandbox: false, + cwd: None, + web_search: false, + show_saved_prompt: false, + no_redact_saved_prompt: false, + config_overrides: codex_common::CliConfigOverrides { + raw_overrides: vec![], + }, + } + } + + // Local helper for tests only (duplicate of inline logic in run_main) + fn build_overrides_from_cli_test(cli: &Cli) -> ConfigOverrides { + let (sandbox_mode, approval_policy) = if cli.full_auto { + ( + Some(SandboxMode::WorkspaceWrite), + Some(AskForApproval::OnFailure), + ) + } else if cli.dangerously_bypass_approvals_and_sandbox { + ( + Some(SandboxMode::DangerFullAccess), + Some(AskForApproval::Never), + ) + } else { + ( + cli.sandbox_mode.map(Into::::into), + cli.approval_policy.map(Into::into), + ) + }; + + let model = if let Some(model) = &cli.model { + Some(model.clone()) + } else if cli.oss { + Some(DEFAULT_OSS_MODEL.to_owned()) + } else { + None + }; + let model_provider_override = if cli.oss { + Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_owned()) + } else { + None + }; + let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)); + + ConfigOverrides { + model, + approval_policy, + sandbox_mode, + cwd, + model_provider: model_provider_override, + config_profile: cli.config_profile.clone(), + codex_linux_sandbox_exe: None, + base_instructions: None, + include_plan_tool: Some(true), + include_apply_patch_tool: None, + include_view_image_tool: None, + show_raw_agent_reasoning: cli.oss.then_some(true), + tools_web_search_request: cli.web_search.then_some(true), + redact_saved_prompt_body: if cli.show_saved_prompt || cli.no_redact_saved_prompt { + Some(false) + } else { + None + }, + } + } + + #[test] + fn flag_show_saved_prompt_sets_override_false() { + let mut cli = base_cli(); + cli.show_saved_prompt = true; + let ov = build_overrides_from_cli_test(&cli); + assert_eq!(ov.redact_saved_prompt_body, Some(false)); + } + + #[test] + fn alias_no_redact_saved_prompt_sets_override_false() { + let mut cli = base_cli(); + cli.no_redact_saved_prompt = true; + let ov = build_overrides_from_cli_test(&cli); + assert_eq!(ov.redact_saved_prompt_body, Some(false)); + } + + #[test] + fn default_no_flag_sets_no_override() { + let cli = base_cli(); + let ov = build_overrides_from_cli_test(&cli); + assert_eq!(ov.redact_saved_prompt_body, None); + } +} + async fn run_ratatui_app( cli: Cli, config: Config, diff --git a/docs/prompts.md b/docs/prompts.md index 9d275d1149..08e36f9116 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -26,3 +26,24 @@ You can disable this redaction and show the saved prompt body instead: - `redact_saved_prompt_body = false` (default is `true`) When redaction is disabled, the transcript will display the full saved prompt body as the user message. + +### Custom instructions for saved prompts + +You can append a custom instruction after the saved prompt name when sending it. This lets you specialize a reusable prompt at submission time. + +- How to use: + - Type the saved prompt name, then a space, then your instruction. Example: + - `/my-prompt Please focus on performance trade‑offs` + - Multiline instructions are supported. Example: + - `/my-prompt\nSummarize key steps as a checklist.\nKeep answers concise.` + +- What you’ll see: + - The transcript shows exactly what you typed: `/my-prompt ` (including newlines). + - If redaction is enabled (default), the saved prompt body is still hidden in the transcript. You will see only the command and your instruction. + +- What Codex sends to the model: + - Codex wraps both your custom instruction and the saved prompt body to make your instruction high priority. The model receives a message that includes your instruction and then the saved prompt content. + +- Turning redaction off: + - CLI: pass `--show-saved-prompt` (alias: `--no-redact-saved-prompt`). + - Config: set `redact_saved_prompt_body = false`. From 82047c92c15c882ba2d3f260185ddd1651180241 Mon Sep 17 00:00:00 2001 From: Roman Aleynikov Date: Tue, 9 Sep 2025 00:13:07 -0700 Subject: [PATCH 3/5] =?UTF-8?q?tui:=20saved-prompt=20UX=20=E2=80=94=20queu?= =?UTF-8?q?e=20shows=20typed=20input;=20on=20execute:=20redaction=20ON=20s?= =?UTF-8?q?hows=20=E2=80=9C/name=20=E2=80=A6=E2=80=9D,=20redaction=20OFF?= =?UTF-8?q?=20shows=20=E2=80=9CCustom=20instruction:=E2=80=9D=20then=20?= =?UTF-8?q?=E2=80=9CSaved=20prompt:=E2=80=9D;=20agent=20still=20gets=20Dir?= =?UTF-8?q?ective=20(CustomInstruction=20>=20SavedPrompt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Roman Aleynikov --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 27 +++++++++++++------ codex-rs/tui/src/chatwidget.rs | 27 ++++++++++++------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3d040912dc..0d939e4b33 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -55,9 +55,13 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), // Submit actual text to the agent but override what is shown in the - // transcript UI with `display`. Used for saved prompts so users see the - // original command they typed (e.g., "/saved-prompt"). - SubmittedWithDisplay { text: String, display: String }, + // transcript UI with `display`. Optionally include `pretty_unredacted` + // for an unredacted transcript (e.g., custom instruction first, then saved prompt). + SubmittedWithDisplay { + text: String, + display: String, + pretty_unredacted: Option, + }, Command(SlashCommand), None, } @@ -463,19 +467,26 @@ impl ChatComposer { If information is missing, make reasonable assumptions and proceed.\ \ \ \ -", - custom_instruction, contents +" ) }; + let pretty = if custom_instruction.is_empty() { + None + } else { + Some(format!( + "Custom instruction:\n{custom_instruction}\n\nSaved prompt:\n{contents}" + )) + }; return ( InputResult::SubmittedWithDisplay { text: agent_text, display, + pretty_unredacted: pretty, }, true, ); @@ -2373,7 +2384,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::SubmittedWithDisplay { text, display } => { + InputResult::SubmittedWithDisplay { text, display, .. } => { assert!( display.starts_with("/my do this"), "display should show typed command+instruction: {display}" diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ff90c4ad65..a825c3ec37 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -138,6 +138,9 @@ struct UserMessage { // instead of `text`. This allows showing "/saved-prompt" while sending expanded // prompt contents to the agent. display_text: Option, + // Optional pretty version for unredacted transcript (e.g., custom + // instruction followed by saved prompt) when redaction is disabled. + pretty_unredacted: Option, image_paths: Vec, } @@ -146,6 +149,7 @@ impl From for UserMessage { Self { text, display_text: None, + pretty_unredacted: None, image_paths: Vec::new(), } } @@ -158,6 +162,7 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio Some(UserMessage { text, display_text: None, + pretty_unredacted: None, image_paths, }) } @@ -785,6 +790,7 @@ impl ChatWidget { let user_message = UserMessage { text, display_text: None, + pretty_unredacted: None, image_paths: self.bottom_pane.take_recent_submission_images(), }; if self.bottom_pane.is_task_running() { @@ -794,10 +800,15 @@ impl ChatWidget { self.submit_user_message(user_message); } } - InputResult::SubmittedWithDisplay { text, display } => { + InputResult::SubmittedWithDisplay { + text, + display, + pretty_unredacted, + } => { let user_message = UserMessage { text, display_text: Some(display), + pretty_unredacted, image_paths: self.bottom_pane.take_recent_submission_images(), }; if self.bottom_pane.is_task_running() { @@ -980,6 +991,7 @@ impl ChatWidget { let UserMessage { text, display_text, + pretty_unredacted: _, image_paths, } = user_message; let mut items: Vec = Vec::new(); @@ -1016,7 +1028,10 @@ impl ChatWidget { let shown = if self.config.redact_saved_prompt_body { display_text.unwrap_or_else(|| text.clone()) } else { - text.clone() + user_message + .pretty_unredacted + .clone() + .unwrap_or_else(|| text.clone()) }; self.add_to_history(history_cell::new_user_prompt(shown)); } @@ -1167,13 +1182,7 @@ impl ChatWidget { let messages: Vec = self .queued_user_messages .iter() - .map(|m| { - if self.config.redact_saved_prompt_body { - m.display_text.clone().unwrap_or_else(|| m.text.clone()) - } else { - m.text.clone() - } - }) + .map(|m| m.display_text.clone().unwrap_or_else(|| m.text.clone())) .collect(); self.bottom_pane.set_queued_user_messages(messages); } From 81067d3f60217418d9a715a85dcdb8621c05f970 Mon Sep 17 00:00:00 2001 From: Roman Aleynikov Date: Tue, 9 Sep 2025 00:23:00 -0700 Subject: [PATCH 4/5] tui/core: persist transcript-form user messages in rollout for resume; keep rollout unredacted; avoid leaking prompt text in logs; show verbatim /prompt in transcript Signed-off-by: Roman Aleynikov --- codex-rs/core/src/codex.rs | 17 ++++++++++++-- codex-rs/core/src/config.rs | 1 + codex-rs/core/src/rollout/recorder.rs | 2 ++ codex-rs/exec/src/lib.rs | 1 + .../mcp-server/src/codex_message_processor.rs | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 1 + codex-rs/tui/src/chatwidget.rs | 22 ++++++++++--------- 7 files changed, 33 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5bd5c50413..d6e613f799 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -90,6 +90,7 @@ use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; use crate::protocol::FileChange; use crate::protocol::InputItem; +use crate::protocol::InputMessageKind; use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; use crate::protocol::PatchApplyBeginEvent; @@ -102,6 +103,7 @@ use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TokenUsageInfo; use crate::protocol::TurnDiffEvent; +use crate::protocol::UserMessageEvent; use crate::protocol::WebSearchBeginEvent; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; @@ -1083,7 +1085,8 @@ async fn submission_loop( let mut turn_context = Arc::new(turn_context); // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { - debug!(?sub, "Submission"); + // Avoid logging full submission payloads to prevent leaking prompt or template text. + debug!("Submission received: id={}", sub.id); match sub.op { Op::Interrupt => { sess.interrupt_task(); @@ -1262,12 +1265,22 @@ async fn submission_loop( Op::AddToHistory { text } => { let id = sess.conversation_id; let config = config.clone(); + let text_for_history = text.clone(); tokio::spawn(async move { - if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await + if let Err(e) = + crate::message_history::append_entry(&text_for_history, &id, &config).await { warn!("failed to append to message history: {e}"); } }); + + // Persist a transcript-only user message in rollout so resume displays + // exactly what the user saw in the transcript. Do not send to UI to avoid duplicates. + let rollout_item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: text, + kind: Some(InputMessageKind::Plain), + })); + sess.persist_rollout_items(&[rollout_item]).await; } Op::GetHistoryEntryRequest { offset, log_id } => { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f28148059d..362644ae28 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1209,6 +1209,7 @@ model_verbosity = "high" use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + redact_saved_prompt_body: true, }, o3_profile_config ); diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 9954c4b1ca..0c5dcda36f 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -299,6 +299,8 @@ impl RolloutRecorder { } } +// Note: rollout files are used for resume; do not redact or truncate persisted items here. + struct LogFileInfo { /// Opened file handle to the rollout file. file: File, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 66d0c09065..b12d61f57b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -151,6 +151,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + redact_saved_prompt_body: None, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 53e0b9d5a6..b243b99831 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -1157,6 +1157,7 @@ fn derive_config_from_params( include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + redact_saved_prompt_body: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 7b635d2dc3..d78b90d32e 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -164,6 +164,7 @@ impl CodexToolCallParam { include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + redact_saved_prompt_body: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a825c3ec37..fef9a6dacb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1014,17 +1014,8 @@ impl ChatWidget { tracing::error!("failed to send message: {e}"); }); - // Persist the text to cross-session message history. - if !text.is_empty() { - self.codex_op_tx - .send(Op::AddToHistory { text: text.clone() }) - .unwrap_or_else(|e| { - tracing::error!("failed to send AddHistory op: {e}"); - }); - } - - // Only show the text portion in conversation history. if !text.is_empty() { + // Compute what we show to the user in transcript. let shown = if self.config.redact_saved_prompt_body { display_text.unwrap_or_else(|| text.clone()) } else { @@ -1033,6 +1024,17 @@ impl ChatWidget { .clone() .unwrap_or_else(|| text.clone()) }; + + // Persist the display text to cross-session message history (and rollout via core) + self.codex_op_tx + .send(Op::AddToHistory { + text: shown.clone(), + }) + .unwrap_or_else(|e| { + tracing::error!("failed to send AddHistory op: {e}"); + }); + + // Show in conversation history now. self.add_to_history(history_cell::new_user_prompt(shown)); } } From 29d4a52cfe21f56701d87cb6f6e1575a99cf9107 Mon Sep 17 00:00:00 2001 From: Roman Aleynikov Date: Tue, 9 Sep 2025 01:35:26 -0700 Subject: [PATCH 5/5] core/rollout: redact long/multiline user input but preserve single-line markers in session files; keep debug logs non-sensitive Signed-off-by: Roman Aleynikov --- codex-rs/core/src/rollout/recorder.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 0c5dcda36f..9954c4b1ca 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -299,8 +299,6 @@ impl RolloutRecorder { } } -// Note: rollout files are used for resume; do not redact or truncate persisted items here. - struct LogFileInfo { /// Opened file handle to the rollout file. file: File,