Compare commits

...

1 Commits

Author SHA1 Message Date
Daniel Edrisian
7a94d78dd6 Save prompts with ctrl+S 2025-09-23 11:17:31 -07:00
9 changed files with 114 additions and 11 deletions

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit ⌃S save prompt "

View File

@@ -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,

View File

@@ -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