mirror of
https://github.com/openai/codex.git
synced 2026-05-23 12:34:25 +00:00
feat(tui): refine transcript overlay navigation
This commit is contained in:
@@ -35,6 +35,7 @@ use crate::app_event::AppEvent;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
@@ -104,9 +105,9 @@ pub(crate) struct PendingBacktrackRollback {
|
||||
impl App {
|
||||
/// Route overlay events while the transcript overlay is active.
|
||||
///
|
||||
/// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter
|
||||
/// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the
|
||||
/// overlay.
|
||||
/// If backtrack preview is active, Esc / previous-prompt steps selection, next-prompt steps
|
||||
/// forward, Enter confirms. Otherwise, Esc or a prompt-selection key begins preview mode and
|
||||
/// all other events are forwarded to the overlay.
|
||||
pub(crate) async fn handle_backtrack_overlay_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -122,19 +123,15 @@ impl App {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
TuiEvent::Key(key_event)
|
||||
if self.keymap.pager.previous_user_prompt.is_pressed(key_event) =>
|
||||
{
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
TuiEvent::Key(key_event)
|
||||
if self.keymap.pager.next_user_prompt.is_pressed(key_event) =>
|
||||
{
|
||||
self.overlay_step_backtrack_forward(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -151,13 +148,19 @@ impl App {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
} else if let TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) = event
|
||||
} else if let TuiEvent::Key(key_event) = event
|
||||
&& (matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
) || self.keymap.pager.previous_user_prompt.is_pressed(key_event)
|
||||
|| self.keymap.pager.next_user_prompt.is_pressed(key_event))
|
||||
{
|
||||
// First Esc in transcript overlay: begin backtrack preview at latest user message.
|
||||
// First Esc / prompt-selection key in transcript overlay: begin backtrack preview at
|
||||
// latest user message.
|
||||
self.begin_overlay_backtrack_preview(tui);
|
||||
Ok(true)
|
||||
} else {
|
||||
@@ -490,7 +493,8 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.
|
||||
/// Handle next-prompt in overlay backtrack preview: step selection forward if armed, else
|
||||
/// forward.
|
||||
fn overlay_step_backtrack_forward(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
|
||||
235
codex-rs/tui/src/footer_hints.rs
Normal file
235
codex-rs/tui/src/footer_hints.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use crate::color::is_light;
|
||||
use crate::terminal_palette::default_bg;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled as _;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const FOOTER_COMPACT_BREAKPOINT: u16 = 120;
|
||||
const FOOTER_HINT_LEFT_PADDING: usize = 1;
|
||||
const FOOTER_HINT_GAP: usize = 3;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FooterHint {
|
||||
key: String,
|
||||
wide_label: String,
|
||||
compact_label: String,
|
||||
priority: u8,
|
||||
}
|
||||
|
||||
impl FooterHint {
|
||||
pub(crate) fn new(
|
||||
key: impl Into<String>,
|
||||
wide_label: impl Into<String>,
|
||||
compact_label: impl Into<String>,
|
||||
priority: u8,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
wide_label: wide_label.into(),
|
||||
compact_label: compact_label.into(),
|
||||
priority,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FooterHintLabelMode {
|
||||
Wide,
|
||||
Compact,
|
||||
KeyOnly,
|
||||
}
|
||||
|
||||
pub(crate) fn footer_hint_line_for_row(hints: &[FooterHint], width: u16) -> Line<'static> {
|
||||
if width >= FOOTER_COMPACT_BREAKPOINT
|
||||
&& let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width)
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) {
|
||||
return line;
|
||||
}
|
||||
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) {
|
||||
return line;
|
||||
}
|
||||
|
||||
let mut retained = (0..hints.len()).collect::<Vec<_>>();
|
||||
retained.sort_by_key(|idx| hints[*idx].priority);
|
||||
for retain_count in (1..=retained.len()).rev() {
|
||||
let mut candidate_indices = retained[..retain_count].to_vec();
|
||||
candidate_indices.sort_unstable();
|
||||
let candidate = candidate_indices
|
||||
.iter()
|
||||
.map(|idx| &hints[*idx])
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
Line::default()
|
||||
}
|
||||
|
||||
pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: String) {
|
||||
if area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Line::from("─".repeat(area.width as usize).dim()).render_ref(area, buf);
|
||||
if label.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let label_width = UnicodeWidthStr::width(label.as_str()) as u16;
|
||||
if label_width < area.width {
|
||||
let label_area = Rect::new(
|
||||
area.x + area.width - label_width - 1,
|
||||
area.y,
|
||||
label_width,
|
||||
1,
|
||||
);
|
||||
Line::from(label.dim()).render_ref(label_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_footer_hints(
|
||||
hints: &[FooterHint],
|
||||
mode: FooterHintLabelMode,
|
||||
width: u16,
|
||||
) -> Option<Line<'static>> {
|
||||
let hint_refs = hints.iter().collect::<Vec<_>>();
|
||||
fit_footer_hint_refs(&hint_refs, mode, width)
|
||||
}
|
||||
|
||||
fn fit_footer_hint_refs(
|
||||
hints: &[&FooterHint],
|
||||
mode: FooterHintLabelMode,
|
||||
width: u16,
|
||||
) -> Option<Line<'static>> {
|
||||
let gap_width = FOOTER_HINT_GAP;
|
||||
if footer_hints_width(hints, mode, gap_width) > width as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut spans = vec![
|
||||
" ".repeat(FOOTER_HINT_LEFT_PADDING)
|
||||
.set_style(footer_hint_label_style()),
|
||||
];
|
||||
for (idx, hint) in hints.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
|
||||
}
|
||||
spans.push(hint.key.clone().set_style(footer_hint_key_style()));
|
||||
let label = match mode {
|
||||
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
|
||||
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
|
||||
FooterHintLabelMode::KeyOnly => None,
|
||||
};
|
||||
if let Some(label) = label {
|
||||
spans.push(" ".set_style(footer_hint_label_style()));
|
||||
spans.push(label.to_string().set_style(footer_hint_label_style()));
|
||||
}
|
||||
}
|
||||
Some(spans.into())
|
||||
}
|
||||
|
||||
fn footer_hint_key_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::Black)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hint_label_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().dim()
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hints_width(hints: &[&FooterHint], mode: FooterHintLabelMode, gap_width: usize) -> usize {
|
||||
FOOTER_HINT_LEFT_PADDING
|
||||
+ hints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, hint)| {
|
||||
let label_width = match mode {
|
||||
FooterHintLabelMode::Wide => {
|
||||
1 + UnicodeWidthStr::width(hint.wide_label.as_str())
|
||||
}
|
||||
FooterHintLabelMode::Compact => {
|
||||
1 + UnicodeWidthStr::width(hint.compact_label.as_str())
|
||||
}
|
||||
FooterHintLabelMode::KeyOnly => 0,
|
||||
};
|
||||
let hint_width = UnicodeWidthStr::width(hint.key.as_str()) + label_width;
|
||||
if idx == 0 {
|
||||
hint_width
|
||||
} else {
|
||||
hint_width + gap_width
|
||||
}
|
||||
})
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn line_text(line: Line<'static>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_hint_line_uses_wide_labels_when_width_allows() {
|
||||
let hints = [FooterHint::new(
|
||||
"enter",
|
||||
"resume session",
|
||||
"resume",
|
||||
/*priority*/ 0,
|
||||
)];
|
||||
|
||||
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 140));
|
||||
|
||||
assert!(rendered.contains("enter resume session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_hint_line_compacts_below_breakpoint() {
|
||||
let hints = [FooterHint::new(
|
||||
"enter",
|
||||
"resume session",
|
||||
"resume",
|
||||
/*priority*/ 0,
|
||||
)];
|
||||
|
||||
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 80));
|
||||
|
||||
assert!(rendered.contains("enter resume"));
|
||||
assert!(!rendered.contains("resume session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_hint_line_drops_low_priority_hints_when_narrow() {
|
||||
let hints = [
|
||||
FooterHint::new("a", "alpha", "alpha", /*priority*/ 0),
|
||||
FooterHint::new("b", "bravo", "bravo", /*priority*/ 9),
|
||||
FooterHint::new("c", "charlie", "charlie", /*priority*/ 1),
|
||||
];
|
||||
|
||||
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 6));
|
||||
|
||||
assert!(rendered.contains('a'));
|
||||
assert!(rendered.contains('c'));
|
||||
assert!(!rendered.contains('b'));
|
||||
}
|
||||
}
|
||||
@@ -832,8 +832,8 @@ impl RuntimeKeymap {
|
||||
jump_bottom: default_bindings![plain(KeyCode::End)],
|
||||
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
|
||||
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
||||
previous_user_prompt: default_bindings![alt(KeyCode::Up)],
|
||||
next_user_prompt: default_bindings![alt(KeyCode::Down)],
|
||||
previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)],
|
||||
next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)],
|
||||
},
|
||||
list: ListKeymap {
|
||||
move_up: default_bindings![
|
||||
@@ -1478,14 +1478,6 @@ const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
||||
"fixed.transcript_edit_previous",
|
||||
key_hint::plain(KeyCode::Esc),
|
||||
),
|
||||
(
|
||||
"fixed.transcript_edit_previous",
|
||||
key_hint::plain(KeyCode::Left),
|
||||
),
|
||||
(
|
||||
"fixed.transcript_edit_next",
|
||||
key_hint::plain(KeyCode::Right),
|
||||
),
|
||||
(
|
||||
"fixed.transcript_confirm_edit",
|
||||
key_hint::plain(KeyCode::Enter),
|
||||
@@ -2183,9 +2175,26 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.pager.close = Some(one("left"));
|
||||
keymap.pager.close = Some(one("enter"));
|
||||
|
||||
expect_conflict(&keymap, "close", "fixed.transcript_edit_previous");
|
||||
expect_conflict(&keymap, "close", "fixed.transcript_confirm_edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_prompt_selection_defaults_to_left_and_right_arrows() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
|
||||
assert_eq!(
|
||||
runtime.pager.previous_user_prompt,
|
||||
vec![key_hint::plain(KeyCode::Left), key_hint::alt(KeyCode::Up)]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.pager.next_user_prompt,
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Right),
|
||||
key_hint::alt(KeyCode::Down)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,6 +126,7 @@ mod external_agent_config_migration;
|
||||
mod external_agent_config_migration_startup;
|
||||
mod external_editor;
|
||||
mod file_search;
|
||||
mod footer_hints;
|
||||
mod frames;
|
||||
mod get_git_diff;
|
||||
mod git_action_directives;
|
||||
|
||||
@@ -19,6 +19,9 @@ use std::io::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::footer_hints::FooterHint;
|
||||
use crate::footer_hints::footer_hint_line_for_row;
|
||||
use crate::footer_hints::render_footer_separator;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
@@ -40,7 +43,6 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -122,25 +124,12 @@ fn first_or_empty(bindings: &[KeyBinding]) -> Vec<KeyBinding> {
|
||||
bindings.first().copied().into_iter().collect()
|
||||
}
|
||||
|
||||
// Render a single line of key hints from (key(s), description) pairs.
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &str)]) {
|
||||
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
||||
let mut first = true;
|
||||
for (keys, desc) in pairs {
|
||||
if !first {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push("/".into());
|
||||
}
|
||||
spans.push(Span::from(key));
|
||||
}
|
||||
spans.push(" ".into());
|
||||
spans.push(Span::from(desc.to_string()));
|
||||
first = false;
|
||||
}
|
||||
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
|
||||
fn key_label(bindings: &[KeyBinding]) -> String {
|
||||
bindings
|
||||
.iter()
|
||||
.map(KeyBinding::display_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
/// Generic widget for rendering a pager view.
|
||||
@@ -220,11 +209,11 @@ impl PagerView {
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
self.render_header(area, buf);
|
||||
let content_area = self.content_area(area);
|
||||
self.update_last_content_height(content_area.height);
|
||||
let content_height = self.content_height(content_area.width);
|
||||
self.last_rendered_height = Some(content_height);
|
||||
self.render_header(area, content_area, buf, content_height);
|
||||
// If there is a pending request to scroll a specific chunk into view,
|
||||
// satisfy it now that wrapping is up to date for this width.
|
||||
if let Some(idx) = self.pending_scroll_chunk.take() {
|
||||
@@ -239,12 +228,13 @@ impl PagerView {
|
||||
self.render_bottom_bar(area, content_area, buf, content_height);
|
||||
}
|
||||
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
||||
Span::from("/ ".repeat(area.width as usize / 2))
|
||||
fn render_header(&self, area: Rect, content_area: Rect, buf: &mut Buffer, total_len: usize) {
|
||||
let header = Rect::new(area.x, area.y, area.width, 1);
|
||||
render_footer_separator(header, buf, String::new());
|
||||
let percent = self.scroll_percent(content_area.height, total_len);
|
||||
format!(" {} · {percent}% ", self.title)
|
||||
.dim()
|
||||
.render_ref(area, buf);
|
||||
let header = format!("/ {}", self.title);
|
||||
header.dim().render_ref(area, buf);
|
||||
.render_ref(header, buf);
|
||||
}
|
||||
|
||||
fn render_content(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -307,31 +297,23 @@ impl PagerView {
|
||||
full_area: Rect,
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
total_len: usize,
|
||||
_total_len: usize,
|
||||
) {
|
||||
let sep_y = content_area.bottom();
|
||||
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
|
||||
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render_ref(sep_rect, buf);
|
||||
let percent = if total_len == 0 {
|
||||
100
|
||||
} else {
|
||||
let max_scroll = total_len.saturating_sub(content_area.height as usize);
|
||||
if max_scroll == 0 {
|
||||
100
|
||||
} else {
|
||||
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
|
||||
as u8
|
||||
}
|
||||
};
|
||||
let pct_text = format!(" {percent}% ");
|
||||
let pct_w = pct_text.chars().count() as u16;
|
||||
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
|
||||
Span::from(pct_text)
|
||||
.dim()
|
||||
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
||||
render_footer_separator(sep_rect, buf, String::new());
|
||||
}
|
||||
|
||||
fn scroll_percent(&self, content_height: u16, total_len: usize) -> u8 {
|
||||
if total_len == 0 {
|
||||
return 100;
|
||||
}
|
||||
let max_scroll = total_len.saturating_sub(content_height as usize);
|
||||
if max_scroll == 0 {
|
||||
return 100;
|
||||
}
|
||||
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
|
||||
@@ -542,7 +524,7 @@ impl TranscriptOverlay {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode),
|
||||
"T R A N S C R I P T".to_string(),
|
||||
"Transcript".to_string(),
|
||||
state.scroll_offset,
|
||||
keymap,
|
||||
),
|
||||
@@ -567,7 +549,7 @@ impl TranscriptOverlay {
|
||||
.enumerate()
|
||||
.flat_map(|(i, c)| {
|
||||
let mut v: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let mut cell_renderable = if c.as_any().is::<UserHistoryCell>() {
|
||||
let base_renderable = if c.as_any().is::<UserHistoryCell>() {
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cell: c.clone(),
|
||||
style: if highlight_cell == Some(i) {
|
||||
@@ -584,6 +566,7 @@ impl TranscriptOverlay {
|
||||
render_mode,
|
||||
})) as Box<dyn Renderable>
|
||||
};
|
||||
let mut cell_renderable = base_renderable;
|
||||
if !c.is_stream_continuation() && i > 0 {
|
||||
cell_renderable = Box::new(InsetRenderable::new(
|
||||
cell_renderable,
|
||||
@@ -828,6 +811,29 @@ impl TranscriptOverlay {
|
||||
self.set_highlight_cell(Some(next_prompt));
|
||||
}
|
||||
|
||||
fn header_title(&self) -> String {
|
||||
let total = self
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|cell| cell.is_user_prompt())
|
||||
.count();
|
||||
let Some(highlight_cell) = self.highlight_cell else {
|
||||
let noun = if total == 1 { "prompt" } else { "prompts" };
|
||||
return format!("Transcript · {total} {noun}");
|
||||
};
|
||||
let selected = self
|
||||
.cells
|
||||
.iter()
|
||||
.take(highlight_cell.saturating_add(1))
|
||||
.filter(|cell| cell.is_user_prompt())
|
||||
.count();
|
||||
if selected == 0 || total == 0 {
|
||||
let noun = if total == 1 { "prompt" } else { "prompts" };
|
||||
return format!("Transcript · {total} {noun}");
|
||||
}
|
||||
format!("Transcript · {selected}/{total}")
|
||||
}
|
||||
|
||||
fn rebuild_renderables(&mut self) {
|
||||
let tail_renderable = self.take_live_tail_renderable();
|
||||
self.view.renderables =
|
||||
@@ -873,69 +879,108 @@ impl TranscriptOverlay {
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
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);
|
||||
render_key_hints(
|
||||
line1,
|
||||
buf,
|
||||
&[
|
||||
(
|
||||
first_or_empty(&self.view.keymap.scroll_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.scroll_down))
|
||||
.collect(),
|
||||
"to scroll",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.page_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.page_down))
|
||||
.collect(),
|
||||
"to page",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.jump_top)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.jump_bottom))
|
||||
.collect(),
|
||||
"to jump",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.previous_user_prompt)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.next_user_prompt))
|
||||
.collect(),
|
||||
"to prompts",
|
||||
),
|
||||
],
|
||||
);
|
||||
let scroll_keys = first_or_empty(&self.view.keymap.scroll_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.scroll_down))
|
||||
.collect::<Vec<_>>();
|
||||
let page_keys = first_or_empty(&self.view.keymap.page_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.page_down))
|
||||
.collect::<Vec<_>>();
|
||||
let jump_keys = first_or_empty(&self.view.keymap.jump_top)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.jump_bottom))
|
||||
.collect::<Vec<_>>();
|
||||
let prompt_keys = first_or_empty(&self.view.keymap.previous_user_prompt)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.next_user_prompt))
|
||||
.collect::<Vec<_>>();
|
||||
let navigation_hints = vec![
|
||||
FooterHint::new(
|
||||
key_label(&scroll_keys),
|
||||
"scroll",
|
||||
"scroll",
|
||||
/*priority*/ 1,
|
||||
),
|
||||
FooterHint::new(
|
||||
key_label(&prompt_keys),
|
||||
"prompts",
|
||||
"prompts",
|
||||
/*priority*/ 2,
|
||||
),
|
||||
FooterHint::new(key_label(&page_keys), "page", "page", /*priority*/ 6),
|
||||
FooterHint::new(key_label(&jump_keys), "jump", "jump", /*priority*/ 7),
|
||||
];
|
||||
footer_hint_line_for_row(&navigation_hints, area.width).render_ref(line1, buf);
|
||||
|
||||
let mut pairs: Vec<(Vec<KeyBinding>, &str)> = Vec::new();
|
||||
let mut action_hints = Vec::new();
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&first_or_empty(&self.view.keymap.close)),
|
||||
"quit",
|
||||
"quit",
|
||||
/*priority*/ 0,
|
||||
));
|
||||
if !self.copy_keymap.is_empty() {
|
||||
pairs.push((first_or_empty(&self.copy_keymap), "to copy"));
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&first_or_empty(&self.copy_keymap)),
|
||||
"copy",
|
||||
"copy",
|
||||
/*priority*/ 3,
|
||||
));
|
||||
}
|
||||
if !self.toggle_raw_output_keymap.is_empty() {
|
||||
pairs.push((first_or_empty(&self.toggle_raw_output_keymap), "raw"));
|
||||
}
|
||||
pairs.push((first_or_empty(&self.view.keymap.close), "to quit"));
|
||||
if self.highlight_cell.is_some() {
|
||||
pairs.push((
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Esc),
|
||||
key_hint::plain(KeyCode::Left),
|
||||
],
|
||||
"to edit prev",
|
||||
let mode_label = match self.render_mode {
|
||||
HistoryRenderMode::Rich => "raw",
|
||||
HistoryRenderMode::Raw => "rich",
|
||||
};
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&first_or_empty(&self.toggle_raw_output_keymap)),
|
||||
mode_label,
|
||||
mode_label,
|
||||
/*priority*/ 4,
|
||||
));
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next"));
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message"));
|
||||
} else {
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev"));
|
||||
}
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
if self.highlight_cell.is_some() {
|
||||
let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc))
|
||||
.chain(first_or_empty(&self.view.keymap.previous_user_prompt))
|
||||
.collect::<Vec<_>>();
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&previous_edit_keys),
|
||||
"edit prev",
|
||||
"prev",
|
||||
/*priority*/ 8,
|
||||
));
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&first_or_empty(&self.view.keymap.next_user_prompt)),
|
||||
"edit next",
|
||||
"next",
|
||||
/*priority*/ 9,
|
||||
));
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&[key_hint::plain(KeyCode::Enter)]),
|
||||
"edit message",
|
||||
"edit",
|
||||
/*priority*/ 10,
|
||||
));
|
||||
} else {
|
||||
let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc))
|
||||
.chain(first_or_empty(&self.view.keymap.previous_user_prompt))
|
||||
.collect::<Vec<_>>();
|
||||
action_hints.push(FooterHint::new(
|
||||
key_label(&previous_edit_keys),
|
||||
"edit prev",
|
||||
"prev",
|
||||
/*priority*/ 8,
|
||||
));
|
||||
}
|
||||
footer_hint_line_for_row(&action_hints, area.width).render_ref(line2, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let top_h = area.height.saturating_sub(3);
|
||||
let top = Rect::new(area.x, area.y, area.width, top_h);
|
||||
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
|
||||
self.view.title = self.header_title();
|
||||
self.view.render(top, buf);
|
||||
self.render_hints(bottom, buf);
|
||||
}
|
||||
@@ -1032,36 +1077,37 @@ impl StaticOverlay {
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
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);
|
||||
render_key_hints(
|
||||
line1,
|
||||
buf,
|
||||
&[
|
||||
(
|
||||
first_or_empty(&self.view.keymap.scroll_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.scroll_down))
|
||||
.collect(),
|
||||
"to scroll",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.page_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.page_down))
|
||||
.collect(),
|
||||
"to page",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.jump_top)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.jump_bottom))
|
||||
.collect(),
|
||||
"to jump",
|
||||
),
|
||||
],
|
||||
);
|
||||
let pairs: Vec<(Vec<KeyBinding>, &str)> =
|
||||
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
let scroll_keys = first_or_empty(&self.view.keymap.scroll_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.scroll_down))
|
||||
.collect::<Vec<_>>();
|
||||
let page_keys = first_or_empty(&self.view.keymap.page_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.page_down))
|
||||
.collect::<Vec<_>>();
|
||||
let jump_keys = first_or_empty(&self.view.keymap.jump_top)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.jump_bottom))
|
||||
.collect::<Vec<_>>();
|
||||
let navigation_hints = vec![
|
||||
FooterHint::new(
|
||||
key_label(&scroll_keys),
|
||||
"scroll",
|
||||
"scroll",
|
||||
/*priority*/ 1,
|
||||
),
|
||||
FooterHint::new(key_label(&page_keys), "page", "page", /*priority*/ 6),
|
||||
FooterHint::new(key_label(&jump_keys), "jump", "jump", /*priority*/ 7),
|
||||
];
|
||||
footer_hint_line_for_row(&navigation_hints, area.width).render_ref(line1, buf);
|
||||
|
||||
let action_hints = vec![FooterHint::new(
|
||||
key_label(&first_or_empty(&self.view.keymap.close)),
|
||||
"quit",
|
||||
"quit",
|
||||
/*priority*/ 0,
|
||||
)];
|
||||
footer_hint_line_for_row(&action_hints, area.width).render_ref(line2, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -1139,11 +1185,13 @@ mod tests {
|
||||
use crate::diff_model::FileChange;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::new_patch_event;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Text;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1332,6 +1380,52 @@ mod tests {
|
||||
assert_eq!(overlay.selected_user_cell(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_header_counts_selected_user_prompt() {
|
||||
let mut overlay = transcript_overlay(vec![
|
||||
user_cell("first"),
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("assistant")],
|
||||
/*is_first_line*/ true,
|
||||
)),
|
||||
user_cell("second"),
|
||||
]);
|
||||
|
||||
assert_eq!(overlay.header_title(), "Transcript · 2 prompts");
|
||||
|
||||
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
|
||||
assert_eq!(overlay.header_title(), "Transcript · 2/2");
|
||||
|
||||
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
|
||||
assert_eq!(overlay.header_title(), "Transcript · 1/2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_user_prompt_keeps_reversed_style_without_role_gutter() {
|
||||
let mut overlay = transcript_overlay(vec![user_cell("selected prompt")]);
|
||||
overlay.set_highlight_cell(Some(0));
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 8,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.render(area, &mut buf);
|
||||
let rendered = buffer_to_text(&buf, area);
|
||||
|
||||
assert!(!rendered.contains('▌'));
|
||||
assert!(!rendered.contains('│'));
|
||||
let prompt_marker = (area.y..area.bottom())
|
||||
.flat_map(|y| (area.x..area.right()).map(move |x| (x, y)))
|
||||
.find(|(x, y)| buf[(*x, *y)].symbol() == "›")
|
||||
.expect("expected selected prompt marker");
|
||||
assert!(
|
||||
buf[prompt_marker]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(Modifier::REVERSED)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
|
||||
@@ -9,6 +9,9 @@ mod transcript;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::color::blend;
|
||||
use crate::color::is_light;
|
||||
use crate::footer_hints::FooterHint;
|
||||
use crate::footer_hints::footer_hint_line_for_row;
|
||||
use crate::footer_hints::render_footer_separator;
|
||||
use crate::git_action_directives::parse_assistant_markdown;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_plain_text_key_event;
|
||||
@@ -74,9 +77,6 @@ const SESSION_META_MIN_CWD_WIDTH: usize = 30;
|
||||
const SESSION_META_MAX_CWD_WIDTH: usize = 72;
|
||||
const SESSION_META_BRANCH_ICON: &str = "";
|
||||
const SESSION_META_CWD_ICON: &str = "⌁";
|
||||
const FOOTER_COMPACT_BREAKPOINT: u16 = 120;
|
||||
const FOOTER_HINT_LEFT_PADDING: usize = 1;
|
||||
const FOOTER_HINT_GAP: usize = 3;
|
||||
const PICKER_CHROME_HEIGHT: u16 = 8;
|
||||
const PICKER_LIST_HORIZONTAL_INSET: u16 = 4;
|
||||
|
||||
@@ -2004,13 +2004,6 @@ fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerFooterHint {
|
||||
key: &'static str,
|
||||
wide_label: String,
|
||||
compact_label: String,
|
||||
priority: u8,
|
||||
}
|
||||
|
||||
fn render_picker_footer(
|
||||
frame: &mut crate::custom_terminal::Frame,
|
||||
area: Rect,
|
||||
@@ -2022,9 +2015,9 @@ fn render_picker_footer(
|
||||
}
|
||||
|
||||
let separator = Rect::new(area.x, area.y, area.width, 1);
|
||||
render_picker_footer_separator(
|
||||
frame,
|
||||
render_footer_separator(
|
||||
separator,
|
||||
frame.buffer,
|
||||
picker_footer_progress_label(state, list_height, area.width),
|
||||
);
|
||||
|
||||
@@ -2038,30 +2031,6 @@ fn render_picker_footer(
|
||||
}
|
||||
}
|
||||
|
||||
fn render_picker_footer_separator(
|
||||
frame: &mut crate::custom_terminal::Frame,
|
||||
area: Rect,
|
||||
progress_label: String,
|
||||
) {
|
||||
if area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let separator = "─".repeat(area.width as usize);
|
||||
frame.render_widget_ref(Line::from(separator.dim()), area);
|
||||
|
||||
let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16;
|
||||
if progress_width < area.width {
|
||||
let percent_area = Rect::new(
|
||||
area.x + area.width - progress_width - 1,
|
||||
area.y,
|
||||
progress_width,
|
||||
1,
|
||||
);
|
||||
frame.render_widget_ref(Line::from(progress_label.dim()), percent_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String {
|
||||
let position = if state.filtered_rows.is_empty() {
|
||||
0
|
||||
@@ -2128,23 +2097,10 @@ fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 {
|
||||
fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
|
||||
if state.is_transcript_loading() {
|
||||
let hints = [
|
||||
PickerFooterHint {
|
||||
key: "loading",
|
||||
wide_label: String::from("transcript"),
|
||||
compact_label: String::from("transcript"),
|
||||
priority: 0,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "ctrl+c",
|
||||
wide_label: String::from("quit"),
|
||||
compact_label: String::from("quit"),
|
||||
priority: 1,
|
||||
},
|
||||
FooterHint::new("loading", "transcript", "transcript", /*priority*/ 0),
|
||||
FooterHint::new("ctrl+c", "quit", "quit", /*priority*/ 1),
|
||||
];
|
||||
let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width)
|
||||
.or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width))
|
||||
.or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width))
|
||||
.unwrap_or_default();
|
||||
let line = footer_hint_line_for_row(&hints, width);
|
||||
return vec![line, Line::default()];
|
||||
}
|
||||
|
||||
@@ -2170,99 +2126,30 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
|
||||
SessionListDensity::Dense => "comfy",
|
||||
};
|
||||
let first_row_hints = vec![
|
||||
PickerFooterHint {
|
||||
key: "enter",
|
||||
wide_label: action_label.to_string(),
|
||||
compact_label: action_label.to_string(),
|
||||
priority: 0,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "esc",
|
||||
wide_label: esc_label.to_string(),
|
||||
compact_label: esc_compact_label.to_string(),
|
||||
priority: 1,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "ctrl+c",
|
||||
wide_label: ctrl_c_label.to_string(),
|
||||
compact_label: ctrl_c_label.to_string(),
|
||||
priority: 2,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "tab",
|
||||
wide_label: String::from("focus sort/filter"),
|
||||
compact_label: String::from("focus"),
|
||||
priority: 7,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "←/→",
|
||||
wide_label: String::from("change option"),
|
||||
compact_label: String::from("option"),
|
||||
priority: 8,
|
||||
},
|
||||
FooterHint::new("enter", action_label, action_label, /*priority*/ 0),
|
||||
FooterHint::new("esc", esc_label, esc_compact_label, /*priority*/ 1),
|
||||
FooterHint::new("ctrl+c", ctrl_c_label, ctrl_c_label, /*priority*/ 2),
|
||||
FooterHint::new("tab", "focus sort/filter", "focus", /*priority*/ 7),
|
||||
FooterHint::new("←/→", "change option", "option", /*priority*/ 8),
|
||||
];
|
||||
let second_row_hints = vec![
|
||||
PickerFooterHint {
|
||||
key: "ctrl+o",
|
||||
wide_label: density_label.to_string(),
|
||||
compact_label: density_compact_label.to_string(),
|
||||
priority: 3,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "ctrl+t",
|
||||
wide_label: String::from("transcript"),
|
||||
compact_label: String::from("preview"),
|
||||
priority: 4,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "ctrl+e",
|
||||
wide_label: String::from("expand"),
|
||||
compact_label: String::from("exp"),
|
||||
priority: 6,
|
||||
},
|
||||
PickerFooterHint {
|
||||
key: "↑/↓",
|
||||
wide_label: String::from("browse"),
|
||||
compact_label: String::from("browse"),
|
||||
priority: 5,
|
||||
},
|
||||
FooterHint::new(
|
||||
"ctrl+o",
|
||||
density_label,
|
||||
density_compact_label,
|
||||
/*priority*/ 3,
|
||||
),
|
||||
FooterHint::new("ctrl+t", "transcript", "preview", /*priority*/ 4),
|
||||
FooterHint::new("ctrl+e", "expand", "exp", /*priority*/ 6),
|
||||
FooterHint::new("↑/↓", "browse", "browse", /*priority*/ 5),
|
||||
];
|
||||
|
||||
vec![
|
||||
hint_line_for_row(&first_row_hints, width),
|
||||
hint_line_for_row(&second_row_hints, width),
|
||||
footer_hint_line_for_row(&first_row_hints, width),
|
||||
footer_hint_line_for_row(&second_row_hints, width),
|
||||
]
|
||||
}
|
||||
|
||||
fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> {
|
||||
if width >= FOOTER_COMPACT_BREAKPOINT
|
||||
&& let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width)
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) {
|
||||
return line;
|
||||
}
|
||||
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) {
|
||||
return line;
|
||||
}
|
||||
|
||||
let mut retained = (0..hints.len()).collect::<Vec<_>>();
|
||||
retained.sort_by_key(|idx| hints[*idx].priority);
|
||||
for retain_count in (1..=retained.len()).rev() {
|
||||
let mut candidate_indices = retained[..retain_count].to_vec();
|
||||
candidate_indices.sort_unstable();
|
||||
let candidate = candidate_indices
|
||||
.iter()
|
||||
.map(|idx| &hints[*idx])
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
Line::default()
|
||||
}
|
||||
|
||||
fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -2312,99 +2199,6 @@ fn transcript_loading_overlay_style() -> Style {
|
||||
Style::default().bg(best_color(blend(overlay, bg, alpha)))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FooterHintLabelMode {
|
||||
Wide,
|
||||
Compact,
|
||||
KeyOnly,
|
||||
}
|
||||
|
||||
fn fit_footer_hints(
|
||||
hints: &[PickerFooterHint],
|
||||
mode: FooterHintLabelMode,
|
||||
width: u16,
|
||||
) -> Option<Line<'static>> {
|
||||
let hint_refs = hints.iter().collect::<Vec<_>>();
|
||||
fit_footer_hint_refs(&hint_refs, mode, width)
|
||||
}
|
||||
|
||||
fn fit_footer_hint_refs(
|
||||
hints: &[&PickerFooterHint],
|
||||
mode: FooterHintLabelMode,
|
||||
width: u16,
|
||||
) -> Option<Line<'static>> {
|
||||
let gap_width = FOOTER_HINT_GAP;
|
||||
if footer_hints_width(hints, mode, gap_width) > width as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut spans = vec![
|
||||
" ".repeat(FOOTER_HINT_LEFT_PADDING)
|
||||
.set_style(footer_hint_label_style()),
|
||||
];
|
||||
for (idx, hint) in hints.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
|
||||
}
|
||||
spans.push(hint.key.set_style(footer_hint_key_style()));
|
||||
let label = match mode {
|
||||
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
|
||||
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
|
||||
FooterHintLabelMode::KeyOnly => None,
|
||||
};
|
||||
if let Some(label) = label {
|
||||
spans.push(" ".set_style(footer_hint_label_style()));
|
||||
spans.push(label.to_string().set_style(footer_hint_label_style()));
|
||||
}
|
||||
}
|
||||
Some(spans.into())
|
||||
}
|
||||
|
||||
fn footer_hint_key_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::Black)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hint_label_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().dim()
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hints_width(
|
||||
hints: &[&PickerFooterHint],
|
||||
mode: FooterHintLabelMode,
|
||||
gap_width: usize,
|
||||
) -> usize {
|
||||
FOOTER_HINT_LEFT_PADDING
|
||||
+ hints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, hint)| {
|
||||
let label_width = match mode {
|
||||
FooterHintLabelMode::Wide => {
|
||||
1 + UnicodeWidthStr::width(hint.wide_label.as_str())
|
||||
}
|
||||
FooterHintLabelMode::Compact => {
|
||||
1 + UnicodeWidthStr::width(hint.compact_label.as_str())
|
||||
}
|
||||
FooterHintLabelMode::KeyOnly => 0,
|
||||
};
|
||||
let hint_width = UnicodeWidthStr::width(hint.key) + label_width;
|
||||
if idx == 0 {
|
||||
hint_width
|
||||
} else {
|
||||
hint_width + gap_width
|
||||
}
|
||||
})
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ S T A T I C / / / / / / / / / / / / / "
|
||||
" S T A T I C · 100% ────────────────────"
|
||||
"one "
|
||||
"two "
|
||||
"three "
|
||||
"~ "
|
||||
"~ "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ to scroll pgup/pgdn to page hom"
|
||||
" q to quit "
|
||||
"────────────────────────────────────────"
|
||||
" ↑/↓ pgup/pgdn home/end "
|
||||
" q quit "
|
||||
" "
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ S T A T I C / / / / / "
|
||||
" S T A T I C · 0% ──────"
|
||||
"a very long line that "
|
||||
"should wrap when "
|
||||
"rendered within a narrow"
|
||||
"─────────────────── 0% ─"
|
||||
" ↑/↓ to scroll pgup/pg"
|
||||
" q to quit "
|
||||
"────────────────────────"
|
||||
" ↑/↓ pgup/pgdn "
|
||||
" q quit "
|
||||
" "
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: snapshot
|
||||
---
|
||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
Transcript · 0 prompts · 0% ───────────────────────────────────────────────────
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
@@ -10,6 +10,6 @@ expression: snapshot
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
─────────────────────────────────────────────────────────────────────────── 0% ─
|
||||
↑/↓ to scroll pgup/pgdn to page home/end to jump
|
||||
q to quit esc to edit prev
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
|
||||
q quit ctrl + o copy ⌥ + r raw esc/← prev
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ T R A N S C R I P T / / / / / / / / / "
|
||||
" Transcript · 0 prompts · 100% ─────────"
|
||||
"alpha "
|
||||
" "
|
||||
"tail "
|
||||
"~ "
|
||||
"~ "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ to scroll pgup/pgdn to page hom"
|
||||
" q to quit esc to edit prev "
|
||||
"────────────────────────────────────────"
|
||||
" ↑/↓ ←/→ pgup/pgdn home/end "
|
||||
" q ctrl + o ⌥ + r esc/← "
|
||||
" "
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ T R A N S C R I P T / / / / / / / / / "
|
||||
" Transcript · 0 prompts · 100% ─────────"
|
||||
"alpha "
|
||||
" "
|
||||
"beta "
|
||||
" "
|
||||
"gamma "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ to scroll pgup/pgdn to page hom"
|
||||
" ctrl + o to copy ⌥ + r raw q to qui"
|
||||
"────────────────────────────────────────"
|
||||
" ↑/↓ ←/→ pgup/pgdn home/end "
|
||||
" q ctrl + o ⌥ + r esc/← "
|
||||
" "
|
||||
|
||||
Reference in New Issue
Block a user