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::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -116,7 +117,13 @@ impl ApprovalOverlay {
.collect();
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,
header,
..Default::default()

View File

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

View File

@@ -43,7 +43,7 @@ pub(crate) struct SelectionItem {
pub(crate) struct SelectionViewParams {
pub title: Option<String>,
pub subtitle: Option<String>,
pub footer_hint: Option<String>,
pub footer_hint: Option<Line<'static>>,
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
@@ -65,7 +65,7 @@ impl Default for SelectionViewParams {
}
pub(crate) struct ListSelectionView {
footer_hint: Option<String>,
footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>,
state: ScrollState,
complete: bool,
@@ -416,7 +416,7 @@ impl Renderable for ListSelectionView {
width: footer_area.width.saturating_sub(2),
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 {
use super::*;
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 ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
@@ -455,7 +455,7 @@ mod tests {
SelectionViewParams {
title: Some("Select Approval Mode".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,
..Default::default()
},
@@ -517,7 +517,7 @@ mod tests {
let mut view = ListSelectionView::new(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),

View File

@@ -1,8 +1,21 @@
//! 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.
/// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8;
/// 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
assertion_line: 1497
expression: terminal.backend()
---
" "

View File

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

View File

@@ -9,4 +9,4 @@ expression: render_lines(&view)
1. Read Only (current) Codex can read 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
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