normalize key hints (#4586)

render key hints the same everywhere.



| Before | After |
|--------|-------|
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 15 42
PM"
src="https://github.com/user-attachments/assets/f88d5db4-04bb-4e89-b571-568222c41e4b"
/> | <img width="672" height="137" alt="Screenshot 2025-10-01 at 5 13 56
PM"
src="https://github.com/user-attachments/assets/1fee6a71-f313-4620-8d9a-10766dc4e195"
/> |
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 17 01
PM"
src="https://github.com/user-attachments/assets/5170ab35-88b7-4131-b485-ecebea9f0835"
/> | <img width="816" height="174" alt="Screenshot 2025-10-01 at 5 14 24
PM"
src="https://github.com/user-attachments/assets/6b6bc64c-25b9-4824-b2d7-56f60370870a"
/> |
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 17 29
PM"
src="https://github.com/user-attachments/assets/2313b36a-e0a8-4cd2-82be-7d0fe7793c19"
/> | <img width="816" height="134" alt="Screenshot 2025-10-01 at 5 14 37
PM"
src="https://github.com/user-attachments/assets/e18934e8-8e9d-4f46-9809-39c8cb6ee893"
/> |
| <img width="816" height="172" alt="Screenshot 2025-10-01 at 5 17 40
PM"
src="https://github.com/user-attachments/assets/0cc69e4e-8cce-420a-b3e4-be75a7e2c8f5"
/> | <img width="816" height="134" alt="Screenshot 2025-10-01 at 5 14 56
PM"
src="https://github.com/user-attachments/assets/329a5121-ae4a-4829-86e5-4c813543770c"
/> |
This commit is contained in:
Jeremy Rose
2025-10-02 11:34:47 -07:00
committed by GitHub
parent b07aafa5f5
commit ec98445abf
28 changed files with 270 additions and 227 deletions

View File

@@ -11,6 +11,7 @@ use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::diff_render::DiffSummary; use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape; use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell; use crate::history_cell;
use crate::key_hint;
use crate::render::highlight::highlight_bash_to_lines; use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable; use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
@@ -116,7 +117,13 @@ impl ApprovalOverlay {
.collect(); .collect();
let params = SelectionViewParams { let params = SelectionViewParams {
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()), footer_hint: Some(Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel".into(),
])),
items, items,
header, header,
..Default::default() ..Default::default()

View File

@@ -14,7 +14,7 @@ use std::cell::RefCell;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use super::popup_consts::STANDARD_POPUP_HINT_LINE; use super::popup_consts::standard_popup_hint_line;
use super::CancellationEvent; use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::BottomPaneView;
@@ -221,7 +221,7 @@ impl Renderable for CustomPromptView {
let hint_y = hint_blank_y.saturating_add(1); let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) { if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(STANDARD_POPUP_HINT_LINE).render( Paragraph::new(standard_popup_hint_line()).render(
Rect { Rect {
x: area.x, x: area.x,
y: hint_y, y: hint_y,

View File

@@ -1,13 +1,15 @@
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::line_utils::prefix_lines;
use crate::ui_consts::FOOTER_INDENT_COLS; use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
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::Paragraph;
use std::iter; use ratatui::widgets::Widget;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps { pub(crate) struct FooterProps {
@@ -61,15 +63,12 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 {
} }
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
let lines = footer_lines(props); Paragraph::new(prefix_lines(
for (idx, line) in lines.into_iter().enumerate() { footer_lines(props),
let y = area.y + idx as u16; " ".repeat(FOOTER_INDENT_COLS).into(),
if y >= area.y + area.height { " ".repeat(FOOTER_INDENT_COLS).into(),
break; ))
} .render(area, buf);
let row = Rect::new(area.x, y, area.width, 1);
line.render_ref(row, buf);
}
} }
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> { fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
@@ -81,7 +80,10 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
if props.is_task_running { if props.is_task_running {
vec![context_window_line(props.context_window_percent)] vec![context_window_line(props.context_window_percent)]
} else { } else {
vec![dim_line(indent_text("? for shortcuts"))] vec![Line::from(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
])]
} }
} }
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
@@ -110,27 +112,36 @@ fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
} else { } else {
"quit" "quit"
}; };
let text = format!("ctrl + c again to {action}"); Line::from(vec![
dim_line(indent_text(&text)) key_hint::ctrl(KeyCode::Char('c')).into(),
format!(" again to {action}").into(),
])
.dim()
} }
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
let text = if esc_backtrack_hint { let esc = key_hint::plain(KeyCode::Esc);
"esc again to edit previous message" if esc_backtrack_hint {
Line::from(vec![esc.into(), " again to edit previous message".into()]).dim()
} else { } else {
"esc esc to edit previous message" Line::from(vec![
}; esc.into(),
dim_line(indent_text(text)) " ".into(),
esc.into(),
" to edit previous message".into(),
])
.dim()
}
} }
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> { fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = String::new(); let mut commands = Line::from("");
let mut newline = String::new(); let mut newline = Line::from("");
let mut file_paths = String::new(); let mut file_paths = Line::from("");
let mut paste_image = String::new(); let mut paste_image = Line::from("");
let mut edit_previous = String::new(); let mut edit_previous = Line::from("");
let mut quit = String::new(); let mut quit = Line::from("");
let mut show_transcript = String::new(); let mut show_transcript = Line::from("");
for descriptor in SHORTCUTS { for descriptor in SHORTCUTS {
if let Some(text) = descriptor.overlay_entry(state) { if let Some(text) = descriptor.overlay_entry(state) {
@@ -153,14 +164,14 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
paste_image, paste_image,
edit_previous, edit_previous,
quit, quit,
String::new(), Line::from(""),
show_transcript, show_transcript,
]; ];
build_columns(ordered) build_columns(ordered)
} }
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> { fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
if entries.is_empty() { if entries.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -174,7 +185,7 @@ fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
let mut entries = entries; let mut entries = entries;
if entries.len() < target_len { if entries.len() < target_len {
entries.extend(std::iter::repeat_n( entries.extend(std::iter::repeat_n(
String::new(), Line::from(""),
target_len - entries.len(), target_len - entries.len(),
)); ));
} }
@@ -183,7 +194,7 @@ fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
for (idx, entry) in entries.iter().enumerate() { for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS; let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.len()); column_widths[column] = column_widths[column].max(entry.width());
} }
for (idx, width) in column_widths.iter_mut().enumerate() { for (idx, width) in column_widths.iter_mut().enumerate() {
@@ -193,42 +204,30 @@ fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
entries entries
.chunks(COLUMNS) .chunks(COLUMNS)
.map(|chunk| { .map(|chunk| {
let mut line = String::new(); let mut line = Line::from("");
for (col, entry) in chunk.iter().enumerate() { for (col, entry) in chunk.iter().enumerate() {
line.push_str(entry); line.extend(entry.spans.clone());
if col < COLUMNS - 1 { if col < COLUMNS - 1 {
let target_width = column_widths[col]; let target_width = column_widths[col];
let padding = target_width.saturating_sub(entry.len()) + COLUMN_GAP; let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP;
line.push_str(&" ".repeat(padding)); line.push_span(Span::from(" ".repeat(padding)));
} }
} }
let indented = indent_text(&line); line.dim()
dim_line(indented)
}) })
.collect() .collect()
} }
fn indent_text(text: &str) -> String {
let mut indented = String::with_capacity(FOOTER_INDENT_COLS + text.len());
indented.extend(iter::repeat_n(' ', FOOTER_INDENT_COLS));
indented.push_str(text);
indented
}
fn dim_line(text: String) -> Line<'static> {
Line::from(text).dim()
}
fn context_window_line(percent: Option<u8>) -> Line<'static> { fn context_window_line(percent: Option<u8>) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new(); let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(indent_text("").into());
match percent { match percent {
Some(percent) => { Some(percent) => {
spans.push(format!("{percent}%").bold()); spans.push(format!("{percent}%").bold());
spans.push(" context left".dim()); spans.push(" context left".dim());
} }
None => { None => {
spans.push("? for shortcuts".dim()); spans.push(key_hint::plain(KeyCode::Char('?')).into());
spans.push(" for shortcuts".dim());
} }
} }
Line::from(spans) Line::from(spans)
@@ -247,9 +246,7 @@ enum ShortcutId {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ShortcutBinding { struct ShortcutBinding {
code: KeyCode, key: KeyBinding,
modifiers: KeyModifiers,
overlay_text: &'static str,
condition: DisplayCondition, condition: DisplayCondition,
} }
@@ -288,20 +285,24 @@ impl ShortcutDescriptor {
self.bindings.iter().find(|binding| binding.matches(state)) self.bindings.iter().find(|binding| binding.matches(state))
} }
fn overlay_entry(&self, state: ShortcutsState) -> Option<String> { fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
let binding = self.binding_for(state)?; let binding = self.binding_for(state)?;
let label = match self.id { let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]);
match self.id {
ShortcutId::EditPrevious => { ShortcutId::EditPrevious => {
if state.esc_backtrack_hint { if state.esc_backtrack_hint {
" again to edit previous message" line.push_span(" again to edit previous message");
} else { } else {
" esc to edit previous message" line.extend(vec![
" ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to edit previous message".into(),
]);
} }
} }
_ => self.label, _ => line.push_span(self.label),
}; };
let text = format!("{}{}{}", self.prefix, binding.overlay_text, label); Some(line)
Some(text)
} }
} }
@@ -309,9 +310,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::Commands, id: ShortcutId::Commands,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('/'), key: key_hint::plain(KeyCode::Char('/')),
modifiers: KeyModifiers::NONE,
overlay_text: "/",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
prefix: "", prefix: "",
@@ -321,15 +320,11 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
id: ShortcutId::InsertNewline, id: ShortcutId::InsertNewline,
bindings: &[ bindings: &[
ShortcutBinding { ShortcutBinding {
code: KeyCode::Enter, key: key_hint::shift(KeyCode::Enter),
modifiers: KeyModifiers::SHIFT,
overlay_text: "shift + enter",
condition: DisplayCondition::WhenShiftEnterHint, condition: DisplayCondition::WhenShiftEnterHint,
}, },
ShortcutBinding { ShortcutBinding {
code: KeyCode::Char('j'), key: key_hint::ctrl(KeyCode::Char('j')),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + j",
condition: DisplayCondition::WhenNotShiftEnterHint, condition: DisplayCondition::WhenNotShiftEnterHint,
}, },
], ],
@@ -339,9 +334,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::FilePaths, id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('@'), key: key_hint::plain(KeyCode::Char('@')),
modifiers: KeyModifiers::NONE,
overlay_text: "@",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
prefix: "", prefix: "",
@@ -350,9 +343,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::PasteImage, id: ShortcutId::PasteImage,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('v'), key: key_hint::ctrl(KeyCode::Char('v')),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + v",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
prefix: "", prefix: "",
@@ -361,9 +352,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::EditPrevious, id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Esc, key: key_hint::plain(KeyCode::Esc),
modifiers: KeyModifiers::NONE,
overlay_text: "esc",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
prefix: "", prefix: "",
@@ -372,9 +361,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::Quit, id: ShortcutId::Quit,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('c'), key: key_hint::ctrl(KeyCode::Char('c')),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + c",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
prefix: "", prefix: "",
@@ -383,9 +370,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor { ShortcutDescriptor {
id: ShortcutId::ShowTranscript, id: ShortcutId::ShowTranscript,
bindings: &[ShortcutBinding { bindings: &[ShortcutBinding {
code: KeyCode::Char('t'), key: key_hint::ctrl(KeyCode::Char('t')),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + t",
condition: DisplayCondition::Always, condition: DisplayCondition::Always,
}], }],
prefix: "", prefix: "",

View File

@@ -43,7 +43,7 @@ pub(crate) struct SelectionItem {
pub(crate) struct SelectionViewParams { pub(crate) struct SelectionViewParams {
pub title: Option<String>, pub title: Option<String>,
pub subtitle: Option<String>, pub subtitle: Option<String>,
pub footer_hint: Option<String>, pub footer_hint: Option<Line<'static>>,
pub items: Vec<SelectionItem>, pub items: Vec<SelectionItem>,
pub is_searchable: bool, pub is_searchable: bool,
pub search_placeholder: Option<String>, pub search_placeholder: Option<String>,
@@ -65,7 +65,7 @@ impl Default for SelectionViewParams {
} }
pub(crate) struct ListSelectionView { pub(crate) struct ListSelectionView {
footer_hint: Option<String>, footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>, items: Vec<SelectionItem>,
state: ScrollState, state: ScrollState,
complete: bool, complete: bool,
@@ -416,7 +416,7 @@ impl Renderable for ListSelectionView {
width: footer_area.width.saturating_sub(2), width: footer_area.width.saturating_sub(2),
height: footer_area.height, height: footer_area.height,
}; };
Line::from(hint.clone().dim()).render(hint_area, buf); hint.clone().dim().render(hint_area, buf);
} }
} }
} }
@@ -425,7 +425,7 @@ impl Renderable for ListSelectionView {
mod tests { mod tests {
use super::*; use super::*;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use insta::assert_snapshot; use insta::assert_snapshot;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
@@ -455,7 +455,7 @@ mod tests {
SelectionViewParams { SelectionViewParams {
title: Some("Select Approval Mode".to_string()), title: Some("Select Approval Mode".to_string()),
subtitle: subtitle.map(str::to_string), subtitle: subtitle.map(str::to_string),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
..Default::default() ..Default::default()
}, },
@@ -517,7 +517,7 @@ mod tests {
let mut view = ListSelectionView::new( let mut view = ListSelectionView::new(
SelectionViewParams { SelectionViewParams {
title: Some("Select Approval Mode".to_string()), title: Some("Select Approval Mode".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
is_searchable: true, is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()), search_placeholder: Some("Type to search branches".to_string()),

View File

@@ -1,8 +1,21 @@
//! Shared popup-related constants for bottom pane widgets. //! Shared popup-related constants for bottom pane widgets.
use crossterm::event::KeyCode;
use ratatui::text::Line;
use crate::key_hint;
/// Maximum number of rows any popup should attempt to display. /// Maximum number of rows any popup should attempt to display.
/// Keep this consistent across all popups for a uniform feel. /// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8; pub(crate) const MAX_POPUP_ROWS: usize = 8;
/// Standard footer hint text used by popups. /// Standard footer hint text used by popups.
pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back"; pub(crate) fn standard_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
}

View File

@@ -1,6 +1,5 @@
--- ---
source: tui/src/bottom_pane/chat_composer.rs source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 1497
expression: terminal.backend() expression: terminal.backend()
--- ---
" " " "

View File

@@ -1,6 +1,5 @@
--- ---
source: tui/src/bottom_pane/footer.rs source: tui/src/bottom_pane/footer.rs
assertion_line: 389
expression: terminal.backend() expression: terminal.backend()
--- ---
" / for commands shift + enter for newline " " / for commands shift + enter for newline "

View File

@@ -9,4 +9,4 @@ expression: render_lines(&view)
1. Read Only (current) Codex can read files 1. Read Only (current) Codex can read files
2. Full Access Codex can edit files 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back Press enter to confirm or esc to go back

View File

@@ -8,4 +8,4 @@ expression: render_lines(&view)
1. Read Only (current) Codex can read files 1. Read Only (current) Codex can read files
2. Full Access Codex can edit files 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back Press enter to confirm or esc to go back

View File

@@ -68,7 +68,7 @@ use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for; use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput; use crate::exec_cell::CommandOutput;
@@ -1625,7 +1625,7 @@ impl ChatWidget {
subtitle: Some( subtitle: Some(
"Switch between OpenAI models for this and future Codex CLI session".to_string(), "Switch between OpenAI models for this and future Codex CLI session".to_string(),
), ),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
..Default::default() ..Default::default()
}); });
@@ -1668,7 +1668,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Approval Mode".to_string()), title: Some("Select Approval Mode".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
..Default::default() ..Default::default()
}); });
@@ -1843,7 +1843,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a review preset".into()), title: Some("Select a review preset".into()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
..Default::default() ..Default::default()
}); });
@@ -1879,7 +1879,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a base branch".to_string()), title: Some("Select a base branch".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
is_searchable: true, is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()), search_placeholder: Some("Type to search branches".to_string()),
@@ -1920,7 +1920,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()), title: Some("Select a commit to review".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
is_searchable: true, is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()), search_placeholder: Some("Type to search commits".to_string()),
@@ -2145,7 +2145,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
chat.bottom_pane.show_selection_view(SelectionViewParams { chat.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()), title: Some("Select a commit to review".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(standard_popup_hint_line()),
items, items,
is_searchable: true, is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()), search_placeholder: Some("Type to search commits".to_string()),

View File

@@ -13,4 +13,4 @@ expression: terminal.backend().vt100().screen().contents()
rest of the session rest of the session
3. Cancel Do not run the command 3. Cancel Do not run the command
Press Enter to confirm or Esc to cancel Press enter to confirm or esc to cancel

View File

@@ -13,5 +13,5 @@ expression: terminal.backend()
" rest of the session " " rest of the session "
" 3. Cancel Do not run the command " " 3. Cancel Do not run the command "
" " " "
" Press Enter to confirm or Esc to cancel " " Press enter to confirm or esc to cancel "
" " " "

View File

@@ -15,5 +15,5 @@ expression: terminal.backend()
" 1. Approve Apply the proposed changes " " 1. Approve Apply the proposed changes "
" 2. Cancel Do not apply the changes " " 2. Cancel Do not apply the changes "
" " " "
" Press Enter to confirm or Esc to cancel " " Press enter to confirm or esc to cancel "
" " " "

View File

@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests.rs source: tui/src/chatwidget/tests.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" Thinking (0s • Esc to interrupt) " " Thinking (0s • esc to interrupt) "
" Ask Codex to do anything " " Ask Codex to do anything "

View File

@@ -9,7 +9,7 @@ expression: term.backend().vt100().screen().contents()
└ Search Change Approved └ Search Change Approved
Read diff_render.rs Read diff_render.rs
Investigating rendering code (0s • Esc to interrupt) Investigating rendering code (0s • esc to interrupt)
Summarize recent commits Summarize recent commits

View File

@@ -18,7 +18,7 @@ Buffer {
" rest of the session ", " rest of the session ",
" 3. Cancel Do not run the command ", " 3. Cancel Do not run the command ",
" ", " ",
" Press Enter to confirm or Esc to cancel ", " Press enter to confirm or esc to cancel ",
" ", " ",
], ],
styles: [ styles: [
@@ -37,6 +37,6 @@ Buffer {
x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 41, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
] ]
} }

View File

@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" " " "
" Analyzing (0s • Esc to interrupt) " " Analyzing (0s • esc to interrupt) "
" " " "
" " " "
" Ask Codex to do anything " " Ask Codex to do anything "

View File

@@ -15,5 +15,5 @@ expression: terminal.backend()
" rest of the session " " rest of the session "
" 3. Cancel Do not run the command " " 3. Cancel Do not run the command "
" " " "
" Press Enter to confirm or Esc to cancel " " Press enter to confirm or esc to cancel "
" " " "

View File

@@ -1,23 +1,86 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Span; use ratatui::text::Span;
use std::fmt::Display;
#[cfg(test)] const ALT_PREFIX: &str = "alt + ";
const ALT_PREFIX: &str = ""; const CTRL_PREFIX: &str = "ctrl + ";
#[cfg(all(not(test), target_os = "macos"))] const SHIFT_PREFIX: &str = "shift + ";
const ALT_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
const ALT_PREFIX: &str = "Alt+"; pub(crate) struct KeyBinding {
key: KeyCode,
modifiers: KeyModifiers,
}
impl KeyBinding {
pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
Self { key, modifiers }
}
pub fn is_press(&self, event: KeyEvent) -> bool {
self.key == event.code
&& self.modifiers == event.modifiers
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
}
}
pub(crate) const fn plain(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::NONE)
}
pub(crate) const fn alt(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::ALT)
}
pub(crate) const fn shift(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::SHIFT)
}
pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::CONTROL)
}
fn modifiers_to_string(modifiers: KeyModifiers) -> String {
let mut result = String::new();
if modifiers.contains(KeyModifiers::CONTROL) {
result.push_str(CTRL_PREFIX);
}
if modifiers.contains(KeyModifiers::SHIFT) {
result.push_str(SHIFT_PREFIX);
}
if modifiers.contains(KeyModifiers::ALT) {
result.push_str(ALT_PREFIX);
}
result
}
impl From<KeyBinding> for Span<'static> {
fn from(binding: KeyBinding) -> Self {
(&binding).into()
}
}
impl From<&KeyBinding> for Span<'static> {
fn from(binding: &KeyBinding) -> Self {
let KeyBinding { key, modifiers } = binding;
let modifiers = modifiers_to_string(*modifiers);
let key = match key {
KeyCode::Enter => "enter".to_string(),
KeyCode::Up => "".to_string(),
KeyCode::Down => "".to_string(),
KeyCode::Left => "".to_string(),
KeyCode::Right => "".to_string(),
KeyCode::PageUp => "pgup".to_string(),
KeyCode::PageDown => "pgdn".to_string(),
_ => format!("{key}").to_ascii_lowercase(),
};
Span::styled(format!("{modifiers}{key}"), key_hint_style())
}
}
fn key_hint_style() -> Style { fn key_hint_style() -> Style {
Style::default().bold() Style::default().dim()
}
fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
Span::styled(format!("{prefix}{key}"), key_hint_style())
}
pub(crate) fn alt(key: impl Display) -> Span<'static> {
modifier_span(ALT_PREFIX, key)
} }

View File

@@ -3,18 +3,16 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::buffer::Cell; use ratatui::buffer::Cell;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
@@ -61,23 +59,40 @@ impl Overlay {
} }
} }
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t'));
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
// Common pager navigation hints rendered on the first line // Common pager navigation hints rendered on the first line
const PAGER_KEY_HINTS: &[(&str, &str)] = &[ const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[
("↑/↓", "scroll"), (&[KEY_UP, KEY_DOWN], "to scroll"),
("PgUp/PgDn", "page"), (&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"),
("Home/End", "jump"), (&[KEY_HOME, KEY_END], "to jump"),
]; ];
// Render a single line of key hints from (key, description) pairs. // Render a single line of key hints from (key(s), description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) { fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
let key_hint_style = Style::default().fg(Color::Cyan);
let mut spans: Vec<Span<'static>> = vec![" ".into()]; let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true; let mut first = true;
for (key, desc) in pairs { for (keys, desc) in pairs {
if !first { if !first {
spans.push(" ".into()); spans.push(" ".into());
} }
spans.push(Span::from(key.to_string()).set_style(key_hint_style)); for (i, key) in keys.iter().enumerate() {
if i > 0 {
spans.push("/".into());
}
spans.push(Span::from(key));
}
spans.push(" ".into()); spans.push(" ".into());
spans.push(Span::from(desc.to_string())); spans.push(Span::from(desc.to_string()));
first = false; first = false;
@@ -214,48 +229,24 @@ impl PagerView {
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
match key_event { match key_event {
KeyEvent { e if KEY_UP.is_press(e) => {
code: KeyCode::Up,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = self.scroll_offset.saturating_sub(1); self.scroll_offset = self.scroll_offset.saturating_sub(1);
} }
KeyEvent { e if KEY_DOWN.is_press(e) => {
code: KeyCode::Down,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = self.scroll_offset.saturating_add(1); self.scroll_offset = self.scroll_offset.saturating_add(1);
} }
KeyEvent { e if KEY_PAGE_UP.is_press(e) => {
code: KeyCode::PageUp,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.content_area(tui.terminal.viewport_area); let area = self.content_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
} }
KeyEvent { e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => {
code: KeyCode::PageDown | KeyCode::Char(' '),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.content_area(tui.terminal.viewport_area); let area = self.content_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
} }
KeyEvent { e if KEY_HOME.is_press(e) => {
code: KeyCode::Home,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = 0; self.scroll_offset = 0;
} }
KeyEvent { e if KEY_END.is_press(e) => {
code: KeyCode::End,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = usize::MAX; self.scroll_offset = usize::MAX;
} }
_ => { _ => {
@@ -434,9 +425,11 @@ impl TranscriptOverlay {
let line1 = Rect::new(area.x, area.y, area.width, 1); let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS); render_key_hints(line1, buf, PAGER_KEY_HINTS);
let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")];
let mut pairs: Vec<(&[KeyBinding], &str)> =
vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")];
if self.highlight_cell.is_some() { if self.highlight_cell.is_some() {
pairs.push(("", "edit message")); pairs.push((&[KEY_ENTER], "to edit message"));
} }
render_key_hints(line2, buf, &pairs); render_key_hints(line2, buf, &pairs);
} }
@@ -454,23 +447,7 @@ impl TranscriptOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event { match event {
TuiEvent::Key(key_event) => match key_event { TuiEvent::Key(key_event) => match key_event {
KeyEvent { e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.is_done = true; self.is_done = true;
Ok(()) Ok(())
} }
@@ -516,7 +493,7 @@ impl StaticOverlay {
let line1 = Rect::new(area.x, area.y, area.width, 1); let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS); render_key_hints(line1, buf, PAGER_KEY_HINTS);
let pairs = [("q", "quit")]; let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
render_key_hints(line2, buf, &pairs); render_key_hints(line2, buf, &pairs);
} }
@@ -533,17 +510,7 @@ impl StaticOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event { match event {
TuiEvent::Key(key_event) => match key_event { TuiEvent::Key(key_event) => match key_event {
KeyEvent { e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.is_done = true; self.is_done = true;
Ok(()) Ok(())
} }

View File

@@ -24,6 +24,7 @@ use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::key_hint;
use crate::text_formatting::truncate_text; use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use crate::tui::Tui; use crate::tui::Tui;
@@ -678,16 +679,18 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
// Hint line // Hint line
let hint_line: Line = vec![ let hint_line: Line = vec![
"Enter".bold(), key_hint::plain(KeyCode::Enter).into(),
" to resume ".into(), " to resume ".dim(),
" ".dim(), " ".dim(),
"Esc".bold(), key_hint::plain(KeyCode::Esc).into(),
" to start new ".into(), " to start new ".dim(),
" ".dim(), " ".dim(),
"Ctrl+C".into(), key_hint::ctrl(KeyCode::Char('c')).into(),
" to quit ".into(), " to quit ".dim(),
" ".dim(), " ".dim(),
"↑/↓".into(), key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to browse".dim(), " to browse".dim(),
] ]
.into(); .into();

View File

@@ -9,6 +9,6 @@ expression: term.backend()
"~ " "~ "
"~ " "~ "
"───────────────────────────────── 100% ─" "───────────────────────────────── 100% ─"
" ↑/↓ scroll PgUp/PgDn page Home/End " " ↑/↓ to scroll pgup/pgdn to page hom"
" q quit " " q to quit "
" " " "

View File

@@ -11,5 +11,5 @@ expression: snapshot
1 +hello 1 +hello
2 +world 2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─ ─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ scroll PgUp/PgDn page Home/End jump ↑/↓ to scroll pgup/pgdn to page home/end to jump
q quit Esc edit prev q to quit esc to edit prev

View File

@@ -9,6 +9,6 @@ expression: term.backend()
" " " "
"gamma " "gamma "
"───────────────────────────────── 100% ─" "───────────────────────────────── 100% ─"
" ↑/↓ scroll PgUp/PgDn page Home/End " " ↑/↓ to scroll pgup/pgdn to page hom"
" q quit Esc edit prev " " q to quit esc to edit prev "
" " " "

View File

@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs source: tui/src/status_indicator_widget.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" Working (0s • Esc " " Working (0s • esc "
" " " "

View File

@@ -2,11 +2,11 @@
source: tui/src/status_indicator_widget.rs source: tui/src/status_indicator_widget.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" Working (0s • Esc to interrupt) " " Working (0s • esc to interrupt) "
" " " "
" ↳ first " " ↳ first "
" ↳ second " " ↳ second "
" ↑ edit " " alt + ↑ edit "
" " " "
" " " "
" " " "

View File

@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs source: tui/src/status_indicator_widget.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" Working (0s • Esc to interrupt) " " Working (0s • esc to interrupt) "
" " " "

View File

@@ -5,6 +5,7 @@ use std::time::Duration;
use std::time::Instant; use std::time::Instant;
use codex_core::protocol::Op; use codex_core::protocol::Op;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Stylize; use ratatui::style::Stylize;
@@ -164,7 +165,7 @@ impl WidgetRef for StatusIndicatorWidget {
spans.extend(vec![ spans.extend(vec![
" ".into(), " ".into(),
format!("({pretty_elapsed}").dim(), format!("({pretty_elapsed}").dim(),
"Esc".dim().bold(), key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(), " to interrupt)".dim(),
]); ]);
@@ -188,8 +189,14 @@ impl WidgetRef for StatusIndicatorWidget {
} }
} }
if !self.queued_messages.is_empty() { if !self.queued_messages.is_empty() {
let shortcut = key_hint::alt(""); lines.push(
lines.push(Line::from(vec![" ".into(), shortcut, " edit".into()]).dim()); Line::from(vec![
" ".into(),
key_hint::alt(KeyCode::Up).into(),
" edit".into(),
])
.dim(),
);
} }
let paragraph = Paragraph::new(lines); let paragraph = Paragraph::new(lines);