mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
TUI footer: right-align context and degrade shortcut summary + mode cleanly (#9944)
## Summary Refines the bottom footer layout to keep `% context left` right-aligned while making the left side degrade cleanly ## Behavior with empty textarea Full width: <img width="607" height="62" alt="Screenshot 2026-01-26 at 2 59 59 PM" src="https://github.com/user-attachments/assets/854f33b7-d714-40be-8840-a52eb3bda442" /> Less: <img width="412" height="66" alt="Screenshot 2026-01-26 at 2 59 48 PM" src="https://github.com/user-attachments/assets/9c501788-c3a2-4b34-8f0b-8ec4395b44fe" /> Min width: <img width="218" height="77" alt="Screenshot 2026-01-26 at 2 59 33 PM" src="https://github.com/user-attachments/assets/0bed2385-bdbf-4254-8ae4-ab3452243628" /> ## Behavior with message in textarea and agent running (steer enabled) Full width: <img width="753" height="63" alt="Screenshot 2026-01-26 at 4 33 54 PM" src="https://github.com/user-attachments/assets/1856b352-914a-44cf-813d-1cb50c7f183b" /> Less: <img width="353" height="61" alt="Screenshot 2026-01-26 at 4 30 12 PM" src="https://github.com/user-attachments/assets/d951c4d5-f3e7-4116-8fe1-6a6c712b3d48" /> Less: <img width="304" height="64" alt="Screenshot 2026-01-26 at 4 30 51 PM" src="https://github.com/user-attachments/assets/1433e994-5cbc-4e20-a98a-79eee13c8699" /> Less: <img width="235" height="61" alt="Screenshot 2026-01-26 at 4 30 56 PM" src="https://github.com/user-attachments/assets/e216c3c6-84cd-40fc-ae4d-83bf28947f0e" /> Less: <img width="165" height="59" alt="Screenshot 2026-01-26 at 4 31 08 PM" src="https://github.com/user-attachments/assets/027de5de-7185-47ce-b1cc-5363ea33d9b1" /> ## Notes / Edge Cases - In steer mode while typing, the queue hint no longer replaces the mode label; it renders as `tab to queue message · {Mode}`. - Collapse priorities differ by state: - With the queue hint active, `% context left` is hidden before shortening or dropping the queue hint. - In the empty + non-running state, `? for shortcuts` is dropped first, and `% context left` is only shown if `(shift+tab to cycle)` can also fit. - Transient instructional states (`?` overlay, Esc hint, Ctrl+C/D reminders, and flash/override hints) intentionally suppress the mode label (and context) to focus the next action. ## Implementation Notes - Renamed the base footer modes to make the state explicit: `ComposerEmpty` and `ComposerHasDraft`, and compute the base mode directly from emptiness. - Unified collapse behavior in `single_line_footer_layout` for both base modes, with: - Queue-hint behavior that prefers keeping the queue hint over context. - A cycle-hint guard that prevents context from reappearing after `(shift+tab to cycle)` is dropped. - Kept rendering responsibilities explicit: - `single_line_footer_layout` decides what fits. - `render_footer_line` renders a chosen line. - `render_footer_from_props` renders the canonical mode-to-text mapping. - Expanded snapshot coverage: - Added `footer_collapse_snapshots` in `chat_composer.rs` to lock the distinct collapse states across widths. - Consolidated the width-aware snapshot helper usage (e.g., `snapshot_composer_state_with_width`, `snapshot_footer_with_mode_indicator`).
This commit is contained in:
committed by
GitHub
parent
067922a734
commit
538e1059a3
@@ -96,15 +96,20 @@ use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::CollaborationModeIndicator;
|
||||
use super::footer::FooterMode;
|
||||
use super::footer::FooterProps;
|
||||
use super::footer::SummaryLeft;
|
||||
use super::footer::can_show_left_with_context;
|
||||
use super::footer::context_window_line;
|
||||
use super::footer::esc_hint_mode;
|
||||
use super::footer::footer_height;
|
||||
use super::footer::footer_hint_items_width;
|
||||
use super::footer::footer_line_width;
|
||||
use super::footer::inset_footer_hint_area;
|
||||
use super::footer::render_footer;
|
||||
use super::footer::render_context_right;
|
||||
use super::footer::render_footer_from_props;
|
||||
use super::footer::render_footer_hint_items;
|
||||
use super::footer::render_mode_indicator;
|
||||
use super::footer::render_footer_line;
|
||||
use super::footer::reset_mode_after_activity;
|
||||
use super::footer::single_line_footer_layout;
|
||||
use super::footer::toggle_shortcut_mode;
|
||||
use super::paste_burst::CharDecision;
|
||||
use super::paste_burst::PasteBurst;
|
||||
@@ -341,7 +346,7 @@ impl ChatComposer {
|
||||
paste_burst: PasteBurst::default(),
|
||||
disable_paste_burst: false,
|
||||
custom_prompts: Vec::new(),
|
||||
footer_mode: FooterMode::ShortcutSummary,
|
||||
footer_mode: FooterMode::ComposerEmpty,
|
||||
footer_hint_override: None,
|
||||
footer_flash: None,
|
||||
context_window_percent: None,
|
||||
@@ -2234,7 +2239,11 @@ impl ChatComposer {
|
||||
return false;
|
||||
}
|
||||
|
||||
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
|
||||
let next = toggle_shortcut_mode(
|
||||
self.footer_mode,
|
||||
self.quit_shortcut_hint_visible(),
|
||||
self.is_empty(),
|
||||
);
|
||||
let changed = next != self.footer_mode;
|
||||
self.footer_mode = next;
|
||||
changed
|
||||
@@ -2254,19 +2263,32 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective footer mode via a small priority waterfall.
|
||||
///
|
||||
/// The base mode is derived solely from whether the composer is empty:
|
||||
/// `ComposerEmpty` iff empty, otherwise `ComposerHasDraft`. Transient
|
||||
/// modes (Esc hint, overlay, quit reminder) can override that base when
|
||||
/// their conditions are active.
|
||||
fn footer_mode(&self) -> FooterMode {
|
||||
let base_mode = if self.is_empty() {
|
||||
FooterMode::ComposerEmpty
|
||||
} else {
|
||||
FooterMode::ComposerHasDraft
|
||||
};
|
||||
|
||||
match self.footer_mode {
|
||||
FooterMode::EscHint => FooterMode::EscHint,
|
||||
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
|
||||
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
|
||||
FooterMode::QuitShortcutReminder
|
||||
}
|
||||
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
|
||||
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
if self.quit_shortcut_hint_visible() =>
|
||||
{
|
||||
FooterMode::QuitShortcutReminder
|
||||
}
|
||||
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
|
||||
other => other,
|
||||
FooterMode::QuitShortcutReminder => base_mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => base_mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2584,6 +2606,28 @@ impl Renderable for ChatComposer {
|
||||
}
|
||||
ActivePopup::None => {
|
||||
let footer_props = self.footer_props();
|
||||
let show_cycle_hint = !footer_props.is_task_running;
|
||||
let show_shortcuts_hint = match footer_props.mode {
|
||||
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
};
|
||||
let show_queue_hint = match footer_props.mode {
|
||||
FooterMode::ComposerHasDraft => {
|
||||
footer_props.is_task_running && footer_props.steer_enabled
|
||||
}
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ComposerEmpty
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let context_line = context_window_line(
|
||||
footer_props.context_window_percent,
|
||||
footer_props.context_window_used_tokens,
|
||||
);
|
||||
let context_width = context_line.width() as u16;
|
||||
let custom_height = self.custom_footer_height();
|
||||
let footer_hint_height =
|
||||
custom_height.unwrap_or_else(|| footer_height(footer_props));
|
||||
@@ -2598,26 +2642,102 @@ impl Renderable for ChatComposer {
|
||||
} else {
|
||||
popup_rect
|
||||
};
|
||||
let mut left_content_width = None;
|
||||
if self.footer_flash_visible() {
|
||||
let left_width = if self.footer_flash_visible() {
|
||||
self.footer_flash
|
||||
.as_ref()
|
||||
.map(|flash| flash.line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
footer_hint_items_width(items)
|
||||
} else {
|
||||
footer_line_width(
|
||||
footer_props,
|
||||
self.collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
)
|
||||
};
|
||||
let can_show_left_and_context =
|
||||
can_show_left_with_context(hint_rect, left_width, context_width);
|
||||
let has_override =
|
||||
self.footer_flash_visible() || self.footer_hint_override.is_some();
|
||||
let single_line_layout = if has_override {
|
||||
None
|
||||
} else {
|
||||
match footer_props.mode {
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => {
|
||||
// Both of these modes render the single-line footer style (with
|
||||
// either the shortcuts hint or the optional queue hint). We still
|
||||
// want the single-line collapse rules so the mode label can win over
|
||||
// the context indicator on narrow widths.
|
||||
Some(single_line_footer_layout(
|
||||
hint_rect,
|
||||
context_width,
|
||||
self.collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
))
|
||||
}
|
||||
FooterMode::EscHint
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay => None,
|
||||
}
|
||||
};
|
||||
let show_context = if matches!(
|
||||
footer_props.mode,
|
||||
FooterMode::EscHint
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
single_line_layout
|
||||
.as_ref()
|
||||
.map(|(_, show_context)| *show_context)
|
||||
.unwrap_or(can_show_left_and_context)
|
||||
};
|
||||
|
||||
if let Some((summary_left, _)) = single_line_layout {
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
buf,
|
||||
footer_props,
|
||||
self.collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
SummaryLeft::Custom(line) => {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
}
|
||||
} else if self.footer_flash_visible() {
|
||||
if let Some(flash) = self.footer_flash.as_ref() {
|
||||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||||
left_content_width = Some(flash.line.width() as u16);
|
||||
}
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
render_footer_hint_items(hint_rect, buf, items);
|
||||
left_content_width = Some(footer_hint_items_width(items));
|
||||
} else {
|
||||
render_footer(hint_rect, buf, footer_props);
|
||||
left_content_width = Some(footer_line_width(footer_props));
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
buf,
|
||||
footer_props,
|
||||
self.collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
|
||||
if show_context {
|
||||
render_context_right(hint_rect, buf, &context_line);
|
||||
}
|
||||
render_mode_indicator(
|
||||
hint_rect,
|
||||
buf,
|
||||
self.collaboration_mode_indicator,
|
||||
!footer_props.is_task_running,
|
||||
left_content_width,
|
||||
);
|
||||
}
|
||||
}
|
||||
let style = user_message_style();
|
||||
@@ -2647,8 +2767,11 @@ impl Renderable for ChatComposer {
|
||||
.unwrap_or("Input disabled.")
|
||||
.to_string()
|
||||
};
|
||||
let placeholder = Span::from(text).dim();
|
||||
Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
if !textarea_rect.is_empty() {
|
||||
let placeholder = Span::from(text).dim();
|
||||
Line::from(vec![placeholder])
|
||||
.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2861,14 +2984,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||||
where
|
||||
fn snapshot_composer_state_with_width<F>(
|
||||
name: &str,
|
||||
width: u16,
|
||||
enhanced_keys_supported: bool,
|
||||
setup: F,
|
||||
) where
|
||||
F: FnOnce(&mut ChatComposer),
|
||||
{
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let width = 100;
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -2890,6 +3016,13 @@ mod tests {
|
||||
insta::assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||||
where
|
||||
F: FnOnce(&mut ChatComposer),
|
||||
{
|
||||
snapshot_composer_state_with_width(name, 100, enhanced_keys_supported, setup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_mode_snapshots() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -2942,6 +3075,100 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_collapse_snapshots() {
|
||||
fn setup_collab_footer(
|
||||
composer: &mut ChatComposer,
|
||||
context_percent: i64,
|
||||
indicator: CollaborationModeIndicator,
|
||||
) {
|
||||
composer.set_collaboration_modes_enabled(true);
|
||||
composer.set_collaboration_mode_indicator(Some(indicator));
|
||||
composer.set_context_window(Some(context_percent), None);
|
||||
}
|
||||
|
||||
// Empty textarea, agent idle: shortcuts hint can show, and cycle hint is available.
|
||||
snapshot_composer_state_with_width("footer_collapse_empty_full", 120, true, |composer| {
|
||||
setup_collab_footer(composer, 100, CollaborationModeIndicator::Code);
|
||||
});
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_empty_mode_cycle_with_context",
|
||||
60,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 100, CollaborationModeIndicator::Code);
|
||||
},
|
||||
);
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_empty_mode_cycle_without_context",
|
||||
44,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 100, CollaborationModeIndicator::Code);
|
||||
},
|
||||
);
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_empty_mode_only",
|
||||
26,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 100, CollaborationModeIndicator::Code);
|
||||
},
|
||||
);
|
||||
|
||||
// Textarea has content, agent running, steer enabled: queue hint is shown.
|
||||
snapshot_composer_state_with_width("footer_collapse_queue_full", 120, true, |composer| {
|
||||
setup_collab_footer(composer, 98, CollaborationModeIndicator::Code);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_task_running(true);
|
||||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||||
});
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_queue_short_with_context",
|
||||
50,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 98, CollaborationModeIndicator::Code);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_task_running(true);
|
||||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||||
},
|
||||
);
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_queue_message_without_context",
|
||||
40,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 98, CollaborationModeIndicator::Code);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_task_running(true);
|
||||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||||
},
|
||||
);
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_queue_short_without_context",
|
||||
30,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 98, CollaborationModeIndicator::Code);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_task_running(true);
|
||||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||||
},
|
||||
);
|
||||
snapshot_composer_state_with_width(
|
||||
"footer_collapse_queue_mode_only",
|
||||
20,
|
||||
true,
|
||||
|composer| {
|
||||
setup_collab_footer(composer, 98, CollaborationModeIndicator::Code);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_task_running(true);
|
||||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_hint_stays_hidden_with_draft_content() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -2962,15 +3189,40 @@ mod tests {
|
||||
|
||||
assert!(!composer.is_empty());
|
||||
assert_eq!(composer.current_text(), "d");
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
|
||||
assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty);
|
||||
assert!(matches!(composer.active_popup, ActivePopup::None));
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
|
||||
assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty);
|
||||
assert!(!composer.esc_backtrack_hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_footer_mode_tracks_empty_state_after_quit_hint_expires() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['d']);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
composer.quit_shortcut_expires_at =
|
||||
Some(Instant::now() - std::time::Duration::from_secs(1));
|
||||
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
|
||||
|
||||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ComposerEmpty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_for_ctrl_c_records_cleared_draft() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -3031,11 +3283,11 @@ mod tests {
|
||||
|
||||
// Toggle back to prompt mode so subsequent typing captures characters.
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
|
||||
assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['h']);
|
||||
assert_eq!(composer.textarea.text(), "h");
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
|
||||
|
||||
let (result, needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
@@ -3043,8 +3295,8 @@ mod tests {
|
||||
assert!(needs_redraw, "typing should still mark the view dirty");
|
||||
let _ = flush_after_paste_burst(&mut composer);
|
||||
assert_eq!(composer.textarea.text(), "h?");
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
|
||||
assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty);
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
|
||||
}
|
||||
|
||||
/// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut
|
||||
|
||||
@@ -8,6 +8,30 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::clipboard_paste::is_probably_wsl;
|
||||
use crate::key_hint;
|
||||
@@ -27,9 +51,10 @@ 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 `render_footer`. 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).
|
||||
/// `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, Copy, Debug)]
|
||||
pub(crate) struct FooterProps {
|
||||
pub(crate) mode: FooterMode,
|
||||
@@ -77,7 +102,7 @@ impl CollaborationModeIndicator {
|
||||
let label = self.label(show_cycle_hint);
|
||||
match self {
|
||||
CollaborationModeIndicator::Plan => Span::from(label).magenta(),
|
||||
CollaborationModeIndicator::Code => Span::from(label).cyan(),
|
||||
CollaborationModeIndicator::Code => Span::from(label).dim(),
|
||||
CollaborationModeIndicator::PairProgramming => Span::from(label).cyan(),
|
||||
CollaborationModeIndicator::Execute => Span::from(label).dim(),
|
||||
}
|
||||
@@ -92,21 +117,36 @@ impl CollaborationModeIndicator {
|
||||
pub(crate) enum FooterMode {
|
||||
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
|
||||
QuitShortcutReminder,
|
||||
ShortcutSummary,
|
||||
/// Multi-line shortcut overlay shown after pressing `?`.
|
||||
ShortcutOverlay,
|
||||
/// Transient "press Esc again" hint shown after the first Esc while idle.
|
||||
EscHint,
|
||||
ContextOnly,
|
||||
/// 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 with
|
||||
/// steer enabled, this mode can show the queue hint instead.
|
||||
ComposerHasDraft,
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
|
||||
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 => {
|
||||
FooterMode::ShortcutSummary
|
||||
}
|
||||
FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => base_mode,
|
||||
_ => FooterMode::ShortcutOverlay,
|
||||
}
|
||||
}
|
||||
@@ -124,57 +164,347 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
||||
FooterMode::EscHint
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
|
||||
| FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn footer_height(props: FooterProps) -> u16 {
|
||||
footer_lines(props).len() as u16
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running && props.steer_enabled,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ComposerEmpty
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
footer_from_props_lines(props, None, false, show_shortcuts_hint, show_queue_hint).len() as u16
|
||||
}
|
||||
|
||||
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
|
||||
/// Render a single precomputed footer line.
|
||||
pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'static>) {
|
||||
Paragraph::new(prefix_lines(
|
||||
footer_lines(props),
|
||||
vec![line],
|
||||
" ".repeat(FOOTER_INDENT_COLS).into(),
|
||||
" ".repeat(FOOTER_INDENT_COLS).into(),
|
||||
))
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn render_mode_indicator(
|
||||
/// 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,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
props: FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
show_cycle_hint: bool,
|
||||
left_content_width: Option<u16>,
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
) {
|
||||
let Some(indicator) = indicator else {
|
||||
return;
|
||||
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,
|
||||
) -> Line<'static> {
|
||||
let mut line = Line::from("");
|
||||
match state.hint {
|
||||
SummaryHintKind::None => {}
|
||||
SummaryHintKind::Shortcuts => {
|
||||
line.push_span(key_hint::plain(KeyCode::Char('?')));
|
||||
line.push_span(" for shortcuts".dim());
|
||||
}
|
||||
SummaryHintKind::QueueMessage => {
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue message".dim());
|
||||
}
|
||||
SummaryHintKind::QueueShort => {
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
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,
|
||||
) -> (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);
|
||||
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)
|
||||
}
|
||||
};
|
||||
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).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,
|
||||
)),
|
||||
true, // show_context
|
||||
);
|
||||
}
|
||||
if left_fits(area, mode_only_width) {
|
||||
return (
|
||||
SummaryLeft::Custom(left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
)),
|
||||
false, // show_context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(SummaryLeft::None, true)
|
||||
}
|
||||
|
||||
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 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;
|
||||
};
|
||||
let left_extent = FOOTER_INDENT_COLS as u16 + left_width;
|
||||
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 span = indicator.styled_span(show_cycle_hint);
|
||||
let label_width = span.width() as u16;
|
||||
if label_width == 0 || label_width > area.width {
|
||||
let context_width = line.width() as u16;
|
||||
let Some(mut x) = right_aligned_x(area, context_width) else {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = area
|
||||
.x
|
||||
.saturating_add(area.width)
|
||||
.saturating_sub(label_width)
|
||||
.saturating_sub(FOOTER_INDENT_COLS as u16);
|
||||
};
|
||||
let y = area.y + area.height.saturating_sub(1);
|
||||
if let Some(left_content_width) = left_content_width {
|
||||
let left_extent = FOOTER_INDENT_COLS as u16 + left_content_width;
|
||||
if left_extent >= x.saturating_sub(area.x) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
buf.set_span(x, y, &span, label_width);
|
||||
}
|
||||
|
||||
pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect {
|
||||
@@ -193,26 +523,35 @@ pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(S
|
||||
footer_hint_items_line(items).render(inset_footer_hint_area(area), buf);
|
||||
}
|
||||
|
||||
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
// Show the context indicator on the left, appended after the primary hint
|
||||
// (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when
|
||||
// the shortcut hint is hidden). Hide it only for the multi-line
|
||||
// ShortcutOverlay.
|
||||
/// 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>> {
|
||||
match props.mode {
|
||||
FooterMode::QuitShortcutReminder => {
|
||||
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
|
||||
}
|
||||
FooterMode::ShortcutSummary => {
|
||||
let mut line = context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
);
|
||||
line.push_span(" · ".dim());
|
||||
line.extend(vec![
|
||||
key_hint::plain(KeyCode::Char('?')).into(),
|
||||
" for shortcuts".dim(),
|
||||
]);
|
||||
vec![line]
|
||||
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)]
|
||||
}
|
||||
FooterMode::ShortcutOverlay => {
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -229,26 +568,37 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
shortcut_overlay_lines(state)
|
||||
}
|
||||
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
|
||||
FooterMode::ContextOnly => {
|
||||
let mut line = context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
);
|
||||
if props.is_task_running && props.steer_enabled {
|
||||
line.push_span(" · ".dim());
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue message".dim());
|
||||
}
|
||||
vec![line]
|
||||
FooterMode::ComposerHasDraft => {
|
||||
let state = LeftSideState {
|
||||
hint: if show_queue_hint {
|
||||
SummaryHintKind::QueueMessage
|
||||
} else {
|
||||
SummaryHintKind::None
|
||||
},
|
||||
show_cycle_hint,
|
||||
};
|
||||
vec![left_side_line(collaboration_mode_indicator, state)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn footer_line_width(props: FooterProps) -> u16 {
|
||||
footer_lines(props)
|
||||
.last()
|
||||
.map(|line| line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
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 {
|
||||
@@ -396,7 +746,7 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn context_window_line(percent: Option<i64>, used_tokens: Option<i64>) -> Line<'static> {
|
||||
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()]);
|
||||
@@ -615,36 +965,102 @@ mod tests {
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
fn snapshot_footer(name: &str, props: FooterProps) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, f.area().width, height);
|
||||
render_footer(area, f.buffer_mut(), props);
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
snapshot_footer_with_mode_indicator(name, 80, props, None);
|
||||
}
|
||||
|
||||
fn snapshot_footer_with_indicator(
|
||||
fn snapshot_footer_with_mode_indicator(
|
||||
name: &str,
|
||||
width: u16,
|
||||
props: FooterProps,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, f.area().width, height);
|
||||
render_footer(area, f.buffer_mut(), props);
|
||||
render_mode_indicator(
|
||||
area,
|
||||
f.buffer_mut(),
|
||||
indicator,
|
||||
!props.is_task_running,
|
||||
Some(footer_line_width(props)),
|
||||
let context_line = context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
);
|
||||
let context_width = context_line.width() as u16;
|
||||
let show_cycle_hint = !props.is_task_running;
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running && props.steer_enabled,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ComposerEmpty
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let left_width = footer_line_width(
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
let can_show_left_and_context =
|
||||
can_show_left_with_context(area, left_width, context_width);
|
||||
if matches!(
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
) {
|
||||
let (summary_left, show_context) = single_line_footer_layout(
|
||||
area,
|
||||
context_width,
|
||||
collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
render_footer_from_props(
|
||||
area,
|
||||
f.buffer_mut(),
|
||||
props,
|
||||
collaboration_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 {
|
||||
render_context_right(area, f.buffer_mut(), &context_line);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
area,
|
||||
f.buffer_mut(),
|
||||
props,
|
||||
collaboration_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::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
);
|
||||
if show_context {
|
||||
render_context_right(area, f.buffer_mut(), &context_line);
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
@@ -655,7 +1071,7 @@ mod tests {
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_default",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
@@ -760,7 +1176,7 @@ mod tests {
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_context_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
@@ -775,7 +1191,7 @@ mod tests {
|
||||
snapshot_footer(
|
||||
"footer_context_tokens_used",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
@@ -788,9 +1204,9 @@ mod tests {
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_context_only_queue_hint_disabled",
|
||||
"footer_composer_has_draft_queue_hint_disabled",
|
||||
FooterProps {
|
||||
mode: FooterMode::ContextOnly,
|
||||
mode: FooterMode::ComposerHasDraft,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
@@ -803,9 +1219,9 @@ mod tests {
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_context_only_queue_hint_enabled",
|
||||
"footer_composer_has_draft_queue_hint_enabled",
|
||||
FooterProps {
|
||||
mode: FooterMode::ContextOnly,
|
||||
mode: FooterMode::ComposerHasDraft,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
@@ -818,7 +1234,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
@@ -829,14 +1245,14 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
};
|
||||
|
||||
snapshot_footer_with_indicator(
|
||||
snapshot_footer_with_mode_indicator(
|
||||
"footer_mode_indicator_wide",
|
||||
120,
|
||||
props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
);
|
||||
|
||||
snapshot_footer_with_indicator(
|
||||
snapshot_footer_with_mode_indicator(
|
||||
"footer_mode_indicator_narrow_overlap_hides",
|
||||
50,
|
||||
props,
|
||||
@@ -844,7 +1260,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
@@ -855,7 +1271,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
};
|
||||
|
||||
snapshot_footer_with_indicator(
|
||||
snapshot_footer_with_mode_indicator(
|
||||
"footer_mode_indicator_running_hides_hint",
|
||||
120,
|
||||
props,
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left · ? for shortcuts "
|
||||
" ? for shortcuts 100% context left "
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts · Code mode (shift+tab to cycle) 100% context left "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" Code mode (shift+tab to cycle) 100% context left "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" Code mode (shift+tab to cycle) "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anythin "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" Code mode "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Test "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" tab to queue message · Code mode 98% context left "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Test "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" tab to queue message · Code mode "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Test "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" Code mode "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Test "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" tab to queue · Code mode 98% context left "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Test "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" tab to queue · Code mode "
|
||||
@@ -10,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2116
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -11,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2116
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -11,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
" 100% context left "
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" tab to queue message 100% context left "
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left "
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · tab to queue message "
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 123K used · ? for shortcuts "
|
||||
" ? for shortcuts 123K used "
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts "
|
||||
" Plan mode (shift+tab to cycle) "
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts Plan mode "
|
||||
" ? for shortcuts · Plan mode 100% context left "
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts Plan mode (shift+tab to cycle) "
|
||||
" ? for shortcuts · Plan mode (shift+tab to cycle) 100% context left "
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 72% context left · ? for shortcuts "
|
||||
" ? for shortcuts 72% context left "
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts "
|
||||
" ? for shortcuts 100% context left "
|
||||
|
||||
@@ -7,4 +7,4 @@ expression: "render_snapshot(&pane, area)"
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
? for shortcuts 100% context left
|
||||
|
||||
@@ -7,4 +7,4 @@ expression: "render_snapshot(&pane, area)"
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for sh
|
||||
100% context left
|
||||
|
||||
@@ -9,4 +9,4 @@ expression: "render_snapshot(&pane, area)"
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
? for shortcuts 100% context left
|
||||
|
||||
@@ -7,4 +7,4 @@ expression: "render_snapshot(&pane, area)"
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
? for shortcuts 100% context left
|
||||
|
||||
@@ -11,4 +11,4 @@ expression: "render_snapshot(&pane, area)"
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
? for shortcuts 100% context left
|
||||
|
||||
@@ -41,4 +41,4 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
100% context left · tab to queue message
|
||||
tab to queue message 100% context left
|
||||
|
||||
@@ -25,4 +25,4 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
? for shortcuts 100% context left
|
||||
|
||||
@@ -8,4 +8,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" 100% context left · ? for shortcuts "
|
||||
" ? for shortcuts 100% context left "
|
||||
|
||||
@@ -19,4 +19,4 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
? for shortcuts 100% context left
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1577
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -9,4 +8,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" 100% context left · ? for shortcuts "
|
||||
" ? for shortcuts 100% context left "
|
||||
|
||||
Reference in New Issue
Block a user