@@ -1,3 +1,4 @@
|
|||||||
|
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;
|
||||||
@@ -18,14 +19,8 @@ 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;
|
||||||
@@ -79,6 +74,7 @@ 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,7 +84,6 @@ 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.
|
||||||
@@ -98,7 +93,7 @@ enum ActivePopup {
|
|||||||
File(FileSearchPopup),
|
File(FileSearchPopup),
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOOTER_SPACING_HEIGHT: u16 = 1;
|
const FOOTER_HINT_HEIGHT: u16 = 1;
|
||||||
|
|
||||||
impl ChatComposer {
|
impl ChatComposer {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
@@ -122,6 +117,7 @@ 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,7 +125,6 @@ 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);
|
||||||
@@ -137,41 +132,26 @@ 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_total_height,
|
ActivePopup::None => FOOTER_HINT_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_total_height),
|
ActivePopup::None => Constraint::Max(FOOTER_HINT_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;
|
||||||
@@ -195,6 +175,13 @@ 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) {
|
||||||
@@ -332,11 +319,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,18 +354,6 @@ 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!();
|
||||||
};
|
};
|
||||||
@@ -508,18 +478,6 @@ 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!();
|
||||||
};
|
};
|
||||||
@@ -771,18 +729,6 @@ 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'),
|
||||||
@@ -926,10 +872,6 @@ 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()
|
||||||
@@ -1203,47 +1145,6 @@ 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.
|
||||||
@@ -1327,18 +1228,10 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1359,26 +1252,20 @@ impl WidgetRef for ChatComposer {
|
|||||||
popup.render_ref(popup_rect, buf);
|
popup.render_ref(popup_rect, buf);
|
||||||
}
|
}
|
||||||
ActivePopup::None => {
|
ActivePopup::None => {
|
||||||
let footer_hint_height = footer_height(self.footer_props());
|
let mut hint_rect = popup_rect;
|
||||||
let footer_spacing = if footer_hint_height > 0 {
|
hint_rect.x += 2;
|
||||||
FOOTER_SPACING_HEIGHT
|
hint_rect.width = hint_rect.width.saturating_sub(2);
|
||||||
} else {
|
render_footer(
|
||||||
0
|
hint_rect,
|
||||||
};
|
buf,
|
||||||
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
|
FooterProps {
|
||||||
let [_, hint_rect] = Layout::vertical([
|
ctrl_c_quit_hint: self.ctrl_c_quit_hint,
|
||||||
Constraint::Length(footer_spacing),
|
is_task_running: self.is_task_running,
|
||||||
Constraint::Length(footer_hint_height),
|
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||||
])
|
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||||
.areas(popup_rect);
|
token_usage_info: self.token_usage_info.as_ref(),
|
||||||
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());
|
||||||
@@ -1417,7 +1304,6 @@ 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::textarea::TextArea;
|
use crate::bottom_pane::textarea::TextArea;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
|
||||||
@@ -1448,7 +1334,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("? for shortcuts") {
|
if row.contains(" send") {
|
||||||
hint_row = Some((y, row));
|
hint_row = Some((y, row));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1475,81 +1361,6 @@ 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![
|
||||||
|
|||||||
@@ -1,206 +1,180 @@
|
|||||||
|
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 {
|
pub(crate) struct FooterProps<'a> {
|
||||||
pub(crate) mode: FooterMode,
|
pub(crate) ctrl_c_quit_hint: bool,
|
||||||
|
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) is_task_running: bool,
|
pub(crate) token_usage_info: Option<&'a TokenUsageInfo>,
|
||||||
}
|
|
||||||
|
|
||||||
#[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 {
|
||||||
is_task_running: bool,
|
pub(crate) is_task_running: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
struct ShortcutsState {
|
struct ShortcutsState {
|
||||||
use_shift_enter_hint: bool,
|
pub(crate) use_shift_enter_hint: bool,
|
||||||
esc_backtrack_hint: bool,
|
pub(crate) esc_backtrack_hint: bool,
|
||||||
is_task_running: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
|
#[derive(Clone, Copy, Debug)]
|
||||||
let action = if state.is_task_running {
|
enum FooterContent {
|
||||||
"interrupt"
|
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 {
|
} else {
|
||||||
"quit"
|
FooterContent::Shortcuts(ShortcutsState {
|
||||||
|
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||||
|
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||||
|
})
|
||||||
};
|
};
|
||||||
Line::from(vec![
|
|
||||||
Span::from(format!(" ctrl + c again to {action}")).dim(),
|
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 esc_hint_line(state: ShortcutsState) -> Line<'static> {
|
fn footer_spans(content: FooterContent) -> Vec<Span<'static>> {
|
||||||
let text = if state.esc_backtrack_hint {
|
match content {
|
||||||
" esc again to edit previous message"
|
FooterContent::Shortcuts(state) => shortcuts_spans(state),
|
||||||
} else {
|
FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state),
|
||||||
" esc esc to edit previous message"
|
}
|
||||||
};
|
|
||||||
Line::from(vec![Span::from(text).dim()])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
fn append_token_usage_spans(spans: &mut Vec<Span<'static>>, token_usage_info: &TokenUsageInfo) {
|
||||||
let mut rendered = Vec::new();
|
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 {
|
for descriptor in SHORTCUTS {
|
||||||
if let Some(text) = descriptor.overlay_entry(state) {
|
if let Some(segment) = descriptor.footer_segment(state) {
|
||||||
rendered.push(text);
|
if !segment.prefix.is_empty() {
|
||||||
|
spans.push(segment.prefix.into());
|
||||||
|
}
|
||||||
|
spans.push(segment.binding.span());
|
||||||
|
spans.push(segment.label.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
build_columns(rendered)
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
|
fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec<Span<'static>> {
|
||||||
if entries.is_empty() {
|
let followup = if state.is_task_running {
|
||||||
return Vec::new();
|
" to interrupt"
|
||||||
}
|
} else {
|
||||||
|
" to quit"
|
||||||
const COLUMNS: usize = 3;
|
};
|
||||||
const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28];
|
vec![
|
||||||
const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0];
|
" ".into(),
|
||||||
|
key_hint::ctrl('C'),
|
||||||
let rows = entries.len().div_ceil(COLUMNS);
|
" again".into(),
|
||||||
let mut column_widths = [0usize; COLUMNS];
|
followup.into(),
|
||||||
|
]
|
||||||
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, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct FooterSegment {
|
||||||
|
prefix: &'static str,
|
||||||
|
binding: ShortcutBinding,
|
||||||
|
label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
enum ShortcutId {
|
enum ShortcutId {
|
||||||
Commands,
|
Send,
|
||||||
InsertNewline,
|
InsertNewline,
|
||||||
ChangeMode,
|
|
||||||
FilePaths,
|
|
||||||
PasteImage,
|
|
||||||
EditPrevious,
|
|
||||||
Quit,
|
|
||||||
ShowTranscript,
|
ShowTranscript,
|
||||||
|
Quit,
|
||||||
|
EditPrevious,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
struct ShortcutBinding {
|
struct ShortcutBinding {
|
||||||
code: KeyCode,
|
code: KeyCode,
|
||||||
modifiers: KeyModifiers,
|
modifiers: KeyModifiers,
|
||||||
overlay_text: &'static str,
|
display: ShortcutDisplay,
|
||||||
condition: DisplayCondition,
|
condition: DisplayCondition,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShortcutBinding {
|
impl ShortcutBinding {
|
||||||
fn matches(&self, state: ShortcutsState) -> bool {
|
fn span(&self) -> Span<'static> {
|
||||||
self.condition.matches(state)
|
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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,55 +198,49 @@ impl DisplayCondition {
|
|||||||
struct ShortcutDescriptor {
|
struct ShortcutDescriptor {
|
||||||
id: ShortcutId,
|
id: ShortcutId,
|
||||||
bindings: &'static [ShortcutBinding],
|
bindings: &'static [ShortcutBinding],
|
||||||
prefix: &'static str,
|
footer_label: &'static str,
|
||||||
label: &'static str,
|
footer_prefix: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShortcutDescriptor {
|
impl ShortcutDescriptor {
|
||||||
fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> {
|
fn binding_for(&self, state: ShortcutsState) -> Option<ShortcutBinding> {
|
||||||
self.bindings.iter().find(|binding| binding.matches(state))
|
self.bindings
|
||||||
|
.iter()
|
||||||
|
.find(|binding| binding.condition.matches(state))
|
||||||
|
.copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn overlay_entry(&self, state: ShortcutsState) -> Option<String> {
|
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)?;
|
let binding = self.binding_for(state)?;
|
||||||
let label = match self.id {
|
Some(FooterSegment {
|
||||||
ShortcutId::Quit => {
|
prefix: self.footer_prefix,
|
||||||
if state.is_task_running {
|
binding,
|
||||||
" to interrupt"
|
label: self.footer_label,
|
||||||
} 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::Commands,
|
id: ShortcutId::Send,
|
||||||
bindings: &[ShortcutBinding {
|
bindings: &[ShortcutBinding {
|
||||||
code: KeyCode::Char('/'),
|
code: KeyCode::Enter,
|
||||||
modifiers: KeyModifiers::NONE,
|
modifiers: KeyModifiers::NONE,
|
||||||
overlay_text: "/",
|
display: ShortcutDisplay::Plain("⏎"),
|
||||||
condition: DisplayCondition::Always,
|
condition: DisplayCondition::Always,
|
||||||
}],
|
}],
|
||||||
prefix: "",
|
footer_label: " send ",
|
||||||
label: " for commands",
|
footer_prefix: "",
|
||||||
},
|
},
|
||||||
ShortcutDescriptor {
|
ShortcutDescriptor {
|
||||||
id: ShortcutId::InsertNewline,
|
id: ShortcutId::InsertNewline,
|
||||||
@@ -280,165 +248,138 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
|||||||
ShortcutBinding {
|
ShortcutBinding {
|
||||||
code: KeyCode::Enter,
|
code: KeyCode::Enter,
|
||||||
modifiers: KeyModifiers::SHIFT,
|
modifiers: KeyModifiers::SHIFT,
|
||||||
overlay_text: "shift + enter",
|
display: ShortcutDisplay::Shift('⏎'),
|
||||||
condition: DisplayCondition::WhenShiftEnterHint,
|
condition: DisplayCondition::WhenShiftEnterHint,
|
||||||
},
|
},
|
||||||
ShortcutBinding {
|
ShortcutBinding {
|
||||||
code: KeyCode::Char('j'),
|
code: KeyCode::Char('j'),
|
||||||
modifiers: KeyModifiers::CONTROL,
|
modifiers: KeyModifiers::CONTROL,
|
||||||
overlay_text: "ctrl + j",
|
display: ShortcutDisplay::Ctrl('J'),
|
||||||
condition: DisplayCondition::WhenNotShiftEnterHint,
|
condition: DisplayCondition::WhenNotShiftEnterHint,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
prefix: "",
|
footer_label: " newline ",
|
||||||
label: " for newline",
|
footer_prefix: "",
|
||||||
},
|
|
||||||
ShortcutDescriptor {
|
|
||||||
id: ShortcutId::ChangeMode,
|
|
||||||
bindings: &[ShortcutBinding {
|
|
||||||
code: KeyCode::BackTab,
|
|
||||||
modifiers: KeyModifiers::SHIFT,
|
|
||||||
overlay_text: "shift + tab",
|
|
||||||
condition: DisplayCondition::Always,
|
|
||||||
}],
|
|
||||||
prefix: "",
|
|
||||||
label: " to change mode",
|
|
||||||
},
|
|
||||||
ShortcutDescriptor {
|
|
||||||
id: ShortcutId::FilePaths,
|
|
||||||
bindings: &[ShortcutBinding {
|
|
||||||
code: KeyCode::Char('@'),
|
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
overlay_text: "@",
|
|
||||||
condition: DisplayCondition::Always,
|
|
||||||
}],
|
|
||||||
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 {
|
|
||||||
id: ShortcutId::EditPrevious,
|
|
||||||
bindings: &[ShortcutBinding {
|
|
||||||
code: KeyCode::Esc,
|
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
overlay_text: "esc",
|
|
||||||
condition: DisplayCondition::Always,
|
|
||||||
}],
|
|
||||||
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 {
|
ShortcutDescriptor {
|
||||||
id: ShortcutId::ShowTranscript,
|
id: ShortcutId::ShowTranscript,
|
||||||
bindings: &[ShortcutBinding {
|
bindings: &[ShortcutBinding {
|
||||||
code: KeyCode::Char('t'),
|
code: KeyCode::Char('t'),
|
||||||
modifiers: KeyModifiers::CONTROL,
|
modifiers: KeyModifiers::CONTROL,
|
||||||
overlay_text: "ctrl + t",
|
display: ShortcutDisplay::Ctrl('T'),
|
||||||
condition: DisplayCondition::Always,
|
condition: DisplayCondition::Always,
|
||||||
}],
|
}],
|
||||||
prefix: "",
|
footer_label: " transcript ",
|
||||||
label: " to view 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)]
|
#[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 height = footer_height(props).max(1);
|
let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap();
|
||||||
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, height);
|
let area = Rect::new(0, 0, f.area().width, 1);
|
||||||
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 {
|
||||||
mode: FooterMode::ShortcutPrompt,
|
ctrl_c_quit_hint: false,
|
||||||
|
is_task_running: false,
|
||||||
esc_backtrack_hint: false,
|
esc_backtrack_hint: false,
|
||||||
use_shift_enter_hint: false,
|
use_shift_enter_hint: false,
|
||||||
is_task_running: false,
|
token_usage_info: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
snapshot_footer(
|
snapshot_footer(
|
||||||
"footer_shortcuts_shift_and_esc",
|
"footer_shortcuts_shift_and_esc",
|
||||||
FooterProps {
|
FooterProps {
|
||||||
mode: FooterMode::ShortcutOverlay,
|
ctrl_c_quit_hint: false,
|
||||||
|
is_task_running: false,
|
||||||
esc_backtrack_hint: true,
|
esc_backtrack_hint: true,
|
||||||
use_shift_enter_hint: true,
|
use_shift_enter_hint: true,
|
||||||
is_task_running: false,
|
token_usage_info: Some(&token_usage(4_200, 900, 8_000)),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
snapshot_footer(
|
snapshot_footer(
|
||||||
"footer_ctrl_c_quit_idle",
|
"footer_ctrl_c_quit_idle",
|
||||||
FooterProps {
|
FooterProps {
|
||||||
mode: FooterMode::CtrlCReminder,
|
ctrl_c_quit_hint: true,
|
||||||
|
is_task_running: false,
|
||||||
esc_backtrack_hint: false,
|
esc_backtrack_hint: false,
|
||||||
use_shift_enter_hint: false,
|
use_shift_enter_hint: false,
|
||||||
is_task_running: false,
|
token_usage_info: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
snapshot_footer(
|
snapshot_footer(
|
||||||
"footer_ctrl_c_quit_running",
|
"footer_ctrl_c_quit_running",
|
||||||
FooterProps {
|
FooterProps {
|
||||||
mode: FooterMode::CtrlCReminder,
|
ctrl_c_quit_hint: true,
|
||||||
esc_backtrack_hint: false,
|
|
||||||
use_shift_enter_hint: false,
|
|
||||||
is_task_running: true,
|
is_task_running: true,
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshot_footer(
|
|
||||||
"footer_esc_hint_idle",
|
|
||||||
FooterProps {
|
|
||||||
mode: FooterMode::EscHint,
|
|
||||||
esc_backtrack_hint: false,
|
esc_backtrack_hint: false,
|
||||||
use_shift_enter_hint: false,
|
use_shift_enter_hint: false,
|
||||||
is_task_running: false,
|
token_usage_info: None,
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshot_footer(
|
|
||||||
"footer_esc_hint_primed",
|
|
||||||
FooterProps {
|
|
||||||
mode: FooterMode::EscHint,
|
|
||||||
esc_backtrack_hint: true,
|
|
||||||
use_shift_enter_hint: false,
|
|
||||||
is_task_running: false,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
@@ -376,6 +377,13 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" ? for shortcuts "
|
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" ? for shortcuts "
|
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/chat_composer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" "
|
|
||||||
"› Ask Codex to do anything "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" ctrl + c again to interrupt "
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/chat_composer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" "
|
|
||||||
"› Ask Codex to do anything "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" ctrl + c again to quit "
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/chat_composer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" "
|
|
||||||
"› Ask Codex to do anything "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" esc esc to edit previous message "
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/chat_composer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" "
|
|
||||||
"› Ask Codex to do anything "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" esc again to edit previous message "
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/chat_composer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" "
|
|
||||||
"› Ask Codex to do anything "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" esc esc to edit previous message "
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/chat_composer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" "
|
|
||||||
"› Ask Codex to do anything "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" esc again to edit previous message "
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
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 "
|
|
||||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" ? for shortcuts "
|
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" ? for shortcuts "
|
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" ? for shortcuts "
|
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
source: tui/src/bottom_pane/footer.rs
|
source: tui/src/bottom_pane/footer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" ctrl + c again to quit "
|
" ⌃C again to quit "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
source: tui/src/bottom_pane/footer.rs
|
source: tui/src/bottom_pane/footer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" ctrl + c again to interrupt "
|
" ⌃C again to interrupt "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/footer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" esc esc to edit previous message "
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/bottom_pane/footer.rs
|
|
||||||
expression: terminal.backend()
|
|
||||||
---
|
|
||||||
" esc again to edit previous message "
|
|
||||||
@@ -2,4 +2,6 @@
|
|||||||
source: tui/src/bottom_pane/footer.rs
|
source: tui/src/bottom_pane/footer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"? for shortcuts "
|
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
source: tui/src/bottom_pane/footer.rs
|
source: tui/src/bottom_pane/footer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" / for commands shift + enter for newline shift + tab to change m"
|
"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use"
|
||||||
" @ for file paths ctrl + v to paste images esc again to edit previ"
|
" "
|
||||||
" ctrl + c to exit ctrl + t to view transcript "
|
" "
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1974,6 +1975,7 @@ 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)> {
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ expression: term.backend().vt100().screen().contents()
|
|||||||
|
|
||||||
› Summarize recent commits
|
› Summarize recent commits
|
||||||
|
|
||||||
|
⏎ send ⌃J newline ⌃T transcript ⌃C quit
|
||||||
? for shortcuts
|
|
||||||
|
|||||||
@@ -8,6 +8,5 @@ expression: terminal.backend()
|
|||||||
" "
|
" "
|
||||||
"› Ask Codex to do anything "
|
"› Ask Codex to do anything "
|
||||||
" "
|
" "
|
||||||
" "
|
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||||
" ? for shortcuts "
|
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -18,6 +32,18 @@ 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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user