reintroduce "? for shortcuts" (#4364)

Reverts openai/codex#4362
This commit is contained in:
Ahmed Ibrahim
2025-09-29 16:35:47 -07:00
committed by GitHub
parent 80ccec6530
commit 98efd352ae
25 changed files with 609 additions and 298 deletions

View File

@@ -1,4 +1,3 @@
use codex_core::protocol::TokenUsageInfo;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
@@ -19,8 +18,14 @@ use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandItem; use super::command_popup::CommandItem;
use super::command_popup::CommandPopup; use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup; use super::file_search_popup::FileSearchPopup;
use super::footer::FooterMode;
use super::footer::FooterProps; use super::footer::FooterProps;
use super::footer::esc_hint_mode;
use super::footer::footer_height;
use super::footer::prompt_mode;
use super::footer::render_footer; use super::footer::render_footer;
use super::footer::reset_mode_after_activity;
use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision; use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst; use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::paste_burst::FlushResult;
@@ -78,7 +83,6 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>, dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>, current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>, pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool, has_focus: bool,
attached_images: Vec<AttachedImage>, attached_images: Vec<AttachedImage>,
placeholder_text: String, placeholder_text: String,
@@ -88,6 +92,7 @@ pub(crate) struct ChatComposer {
// When true, disables paste-burst logic and inserts characters immediately. // When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool, disable_paste_burst: bool,
custom_prompts: Vec<CustomPrompt>, custom_prompts: Vec<CustomPrompt>,
footer_mode: FooterMode,
} }
/// Popup state at most one can be visible at any time. /// Popup state at most one can be visible at any time.
@@ -97,7 +102,7 @@ enum ActivePopup {
File(FileSearchPopup), File(FileSearchPopup),
} }
const FOOTER_HINT_HEIGHT: u16 = 1; const FOOTER_SPACING_HEIGHT: u16 = 1;
impl ChatComposer { impl ChatComposer {
pub fn new( pub fn new(
@@ -121,7 +126,6 @@ impl ChatComposer {
dismissed_file_popup_token: None, dismissed_file_popup_token: None,
current_file_query: None, current_file_query: None,
pending_pastes: Vec::new(), pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus, has_focus: has_input_focus,
attached_images: Vec::new(), attached_images: Vec::new(),
placeholder_text, placeholder_text,
@@ -129,6 +133,7 @@ impl ChatComposer {
paste_burst: PasteBurst::default(), paste_burst: PasteBurst::default(),
disable_paste_burst: false, disable_paste_burst: false,
custom_prompts: Vec::new(), custom_prompts: Vec::new(),
footer_mode: FooterMode::ShortcutPrompt,
}; };
// Apply configuration via the setter to keep side-effects centralized. // Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst); this.set_disable_paste_burst(disable_paste_burst);
@@ -136,26 +141,41 @@ impl ChatComposer {
} }
pub fn desired_height(&self, width: u16) -> u16 { pub fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(footer_props);
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let footer_total_height = footer_hint_height + footer_spacing;
self.textarea self.textarea
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) .desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
+ 2 + 2
+ match &self.active_popup { + match &self.active_popup {
ActivePopup::None => FOOTER_HINT_HEIGHT, ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(), ActivePopup::File(c) => c.calculate_required_height(),
} }
} }
fn layout_areas(&self, area: Rect) -> [Rect; 3] { fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(footer_props);
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let footer_total_height = footer_hint_height + footer_spacing;
let popup_constraint = match &self.active_popup { let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => { ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width)) Constraint::Max(popup.calculate_required_height(area.width))
} }
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT), ActivePopup::None => Constraint::Max(footer_total_height),
}; };
let mut area = area; let mut area = area;
// Leave an empty row at the top, unless there isn't room.
if area.height > 1 { if area.height > 1 {
area.height -= 1; area.height -= 1;
area.y += 1; area.y += 1;
@@ -179,13 +199,6 @@ impl ChatComposer {
self.textarea.is_empty() self.textarea.is_empty()
} }
/// Update the cached *context-left* percentage and refresh the placeholder
/// text. The UI relies on the placeholder to convey the remaining
/// context when the composer is empty.
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.token_usage_info = token_info;
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so /// Record the history metadata advertised by `SessionConfiguredEvent` so
/// that the composer can navigate cross-session history. /// that the composer can navigate cross-session history.
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
@@ -323,6 +336,11 @@ impl ChatComposer {
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show; self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = prompt_mode();
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
self.set_has_focus(has_focus); self.set_has_focus(has_focus);
} }
@@ -358,6 +376,18 @@ impl ChatComposer {
/// Handle key event when the slash-command popup is visible. /// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::Command(popup) = &mut self.active_popup else { let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!(); unreachable!();
}; };
@@ -513,6 +543,18 @@ impl ChatComposer {
/// Handle key events when file search popup is visible. /// Handle key events when file search popup is visible.
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::File(popup) = &mut self.active_popup else { let ActivePopup::File(popup) = &mut self.active_popup else {
unreachable!(); unreachable!();
}; };
@@ -774,6 +816,18 @@ impl ChatComposer {
/// Handle key event when no popup is visible. /// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
match key_event { match key_event {
KeyEvent { KeyEvent {
code: KeyCode::Char('d'), code: KeyCode::Char('d'),
@@ -925,6 +979,10 @@ impl ChatComposer {
let now = Instant::now(); let now = Instant::now();
self.handle_paste_burst_flush(now); self.handle_paste_burst_flush(now);
if !matches!(input.code, KeyCode::Esc) {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting. // If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter) if matches!(input.code, KeyCode::Enter)
&& self.paste_burst.is_active() && self.paste_burst.is_active()
@@ -1198,6 +1256,47 @@ impl ChatComposer {
false false
} }
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let toggles = match key_event.code {
KeyCode::Char('?') if key_event.modifiers.is_empty() => true,
KeyCode::BackTab => true,
KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => true,
_ => false,
};
if !toggles {
return false;
}
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
}
fn footer_props(&self) -> FooterProps {
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,
}
}
fn footer_mode(&self) -> FooterMode {
if matches!(self.footer_mode, FooterMode::EscHint) {
FooterMode::EscHint
} else if self.ctrl_c_quit_hint {
FooterMode::CtrlCReminder
} else {
self.footer_mode
}
}
/// Synchronize `self.command_popup` with the current text in the /// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change /// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate. /// the text so the popup is shown/updated/hidden as appropriate.
@@ -1306,10 +1405,18 @@ impl ChatComposer {
pub fn set_task_running(&mut self, running: bool) { pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running; self.is_task_running = running;
if running {
self.footer_mode = prompt_mode();
}
} }
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
self.esc_backtrack_hint = show; self.esc_backtrack_hint = show;
if show {
self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
} }
} }
@@ -1324,20 +1431,26 @@ impl WidgetRef for ChatComposer {
popup.render_ref(popup_rect, buf); popup.render_ref(popup_rect, buf);
} }
ActivePopup::None => { ActivePopup::None => {
let mut hint_rect = popup_rect; let footer_hint_height = footer_height(self.footer_props());
hint_rect.x += 2; let footer_spacing = if footer_hint_height > 0 {
hint_rect.width = hint_rect.width.saturating_sub(2); FOOTER_SPACING_HEIGHT
render_footer( } else {
hint_rect, 0
buf, };
FooterProps { let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
ctrl_c_quit_hint: self.ctrl_c_quit_hint, let [_, hint_rect] = Layout::vertical([
is_task_running: self.is_task_running, Constraint::Length(footer_spacing),
esc_backtrack_hint: self.esc_backtrack_hint, Constraint::Length(footer_hint_height),
use_shift_enter_hint: self.use_shift_enter_hint, ])
token_usage_info: self.token_usage_info.as_ref(), .areas(popup_rect);
}, hint_rect
); } else {
popup_rect
};
let mut footer_rect = hint_rect;
footer_rect.x = footer_rect.x.saturating_add(2);
footer_rect.width = footer_rect.width.saturating_sub(2);
render_footer(footer_rect, buf, self.footer_props());
} }
} }
let style = user_message_style(terminal_palette::default_bg()); let style = user_message_style(terminal_palette::default_bg());
@@ -1376,6 +1489,7 @@ mod tests {
use crate::bottom_pane::InputResult; use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::footer::footer_height;
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
@@ -1407,7 +1521,7 @@ mod tests {
let mut hint_row: Option<(u16, String)> = None; let mut hint_row: Option<(u16, String)> = None;
for y in 0..area.height { for y in 0..area.height {
let row = row_to_string(y); let row = row_to_string(y);
if row.contains(" send") { if row.contains("? for shortcuts") {
hint_row = Some((y, row)); hint_row = Some((y, row));
break; break;
} }
@@ -1434,6 +1548,81 @@ mod tests {
); );
} }
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
{
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let width = 100;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
enhanced_keys_supported,
"Ask Codex to do anything".to_string(),
false,
);
setup(&mut composer);
let footer_lines = footer_height(composer.footer_props());
let height = footer_lines + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
#[test]
fn footer_mode_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state(
"footer_mode_overlay_then_external_esc_hint",
true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.set_esc_backtrack_hint(true);
},
);
}
#[test] #[test]
fn test_current_at_token_basic_cases() { fn test_current_at_token_basic_cases() {
let test_cases = vec![ let test_cases = vec![

View File

@@ -1,180 +1,206 @@
use codex_core::protocol::TokenUsageInfo;
use codex_protocol::num_format::format_si_suffix;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use crate::key_hint;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps<'a> { pub(crate) struct FooterProps {
pub(crate) ctrl_c_quit_hint: bool, pub(crate) mode: FooterMode,
pub(crate) is_task_running: bool,
pub(crate) esc_backtrack_hint: bool, pub(crate) esc_backtrack_hint: bool,
pub(crate) use_shift_enter_hint: bool, pub(crate) use_shift_enter_hint: bool,
pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, pub(crate) is_task_running: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
CtrlCReminder,
ShortcutPrompt,
ShortcutOverlay,
EscHint,
}
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
if ctrl_c_hint {
return current;
}
match current {
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt,
_ => FooterMode::ShortcutOverlay,
}
}
pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode {
if is_task_running {
current
} else {
FooterMode::EscHint
}
}
pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint | FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt,
other => other,
}
}
pub(crate) fn prompt_mode() -> FooterMode {
FooterMode::ShortcutPrompt
}
pub(crate) fn footer_height(props: FooterProps) -> u16 {
footer_lines(props).len() as u16
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
let lines = footer_lines(props);
for (idx, line) in lines.into_iter().enumerate() {
let y = area.y + idx as u16;
if y >= area.y + area.height {
break;
}
let row = Rect::new(area.x, y, area.width, 1);
line.render_ref(row, buf);
}
}
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
match props.mode {
FooterMode::CtrlCReminder => {
vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})]
}
FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])],
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
}),
FooterMode::EscHint => {
vec![esc_hint_line(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
})]
}
}
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct CtrlCReminderState { struct CtrlCReminderState {
pub(crate) is_task_running: bool, is_task_running: bool,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct ShortcutsState { struct ShortcutsState {
pub(crate) use_shift_enter_hint: bool, use_shift_enter_hint: bool,
pub(crate) esc_backtrack_hint: bool, esc_backtrack_hint: bool,
is_task_running: bool,
} }
#[derive(Clone, Copy, Debug)] fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
enum FooterContent { let action = if state.is_task_running {
Shortcuts(ShortcutsState), "interrupt"
CtrlCReminder(CtrlCReminderState),
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) {
let content = if props.ctrl_c_quit_hint {
FooterContent::CtrlCReminder(CtrlCReminderState {
is_task_running: props.is_task_running,
})
} else { } else {
FooterContent::Shortcuts(ShortcutsState { "quit"
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
})
}; };
Line::from(vec![
let mut spans = footer_spans(content); Span::from(format!(" ctrl + c again to {action}")).dim(),
if let Some(token_usage_info) = props.token_usage_info { ])
append_token_usage_spans(&mut spans, token_usage_info);
}
let spans = spans
.into_iter()
.map(|span| span.patch_style(Style::default().dim()))
.collect::<Vec<_>>();
Line::from(spans).render_ref(area, buf);
} }
fn footer_spans(content: FooterContent) -> Vec<Span<'static>> { fn esc_hint_line(state: ShortcutsState) -> Line<'static> {
match content { let text = if state.esc_backtrack_hint {
FooterContent::Shortcuts(state) => shortcuts_spans(state), " esc again to edit previous message"
FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), } else {
} " esc esc to edit previous message"
};
Line::from(vec![Span::from(text).dim()])
} }
fn append_token_usage_spans(spans: &mut Vec<Span<'static>>, token_usage_info: &TokenUsageInfo) { fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let token_usage = &token_usage_info.total_token_usage; let mut rendered = Vec::new();
spans.push(" ".into());
spans.push(
Span::from(format!(
"{} tokens used",
format_si_suffix(token_usage.blended_total())
))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
last_token_usage.percent_of_context_window_remaining(context_window)
} else {
100
};
let context_style = if percent_remaining < 20 {
Style::default().fg(Color::Yellow)
} else {
Style::default().add_modifier(Modifier::DIM)
};
spans.push(" ".into());
spans.push(Span::styled(
format!("{percent_remaining}% context left"),
context_style,
));
}
}
fn shortcuts_spans(state: ShortcutsState) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for descriptor in SHORTCUTS { for descriptor in SHORTCUTS {
if let Some(segment) = descriptor.footer_segment(state) { if let Some(text) = descriptor.overlay_entry(state) {
if !segment.prefix.is_empty() { rendered.push(text);
spans.push(segment.prefix.into());
}
spans.push(segment.binding.span());
spans.push(segment.label.into());
} }
} }
spans build_columns(rendered)
} }
fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec<Span<'static>> { fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
let followup = if state.is_task_running { if entries.is_empty() {
" to interrupt" return Vec::new();
} else { }
" to quit"
}; const COLUMNS: usize = 3;
vec![ const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28];
" ".into(), const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0];
key_hint::ctrl('C'),
" again".into(), let rows = entries.len().div_ceil(COLUMNS);
followup.into(), let mut column_widths = [0usize; COLUMNS];
]
for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.len());
}
let mut lines = Vec::new();
for row in 0..rows {
let mut line = String::from(" ");
for col in 0..COLUMNS {
let idx = row * COLUMNS + col;
if idx >= entries.len() {
continue;
}
let entry = &entries[idx];
if col < COLUMNS - 1 {
let max_width = MAX_PADDED_WIDTHS[col];
let mut target_width = column_widths[col];
target_width = target_width.max(MIN_PADDED_WIDTHS[col]).min(max_width);
let pad_width = target_width + 2;
line.push_str(&format!("{entry:<pad_width$}"));
} else {
if col != 0 {
line.push_str(" ");
}
line.push_str(entry);
}
}
lines.push(Line::from(vec![Span::from(line).dim()]));
}
lines
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FooterSegment {
prefix: &'static str,
binding: ShortcutBinding,
label: &'static str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
enum ShortcutId { enum ShortcutId {
Send, Commands,
InsertNewline, InsertNewline,
ShowTranscript, ChangeMode,
Quit, FilePaths,
PasteImage,
EditPrevious, EditPrevious,
Quit,
ShowTranscript,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ShortcutBinding { struct ShortcutBinding {
code: KeyCode, code: KeyCode,
modifiers: KeyModifiers, modifiers: KeyModifiers,
display: ShortcutDisplay, overlay_text: &'static str,
condition: DisplayCondition, condition: DisplayCondition,
} }
impl ShortcutBinding { impl ShortcutBinding {
fn span(&self) -> Span<'static> { fn matches(&self, state: ShortcutsState) -> bool {
self.display.into_span() self.condition.matches(state)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShortcutDisplay {
Plain(&'static str),
Ctrl(char),
Shift(char),
}
impl ShortcutDisplay {
fn into_span(self) -> Span<'static> {
match self {
ShortcutDisplay::Plain(text) => key_hint::plain(text),
ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch),
ShortcutDisplay::Shift(ch) => key_hint::shift(ch),
}
} }
} }
@@ -198,49 +224,55 @@ impl DisplayCondition {
struct ShortcutDescriptor { struct ShortcutDescriptor {
id: ShortcutId, id: ShortcutId,
bindings: &'static [ShortcutBinding], bindings: &'static [ShortcutBinding],
footer_label: &'static str, prefix: &'static str,
footer_prefix: &'static str, label: &'static str,
} }
impl ShortcutDescriptor { impl ShortcutDescriptor {
fn binding_for(&self, state: ShortcutsState) -> Option<ShortcutBinding> { fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> {
self.bindings self.bindings.iter().find(|binding| binding.matches(state))
.iter()
.find(|binding| binding.condition.matches(state))
.copied()
} }
fn should_show(&self, state: ShortcutsState) -> bool { fn overlay_entry(&self, state: ShortcutsState) -> Option<String> {
match self.id {
ShortcutId::EditPrevious => state.esc_backtrack_hint,
_ => true,
}
}
fn footer_segment(&self, state: ShortcutsState) -> Option<FooterSegment> {
if !self.should_show(state) {
return None;
}
let binding = self.binding_for(state)?; let binding = self.binding_for(state)?;
Some(FooterSegment { let label = match self.id {
prefix: self.footer_prefix, ShortcutId::Quit => {
binding, if state.is_task_running {
label: self.footer_label, " to interrupt"
}) } else {
self.label
}
}
ShortcutId::EditPrevious => {
if state.esc_backtrack_hint {
" again to edit previous message"
} else {
" esc to edit previous message"
}
}
_ => self.label,
};
let text = match self.id {
ShortcutId::Quit if state.is_task_running => {
format!("{}{} to interrupt", self.prefix, binding.overlay_text)
}
_ => format!("{}{}{}", self.prefix, binding.overlay_text, label),
};
Some(text)
} }
} }
const SHORTCUTS: &[ShortcutDescriptor] = &[ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::Send, id: ShortcutId::Commands,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Enter, code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
display: ShortcutDisplay::Plain(""), overlay_text: "/",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
footer_label: " send ", prefix: "",
footer_prefix: "", label: " for commands",
}, },
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::InsertNewline, id: ShortcutId::InsertNewline,
@@ -248,138 +280,165 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutBinding { ShortcutBinding {
code: KeyCode::Enter, code: KeyCode::Enter,
modifiers: KeyModifiers::SHIFT, modifiers: KeyModifiers::SHIFT,
display: ShortcutDisplay::Shift('⏎'), overlay_text: "shift + enter",
condition: DisplayCondition::WhenShiftEnterHint, condition: DisplayCondition::WhenShiftEnterHint,
}, },
ShortcutBinding { ShortcutBinding {
code: KeyCode::Char('j'), code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
display: ShortcutDisplay::Ctrl('J'), overlay_text: "ctrl + j",
condition: DisplayCondition::WhenNotShiftEnterHint, condition: DisplayCondition::WhenNotShiftEnterHint,
}, },
], ],
footer_label: " newline ", prefix: "",
footer_prefix: "", label: " for newline",
}, },
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::ShowTranscript, id: ShortcutId::ChangeMode,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('t'), code: KeyCode::BackTab,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::SHIFT,
display: ShortcutDisplay::Ctrl('T'), overlay_text: "shift + tab",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
footer_label: " transcript ", prefix: "",
footer_prefix: "", label: " to change mode",
}, },
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::Quit, id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('c'), code: KeyCode::Char('@'),
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::NONE,
display: ShortcutDisplay::Ctrl('C'), overlay_text: "@",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
footer_label: " quit", prefix: "",
footer_prefix: "", label: " for file paths",
},
ShortcutDescriptor {
id: ShortcutId::PasteImage,
bindings: &[ShortcutBinding {
code: KeyCode::Char('v'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + v",
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to paste images",
}, },
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::EditPrevious, id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Esc, code: KeyCode::Esc,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
display: ShortcutDisplay::Plain("Esc"), overlay_text: "esc",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
footer_label: " edit prev", prefix: "",
footer_prefix: " ", label: "",
},
ShortcutDescriptor {
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + c",
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to exit",
},
ShortcutDescriptor {
id: ShortcutId::ShowTranscript,
bindings: &[ShortcutBinding {
code: KeyCode::Char('t'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + t",
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to view transcript",
}, },
]; ];
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use codex_core::protocol::TokenUsage;
use insta::assert_snapshot; use insta::assert_snapshot;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps<'_>) { fn snapshot_footer(name: &str, props: FooterProps) {
let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); let height = footer_height(props).max(1);
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
terminal terminal
.draw(|f| { .draw(|f| {
let area = Rect::new(0, 0, f.area().width, 1); let area = Rect::new(0, 0, f.area().width, height);
render_footer(area, f.buffer_mut(), props); render_footer(area, f.buffer_mut(), props);
}) })
.unwrap(); .unwrap();
assert_snapshot!(name, terminal.backend()); assert_snapshot!(name, terminal.backend());
} }
fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo {
let usage = TokenUsage {
input_tokens: total_tokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens,
};
let last = TokenUsage {
input_tokens: last_tokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: last_tokens,
};
TokenUsageInfo {
total_token_usage: usage,
last_token_usage: last,
model_context_window: Some(context_window),
}
}
#[test] #[test]
fn footer_snapshots() { fn footer_snapshots() {
snapshot_footer( snapshot_footer(
"footer_shortcuts_default", "footer_shortcuts_default",
FooterProps { FooterProps {
ctrl_c_quit_hint: false, mode: FooterMode::ShortcutPrompt,
is_task_running: false,
esc_backtrack_hint: false, esc_backtrack_hint: false,
use_shift_enter_hint: false, use_shift_enter_hint: false,
token_usage_info: None, is_task_running: false,
}, },
); );
snapshot_footer( snapshot_footer(
"footer_shortcuts_shift_and_esc", "footer_shortcuts_shift_and_esc",
FooterProps { FooterProps {
ctrl_c_quit_hint: false, mode: FooterMode::ShortcutOverlay,
is_task_running: false,
esc_backtrack_hint: true, esc_backtrack_hint: true,
use_shift_enter_hint: true, use_shift_enter_hint: true,
token_usage_info: Some(&token_usage(4_200, 900, 8_000)), is_task_running: false,
}, },
); );
snapshot_footer( snapshot_footer(
"footer_ctrl_c_quit_idle", "footer_ctrl_c_quit_idle",
FooterProps { FooterProps {
ctrl_c_quit_hint: true, mode: FooterMode::CtrlCReminder,
is_task_running: false,
esc_backtrack_hint: false, esc_backtrack_hint: false,
use_shift_enter_hint: false, use_shift_enter_hint: false,
token_usage_info: None, is_task_running: false,
}, },
); );
snapshot_footer( snapshot_footer(
"footer_ctrl_c_quit_running", "footer_ctrl_c_quit_running",
FooterProps { FooterProps {
ctrl_c_quit_hint: true, mode: FooterMode::CtrlCReminder,
is_task_running: true,
esc_backtrack_hint: false, esc_backtrack_hint: false,
use_shift_enter_hint: false, use_shift_enter_hint: false,
token_usage_info: None, is_task_running: true,
},
);
snapshot_footer(
"footer_esc_hint_idle",
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
},
);
snapshot_footer(
"footer_esc_hint_primed",
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: true,
use_shift_enter_hint: false,
is_task_running: false,
}, },
); );
} }

View File

@@ -4,7 +4,6 @@ use std::path::PathBuf;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use bottom_pane_view::BottomPaneView; use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch; use codex_file_search::FileMatch;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
@@ -378,13 +377,6 @@ impl BottomPane {
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
} }
/// Update the *context-window remaining* indicator in the composer. This
/// is forwarded directly to the underlying `ChatComposer`.
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.composer.set_token_usage(token_info);
self.request_redraw();
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) { pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view); self.push_view(view);
} }

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
" " " "
" " " "
" " " "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " ? for shortcuts "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
" " " "
" " " "
" " " "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " ? for shortcuts "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ctrl + c again to interrupt "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ctrl + c again to quit "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc esc to edit previous message "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc again to edit previous message "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc esc to edit previous message "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc again to edit previous message "

View File

@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" / for commands shift + enter for newline shift + tab to change mode "
" @ for file paths ctrl + v to paste images esc again to edit previous message "
" ctrl + c to exit ctrl + t to view transcript "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
" " " "
" " " "
" " " "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " ? for shortcuts "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
" " " "
" " " "
" " " "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " ? for shortcuts "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
" " " "
" " " "
" " " "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " ? for shortcuts "

View File

@@ -2,6 +2,4 @@
source: tui/src/bottom_pane/footer.rs source: tui/src/bottom_pane/footer.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" ⌃C again to quit " " ctrl + c again to quit "
" "
" "

View File

@@ -2,6 +2,4 @@
source: tui/src/bottom_pane/footer.rs source: tui/src/bottom_pane/footer.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" ⌃C again to interrupt " " ctrl + c again to interrupt "
" "
" "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc esc to edit previous message "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc again to edit previous message "

View File

@@ -2,6 +2,4 @@
source: tui/src/bottom_pane/footer.rs source: tui/src/bottom_pane/footer.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
"⏎ send ⌃J newline ⌃T transcript ⌃C quit " "? for shortcuts "
" "
" "

View File

@@ -2,6 +2,6 @@
source: tui/src/bottom_pane/footer.rs source: tui/src/bottom_pane/footer.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" " / for commands shift + enter for newline shift + tab to change m"
" " " @ for file paths ctrl + v to paste images esc again to edit previ"
" " " ctrl + c to exit ctrl + t to view transcript "

View File

@@ -395,7 +395,6 @@ impl ChatWidget {
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) { pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
if info.is_some() { if info.is_some() {
self.bottom_pane.set_token_usage(info.clone());
self.token_info = info; self.token_info = info;
} }
} }
@@ -1975,7 +1974,6 @@ impl ChatWidget {
pub(crate) fn clear_token_usage(&mut self) { pub(crate) fn clear_token_usage(&mut self) {
self.token_info = None; self.token_info = None;
self.bottom_pane.set_token_usage(None);
} }
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {

View File

@@ -14,4 +14,5 @@ expression: term.backend().vt100().screen().contents()
Summarize recent commits Summarize recent commits
⏎ send ⌃J newline ⌃T transcript ⌃C quit
? for shortcuts

View File

@@ -8,5 +8,6 @@ expression: terminal.backend()
" " " "
" Ask Codex to do anything " " Ask Codex to do anything "
" " " "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " "
" ? for shortcuts "
" " " "

View File

@@ -10,20 +10,6 @@ const ALT_PREFIX: &str = "⌥";
#[cfg(all(not(test), not(target_os = "macos")))] #[cfg(all(not(test), not(target_os = "macos")))]
const ALT_PREFIX: &str = "Alt+"; const ALT_PREFIX: &str = "Alt+";
#[cfg(test)]
const CTRL_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const CTRL_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const CTRL_PREFIX: &str = "Ctrl+";
#[cfg(test)]
const SHIFT_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const SHIFT_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const SHIFT_PREFIX: &str = "Shift+";
fn key_hint_style() -> Style { fn key_hint_style() -> Style {
Style::default().bold() Style::default().bold()
} }
@@ -32,18 +18,6 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
Span::styled(format!("{prefix}{key}"), key_hint_style()) Span::styled(format!("{prefix}{key}"), key_hint_style())
} }
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
modifier_span(CTRL_PREFIX, key)
}
pub(crate) fn alt(key: impl Display) -> Span<'static> { pub(crate) fn alt(key: impl Display) -> Span<'static> {
modifier_span(ALT_PREFIX, key) modifier_span(ALT_PREFIX, key)
} }
pub(crate) fn shift(key: impl Display) -> Span<'static> {
modifier_span(SHIFT_PREFIX, key)
}
pub(crate) fn plain(key: impl Display) -> Span<'static> {
Span::styled(format!("{key}"), key_hint_style())
}