mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Users have asked for a `/ide` command in the TUI so Codex can use the active IDE session for live context such as the current file, open tabs, and selected ranges. We already support a similar feature in the Codex desktop app, so bringing it to the TUI makes sense. One subtle compatibility constraint is that the injected prompt wrapper and transcript stripping should match the desktop app and IDE extension. By using the same `## My request for Codex:` delimiter and hiding the injected context from transcript rendering the same way, threads created in the TUI render correctly in desktop and IDE surfaces, and threads created there replay correctly in the TUI, even when IDE context was included. Addresses https://github.com/openai/codex/issues/13834. ## What changed ### Summary This PR consists of four four pieces: 1. An IPC client that uses a socket (Mac/Linux) or named pipe (Windows) to talk to the IDE Extension 2. Logic that establishes the IPC connection and requests IDE context (open files, selection) on demand 3. Logic that injects this context into the user prompt (using the same technique as the desktop app) and hides the added context when rendering the prompt in the TUI transcript 4. A new slash command for enabling/disabling this mode and text within the footer to indicate when it's enabled ### Details - Added `/ide [on|off|status]` to the TUI, with bare `/ide` toggling IDE context on or off. - Added a Rust IDE context client that connects to the local Codex IDE IPC route as a client and requests context from the IDE extension flow. - Injected IDE context using the same prompt delimiter and transcript-stripping convention as the desktop app and IDE extension so shared threads render consistently across surfaces. - Added an `IDE context` status-line indicator while the feature is active and cleared it when enabling or fetching context fails. - Added handling for multiple selection ranges, oversized selections, interleaved IPC messages, and transient reconnect timing after quick toggles. ## Verification Did extensive manual testing in addition to running automated unit and regression tests. To test: - Launch VS Code (or Cursor) with the IDE extension. - Open one or more files in the IDE and select a range of text within one of them. - Start the TUI. - Ask the agent which files you have open in your IDE, and it should say that it does not know. - Enable `/ide` mode; note that `IDE context` appears in the lower right. - Ask the agent what files you have open in your IDE and what text is selected.
2018 lines
71 KiB
Rust
2018 lines
71 KiB
Rust
//! 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) 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,
|
|
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::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_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_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::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(),
|
|
]);
|
|
}
|
|
}
|
|
_ => 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,
|
|
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,
|
|
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,
|
|
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_ctrl_c_quit_idle",
|
|
FooterProps {
|
|
mode: FooterMode::QuitShortcutReminder,
|
|
esc_backtrack_hint: false,
|
|
use_shift_enter_hint: false,
|
|
is_task_running: 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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_wsl,
|
|
collaboration_modes_enabled: false,
|
|
key_hints: FooterKeyHints::default_bindings(),
|
|
})
|
|
.expect("shortcut binding")
|
|
.key;
|
|
|
|
assert_eq!(actual_key, expected_key);
|
|
}
|
|
}
|