feat(tui/help): add Ctrl+H help overlay and replace footer hint\n\n- New in-app Help overlay (Ctrl+H) with key summary (Ctrl+R/T, Ctrl+D, Shift+Up/Down, editing keys, panels).\n- Footer hint now shows "Ctrl+H help" instead of "Ctrl+C quit"; quit flow via Ctrl+C remains intact.\n- Mirrors Diff Viewer styling for a consistent popup experience.\n\nCloses #60

This commit is contained in:
James Peter
2025-09-08 11:23:46 +10:00
parent 51e2081fc7
commit c1b265f8f8
10 changed files with 210 additions and 12 deletions
@@ -1437,7 +1437,7 @@ impl WidgetRef for ChatComposer {
}
}
// Right side: command key hints (Ctrl+R/D/C) followed by token usage if available
// Right side: command key hints (Ctrl+R/D/H) followed by token usage if available
// We will elide hints when space is tight in this order: hide reasoning, diff viewer.
let mut right_spans: Vec<Span> = Vec::new();
@@ -1478,10 +1478,10 @@ impl WidgetRef for ChatComposer {
spans.push(Span::from("Ctrl+D").style(key_hint_style));
spans.push(Span::from(" diff viewer").style(label_style));
}
// Always show quit at the end of the command hints
// Always show help at the end of the command hints
if !spans.is_empty() { spans.push(Span::from(" • ").style(label_style)); }
spans.push(Span::from("Ctrl+C").style(key_hint_style));
spans.push(Span::from(" quit").style(label_style));
spans.push(Span::from("Ctrl+H").style(key_hint_style));
spans.push(Span::from(" help").style(label_style));
}
spans
};
@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help "
@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help "
@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help "
@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help "
@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help "
+132 -1
View File
@@ -20,6 +20,7 @@ mod gh_actions;
mod tools;
mod layout_scroll;
mod diff_handlers;
mod help_handlers;
mod perf;
mod diff_ui;
mod message;
@@ -197,6 +198,9 @@ pub(crate) struct ChatWidget<'a> {
// Accumulated diff/session state
diffs: DiffsState,
// Help overlay state
help: HelpState,
// Cache for expensive height calculations per cell and width
height_cache: std::cell::RefCell<std::collections::HashMap<(usize, u16), u16>>,
// Track last width used to opportunistically clear cache when layout changes
@@ -306,6 +310,7 @@ static BG_SHOT_IN_FLIGHT: Lazy<AtomicBool> = Lazy::new(|| AtomicBool::new(false)
static BG_SHOT_LAST_START_MS: Lazy<AtomicU64> = Lazy::new(|| AtomicU64::new(0));
use self::diff_ui::{DiffBlock, DiffConfirm, DiffOverlay};
use ratatui::text::{Line as RtLine, Span as RtSpan};
use self::message::UserMessage;
@@ -1211,6 +1216,7 @@ impl ChatWidget<'_> {
interrupts: interrupts::InterruptManager::new(),
ended_call_ids: HashSet::new(),
diffs: DiffsState { session_patch_sets: Vec::new(), baseline_file_contents: HashMap::new(), overlay: None, confirm: None, body_visible_rows: std::cell::Cell::new(0) },
help: HelpState { overlay: None, body_visible_rows: std::cell::Cell::new(0) },
height_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
height_cache_last_width: std::cell::Cell::new(0),
height_manager: RefCell::new(HeightManager::new(
@@ -1334,6 +1340,7 @@ impl ChatWidget<'_> {
interrupts: interrupts::InterruptManager::new(),
ended_call_ids: HashSet::new(),
diffs: DiffsState { session_patch_sets: Vec::new(), baseline_file_contents: HashMap::new(), overlay: None, confirm: None, body_visible_rows: std::cell::Cell::new(0) },
help: HelpState { overlay: None, body_visible_rows: std::cell::Cell::new(0) },
height_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
height_cache_last_width: std::cell::Cell::new(0),
height_manager: RefCell::new(HeightManager::new(
@@ -1440,7 +1447,8 @@ impl ChatWidget<'_> {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
// Intercept keys for diff overlay when active
// Intercept keys for overlays when active (help first, then diff)
if help_handlers::handle_help_key(self, key_event) { return; }
if diff_handlers::handle_diff_key(self, key_event) { return; }
if key_event.kind == KeyEventKind::Press {
self.bottom_pane.clear_ctrl_c_quit_hint();
@@ -3254,6 +3262,59 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn show_help_popup(&mut self) {
let t_dim = Style::default().fg(crate::colors::text_dim());
let t_fg = Style::default().fg(crate::colors::text());
let mut lines: Vec<RtLine<'static>> = Vec::new();
lines.push(RtLine::from(vec![RtSpan::styled("Keyboard shortcuts", t_fg.add_modifier(Modifier::BOLD))]));
lines.push(RtLine::from(""));
let kv = |k: &str, v: &str| -> RtLine<'static> {
RtLine::from(vec![
RtSpan::styled(format!("{k:>12}"), t_fg),
RtSpan::raw(" — "),
RtSpan::styled(v.to_string(), t_dim),
])
};
// Global
lines.push(kv("Ctrl+H", "Help overlay"));
lines.push(kv("Ctrl+R", "Toggle reasoning"));
lines.push(kv("Ctrl+T", "Transcript / toggle reasoning"));
lines.push(kv("Ctrl+D", "Diff viewer"));
lines.push(kv("Esc", "Edit previous message / close popups"));
lines.push(RtLine::from(""));
// Composer
lines.push(RtLine::from(vec![RtSpan::styled("Compose field", t_fg.add_modifier(Modifier::BOLD))]));
lines.push(kv("Enter", "Send message"));
lines.push(kv("Ctrl+J", "Insert newline"));
lines.push(kv("Shift+Up/Down", "Browse input history"));
lines.push(kv("Ctrl+B/Ctrl+F", "Move left/right"));
lines.push(kv("Alt+Left/Right", "Move by word"));
lines.push(kv("Ctrl+W / Alt+Backspace", "Delete previous word"));
lines.push(kv("Ctrl+H / Backspace", "Delete previous char"));
lines.push(kv("Ctrl+D / Delete", "Delete next char"));
lines.push(kv("Ctrl+U", "Delete to line start"));
lines.push(kv("Ctrl+K", "Delete to line end"));
lines.push(kv("Home/End", "Jump to line start/end"));
lines.push(RtLine::from(""));
// Panels
lines.push(RtLine::from(vec![RtSpan::styled("Panels", t_fg.add_modifier(Modifier::BOLD))]));
lines.push(kv("Ctrl+B", "Toggle Browser panel"));
lines.push(kv("Ctrl+A", "Toggle Agents panel"));
self.help.overlay = Some(HelpOverlay::new(lines));
self.request_redraw();
}
pub(crate) fn toggle_help_popup(&mut self) {
if self.help.overlay.is_some() { self.help.overlay = None; } else { self.show_help_popup(); }
self.request_redraw();
}
pub(crate) fn handle_reasoning_command(&mut self, command_args: String) {
// command_args contains only the arguments after the command (e.g., "high" not "/reasoning high")
let trimmed = command_args.trim();
@@ -7944,6 +8005,61 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
}
// Render help overlay (covering the history area) if active
if let Some(overlay) = &self.help.overlay {
// Global scrim across widget
let scrim_bg = Style::default()
.bg(crate::colors::overlay_scrim())
.fg(crate::colors::text_dim());
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(scrim_bg);
}
}
let padding = 1u16;
let window_area = Rect {
x: history_area.x + padding,
y: history_area.y,
width: history_area.width.saturating_sub(padding * 2),
height: history_area.height,
};
Clear.render(window_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(" ", Style::default().fg(crate::colors::text_dim())),
ratatui::text::Span::styled("Help", Style::default().fg(crate::colors::text())),
ratatui::text::Span::styled(" ——— ", Style::default().fg(crate::colors::text_dim())),
ratatui::text::Span::styled("Esc", Style::default().fg(crate::colors::text())),
ratatui::text::Span::styled(" close ", Style::default().fg(crate::colors::text_dim())),
]))
.style(Style::default().bg(crate::colors::background()))
.border_style(Style::default().fg(crate::colors::border()).bg(crate::colors::background()));
let inner = block.inner(window_area);
block.render(window_area, buf);
// Paint inner bg
let inner_bg = Style::default().bg(crate::colors::background());
for y in inner.y..inner.y + inner.height {
for x in inner.x..inner.x + inner.width {
buf[(x, y)].set_style(inner_bg);
}
}
// Body area with one cell padding
let body = inner.inner(ratatui::layout::Margin::new(1, 1));
// Compute visible slice
let visible_rows = body.height as usize;
self.help.body_visible_rows.set(body.height);
let max_off = overlay.lines.len().saturating_sub(visible_rows.max(1));
let skip = (overlay.scroll as usize).min(max_off);
let end = (skip + visible_rows).min(overlay.lines.len());
let visible = if skip < overlay.lines.len() { &overlay.lines[skip..end] } else { &[] };
let paragraph = Paragraph::new(RtText::from(visible.to_vec())).wrap(ratatui::widgets::Wrap { trim: false });
ratatui::widgets::Widget::render(paragraph, body, buf);
}
// Finalize widget render timing
if let Some(t0) = _perf_widget_start {
let dt = t0.elapsed().as_nanos();
@@ -8101,6 +8217,21 @@ struct DiffsState {
confirm: Option<DiffConfirm>,
body_visible_rows: std::cell::Cell<u16>,
}
#[derive(Default)]
struct HelpState {
overlay: Option<HelpOverlay>,
body_visible_rows: std::cell::Cell<u16>,
}
struct HelpOverlay {
lines: Vec<RtLine<'static>>,
scroll: u16,
}
impl HelpOverlay {
fn new(lines: Vec<RtLine<'static>>) -> Self { Self { lines, scroll: 0 } }
}
#[derive(Default)]
struct PerfState {
enabled: bool,
@@ -0,0 +1,67 @@
//! Help overlay key handling similar to the diff overlay, but simpler.
use super::ChatWidget;
use crossterm::event::{KeyCode, KeyEvent};
// Returns true if the key was handled by the help overlay (or toggled it closed).
pub(super) fn handle_help_key(chat: &mut ChatWidget<'_>, key_event: KeyEvent) -> bool {
// If no help overlay, only intercept Ctrl+H to open it.
if chat.help.overlay.is_none() {
if let KeyEvent { code: KeyCode::Char('h'), modifiers: crossterm::event::KeyModifiers::CONTROL, .. } = key_event {
chat.toggle_help_popup();
return true;
}
return false;
}
// Overlay active: process navigation + close
let Some(ref mut overlay) = chat.help.overlay else { return false };
match key_event.code {
KeyCode::Up => {
overlay.scroll = overlay.scroll.saturating_sub(1);
chat.request_redraw();
true
}
KeyCode::Down => {
let visible_rows = chat.help.body_visible_rows.get() as usize;
let max_off = overlay.lines.len().saturating_sub(visible_rows.max(1));
let next = (overlay.scroll as usize).saturating_add(1).min(max_off);
overlay.scroll = next as u16;
chat.request_redraw();
true
}
KeyCode::PageUp => {
let h = chat.help.body_visible_rows.get() as usize;
let cur = overlay.scroll as usize;
overlay.scroll = cur.saturating_sub(h) as u16;
chat.request_redraw();
true
}
KeyCode::PageDown | KeyCode::Char(' ') => {
let h = chat.help.body_visible_rows.get() as usize;
let cur = overlay.scroll as usize;
let visible_rows = chat.help.body_visible_rows.get() as usize;
let max_off = overlay.lines.len().saturating_sub(visible_rows.max(1));
overlay.scroll = cur.saturating_add(h).min(max_off) as u16;
chat.request_redraw();
true
}
KeyCode::Home => {
overlay.scroll = 0;
chat.request_redraw();
true
}
KeyCode::End => {
overlay.scroll = u16::MAX;
chat.request_redraw();
true
}
KeyCode::Esc | KeyCode::Char('h') => {
// Close on Esc or Ctrl+H
chat.help.overlay = None;
chat.request_redraw();
true
}
_ => false,
}
}
@@ -12,4 +12,4 @@ expression: visual
Investigating rendering code (0s • Esc to interrupt)
▌Summarize recent commits
⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit
⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help
@@ -7,5 +7,5 @@ expression: terminal.backend()
" Analyzing (0s • Esc to interrupt) "
" "
"▌ Ask Codex to do anything "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+H help "
" "