diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 65203b5193..8c4cdd87da 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -269,6 +269,11 @@ impl ChatComposer { self.textarea.text().to_string() } + /// Get the current composer text (for runtime use). + pub(crate) fn text_content(&self) -> String { + self.textarea.text().to_string() + } + /// Attempt to start a burst by retro-capturing recent chars before the cursor. pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) { let placeholder = format!("[image {width}x{height} {format_label}]"); @@ -1282,7 +1287,7 @@ impl WidgetRef for ChatComposer { } else { key_hint::ctrl('J') }; - vec![ + let mut base: Vec> = vec![ key_hint::plain('⏎'), " send ".into(), newline_hint_key, @@ -1291,7 +1296,14 @@ impl WidgetRef for ChatComposer { " transcript ".into(), key_hint::ctrl('C'), " quit".into(), - ] + ]; + // When there is text in the composer, show Ctrl+S to save as a custom prompt. + if !self.textarea.is_empty() { + base.push(" ".into()); + base.push(key_hint::ctrl('S')); + base.push(" save prompt".into()); + } + base }; if !self.ctrl_c_quit_hint && self.esc_backtrack_hint { diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index a8084d0d04..946899d280 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -20,7 +20,7 @@ use super::textarea::TextArea; use super::textarea::TextAreaState; /// Callback invoked when the user submits a custom prompt. -pub(crate) type PromptSubmitted = Box; +pub(crate) type PromptSubmitted = Box bool + Send + Sync>; /// Minimal multi-line text input view to collect custom review instructions. pub(crate) struct CustomPromptView { @@ -68,8 +68,7 @@ impl BottomPaneView for CustomPromptView { .. } => { let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { - (self.on_submit)(text); + if !text.is_empty() && (self.on_submit)(text) { self.complete = true; } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fc9f06c39d..a1eae97fb4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -360,6 +360,11 @@ impl BottomPane { self.composer.is_empty() } + /// Get the current composer text (without mutating state). + pub(crate) fn composer_text_now(&self) -> String { + self.composer.text_content() + } + pub(crate) fn is_task_running(&self) -> bool { self.is_task_running } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index c9f5cb9720..62f440647e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index d89c4033de..e7cb007615 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 636d5c22af..f66cfbd046 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 111e45b65b..31ffd97273 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b665d5254c..fe0348780a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -54,6 +54,7 @@ use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use std::fs; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -315,6 +316,57 @@ impl ChatWidget { self.request_redraw(); } + fn open_save_prompt_popup(&mut self) { + let tx = self.app_event_tx.clone(); + let get_content = self.bottom_pane.composer_text_now(); + let prompts_dir = self.config.codex_home.join("prompts"); + let view = CustomPromptView::new( + "Save prompt".to_string(), + "Set custom prompt name".to_string(), + None, + Box::new(move |name: String| { + let content = get_content.clone(); + let mut name_slug = slugify_prompt_name(&name); + if name_slug.is_empty() { + name_slug = "prompt".to_string(); + } + if let Err(e) = fs::create_dir_all(&prompts_dir) { + tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_error_event(format!( + "Failed to create prompts dir: {e}" + )), + ))); + return false; + } + let path = prompts_dir.join(format!("{name_slug}.md")); + if path.exists() { + tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_error_event(format!( + "Prompt \"{name}\" already exists; choose a different name." + )), + ))); + return false; + } + if let Err(e) = fs::write(&path, content) { + tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_error_event(format!("Failed to save prompt: {e}")), + ))); + return false; + } + // Informational message and refresh custom prompts list. + tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_info_event( + format!("Saved prompt as {}", path.display()), + None, + ), + ))); + tx.send(AppEvent::CodexOp(Op::ListCustomPrompts)); + true + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + fn on_reasoning_section_break(&mut self) { // Start a new reasoning block for header extraction and accumulate transcript. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); @@ -899,6 +951,17 @@ impl ChatWidget { } return; } + KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + if !self.bottom_pane.composer_is_empty() { + self.open_save_prompt_popup(); + } + return; + } other if other.kind == KeyEventKind::Press => { self.bottom_pane.clear_ctrl_c_quit_hint(); } @@ -1818,7 +1881,7 @@ impl ChatWidget { Box::new(move |prompt: String| { let trimmed = prompt.trim().to_string(); if trimmed.is_empty() { - return; + return false; } tx.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { @@ -1826,6 +1889,7 @@ impl ChatWidget { user_facing_hint: trimmed, }, })); + true }), ); self.bottom_pane.show_view(Box::new(view)); @@ -1987,6 +2051,29 @@ fn extract_first_bold(s: &str) -> Option { None } +fn slugify_prompt_name(name: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + last_dash = false; + } else if ch == '-' || ch == '_' || ch.is_whitespace() { + if !last_dash { + out.push('-'); + last_dash = true; + } + } else { + // skip other characters + if !last_dash { + out.push('-'); + last_dash = true; + } + } + } + out.trim_matches('-').to_string() +} + #[cfg(test)] pub(crate) fn show_review_commit_picker_with_entries( chat: &mut ChatWidget, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 9a2859046a..fdab762baa 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -13,4 +13,4 @@ expression: visual ▌ Summarize recent commits -⏎ send ⌃J newline ⌃T transcript ⌃C quit +⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt