mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Save prompts with ctrl+S
This commit is contained in:
@@ -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<Span<'static>> = 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 {
|
||||
|
||||
@@ -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<dyn Fn(String) + Send + Sync>;
|
||||
pub(crate) type PromptSubmitted = Box<dyn Fn(String) -> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
" "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
" "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
" "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
"▌ "
|
||||
"▌ "
|
||||
" "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "
|
||||
|
||||
@@ -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<String> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user