//! The bottom-pane footer renders transient hints and context indicators.//!//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state.//! It intentionally does not decide *which* footer content should be shown; that is owned by the//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like//! `ChatWidget` (which decides when quit/interrupt is allowed).//!//! Some footer content is time-based rather than event-based, such as the "press again to quit"//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is//! otherwise idle.//!//! Terminology used in this module://! - "status line" means the configurable contextual row built from `/statusline` items such as//! model, git branch, and context usage.//! - "instructional footer" means a row that tells the user what to do next, such as quit//! confirmation, shortcut help, or queue hints.//! - "contextual footer" means the footer is free to show ambient context instead of an//! instruction. In that state, the footer may render the configured status line, the active//! agent label, side-conversation state, or some combination of those.//!//! Single-line collapse overview://! 1. The composer decides the current `FooterMode` and hint flags, then calls//! `single_line_footer_layout` for the base single-line modes.//! 2. `single_line_footer_layout` applies the width-based fallback rules://! (If this description is hard to follow, just try it out by resizing//! your terminal width; these rules were built out of trial and error.)//! - Start with the fullest left-side hint plus the right-side context.//! - When the queue hint is active, prefer keeping that queue hint visible,//! even if it means dropping the right-side context earlier; the queue//! hint may also be shortened before it is removed.//! - When the queue hint is not active but the mode cycle hint is applicable,//! drop "? for shortcuts" before dropping "(shift+tab to cycle)".//! - If "(shift+tab to cycle)" cannot fit, also hide the right-side//! context to avoid too many state transitions in quick succession.//! - Finally, try a mode-only line (with and without context), and fall//! back to no left-side footer if nothing can fit.//! 3. When collapse chooses a specific line, callers render it via//! `render_footer_line`. Otherwise, callers render the straightforward//! mode-to-text mapping via `render_footer_from_props`.//!//! In short: `single_line_footer_layout` chooses *what* best fits, and the two//! render helpers choose whether to draw the chosen line or the default//! `FooterProps` mapping.use crate::key_hint;use crate::key_hint::KeyBinding;use crate::render::line_utils::prefix_lines;use crate::status::format_tokens_compact;use crate::ui_consts::FOOTER_INDENT_COLS;use crossterm::event::KeyCode;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::text::Span;use ratatui::widgets::Paragraph;use ratatui::widgets::Widget;/// The rendering inputs for the footer area under the composer.////// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,/// `BottomPane`, and `ChatWidget`) and pass it to the footer render helpers/// (`render_footer_from_props` or the single-line collapse logic). The footer/// treats these values as authoritative and does not attempt to infer missing/// state (for example, it does not query whether a task is running).#[derive(Clone, Debug)]pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) queue_submissions: bool, pub(crate) collaboration_modes_enabled: bool, pub(crate) is_wsl: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. pub(crate) quit_shortcut_key: KeyBinding, pub(crate) status_line_value: Option<Line<'static>>, pub(crate) status_line_enabled: bool, pub(crate) key_hints: FooterKeyHints, /// Active thread label shown when the footer is rendering contextual information instead of an /// instructional hint. /// /// When both this label and the configured status line are available, they are rendered on the /// same row separated by ` · `. pub(crate) active_agent_label: Option<String>,}#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub(crate) enum CollaborationModeIndicator { Plan, #[allow(dead_code)] // Hidden by current mode filtering; kept for future UI re-enablement. PairProgramming, #[allow(dead_code)] // Hidden by current mode filtering; kept for future UI re-enablement. Execute,}#[derive(Clone, Debug, Eq, PartialEq)]pub(crate) enum GoalStatusIndicator { Active { usage: Option<String> }, Paused, Blocked, UsageLimited, BudgetLimited { usage: Option<String> }, Complete { usage: Option<String> },}const MODE_CYCLE_HINT: &str = "shift+tab to cycle";const FOOTER_CONTEXT_GAP_COLS: u16 = 1;#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub(crate) struct FooterKeyHints { pub(crate) toggle_shortcuts: Option<KeyBinding>, pub(crate) queue: Option<KeyBinding>, pub(crate) insert_newline: Option<KeyBinding>, pub(crate) external_editor: Option<KeyBinding>, pub(crate) edit_previous: Option<KeyBinding>, pub(crate) show_transcript: Option<KeyBinding>, pub(crate) history_search: Option<KeyBinding>, pub(crate) reasoning_down: Option<KeyBinding>, pub(crate) reasoning_up: Option<KeyBinding>,}impl FooterKeyHints { #[cfg(test)] pub(crate) fn default_bindings() -> Self { Self { toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?'))), queue: Some(key_hint::plain(KeyCode::Tab)), insert_newline: Some(key_hint::ctrl(KeyCode::Char('j'))), external_editor: Some(key_hint::ctrl(KeyCode::Char('g'))), edit_previous: Some(key_hint::plain(KeyCode::Esc)), show_transcript: Some(key_hint::ctrl(KeyCode::Char('t'))), history_search: Some(key_hint::ctrl(KeyCode::Char('r'))), reasoning_down: Some(key_hint::alt(KeyCode::Char(','))), reasoning_up: Some(key_hint::alt(KeyCode::Char('.'))), } }}impl CollaborationModeIndicator { fn label(self, show_cycle_hint: bool) -> String { let suffix = if show_cycle_hint { format!(" ({MODE_CYCLE_HINT})") } else { String::new() }; match self { CollaborationModeIndicator::Plan => format!("Plan mode{suffix}"), CollaborationModeIndicator::PairProgramming => { format!("Pair Programming mode{suffix}") } CollaborationModeIndicator::Execute => format!("Execute mode{suffix}"), } } fn styled_span(self, show_cycle_hint: bool) -> Span<'static> { let label = self.label(show_cycle_hint); match self { CollaborationModeIndicator::Plan => Span::from(label).magenta(), CollaborationModeIndicator::PairProgramming => Span::from(label).cyan(), CollaborationModeIndicator::Execute => Span::from(label).dim(), } }}/// Selects which footer content is rendered.////// The current mode is owned by `ChatComposer`, which may override it based on transient state/// (for example, showing `QuitShortcutReminder` only while its timer is active).#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub(crate) enum FooterMode { /// Single-line incremental history search prompt shown while Ctrl+R search is active. HistorySearch, /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). QuitShortcutReminder, /// Multi-line shortcut overlay shown after pressing `?`. ShortcutOverlay, /// Transient "press Esc again" hint shown after the first Esc while idle. EscHint, /// Base single-line footer when the composer is empty. ComposerEmpty, /// Base single-line footer when the composer contains a draft. /// /// The shortcuts hint is suppressed here; when a task is running, this /// mode can show the queue hint instead. ComposerHasDraft,}pub(crate) fn toggle_shortcut_mode( current: FooterMode, ctrl_c_hint: bool, is_empty: bool,) -> FooterMode { if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) { return current; } let base_mode = if is_empty { FooterMode::ComposerEmpty } else { FooterMode::ComposerHasDraft }; match current { FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => base_mode, _ => FooterMode::ShortcutOverlay, }}pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { if is_task_running { current } else { FooterMode::EscHint }}pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { match current { FooterMode::EscHint | FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder | FooterMode::HistorySearch | FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty, other => other, }}pub(crate) fn footer_height(props: &FooterProps) -> u16 { let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => false, FooterMode::HistorySearch | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; let show_queue_hint = match props.mode { FooterMode::ComposerHasDraft => props.is_task_running, FooterMode::QuitShortcutReminder | FooterMode::HistorySearch | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; footer_from_props_lines( props, /*collaboration_mode_indicator*/ None, /*show_cycle_hint*/ false, show_shortcuts_hint, show_queue_hint, ) .len() as u16}/// Render a single precomputed footer line.pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'static>) { Paragraph::new(prefix_lines( vec![line], " ".repeat(FOOTER_INDENT_COLS).into(), " ".repeat(FOOTER_INDENT_COLS).into(), )) .render(area, buf);}/// Render footer content directly from `FooterProps`.////// This is intentionally not part of the width-based collapse/fallback logic./// Transient instructional states (shortcut overlay, Esc hint, quit reminder)/// prioritize "what to do next" instructions and currently suppress the/// collaboration mode label entirely. When collapse logic has already chosen a/// specific single line, prefer `render_footer_line`.pub(crate) fn render_footer_from_props( area: Rect, buf: &mut Buffer, props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, show_cycle_hint: bool, show_shortcuts_hint: bool, show_queue_hint: bool,) { Paragraph::new(prefix_lines( footer_from_props_lines( props, collaboration_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ), " ".repeat(FOOTER_INDENT_COLS).into(), " ".repeat(FOOTER_INDENT_COLS).into(), )) .render(area, buf);}pub(crate) fn left_fits(area: Rect, left_width: u16) -> bool { let max_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16); left_width <= max_width}#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum SummaryHintKind { None, Shortcuts, QueueMessage, QueueShort,}#[derive(Clone, Copy, Debug, PartialEq, Eq)]struct LeftSideState { hint: SummaryHintKind, show_cycle_hint: bool,}fn left_side_line( collaboration_mode_indicator: Option<CollaborationModeIndicator>, state: LeftSideState, key_hints: FooterKeyHints,) -> Line<'static> { let mut line = Line::from(""); match state.hint { SummaryHintKind::None => {} SummaryHintKind::Shortcuts => { if let Some(key) = key_hints.toggle_shortcuts { line.push_span(key); line.push_span(" for shortcuts".dim()); } } SummaryHintKind::QueueMessage => { if let Some(key) = key_hints.queue { line.push_span(key); line.push_span(" to queue message".dim()); } } SummaryHintKind::QueueShort => { if let Some(key) = key_hints.queue { line.push_span(key); line.push_span(" to queue".dim()); } } }; if let Some(collaboration_mode_indicator) = collaboration_mode_indicator { if !matches!(state.hint, SummaryHintKind::None) { line.push_span(" · ".dim()); } line.push_span(collaboration_mode_indicator.styled_span(state.show_cycle_hint)); } line}pub(crate) enum SummaryLeft { Default, Custom(Line<'static>), None,}/// Compute the single-line footer layout and whether the right-side context/// indicator can be shown alongside it.pub(crate) fn single_line_footer_layout( area: Rect, context_width: u16, collaboration_mode_indicator: Option<CollaborationModeIndicator>, show_cycle_hint: bool, show_shortcuts_hint: bool, show_queue_hint: bool, key_hints: FooterKeyHints,) -> (SummaryLeft, bool) { let hint_kind = if show_queue_hint { SummaryHintKind::QueueMessage } else if show_shortcuts_hint { SummaryHintKind::Shortcuts } else { SummaryHintKind::None }; let default_state = LeftSideState { hint: hint_kind, show_cycle_hint, }; let default_line = left_side_line(collaboration_mode_indicator, default_state, key_hints); let default_width = default_line.width() as u16; if default_width > 0 && can_show_left_with_context(area, default_width, context_width) { return (SummaryLeft::Default, true); } let state_line = |state: LeftSideState| -> Line<'static> { if state == default_state { default_line.clone() } else { left_side_line(collaboration_mode_indicator, state, key_hints) } }; let state_width = |state: LeftSideState| -> u16 { state_line(state).width() as u16 }; // When the mode cycle hint is applicable (idle, non-queue mode), only show // the right-side context indicator if the "(shift+tab to cycle)" variant // can also fit. let context_requires_cycle_hint = show_cycle_hint && !show_queue_hint; if show_queue_hint { // In queue mode, prefer dropping context before dropping the queue hint. let queue_states = [ default_state, LeftSideState { hint: SummaryHintKind::QueueMessage, show_cycle_hint: false, }, LeftSideState { hint: SummaryHintKind::QueueShort, show_cycle_hint: false, }, ]; // Pass 1: keep the right-side context indicator if any queue variant // can fit alongside it. We skip adjacent duplicates because // `default_state` can already be the no-cycle queue variant. let mut previous_state: Option<LeftSideState> = None; for state in queue_states { if previous_state == Some(state) { continue; } previous_state = Some(state); let width = state_width(state); if width > 0 && can_show_left_with_context(area, width, context_width) { if state == default_state { return (SummaryLeft::Default, true); } return (SummaryLeft::Custom(state_line(state)), true); } } // Pass 2: if context cannot fit, drop it before dropping the queue // hint. Reuse the same dedupe so we do not try equivalent states twice. let mut previous_state: Option<LeftSideState> = None; for state in queue_states { if previous_state == Some(state) { continue; } previous_state = Some(state); let width = state_width(state); if width > 0 && left_fits(area, width) { if state == default_state { return (SummaryLeft::Default, false); } return (SummaryLeft::Custom(state_line(state)), false); } } } else if collaboration_mode_indicator.is_some() { if show_cycle_hint { // First fallback: drop shortcut hint but keep the cycle // hint on the mode label if it can fit. let cycle_state = LeftSideState { hint: SummaryHintKind::None, show_cycle_hint: true, }; let cycle_width = state_width(cycle_state); if cycle_width > 0 && can_show_left_with_context(area, cycle_width, context_width) { return (SummaryLeft::Custom(state_line(cycle_state)), true); } if cycle_width > 0 && left_fits(area, cycle_width) { return (SummaryLeft::Custom(state_line(cycle_state)), false); } } // Next fallback: mode label only. If the cycle hint is applicable but // cannot fit, we also suppress context so the right side does not // outlive "(shift+tab to cycle)" on the left. let mode_only_state = LeftSideState { hint: SummaryHintKind::None, show_cycle_hint: false, }; let mode_only_width = state_width(mode_only_state); if !context_requires_cycle_hint && mode_only_width > 0 && can_show_left_with_context(area, mode_only_width, context_width) { return ( SummaryLeft::Custom(state_line(mode_only_state)), true, // show_context ); } if mode_only_width > 0 && left_fits(area, mode_only_width) { return ( SummaryLeft::Custom(state_line(mode_only_state)), false, // show_context ); } } // Final fallback: if queue variants (or other earlier states) could not fit // at all, drop every hint and try to show just the mode label. if let Some(collaboration_mode_indicator) = collaboration_mode_indicator { let mode_only_state = LeftSideState { hint: SummaryHintKind::None, show_cycle_hint: false, }; // Compute the width without going through `state_line` so we do not // depend on `default_state` (which may still be a queue variant). let mode_only_width = left_side_line( Some(collaboration_mode_indicator), mode_only_state, key_hints, ) .width() as u16; if !context_requires_cycle_hint && can_show_left_with_context(area, mode_only_width, context_width) { return ( SummaryLeft::Custom(left_side_line( Some(collaboration_mode_indicator), mode_only_state, key_hints, )), true, // show_context ); } if left_fits(area, mode_only_width) { return ( SummaryLeft::Custom(left_side_line( Some(collaboration_mode_indicator), mode_only_state, key_hints, )), false, // show_context ); } } (SummaryLeft::None, true)}pub(crate) fn mode_indicator_line( indicator: Option<CollaborationModeIndicator>, show_cycle_hint: bool,) -> Option<Line<'static>> { indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)]))}pub(crate) fn goal_status_indicator_line( indicator: Option<&GoalStatusIndicator>,) -> Option<Line<'static>> { let indicator = indicator?; let label = match indicator { GoalStatusIndicator::Active { usage } => { if let Some(usage) = usage { format!("Pursuing goal ({usage})") } else { "Pursuing goal".to_string() } } GoalStatusIndicator::Paused => "Goal paused (/goal resume)".to_string(), GoalStatusIndicator::Blocked => "Goal blocked (/goal resume)".to_string(), GoalStatusIndicator::UsageLimited => "Goal hit usage limits (/goal resume)".to_string(), GoalStatusIndicator::BudgetLimited { usage } => { if let Some(usage) = usage { format!("Goal unmet ({usage})") } else { "Goal abandoned".to_string() } } GoalStatusIndicator::Complete { usage } => { if let Some(usage) = usage { format!("Goal achieved ({usage})") } else { "Goal achieved".to_string() } } }; Some(Line::from(vec![Span::from(label).magenta()]))}pub(crate) fn status_line_right_indicator_line( collaboration_mode_indicator: Option<CollaborationModeIndicator>, goal_status_indicator: Option<&GoalStatusIndicator>, ide_context_active: bool, show_cycle_hint: bool,) -> Option<Line<'static>> { let primary_indicator = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) .or_else(|| goal_status_indicator_line(goal_status_indicator)); let ide_context_indicator = ide_context_active.then(|| Line::from(vec!["IDE context".cyan()])); let mut line: Option<Line<'static>> = None; for indicator in [primary_indicator, ide_context_indicator] .into_iter() .flatten() { if let Some(line) = line.as_mut() { line.push_span(" · ".dim()); for span in indicator.spans { line.push_span(span); } } else { line = Some(indicator); } } line}pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> { if let Some(rest) = label.strip_prefix("Side ") { Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()]) } else { Line::from(label.to_string()).magenta() }}fn right_aligned_x(area: Rect, content_width: u16) -> Option<u16> { if area.is_empty() { return None; } let right_padding = FOOTER_INDENT_COLS as u16; let max_width = area.width.saturating_sub(right_padding); if content_width == 0 || max_width == 0 { return None; } if content_width >= max_width { return Some(area.x.saturating_add(right_padding)); } Some( area.x .saturating_add(area.width) .saturating_sub(content_width) .saturating_sub(right_padding), )}pub(crate) fn max_left_width_for_right(area: Rect, right_width: u16) -> Option<u16> { let context_x = right_aligned_x(area, right_width)?; let left_start = area.x + FOOTER_INDENT_COLS as u16; // minimal one column gap between left and right let gap = FOOTER_CONTEXT_GAP_COLS; if context_x <= left_start + gap { return Some(0); } Some(context_x.saturating_sub(left_start + gap))}pub(crate) fn can_show_left_with_context(area: Rect, left_width: u16, context_width: u16) -> bool { let Some(context_x) = right_aligned_x(area, context_width) else { return true; }; if left_width == 0 { return true; } let left_extent = FOOTER_INDENT_COLS as u16 + left_width + FOOTER_CONTEXT_GAP_COLS; left_extent <= context_x.saturating_sub(area.x)}pub(crate) fn render_context_right(area: Rect, buf: &mut Buffer, line: &Line<'static>) { if area.is_empty() { return; } let context_width = line.width() as u16; let Some(mut x) = right_aligned_x(area, context_width) else { return; }; let y = area.y + area.height.saturating_sub(1); let max_x = area.x.saturating_add(area.width); for span in &line.spans { if x >= max_x { break; } let span_width = span.width() as u16; if span_width == 0 { continue; } let remaining = max_x.saturating_sub(x); let draw_width = span_width.min(remaining); buf.set_span(x, y, span, draw_width); x = x.saturating_add(span_width); }}pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { if area.width > 2 { area.x += 2; area.width = area.width.saturating_sub(2); } area}pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { if items.is_empty() { return; } footer_hint_items_line(items).render(inset_footer_hint_area(area), buf);}/// Map `FooterProps` to footer lines without width-based collapse.////// This is the canonical FooterMode-to-text mapping. It powers transient,/// instructional states (shortcut overlay, Esc hint, quit reminder) and also/// the default rendering for base states when collapse is not applied (or when/// `single_line_footer_layout` returns `SummaryLeft::Default`). Collapse and/// fallback decisions live in `single_line_footer_layout`; this function only/// formats the chosen/default content.fn footer_from_props_lines( props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, show_cycle_hint: bool, show_shortcuts_hint: bool, show_queue_hint: bool,) -> Vec<Line<'static>> { let key_hints = props.key_hints; // Passive footer context can come from the configurable status line, the // active agent label, or both combined. if let Some(status_line) = passive_footer_status_line(props) { return vec![status_line]; } match props.mode { FooterMode::QuitShortcutReminder => { vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] } FooterMode::HistorySearch => vec![Line::from("reverse-i-search: ").dim()], FooterMode::ComposerEmpty => { let state = LeftSideState { hint: if show_shortcuts_hint { SummaryHintKind::Shortcuts } else { SummaryHintKind::None }, show_cycle_hint, }; vec![left_side_line( collaboration_mode_indicator, state, key_hints, )] } FooterMode::ShortcutOverlay => { let state = ShortcutsState { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_task_running: props.is_task_running, queue_submissions: props.queue_submissions, is_wsl: props.is_wsl, collaboration_modes_enabled: props.collaboration_modes_enabled, key_hints, }; shortcut_overlay_lines(state) } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], FooterMode::ComposerHasDraft => { let state = LeftSideState { hint: if show_queue_hint { SummaryHintKind::QueueMessage } else if show_shortcuts_hint { SummaryHintKind::Shortcuts } else { SummaryHintKind::None }, show_cycle_hint, }; vec![left_side_line( collaboration_mode_indicator, state, key_hints, )] } }}/// Returns the contextual footer row when the footer is not busy showing an instructional hint.////// The returned line may contain the configured status line, the currently viewed agent label, or/// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue/// prompts deliberately return `None` so those call-to-action hints stay visible.pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'static>> { if !shows_passive_footer_line(props) { return None; } let mut line = if props.status_line_enabled { props.status_line_value.clone() } else { None }; if let Some(active_agent_label) = props.active_agent_label.as_ref() { if let Some(existing) = line.as_mut() { existing.spans.push(" · ".dim()); existing.spans.push(active_agent_label.clone().dim()); } else { line = Some(Line::from(active_agent_label.clone()).dim()); } } line}/// Whether the current footer mode allows contextual information to replace instructional hints.////// In practice this means the composer is idle, or it has a draft but is not currently running a/// task, so the footer can spend the row on ambient context instead of "what to do next" text.pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> bool { match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => !props.is_task_running, FooterMode::HistorySearch | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }}/// Whether callers should reserve the dedicated status-line layout for a contextual footer row.////// The dedicated layout exists for the configurable `/statusline` row. An agent label by itself/// can be rendered by the standard footer flow, so this only becomes `true` when the status line/// feature is enabled and the current mode allows contextual footer content.pub(crate) fn uses_passive_footer_status_layout(props: &FooterProps) -> bool { props.status_line_enabled && shows_passive_footer_line(props)}pub(crate) fn footer_line_width( props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, show_cycle_hint: bool, show_shortcuts_hint: bool, show_queue_hint: bool,) -> u16 { footer_from_props_lines( props, collaboration_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ) .last() .map(|line| line.width() as u16) .unwrap_or(0)}pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 { if items.is_empty() { return 0; } footer_hint_items_line(items).width() as u16}fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> { let mut spans = Vec::with_capacity(items.len() * 4); for (idx, (key, label)) in items.iter().enumerate() { spans.push(" ".into()); spans.push(key.clone().bold()); spans.push(format!(" {label}").into()); if idx + 1 != items.len() { spans.push(" ".into()); } } Line::from(spans)}#[derive(Clone, Copy, Debug)]struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_task_running: bool, queue_submissions: bool, is_wsl: bool, collaboration_modes_enabled: bool, key_hints: FooterKeyHints,}fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { Line::from(vec![key.into(), " again to quit".into()]).dim()}fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { let esc = key_hint::plain(KeyCode::Esc); if esc_backtrack_hint { Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() } else { 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 = Line::from(""); let mut shell_commands = Line::from(""); let mut newline = Line::from(""); let mut queue_message_tab = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); let mut history_search = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); let mut change_mode = Line::from(""); let mut reasoning_down = Line::from(""); let mut reasoning_up = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { match descriptor.id { ShortcutId::Commands => commands = text, ShortcutId::ShellCommands => shell_commands = text, ShortcutId::InsertNewline => newline = text, ShortcutId::QueueMessageTab => queue_message_tab = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::HistorySearch => history_search = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, ShortcutId::ChangeMode => change_mode = text, ShortcutId::ReasoningDown => reasoning_down = text, ShortcutId::ReasoningUp => reasoning_up = text, } } } let mut ordered = vec![ commands, shell_commands, newline, queue_message_tab, file_paths, paste_image, external_editor, edit_previous, history_search, quit, reasoning_down, reasoning_up, ]; if change_mode.width() > 0 { ordered.push(change_mode); } ordered.push(show_transcript); let mut lines = build_columns(ordered); lines.push(Line::from("")); lines.push(Line::from(vec![ "customize shortcuts with ".into(), "/keymap".cyan(), ])); lines}fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> { if entries.is_empty() { return Vec::new(); } const COLUMNS: usize = 2; const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; const COLUMN_GAP: usize = 4; let rows = entries.len().div_ceil(COLUMNS); let target_len = rows * COLUMNS; let mut entries = entries; if entries.len() < target_len { entries.extend(std::iter::repeat_n( Line::from(""), target_len - entries.len(), )); } let mut column_widths = [0usize; COLUMNS]; for (idx, entry) in entries.iter().enumerate() { let column = idx % COLUMNS; column_widths[column] = column_widths[column].max(entry.width()); } for (idx, width) in column_widths.iter_mut().enumerate() { *width += COLUMN_PADDING[idx]; } entries .chunks(COLUMNS) .map(|chunk| { let mut line = Line::from(""); for (col, entry) in chunk.iter().enumerate() { line.extend(entry.spans.clone()); if col < COLUMNS - 1 { let target_width = column_widths[col]; let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; line.push_span(Span::from(" ".repeat(padding))); } } line.dim() }) .collect()}pub(crate) fn context_window_line(percent: Option<i64>, used_tokens: Option<i64>) -> Line<'static> { if let Some(percent) = percent { let percent = percent.clamp(0, 100); return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); } if let Some(tokens) = used_tokens { let used_fmt = format_tokens_compact(tokens); return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); } Line::from(vec![Span::from("100% context left").dim()])}#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum ShortcutId { Commands, ShellCommands, InsertNewline, QueueMessageTab, FilePaths, PasteImage, ExternalEditor, EditPrevious, HistorySearch, Quit, ShowTranscript, ChangeMode, ReasoningDown, ReasoningUp,}#[derive(Clone, Copy, Debug, Eq, PartialEq)]struct ShortcutBinding { key: KeyBinding, condition: DisplayCondition,}impl ShortcutBinding { fn matches(&self, state: ShortcutsState) -> bool { self.condition.matches(state) }}#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum DisplayCondition { Always, WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, WhenCollaborationModesEnabled,}impl DisplayCondition { fn matches(self, state: ShortcutsState) -> bool { match self { DisplayCondition::Always => true, DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } }}struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], prefix: &'static str, label: &'static str,}impl ShortcutDescriptor { fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { self.bindings.iter().find(|binding| binding.matches(state)) } fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> { let key = match self.id { ShortcutId::InsertNewline => state.key_hints.insert_newline, ShortcutId::QueueMessageTab => state.key_hints.queue, ShortcutId::ExternalEditor => state.key_hints.external_editor, ShortcutId::EditPrevious => state.key_hints.edit_previous, ShortcutId::ShowTranscript => state.key_hints.show_transcript, ShortcutId::HistorySearch => state.key_hints.history_search, ShortcutId::ReasoningDown => state.key_hints.reasoning_down, ShortcutId::ReasoningUp => state.key_hints.reasoning_up, ShortcutId::Commands | ShortcutId::ShellCommands | ShortcutId::FilePaths | ShortcutId::PasteImage | ShortcutId::Quit | ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key), }?; let mut line = Line::from(vec![self.prefix.into(), key.into()]); match self.id { ShortcutId::QueueMessageTab => { if state.is_task_running || state.queue_submissions { line.push_span(" to queue message"); } else { line.push_span(" to submit message"); } } ShortcutId::EditPrevious => { if state.esc_backtrack_hint { line.push_span(" again to edit previous message"); } else { line.extend(vec![ " ".into(), key.into(), " to edit previous message".into(), ]); } } ShortcutId::Quit => { if state.is_task_running { line.push_span(" to interrupt"); } else { line.push_span(" to exit"); } } _ => line.push_span(self.label), }; Some(line) }}const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::Commands, bindings: &[ShortcutBinding { key: key_hint::plain(KeyCode::Char('/')), condition: DisplayCondition::Always, }], prefix: "", label: " for commands", }, ShortcutDescriptor { id: ShortcutId::ShellCommands, bindings: &[ShortcutBinding { key: key_hint::plain(KeyCode::Char('!')), condition: DisplayCondition::Always, }], prefix: "", label: " for shell commands", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, bindings: &[ ShortcutBinding { key: key_hint::shift(KeyCode::Enter), condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('j')), condition: DisplayCondition::WhenNotShiftEnterHint, }, ], prefix: "", label: " for newline", }, ShortcutDescriptor { id: ShortcutId::QueueMessageTab, bindings: &[ShortcutBinding { key: key_hint::plain(KeyCode::Tab), condition: DisplayCondition::Always, }], prefix: "", label: " to queue message", }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { key: key_hint::plain(KeyCode::Char('@')), condition: DisplayCondition::Always, }], prefix: "", label: " for file paths", }, ShortcutDescriptor { id: ShortcutId::PasteImage, // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain // Ctrl+V); otherwise fall back to Ctrl+V. bindings: &[ ShortcutBinding { key: key_hint::ctrl_alt(KeyCode::Char('v')), condition: DisplayCondition::WhenUnderWSL, }, ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('v')), condition: DisplayCondition::Always, }, ], prefix: "", label: " to paste images", }, ShortcutDescriptor { id: ShortcutId::ExternalEditor, bindings: &[ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('g')), condition: DisplayCondition::Always, }], prefix: "", label: " to edit in external editor", }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { key: key_hint::plain(KeyCode::Esc), condition: DisplayCondition::Always, }], prefix: "", label: "", }, ShortcutDescriptor { id: ShortcutId::HistorySearch, bindings: &[ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('r')), condition: DisplayCondition::Always, }], prefix: "", label: " search history", }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('c')), condition: DisplayCondition::Always, }], prefix: "", label: " to exit", }, ShortcutDescriptor { id: ShortcutId::ShowTranscript, bindings: &[ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('t')), condition: DisplayCondition::Always, }], prefix: "", label: " to view transcript", }, ShortcutDescriptor { id: ShortcutId::ChangeMode, bindings: &[ShortcutBinding { key: key_hint::shift(KeyCode::Tab), condition: DisplayCondition::WhenCollaborationModesEnabled, }], prefix: "", label: " to change mode", }, ShortcutDescriptor { id: ShortcutId::ReasoningDown, bindings: &[ShortcutBinding { key: key_hint::alt(KeyCode::Char(',')), condition: DisplayCondition::Always, }], prefix: "", label: " reasoning down", }, ShortcutDescriptor { id: ShortcutId::ReasoningUp, bindings: &[ShortcutBinding { key: key_hint::alt(KeyCode::Char('.')), condition: DisplayCondition::Always, }], prefix: "", label: " reasoning up", },];#[cfg(test)]mod tests { use super::*; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::test_backend::VT100Backend; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::Terminal; use ratatui::backend::Backend; use ratatui::backend::TestBackend; fn snapshot_footer(name: &str, props: FooterProps) { snapshot_footer_with_mode_indicator( name, /*width*/ 80, &props, /*collaboration_mode_indicator*/ None, ); } fn snapshot_footer_with_context( name: &str, props: FooterProps, percent: Option<i64>, used_tokens: Option<i64>, ) { snapshot_footer_with_mode_indicator_and_context( name, /*width*/ 80, &props, /*collaboration_mode_indicator*/ None, context_window_line(percent, used_tokens), ); } fn draw_footer_frame<B: Backend>( terminal: &mut Terminal<B>, height: u16, props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, ide_context_active: bool, context_line: Line<'static>, ) { terminal .draw(|f| { let area = Rect::new(0, 0, f.area().width, height); let show_cycle_hint = !props.is_task_running; let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => false, FooterMode::HistorySearch | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; let show_queue_hint = match props.mode { FooterMode::ComposerHasDraft => props.is_task_running, FooterMode::HistorySearch | FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; let status_line_active = uses_passive_footer_status_layout(props); let passive_status_line = if status_line_active { passive_footer_status_line(props) } else { None }; let left_mode_indicator = if status_line_active { None } else { collaboration_mode_indicator }; let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; let mut truncated_status_line = if status_line_active && matches!( props.mode, FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft ) { passive_status_line.as_ref().map(|line| { truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) }) } else { None }; let mut left_width = if status_line_active { truncated_status_line .as_ref() .map(|line| line.width() as u16) .unwrap_or(0) } else { footer_line_width( props, left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ) }; let right_line = if status_line_active { let full = status_line_right_indicator_line( collaboration_mode_indicator, /*goal_status_indicator*/ None, ide_context_active, show_cycle_hint, ); let compact = status_line_right_indicator_line( collaboration_mode_indicator, /*goal_status_indicator*/ None, ide_context_active, /*show_cycle_hint*/ false, ); let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0); if can_show_left_with_context(area, left_width, full_width) { full } else { compact } } else { Some(context_line.clone()) }; let right_width = right_line .as_ref() .map(|line| line.width() as u16) .unwrap_or(0); if status_line_active && let Some(max_left) = max_left_width_for_right(area, right_width) && left_width > max_left && let Some(line) = passive_status_line.as_ref().map(|line| { truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) }) { left_width = line.width() as u16; truncated_status_line = Some(line); } let can_show_left_and_context = can_show_left_with_context(area, left_width, right_width); if matches!( props.mode, FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft ) { if status_line_active { if let Some(line) = truncated_status_line.clone() { render_footer_line(area, f.buffer_mut(), line); } if can_show_left_and_context && let Some(line) = &right_line { render_context_right(area, f.buffer_mut(), line); } } else { let (summary_left, show_context) = single_line_footer_layout( area, right_width, left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, props.key_hints, ); match summary_left { SummaryLeft::Default => { render_footer_from_props( area, f.buffer_mut(), props, left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ); } SummaryLeft::Custom(line) => { render_footer_line(area, f.buffer_mut(), line); } SummaryLeft::None => {} } if show_context && let Some(line) = &right_line { render_context_right(area, f.buffer_mut(), line); } } } else { render_footer_from_props( area, f.buffer_mut(), props, left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ); let show_context = can_show_left_and_context && !matches!( props.mode, FooterMode::EscHint | FooterMode::HistorySearch | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay ); if show_context && let Some(line) = &right_line { render_context_right(area, f.buffer_mut(), line); } } }) .unwrap(); } fn snapshot_footer_with_mode_indicator( name: &str, width: u16, props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, ) { snapshot_footer_with_mode_indicator_and_context( name, width, props, collaboration_mode_indicator, context_window_line(/*percent*/ None, /*used_tokens*/ None), ); } fn snapshot_footer_with_mode_indicator_and_context( name: &str, width: u16, props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, context_line: Line<'static>, ) { let height = footer_height(props).max(1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); draw_footer_frame( &mut terminal, height, props, collaboration_mode_indicator, /*ide_context_active*/ false, context_line, ); assert_snapshot!(name, terminal.backend()); } fn render_footer_with_mode_indicator_and_context( width: u16, props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, context_line: Line<'static>, ) -> String { let height = footer_height(props).max(1); let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); draw_footer_frame( &mut terminal, height, props, collaboration_mode_indicator, /*ide_context_active*/ false, context_line, ); terminal.backend().vt100().screen().contents() } fn snapshot_footer_with_indicators( name: &str, width: u16, props: &FooterProps, collaboration_mode_indicator: Option<CollaborationModeIndicator>, ide_context_active: bool, ) { let height = footer_height(props).max(1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); draw_footer_frame( &mut terminal, height, props, collaboration_mode_indicator, ide_context_active, context_window_line(/*percent*/ None, /*used_tokens*/ None), ); assert_snapshot!(name, terminal.backend()); } #[test] fn footer_snapshots() { snapshot_footer( "footer_shortcuts_default", FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: true, use_shift_enter_hint: true, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints { insert_newline: Some(key_hint::shift(KeyCode::Enter)), ..FooterKeyHints::default_bindings() }, active_agent_label: None, }, ); snapshot_footer( "footer_shortcuts_collaboration_modes_enabled", FooterProps { mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer( "footer_shortcuts_running", FooterProps { mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { mode: FooterMode::QuitShortcutReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { mode: FooterMode::QuitShortcutReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer( "footer_esc_hint_idle", FooterProps { mode: FooterMode::EscHint, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer( "footer_esc_hint_primed", FooterProps { mode: FooterMode::EscHint, esc_backtrack_hint: true, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); snapshot_footer_with_context( "footer_shortcuts_context_running", FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, Some(72), /*used_tokens*/ None, ); snapshot_footer_with_context( "footer_context_tokens_used", FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, /*percent*/ None, Some(123_456), ); snapshot_footer( "footer_composer_has_draft_queue_hint_enabled", FooterProps { mode: FooterMode::ComposerHasDraft, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer_with_mode_indicator( "footer_mode_indicator_wide", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), ); snapshot_footer_with_mode_indicator( "footer_mode_indicator_narrow_overlap_hides", /*width*/ 50, &props, Some(CollaborationModeIndicator::Plan), ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer_with_mode_indicator( "footer_mode_indicator_running_hides_hint", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer("footer_status_line_overrides_shortcuts", props); let props = FooterProps { mode: FooterMode::ComposerHasDraft, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer("footer_status_line_yields_to_queue_hint", props); let props = FooterProps { mode: FooterMode::ComposerHasDraft, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer("footer_status_line_overrides_draft_idle", props); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, // command timed out / empty status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer_with_mode_indicator_and_context( "footer_status_line_enabled_mode_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), context_window_line(Some(50), /*used_tokens*/ None), ); snapshot_footer_with_indicators( "footer_status_line_enabled_mode_and_ide_context_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), /*ide_context_active*/ true, ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer_with_mode_indicator_and_context( "footer_status_line_disabled_context_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; // has status line and no collaboration mode snapshot_footer_with_mode_indicator_and_context( "footer_status_line_enabled_no_mode_right", /*width*/ 120, &props, /*collaboration_mode_indicator*/ None, context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from( "Status line content that should truncate before the mode indicator".to_string(), )), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; snapshot_footer_with_mode_indicator_and_context( "footer_status_line_truncated_with_gap", /*width*/ 40, &props, Some(CollaborationModeIndicator::Plan), context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, key_hints: FooterKeyHints::default_bindings(), active_agent_label: Some("Robie [explorer]".to_string()), }; snapshot_footer("footer_active_agent_label", props); let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: Some("Robie [explorer]".to_string()), }; snapshot_footer("footer_status_line_with_active_agent_label", props); } #[test] fn footer_status_line_truncates_to_keep_mode_indicator() { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from( "Status line content that is definitely too long to fit alongside the mode label" .to_string(), )), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; let screen = render_footer_with_mode_indicator_and_context( /*width*/ 80, &props, Some(CollaborationModeIndicator::Plan), context_window_line(Some(50), /*used_tokens*/ None), ); let collapsed = screen.split_whitespace().collect::<Vec<_>>().join(" "); assert!( collapsed.contains("Plan mode"), "mode indicator should remain visible" ); assert!( !collapsed.contains("shift+tab to cycle"), "compact mode indicator should be used when space is tight" ); assert!( screen.contains('…'), "status line should be truncated with ellipsis to keep mode indicator" ); } #[test] fn paste_image_shortcut_prefers_ctrl_alt_v_under_wsl() { let descriptor = SHORTCUTS .iter() .find(|descriptor| descriptor.id == ShortcutId::PasteImage) .expect("paste image shortcut"); let is_wsl = { #[cfg(target_os = "linux")] { crate::clipboard_paste::is_probably_wsl() } #[cfg(not(target_os = "linux"))] { false } }; let expected_key = if is_wsl { key_hint::ctrl_alt(KeyCode::Char('v')) } else { key_hint::ctrl(KeyCode::Char('v')) }; let actual_key = descriptor .binding_for(ShortcutsState { use_shift_enter_hint: false, esc_backtrack_hint: false, is_task_running: false, queue_submissions: false, is_wsl, collaboration_modes_enabled: false, key_hints: FooterKeyHints::default_bindings(), }) .expect("shortcut binding") .key; assert_eq!(actual_key, expected_key); }}