mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Modes label below textarea (#9645)
# Summary - Add a collaboration mode indicator rendered at the bottom-right of the TUI composer footer. - Style modes per design (Plan in #D72EE1, Execute matching dim context style, Pair Programming using the same cyan as text elements). - Add shared “(shift+tab to cycle)” hint text for all mode labels and align the indicator with the left footer margin. NOTE: currently this is hidden if the Collaboration Modes feature flag is disabled, or in Custom mode. Maybe we should show it in Custom mode too? I'll leave that out of this PR though # UI - Mode indicator appears below the textarea, bottom-right of the footer line. - Includes “(shift+tab to cycle)” and keeps right padding aligned to the left footer indent. <img width="983" height="200" alt="Screenshot 2026-01-21 at 7 17 54 PM" src="https://github.com/user-attachments/assets/d1c5e4ed-7d7b-4f6c-9e71-bc3cf6400e0e" /> <img width="980" height="200" alt="Screenshot 2026-01-21 at 7 18 53 PM" src="https://github.com/user-attachments/assets/d22ff0da-a406-4930-85c5-affb2234e84b" /> <img width="979" height="201" alt="Screenshot 2026-01-21 at 7 19 12 PM" src="https://github.com/user-attachments/assets/862cb17f-0495-46fa-9b01-a4a9f29b52d5" />
This commit is contained in:
@@ -93,13 +93,17 @@ use super::command_popup::CommandItem;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::command_popup::CommandPopupFlags;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::CollaborationModeIndicator;
|
||||
use super::footer::FooterMode;
|
||||
use super::footer::FooterProps;
|
||||
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_footer_hint_items;
|
||||
use super::footer::render_mode_indicator;
|
||||
use super::footer::reset_mode_after_activity;
|
||||
use super::footer::toggle_shortcut_mode;
|
||||
use super::paste_burst::CharDecision;
|
||||
@@ -229,6 +233,7 @@ pub(crate) struct ChatComposer {
|
||||
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
|
||||
steer_enabled: bool,
|
||||
collaboration_modes_enabled: bool,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -289,6 +294,7 @@ impl ChatComposer {
|
||||
dismissed_skill_popup_token: None,
|
||||
steer_enabled: false,
|
||||
collaboration_modes_enabled: false,
|
||||
collaboration_mode_indicator: None,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -313,6 +319,13 @@ impl ChatComposer {
|
||||
self.collaboration_modes_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_collaboration_mode_indicator(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
) {
|
||||
self.collaboration_mode_indicator = indicator;
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
@@ -545,6 +558,7 @@ impl ChatComposer {
|
||||
self.footer_hint_override = items;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) {
|
||||
let expires_at = Instant::now()
|
||||
.checked_add(duration)
|
||||
@@ -2510,15 +2524,25 @@ impl Renderable for ChatComposer {
|
||||
} else {
|
||||
popup_rect
|
||||
};
|
||||
let mut left_content_width = None;
|
||||
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_mode_indicator(
|
||||
hint_rect,
|
||||
buf,
|
||||
self.collaboration_mode_indicator,
|
||||
left_content_width,
|
||||
);
|
||||
}
|
||||
}
|
||||
let style = user_message_style();
|
||||
|
||||
@@ -46,6 +46,36 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CollaborationModeIndicator {
|
||||
Plan,
|
||||
PairProgramming,
|
||||
Execute,
|
||||
}
|
||||
|
||||
const MODE_CYCLE_HINT: &str = "shift+tab to cycle";
|
||||
|
||||
impl CollaborationModeIndicator {
|
||||
fn label(self) -> String {
|
||||
match self {
|
||||
CollaborationModeIndicator::Plan => format!("Plan mode ({MODE_CYCLE_HINT})"),
|
||||
CollaborationModeIndicator::PairProgramming => {
|
||||
format!("Pair Programming mode ({MODE_CYCLE_HINT})")
|
||||
}
|
||||
CollaborationModeIndicator::Execute => format!("Execute mode ({MODE_CYCLE_HINT})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn styled_span(self) -> Span<'static> {
|
||||
let label = self.label();
|
||||
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
|
||||
@@ -104,6 +134,40 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn render_mode_indicator(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
left_content_width: Option<u16>,
|
||||
) {
|
||||
let Some(indicator) = indicator else {
|
||||
return;
|
||||
};
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let span = indicator.styled_span();
|
||||
let label_width = span.width() as u16;
|
||||
if label_width == 0 || label_width > area.width {
|
||||
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;
|
||||
}
|
||||
}
|
||||
buf.set_span(x, y, &span, label_width);
|
||||
}
|
||||
|
||||
pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect {
|
||||
if area.width > 2 {
|
||||
area.x += 2;
|
||||
@@ -117,16 +181,7 @@ pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(S
|
||||
return;
|
||||
}
|
||||
|
||||
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).render(inset_footer_hint_area(area), buf);
|
||||
footer_hint_items_line(items).render(inset_footer_hint_area(area), buf);
|
||||
}
|
||||
|
||||
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
@@ -180,6 +235,33 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
}
|
||||
}
|
||||
|
||||
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_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,
|
||||
@@ -535,6 +617,29 @@ mod tests {
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
fn snapshot_footer_with_indicator(
|
||||
name: &str,
|
||||
width: u16,
|
||||
props: FooterProps,
|
||||
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,
|
||||
Some(footer_line_width(props)),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_snapshots() {
|
||||
snapshot_footer(
|
||||
@@ -701,5 +806,31 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
collaboration_modes_enabled: true,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
};
|
||||
|
||||
snapshot_footer_with_indicator(
|
||||
"footer_mode_indicator_wide",
|
||||
120,
|
||||
props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
);
|
||||
|
||||
snapshot_footer_with_indicator(
|
||||
"footer_mode_indicator_narrow_overlap_hides",
|
||||
50,
|
||||
props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_overlay;
|
||||
@@ -60,11 +59,14 @@ mod list_selection_view;
|
||||
mod prompt_args;
|
||||
mod skill_popup;
|
||||
mod skills_toggle_view;
|
||||
pub(crate) use footer::CollaborationModeIndicator;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod feedback_view;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
pub(crate) use feedback_view::feedback_selection_params;
|
||||
pub(crate) use feedback_view::feedback_upload_consent_params;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
mod paste_burst;
|
||||
pub mod popup_consts;
|
||||
mod queued_user_messages;
|
||||
@@ -110,8 +112,6 @@ pub(crate) use experimental_features_view::BetaFeatureItem;
|
||||
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
///
|
||||
@@ -207,6 +207,14 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_collaboration_mode_indicator(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
) {
|
||||
self.composer.set_collaboration_mode_indicator(indicator);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
@@ -548,23 +556,6 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) {
|
||||
self.composer.show_footer_flash(line, duration);
|
||||
let frame_requester = self.frame_requester.clone();
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(duration).await;
|
||||
frame_requester.schedule_frame();
|
||||
});
|
||||
} else {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(duration);
|
||||
frame_requester.schedule_frame();
|
||||
});
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
self.composer.is_empty()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts Plan mode (shift+tab to cycle) "
|
||||
@@ -131,6 +131,7 @@ use crate::bottom_pane::BetaFeatureItem;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::CollaborationModeIndicator;
|
||||
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
|
||||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||||
use crate::bottom_pane::InputResult;
|
||||
@@ -1937,6 +1938,7 @@ impl ChatWidget {
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget.update_collaboration_mode_indicator();
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -2055,6 +2057,7 @@ impl ChatWidget {
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget.update_collaboration_mode_indicator();
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -4330,6 +4333,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
CollaborationMode::Custom(settings)
|
||||
};
|
||||
self.update_collaboration_mode_indicator();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4415,6 +4419,25 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn collaboration_mode_indicator(&self) -> Option<CollaborationModeIndicator> {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return None;
|
||||
}
|
||||
match &self.stored_collaboration_mode {
|
||||
CollaborationMode::Plan(_) => Some(CollaborationModeIndicator::Plan),
|
||||
CollaborationMode::PairProgramming(_) => {
|
||||
Some(CollaborationModeIndicator::PairProgramming)
|
||||
}
|
||||
CollaborationMode::Execute(_) => Some(CollaborationModeIndicator::Execute),
|
||||
CollaborationMode::Custom(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_collaboration_mode_indicator(&mut self) {
|
||||
let indicator = self.collaboration_mode_indicator();
|
||||
self.bottom_pane.set_collaboration_mode_indicator(indicator);
|
||||
}
|
||||
|
||||
/// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan).
|
||||
fn cycle_collaboration_mode(&mut self) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
@@ -4439,18 +4462,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.stored_collaboration_mode = mode;
|
||||
|
||||
let label = self.collaboration_mode_label();
|
||||
if let Some(label) = label {
|
||||
let flash = Line::from(vec![
|
||||
label.bold(),
|
||||
" (".dim(),
|
||||
key_hint::shift(KeyCode::Tab).into(),
|
||||
" to change mode)".dim(),
|
||||
]);
|
||||
const FLASH_DURATION: Duration = Duration::from_secs(2);
|
||||
self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION);
|
||||
}
|
||||
self.update_collaboration_mode_indicator();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user