Compare commits

...

1 Commits

Author SHA1 Message Date
Lawrence Xing
b7c2c80c12 feat: add prompt stashing 2026-01-08 09:21:00 -08:00
14 changed files with 523 additions and 36 deletions

View File

@@ -54,6 +54,7 @@ use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::text_formatting::truncate_text;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
@@ -82,6 +83,25 @@ struct AttachedImage {
path: PathBuf,
}
#[derive(Debug)]
struct StashedDraft {
text: String,
pending_pastes: Vec<(String, String)>,
attached_images: Vec<AttachedImage>,
}
impl StashedDraft {
fn preview(&self) -> String {
let first_line = self
.text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or_default()
.trim();
truncate_text(first_line, 20)
}
}
enum PromptSelectionMode {
Completion,
Submit,
@@ -104,6 +124,7 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
stashed_draft: Option<StashedDraft>,
large_paste_counters: HashMap<usize, usize>,
has_focus: bool,
attached_images: Vec<AttachedImage>,
@@ -154,6 +175,7 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
stashed_draft: None,
large_paste_counters: HashMap::new(),
has_focus: has_input_focus,
attached_images: Vec::new(),
@@ -500,6 +522,42 @@ impl ChatComposer {
result
}
fn stash_draft(&mut self) -> bool {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if self.is_empty() {
return false;
}
self.stashed_draft = Some(StashedDraft {
text: self.textarea.text().to_string(),
pending_pastes: std::mem::take(&mut self.pending_pastes),
attached_images: std::mem::take(&mut self.attached_images),
});
self.set_text_content(String::new());
self.active_popup = ActivePopup::None;
true
}
pub(crate) fn restore_stashed_draft_if_possible(&mut self) -> bool {
if !self.is_empty() {
return false;
}
let Some(stashed) = self.stashed_draft.take() else {
return false;
};
// Reuse attachment rebuild logic so placeholders become elements again.
self.attached_images = stashed.attached_images;
self.apply_external_edit(stashed.text);
self.pending_pastes = stashed.pending_pastes;
true
}
/// Return true if either the slash-command popup or the file-search popup is active.
pub(crate) fn popup_active(&self) -> bool {
!matches!(self.active_popup, ActivePopup::None)
@@ -1125,6 +1183,12 @@ impl ChatComposer {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
match key_event {
KeyEvent {
code: KeyCode::Char('s'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => (InputResult::None, self.stash_draft()),
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -1621,6 +1685,7 @@ impl ChatComposer {
}
fn footer_props(&self) -> FooterProps {
let stashed_draft_preview = self.stashed_draft.as_ref().map(StashedDraft::preview);
FooterProps {
mode: self.footer_mode(),
esc_backtrack_hint: self.esc_backtrack_hint,
@@ -1628,6 +1693,7 @@ impl ChatComposer {
is_task_running: self.is_task_running,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
stashed_draft_preview,
}
}
@@ -1919,7 +1985,7 @@ impl Renderable for ChatComposer {
let footer_props = self.footer_props();
let custom_height = self.custom_footer_height();
let footer_hint_height =
custom_height.unwrap_or_else(|| footer_height(footer_props));
custom_height.unwrap_or_else(|| footer_height(footer_props.clone()));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([

View File

@@ -14,7 +14,7 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
pub(crate) esc_backtrack_hint: bool,
@@ -22,6 +22,7 @@ pub(crate) struct FooterProps {
pub(crate) is_task_running: bool,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
pub(crate) stashed_draft_preview: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -90,10 +91,18 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
props.context_window_used_tokens,
);
line.push_span(" · ".dim());
line.extend(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
]);
if let Some(preview) = props.stashed_draft_preview {
line.extend(vec![
"STASHED:".cyan(),
" ".into(),
Span::from(format!("\"{preview}\"")).dim(),
]);
} else {
line.extend(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
]);
}
vec![line]
}
FooterMode::ShortcutOverlay => {
@@ -110,10 +119,21 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
shortcut_overlay_lines(state)
}
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
FooterMode::ContextOnly => vec![context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
)],
FooterMode::ContextOnly => {
let mut line = context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
);
if let Some(preview) = props.stashed_draft_preview {
line.push_span(" · ".dim());
line.extend(vec![
"STASHED:".cyan(),
" ".into(),
Span::from(format!("\"{preview}\"")).dim(),
]);
}
vec![line]
}
}
}
@@ -163,6 +183,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut external_editor = Line::from("");
let mut stash_draft = Line::from("");
let mut edit_previous = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
@@ -175,6 +196,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::FilePaths => file_paths = text,
ShortcutId::PasteImage => paste_image = text,
ShortcutId::ExternalEditor => external_editor = text,
ShortcutId::StashDraft => stash_draft = text,
ShortcutId::EditPrevious => edit_previous = text,
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
@@ -188,9 +210,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
file_paths,
paste_image,
external_editor,
stash_draft,
edit_previous,
quit,
Line::from(""),
show_transcript,
];
@@ -265,6 +287,7 @@ enum ShortcutId {
FilePaths,
PasteImage,
ExternalEditor,
StashDraft,
EditPrevious,
Quit,
ShowTranscript,
@@ -394,6 +417,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " to edit in external editor",
},
ShortcutDescriptor {
id: ShortcutId::StashDraft,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('s')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to stash prompt",
},
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
@@ -431,7 +463,7 @@ mod tests {
use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps) {
let height = footer_height(props).max(1);
let height = footer_height(props.clone()).max(1);
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
terminal
.draw(|f| {
@@ -453,6 +485,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -465,6 +498,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -477,6 +511,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -489,6 +524,7 @@ mod tests {
is_task_running: true,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -501,6 +537,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -513,6 +550,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -525,6 +563,7 @@ mod tests {
is_task_running: true,
context_window_percent: Some(72),
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);
@@ -537,6 +576,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: Some(123_456),
stashed_draft_preview: None,
},
);
}

View File

@@ -264,6 +264,12 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn restore_stashed_draft_if_possible(&mut self) {
if self.composer.restore_stashed_draft_if_possible() {
self.request_redraw();
}
}
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
self.composer.clear_for_ctrl_c();
self.request_redraw();

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2190
expression: terminal.backend()
---
" "
@@ -12,6 +13,6 @@ expression: terminal.backend()
" "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + g to edit in external editor ctrl + s to stash prompt "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "

View File

@@ -1,9 +1,10 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 474
expression: terminal.backend()
---
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + g to edit in external editor ctrl + s to stash prompt "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "

View File

@@ -577,6 +577,7 @@ impl ChatWidget {
self.running_commands.clear();
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.bottom_pane.restore_stashed_draft_if_possible();
self.request_redraw();
// If there is a queued user message, send exactly one now to begin the next turn.
@@ -709,6 +710,7 @@ impl ChatWidget {
self.running_commands.clear();
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.bottom_pane.restore_stashed_draft_if_possible();
self.stream_controller = None;
self.maybe_show_pending_rate_limit_prompt();
}
@@ -1629,12 +1631,16 @@ impl ChatWidget {
_ => {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
let was_running = self.bottom_pane.is_task_running();
// If a task is running, queue the user input to be sent after the turn completes.
let user_message = UserMessage {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.queue_user_message(user_message);
if !was_running {
self.bottom_pane.restore_stashed_draft_if_possible();
}
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);

View File

@@ -1120,6 +1120,98 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
);
}
#[tokio::test]
async fn ctrl_s_noops_when_composer_empty() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
assert!(chat.bottom_pane.composer_text().is_empty());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.on_task_started();
chat.on_task_complete(None);
assert!(chat.bottom_pane.composer_text().is_empty());
}
#[tokio::test]
async fn ctrl_s_stashes_and_restores_after_next_submission_when_idle() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.bottom_pane.set_composer_text("send now".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "draft A");
}
#[tokio::test]
async fn ctrl_s_stashes_during_task_and_restores_after_task_complete() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "draft A");
}
#[tokio::test]
async fn ctrl_s_does_not_stash_when_slash_popup_is_active() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.insert_str("/");
assert_eq!(chat.bottom_pane.composer_text(), "/");
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert_eq!(chat.bottom_pane.composer_text(), "/");
// Ensure nothing was stashed to restore.
chat.on_task_started();
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "/");
}
#[tokio::test]
async fn ctrl_s_restore_is_deferred_until_composer_is_empty() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
// User starts writing a different draft before the task ends; do not overwrite it.
chat.bottom_pane.set_composer_text("draft B".to_string());
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "draft B");
// Next completion while the composer is empty should restore the stash.
chat.bottom_pane.set_composer_text(String::new());
chat.on_task_started();
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "draft A");
}
#[tokio::test]
async fn ctrl_s_overwrites_existing_stash() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
chat.bottom_pane.set_composer_text("draft B".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
chat.bottom_pane.set_composer_text("send now".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "draft B");
}
#[tokio::test]
async fn exec_history_cell_shows_working_then_completed() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;

View File

@@ -57,6 +57,7 @@ use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::text_formatting::truncate_text;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
@@ -85,6 +86,25 @@ struct AttachedImage {
path: PathBuf,
}
#[derive(Debug)]
struct StashedDraft {
text: String,
pending_pastes: Vec<(String, String)>,
attached_images: Vec<AttachedImage>,
}
impl StashedDraft {
fn preview(&self) -> String {
let first_line = self
.text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or_default()
.trim();
truncate_text(first_line, 20)
}
}
enum PromptSelectionMode {
Completion,
Submit,
@@ -107,6 +127,7 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
stashed_draft: Option<StashedDraft>,
large_paste_counters: HashMap<usize, usize>,
has_focus: bool,
attached_images: Vec<AttachedImage>,
@@ -162,6 +183,7 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
stashed_draft: None,
large_paste_counters: HashMap::new(),
has_focus: has_input_focus,
attached_images: Vec::new(),
@@ -312,6 +334,69 @@ impl ChatComposer {
self.sync_popups();
}
/// Replace the composer content with text, rebuilding attachment elements.
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.pending_pastes.clear();
let mut placeholder_counts: HashMap<String, usize> = HashMap::new();
for placeholder in self.attached_images.iter().map(|img| &img.placeholder) {
if placeholder_counts.contains_key(placeholder) {
continue;
}
let count = text.match_indices(placeholder).count();
if count > 0 {
placeholder_counts.insert(placeholder.clone(), count);
}
}
let mut kept_images = Vec::new();
for img in self.attached_images.drain(..) {
if let Some(count) = placeholder_counts.get_mut(&img.placeholder)
&& *count > 0
{
*count -= 1;
kept_images.push(img);
}
}
self.attached_images = kept_images;
self.textarea.set_text("");
let mut remaining: HashMap<&str, usize> = HashMap::new();
for img in &self.attached_images {
*remaining.entry(img.placeholder.as_str()).or_insert(0) += 1;
}
let mut occurrences: Vec<(usize, &str)> = Vec::new();
for placeholder in remaining.keys() {
for (pos, _) in text.match_indices(placeholder) {
occurrences.push((pos, *placeholder));
}
}
occurrences.sort_unstable_by_key(|(pos, _)| *pos);
let mut idx = 0usize;
for (pos, ph) in occurrences {
let Some(count) = remaining.get_mut(ph) else {
continue;
};
if *count == 0 {
continue;
}
if pos > idx {
self.textarea.insert_str(&text[idx..pos]);
}
self.textarea.insert_element(ph);
*count -= 1;
idx = pos + ph.len();
}
if idx < text.len() {
self.textarea.insert_str(&text[idx..]);
}
self.textarea.set_cursor(self.textarea.text().len());
self.sync_popups();
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;
@@ -417,6 +502,41 @@ impl ChatComposer {
result
}
fn stash_draft(&mut self) -> bool {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if self.is_empty() {
return false;
}
self.stashed_draft = Some(StashedDraft {
text: self.textarea.text().to_string(),
pending_pastes: std::mem::take(&mut self.pending_pastes),
attached_images: std::mem::take(&mut self.attached_images),
});
self.set_text_content(String::new());
self.active_popup = ActivePopup::None;
true
}
pub(crate) fn restore_stashed_draft_if_possible(&mut self) -> bool {
if !self.is_empty() {
return false;
}
let Some(stashed) = self.stashed_draft.take() else {
return false;
};
self.attached_images = stashed.attached_images;
self.apply_external_edit(stashed.text);
self.pending_pastes = stashed.pending_pastes;
true
}
/// Return true if either the slash-command popup or the file-search popup is active.
pub(crate) fn popup_active(&self) -> bool {
!matches!(self.active_popup, ActivePopup::None)
@@ -1042,6 +1162,12 @@ impl ChatComposer {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
match key_event {
KeyEvent {
code: KeyCode::Char('s'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => (InputResult::None, self.stash_draft()),
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -1537,6 +1663,7 @@ impl ChatComposer {
}
fn footer_props(&self) -> FooterProps {
let stashed_draft_preview = self.stashed_draft.as_ref().map(StashedDraft::preview);
FooterProps {
mode: self.footer_mode(),
esc_backtrack_hint: self.esc_backtrack_hint,
@@ -1549,6 +1676,7 @@ impl ChatComposer {
transcript_scroll_position: self.transcript_scroll_position,
transcript_copy_selection_key: self.transcript_copy_selection_key,
transcript_copy_feedback: self.transcript_copy_feedback,
stashed_draft_preview,
}
}
@@ -1871,7 +1999,7 @@ impl Renderable for ChatComposer {
let footer_props = self.footer_props();
let custom_height = self.custom_footer_height();
let footer_hint_height =
custom_height.unwrap_or_else(|| footer_height(footer_props));
custom_height.unwrap_or_else(|| footer_height(footer_props.clone()));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([

View File

@@ -15,7 +15,7 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
pub(crate) esc_backtrack_hint: bool,
@@ -28,6 +28,7 @@ pub(crate) struct FooterProps {
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
pub(crate) transcript_copy_selection_key: KeyBinding,
pub(crate) transcript_copy_feedback: Option<TranscriptCopyFeedback>,
pub(crate) stashed_draft_preview: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -111,10 +112,18 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
props.context_window_used_tokens,
);
line.push_span(" · ".dim());
line.extend(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
]);
if let Some(preview) = props.stashed_draft_preview {
line.extend(vec![
"STASHED:".cyan(),
" ".into(),
Span::from(format!("\"{preview}\"")).dim(),
]);
} else {
line.extend(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
]);
}
if props.transcript_scrolled {
line.push_span(" · ".dim());
line.push_span(key_hint::plain(KeyCode::PageUp));
@@ -152,10 +161,21 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
shortcut_overlay_lines(state)
}
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
FooterMode::ContextOnly => vec![context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
)],
FooterMode::ContextOnly => {
let mut line = context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
);
if let Some(preview) = props.stashed_draft_preview {
line.push_span(" · ".dim());
line.extend(vec![
"STASHED:".cyan(),
" ".into(),
Span::from(format!("\"{preview}\"")).dim(),
]);
}
vec![line]
}
};
apply_copy_feedback(&mut lines, props.transcript_copy_feedback);
lines
@@ -206,6 +226,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut newline = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut stash_draft = Line::from("");
let mut edit_previous = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
@@ -217,6 +238,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::InsertNewline => newline = text,
ShortcutId::FilePaths => file_paths = text,
ShortcutId::PasteImage => paste_image = text,
ShortcutId::StashDraft => stash_draft = text,
ShortcutId::EditPrevious => edit_previous = text,
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
@@ -229,9 +251,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
newline,
file_paths,
paste_image,
stash_draft,
edit_previous,
quit,
Line::from(""),
show_transcript,
];
@@ -305,6 +327,7 @@ enum ShortcutId {
InsertNewline,
FilePaths,
PasteImage,
StashDraft,
EditPrevious,
Quit,
ShowTranscript,
@@ -425,6 +448,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " to paste images",
},
ShortcutDescriptor {
id: ShortcutId::StashDraft,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('s')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to stash prompt",
},
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
@@ -462,7 +494,7 @@ mod tests {
use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps) {
let height = footer_height(props).max(1);
let height = footer_height(props.clone()).max(1);
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
terminal
.draw(|f| {
@@ -489,6 +521,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -506,6 +539,7 @@ mod tests {
transcript_scroll_position: Some((3, 42)),
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -523,6 +557,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -540,6 +575,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -557,6 +593,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -574,6 +611,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -591,6 +629,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -608,6 +647,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -625,6 +665,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: None,
stashed_draft_preview: None,
},
);
@@ -642,6 +683,7 @@ mod tests {
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied),
stashed_draft_preview: None,
},
);
}

View File

@@ -266,6 +266,14 @@ impl BottomPane {
self.composer.current_text()
}
pub(crate) fn restore_stashed_draft_if_possible(&mut self) -> bool {
let restored = self.composer.restore_stashed_draft_if_possible();
if restored {
self.request_redraw();
}
restored
}
/// Update the status indicator header (defaults to "Working") and details below it.
///
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.

View File

@@ -1,5 +1,6 @@
---
source: tui2/src/bottom_pane/chat_composer.rs
assertion_line: 2204
expression: terminal.backend()
---
" "
@@ -10,7 +11,7 @@ expression: terminal.backend()
" "
" "
" "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + s to stash prompt esc again to edit previous message "
" ctrl + c to exit ctrl + t to view transcript "

View File

@@ -1,8 +1,9 @@
---
source: tui2/src/bottom_pane/footer.rs
assertion_line: 505
expression: terminal.backend()
---
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + s to stash prompt esc again to edit previous message "
" ctrl + c to exit ctrl + t to view transcript "

View File

@@ -542,6 +542,7 @@ impl ChatWidget {
self.running_commands.clear();
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.bottom_pane.restore_stashed_draft_if_possible();
self.request_redraw();
// If there is a queued user message, send exactly one now to begin the next turn.
@@ -674,6 +675,7 @@ impl ChatWidget {
self.running_commands.clear();
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.bottom_pane.restore_stashed_draft_if_possible();
self.stream_controller = None;
self.maybe_show_pending_rate_limit_prompt();
}
@@ -1490,12 +1492,16 @@ impl ChatWidget {
_ => {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
let was_running = self.bottom_pane.is_task_running();
// If a task is running, queue the user input to be sent after the turn completes.
let user_message = UserMessage {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.queue_user_message(user_message);
if !was_running {
self.bottom_pane.restore_stashed_draft_if_possible();
}
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);

View File

@@ -1080,6 +1080,95 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
);
}
#[tokio::test]
async fn ctrl_s_noops_when_composer_empty() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
assert!(chat.bottom_pane.composer_text().is_empty());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.on_task_started();
chat.on_task_complete(None);
assert!(chat.bottom_pane.composer_text().is_empty());
}
#[tokio::test]
async fn ctrl_s_stashes_and_restores_after_next_submission_when_idle() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.bottom_pane.set_composer_text("send now".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "draft A");
}
#[tokio::test]
async fn ctrl_s_stashes_during_task_and_restores_after_task_complete() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "draft A");
}
#[tokio::test]
async fn ctrl_s_does_not_stash_when_slash_popup_is_active() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.insert_str("/");
assert_eq!(chat.bottom_pane.composer_text(), "/");
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert_eq!(chat.bottom_pane.composer_text(), "/");
chat.on_task_started();
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "/");
}
#[tokio::test]
async fn ctrl_s_restore_is_deferred_until_composer_is_empty() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
chat.bottom_pane.set_composer_text("draft B".to_string());
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "draft B");
chat.bottom_pane.set_composer_text(String::new());
chat.on_task_started();
chat.on_task_complete(None);
assert_eq!(chat.bottom_pane.composer_text(), "draft A");
}
#[tokio::test]
async fn ctrl_s_overwrites_existing_stash() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_composer_text("draft A".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
chat.bottom_pane.set_composer_text("draft B".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
chat.bottom_pane.set_composer_text("send now".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "draft B");
}
#[tokio::test]
async fn exec_history_cell_shows_working_then_completed() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;