Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cec6969
feat: stashes current message with Ctrl+S
fcoury Jan 24, 2026
94f803f
feat: Ctrl+S with a current stash restores it
fcoury Jan 24, 2026
4918487
chore: removed leftover comment
fcoury Jan 24, 2026
6b2da18
fix: records history only for ForSubmit on prepare_composer_text
fcoury Jan 24, 2026
a87bc8f
feat: shows hint when attempting to restore stash with existing draft
fcoury Jan 24, 2026
3ec83bc
fix: avoid cloning every local image when stashing
fcoury Jan 24, 2026
4ef23df
fix: doesn't expand pending pastes when preparing draft to stash
fcoury Jan 24, 2026
1defa6a
fix: proper removal of clear_stash_hint
fcoury Jan 24, 2026
a72494c
chore: removed leftover trace
fcoury Jan 24, 2026
468a866
test(tui): correct expectation for stash message after wrapping
fcoury Jan 26, 2026
9ac7b01
style(tui): fix import style
fcoury Jan 26, 2026
933ceda
test(tui): uses width 20 when testing stash to avoid spelling errors
fcoury Jan 26, 2026
ab1fc08
fix: add CantUnstashHint to match exhaustiveness after rebase
fcoury Jan 29, 2026
ff5ae07
chore: line indentations
fcoury Jan 30, 2026
ae5e2db
fix(tui): preserve image placeholder mappings in stash restore
fcoury Jan 30, 2026
54f66de
chore(tui): wrong comment on stash test
fcoury Jan 30, 2026
36648ff
fix(tui): hint message when stash is preserved
fcoury Jan 30, 2026
fe54c5e
test(tui): stash coverage for pending pastes and the auto-restore path
fcoury Jan 30, 2026
fd1e85c
docs(tui): PreparedDraft
fcoury Jan 30, 2026
ab6fa0e
fix(tui): clippy errors
fcoury Jan 30, 2026
977ddad
fix(tui): copilot review feedback
fcoury Jan 30, 2026
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
389 changes: 340 additions & 49 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion codex-rs/tui/src/bottom_pane/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub(crate) enum FooterMode {
/// The shortcuts hint is suppressed here; when a task is running with
/// steer enabled, this mode can show the queue hint instead.
ComposerHasDraft,
CantUnstashHint,
}

pub(crate) fn toggle_shortcut_mode(
Expand Down Expand Up @@ -156,9 +157,18 @@ pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> Foote
}
}

pub(crate) fn stash_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode {
if is_task_running {
current
} else {
FooterMode::CantUnstashHint
}
}

pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint
FooterMode::CantUnstashHint
| FooterMode::EscHint
| FooterMode::ShortcutOverlay
| FooterMode::QuitShortcutReminder
| FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty,
Expand All @@ -172,13 +182,15 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 {
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::CantUnstashHint
| FooterMode::ComposerHasDraft => false,
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running && props.steer_enabled,
FooterMode::QuitShortcutReminder
| FooterMode::ComposerEmpty
| FooterMode::ShortcutOverlay
| FooterMode::CantUnstashHint
| FooterMode::EscHint => false,
};
footer_from_props_lines(props, None, false, show_shortcuts_hint, show_queue_hint).len() as u16
Expand Down Expand Up @@ -563,6 +575,7 @@ fn footer_from_props_lines(
shortcut_overlay_lines(state)
}
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
FooterMode::CantUnstashHint => vec![show_stash_preserved_hint_line()],
FooterMode::ComposerHasDraft => {
let state = LeftSideState {
hint: if show_queue_hint {
Expand Down Expand Up @@ -643,6 +656,12 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
}
}

fn show_stash_preserved_hint_line() -> Line<'static> {
Line::from(vec![
"Stash preserved — submit current message to auto-restore, or clear input and press Ctrl+S to restore".dim(),
])
}

fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = Line::from("");
let mut shell_commands = Line::from("");
Expand Down Expand Up @@ -986,13 +1005,15 @@ mod tests {
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::CantUnstashHint
| FooterMode::ComposerHasDraft => false,
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running && props.steer_enabled,
FooterMode::QuitShortcutReminder
| FooterMode::ComposerEmpty
| FooterMode::ShortcutOverlay
| FooterMode::CantUnstashHint
| FooterMode::EscHint => false,
};
let left_width = footer_line_width(
Expand Down
171 changes: 168 additions & 3 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::stash_indicator::StashIndicator;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
Expand Down Expand Up @@ -78,8 +79,10 @@ pub mod popup_consts;
mod queued_user_messages;
mod scroll_state;
mod selection_popup_common;
mod stash_indicator;
mod textarea;
mod unified_exec_footer;
pub(crate) use chat_composer::PreparedDraft;
pub(crate) use feedback_view::FeedbackNoteView;

/// How long the "press again to quit" hint stays visible.
Expand Down Expand Up @@ -149,6 +152,8 @@ pub(crate) struct BottomPane {
unified_exec_footer: UnifiedExecFooter,
/// Queued user messages to show above the composer while a turn is running.
queued_user_messages: QueuedUserMessages,
/// Shows an indicator when there are stashed changes.
stash_indicator: StashIndicator,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
}
Expand Down Expand Up @@ -197,6 +202,7 @@ impl BottomPane {
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
queued_user_messages: QueuedUserMessages::new(),
stash_indicator: StashIndicator::new(),
esc_backtrack_hint: false,
animations_enabled,
context_window_percent: None,
Expand Down Expand Up @@ -407,6 +413,19 @@ impl BottomPane {
self.request_redraw();
}

/// Restores composer text, images, pending pastes, and mention paths from a PreparedDraft
pub(crate) fn restore_stash(&mut self, stash: PreparedDraft) {
self.composer
.set_text_content_with_local_images_and_pending_pastes(
stash.text,
stash.text_elements,
stash.local_images,
stash.pending_pastes,
stash.mention_paths,
);
self.request_redraw();
}

#[allow(dead_code)]
pub(crate) fn set_composer_input_enabled(
&mut self,
Expand Down Expand Up @@ -525,6 +544,11 @@ impl BottomPane {
}
}

pub(crate) fn show_stash_hint(&mut self) {
self.composer.set_stash_hint(true);
self.request_redraw();
}

// esc_backtrack_hint_visible removed; hints are controlled internally.

pub fn set_task_running(&mut self, running: bool) {
Expand Down Expand Up @@ -602,6 +626,11 @@ impl BottomPane {
self.request_redraw();
}

pub(crate) fn set_stashed(&mut self, stashed: bool) {
self.stash_indicator.stash_exists = stashed;
self.request_redraw();
}

pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec<String>) {
if self.unified_exec_footer.set_processes(processes) {
self.request_redraw();
Expand All @@ -618,6 +647,11 @@ impl BottomPane {
self.composer.is_empty()
}

// Check if the composer has a text, images, or pending pastes draft.
pub(crate) fn composer_has_draft(&self) -> bool {
self.composer.has_draft()
}

pub(crate) fn is_task_running(&self) -> bool {
self.is_task_running
}
Expand Down Expand Up @@ -799,13 +833,21 @@ impl BottomPane {
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
}
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
let has_stash = self.stash_indicator.stash_exists;
let has_aux = has_queued_messages || has_stash;

let has_status_or_footer =
self.status.is_some() || !self.unified_exec_footer.is_empty();
if has_queued_messages && has_status_or_footer {
if has_aux && has_status_or_footer {
flex.push(0, RenderableItem::Owned("".into()));
}
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
if !has_queued_messages && has_status_or_footer {
if has_queued_messages {
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
}
if has_stash {
flex.push(0, RenderableItem::Borrowed(&self.stash_indicator));
}
if !has_aux && has_status_or_footer {
flex.push(0, RenderableItem::Owned("".into()));
}
let mut flex2 = FlexRenderable::new();
Expand All @@ -814,6 +856,11 @@ impl BottomPane {
RenderableItem::Owned(Box::new(flex2))
}
}

#[cfg(test)]
pub(crate) fn stash_exists(&self) -> bool {
self.stash_indicator.stash_exists
}
}

impl Renderable for BottomPane {
Expand All @@ -833,9 +880,11 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_core::protocol::Op;
use codex_protocol::models::local_image_label_text;
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::cell::Cell;
Expand Down Expand Up @@ -1162,6 +1211,122 @@ mod tests {
);
}

#[test]
fn status_and_stash_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});

pane.set_task_running(true);
pane.set_stashed(true);

let width = 48;
let height = pane.desired_height(width);
let area = Rect::new(0, 0, width, height);
assert_snapshot!("status_and_stash_snapshot", render_snapshot(&pane, area));
}

#[test]
fn status_and_queued_messages_and_stash_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});

pane.set_task_running(true);
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
pane.set_stashed(true);

let width = 48;
let height = pane.desired_height(width);
let area = Rect::new(0, 0, width, height);
assert_snapshot!(
"status_and_queued_messages_and_stash_snapshot",
render_snapshot(&pane, area)
);
}

#[test]
fn restore_stash_preserves_image_placeholders() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});

let placeholder1 = local_image_label_text(1);
let placeholder3 = local_image_label_text(3);
let text = format!("{placeholder1} then {placeholder3}");
let mut text_elements = Vec::new();
for placeholder in [&placeholder1, &placeholder3] {
let start = text
.find(placeholder)
.expect("placeholder should exist in text");
let end = start + placeholder.len();
text_elements.push(TextElement::new((start..end).into(), None));
}

let path1 = PathBuf::from("/tmp/image1.png");
let path3 = PathBuf::from("/tmp/image3.png");
let stash = PreparedDraft {
text: text.clone(),
text_elements,
local_images: vec![
LocalImageAttachment {
placeholder: placeholder1.clone(),
path: path1.clone(),
},
LocalImageAttachment {
placeholder: placeholder3.clone(),
path: path3.clone(),
},
],
pending_pastes: Vec::new(),
mention_paths: HashMap::new(),
};

pane.restore_stash(stash);

assert_eq!(pane.composer.current_text(), text);
assert_eq!(
pane.composer.local_images(),
vec![
LocalImageAttachment {
placeholder: placeholder1,
path: path1,
},
LocalImageAttachment {
placeholder: placeholder3,
path: path3,
},
]
);
}

#[test]
fn esc_with_skill_popup_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: tui/src/bottom_pane/stash_indicator.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 20, height: 4 },
content: [
" ↳ Stashed ",
" (restores after ",
" current message ",
" is sent) ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 19, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 19, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 12, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)

↳ Queued follow-up question
⌥ + ↑ edit
↳ Stashed (restores after current message is
sent)

› Ask Codex to do anything

? for shortcuts 100% context left
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)

↳ Stashed (restores after current message is
sent)

› Ask Codex to do anything

? for shortcuts 100% context left
Loading
Loading