mirror of
https://github.com/openai/codex.git
synced 2026-05-07 12:56:45 +00:00
Compare commits
1 Commits
pr19462
...
llx/stash-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c2c80c12 |
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user