Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1621,13 +1685,15 @@ 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,
use_shift_enter_hint: self.use_shift_enter_hint,
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,
}
}

Expand Down Expand Up @@ -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([
Expand Down
62 changes: 51 additions & 11 deletions codex-rs/tui/src/bottom_pane/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ 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,
pub(crate) use_shift_enter_hint: bool,
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)]
Expand Down Expand Up @@ -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 => {
Expand All @@ -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]
}
}
}

Expand Down Expand Up @@ -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("");
Expand All @@ -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,
Expand All @@ -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,
];

Expand Down Expand Up @@ -265,6 +287,7 @@ enum ShortcutId {
FilePaths,
PasteImage,
ExternalEditor,
StashDraft,
EditPrevious,
Quit,
ShowTranscript,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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| {
Expand All @@ -453,6 +485,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -465,6 +498,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -477,6 +511,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -489,6 +524,7 @@ mod tests {
is_task_running: true,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -501,6 +537,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -513,6 +550,7 @@ mod tests {
is_task_running: false,
context_window_percent: None,
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -525,6 +563,7 @@ mod tests {
is_task_running: true,
context_window_percent: Some(72),
context_window_used_tokens: None,
stashed_draft_preview: None,
},
);

Expand All @@ -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,
},
);
}
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2190
expression: terminal.backend()
---
" "
Expand All @@ -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 "
Original file line number Diff line number Diff line change
@@ -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 "
6 changes: 6 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading