Refactor the footer logic to a new file (#4259)
This will help us have more control over the footer --------- Co-authored-by: pakrym-oai <pakrym@openai.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::num_format::format_si_suffix;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -25,6 +24,8 @@ use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::command_popup::CommandItem;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::FooterProps;
|
||||
use super::footer::render_footer;
|
||||
use super::paste_burst::CharDecision;
|
||||
use super::paste_burst::PasteBurst;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
@@ -37,7 +38,6 @@ use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
use crate::clipboard_paste::pasted_image_format;
|
||||
use crate::key_hint;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
@@ -1263,78 +1263,17 @@ impl WidgetRef for ChatComposer {
|
||||
} else {
|
||||
popup_rect
|
||||
};
|
||||
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
|
||||
let ctrl_c_followup = if self.is_task_running {
|
||||
" to interrupt"
|
||||
} else {
|
||||
" to quit"
|
||||
};
|
||||
vec![
|
||||
" ".into(),
|
||||
key_hint::ctrl('C'),
|
||||
" again".into(),
|
||||
ctrl_c_followup.into(),
|
||||
]
|
||||
} else {
|
||||
let newline_hint_key = if self.use_shift_enter_hint {
|
||||
key_hint::shift('⏎')
|
||||
} else {
|
||||
key_hint::ctrl('J')
|
||||
};
|
||||
vec![
|
||||
key_hint::plain('⏎'),
|
||||
" send ".into(),
|
||||
newline_hint_key,
|
||||
" newline ".into(),
|
||||
key_hint::ctrl('T'),
|
||||
" transcript ".into(),
|
||||
key_hint::ctrl('C'),
|
||||
" quit".into(),
|
||||
]
|
||||
};
|
||||
|
||||
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
|
||||
hint.push(" ".into());
|
||||
hint.push(key_hint::plain("Esc"));
|
||||
hint.push(" edit prev".into());
|
||||
}
|
||||
|
||||
// Append token/context usage info to the footer hints when available.
|
||||
if let Some(token_usage_info) = &self.token_usage_info {
|
||||
let token_usage = &token_usage_info.total_token_usage;
|
||||
hint.push(" ".into());
|
||||
hint.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)
|
||||
};
|
||||
hint.push(" ".into());
|
||||
hint.push(Span::styled(
|
||||
format!("{percent_remaining}% context left"),
|
||||
context_style,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let hint = hint
|
||||
.into_iter()
|
||||
.map(|span| span.patch_style(Style::default().dim()))
|
||||
.collect::<Vec<_>>();
|
||||
Line::from(hint).render_ref(hint_rect, buf);
|
||||
render_footer(
|
||||
hint_rect,
|
||||
buf,
|
||||
FooterProps {
|
||||
ctrl_c_quit_hint: self.ctrl_c_quit_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
token_usage_info: self.token_usage_info.as_ref(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let border_style = if self.has_focus {
|
||||
|
||||
386
codex-rs/tui/src/bottom_pane/footer.rs
Normal file
386
codex-rs/tui/src/bottom_pane/footer.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::num_format::format_si_suffix;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::key_hint;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FooterProps<'a> {
|
||||
pub(crate) ctrl_c_quit_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) token_usage_info: Option<&'a TokenUsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CtrlCReminderState {
|
||||
pub(crate) is_task_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ShortcutsState {
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum FooterContent {
|
||||
Shortcuts(ShortcutsState),
|
||||
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 {
|
||||
FooterContent::Shortcuts(ShortcutsState {
|
||||
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
})
|
||||
};
|
||||
|
||||
let mut spans = footer_spans(content);
|
||||
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>> {
|
||||
match content {
|
||||
FooterContent::Shortcuts(state) => shortcuts_spans(state),
|
||||
FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_token_usage_spans(spans: &mut Vec<Span<'static>>, token_usage_info: &TokenUsageInfo) {
|
||||
let token_usage = &token_usage_info.total_token_usage;
|
||||
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 {
|
||||
if let Some(segment) = descriptor.footer_segment(state) {
|
||||
if !segment.prefix.is_empty() {
|
||||
spans.push(segment.prefix.into());
|
||||
}
|
||||
spans.push(segment.binding.span());
|
||||
spans.push(segment.label.into());
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec<Span<'static>> {
|
||||
let followup = if state.is_task_running {
|
||||
" to interrupt"
|
||||
} else {
|
||||
" to quit"
|
||||
};
|
||||
vec![
|
||||
" ".into(),
|
||||
key_hint::ctrl('C'),
|
||||
" again".into(),
|
||||
followup.into(),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FooterSegment {
|
||||
prefix: &'static str,
|
||||
binding: ShortcutBinding,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
enum ShortcutId {
|
||||
Send,
|
||||
InsertNewline,
|
||||
ShowTranscript,
|
||||
Quit,
|
||||
EditPrevious,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct ShortcutBinding {
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
display: ShortcutDisplay,
|
||||
condition: DisplayCondition,
|
||||
}
|
||||
|
||||
impl ShortcutBinding {
|
||||
fn span(&self) -> Span<'static> {
|
||||
self.display.into_span()
|
||||
}
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DisplayCondition {
|
||||
Always,
|
||||
WhenShiftEnterHint,
|
||||
WhenNotShiftEnterHint,
|
||||
}
|
||||
|
||||
impl DisplayCondition {
|
||||
fn matches(self, state: ShortcutsState) -> bool {
|
||||
match self {
|
||||
DisplayCondition::Always => true,
|
||||
DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint,
|
||||
DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShortcutDescriptor {
|
||||
id: ShortcutId,
|
||||
bindings: &'static [ShortcutBinding],
|
||||
footer_label: &'static str,
|
||||
footer_prefix: &'static str,
|
||||
}
|
||||
|
||||
impl ShortcutDescriptor {
|
||||
fn binding_for(&self, state: ShortcutsState) -> Option<ShortcutBinding> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.find(|binding| binding.condition.matches(state))
|
||||
.copied()
|
||||
}
|
||||
|
||||
fn should_show(&self, state: ShortcutsState) -> bool {
|
||||
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)?;
|
||||
Some(FooterSegment {
|
||||
prefix: self.footer_prefix,
|
||||
binding,
|
||||
label: self.footer_label,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Send,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: ShortcutDisplay::Plain("⏎"),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
footer_label: " send ",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::InsertNewline,
|
||||
bindings: &[
|
||||
ShortcutBinding {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::SHIFT,
|
||||
display: ShortcutDisplay::Shift('⏎'),
|
||||
condition: DisplayCondition::WhenShiftEnterHint,
|
||||
},
|
||||
ShortcutBinding {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
display: ShortcutDisplay::Ctrl('J'),
|
||||
condition: DisplayCondition::WhenNotShiftEnterHint,
|
||||
},
|
||||
],
|
||||
footer_label: " newline ",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::ShowTranscript,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
display: ShortcutDisplay::Ctrl('T'),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
footer_label: " transcript ",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Quit,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
display: ShortcutDisplay::Ctrl('C'),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
footer_label: " quit",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::EditPrevious,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: ShortcutDisplay::Plain("Esc"),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
footer_label: " edit prev",
|
||||
footer_prefix: " ",
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
fn snapshot_footer(name: &str, props: FooterProps<'_>) {
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, f.area().width, 1);
|
||||
render_footer(area, f.buffer_mut(), props);
|
||||
})
|
||||
.unwrap();
|
||||
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]
|
||||
fn footer_snapshots() {
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_default",
|
||||
FooterProps {
|
||||
ctrl_c_quit_hint: false,
|
||||
is_task_running: false,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
token_usage_info: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_shift_and_esc",
|
||||
FooterProps {
|
||||
ctrl_c_quit_hint: false,
|
||||
is_task_running: false,
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: true,
|
||||
token_usage_info: Some(&token_usage(4_200, 900, 8_000)),
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_idle",
|
||||
FooterProps {
|
||||
ctrl_c_quit_hint: true,
|
||||
is_task_running: false,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
token_usage_info: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_running",
|
||||
FooterProps {
|
||||
ctrl_c_quit_hint: true,
|
||||
is_task_running: true,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
token_usage_info: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ mod chat_composer_history;
|
||||
mod command_popup;
|
||||
pub mod custom_prompt_view;
|
||||
mod file_search_popup;
|
||||
mod footer;
|
||||
mod list_selection_view;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod paste_burst;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ⌃C again to quit "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ⌃C again to interrupt "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use"
|
||||
" "
|
||||
" "
|
||||
Reference in New Issue
Block a user