Fit composer footer hints to available terminal space (#60683)

## Why

Narrow composer footers can clip hints or crowd out mode indicators and actions.

## What changed

Use a shared layout for navigation, actions, mode indicators, context, and warnings. Shorten or drop whole labels in priority order instead of clipping them. Add a second row when input modes and queue or active side-conversation actions fit separately but not together, keeping navigation and modes above actions.

## Testing

Add coverage for fullscreen and scrollback layouts, narrow Plan mode hints, active side-conversation actions, oversized goal indicators, and warning hit areas.

GitOrigin-RevId: 849a53d45ef1b36b40c52282e329b48ec7d7d12e
This commit is contained in:
Joe Savona
2026-11-10 23:36:04 +00:00
committed by copyberry
parent 65dbdb5f03
commit 87b7bdbf2e
58 changed files with 642 additions and 228 deletions
@@ -50,7 +50,7 @@ Conversation
› draft stays
GPT-5.6-Sol def…
tab to queue mes
tab to queue
80 columns, 18 rows, running=true, queued=true
Conversation row 18
@@ -30,7 +30,7 @@ transcript row 39
here
GPT-5.6-Sol def…
tab to queue mes
tab to queue
6 columns, 18 rows
row 38
@@ -49,8 +49,8 @@ row 39
e
GPT-
5.6…
tab
5.6-
Sol…
80 columns, 6 rows
• Working (0s • esc to interrupt)
@@ -152,8 +152,8 @@ working=false, response rows=1, 12x12
› Ask Codex
gpt-test …
? for shor
gpt-test
default…
working=false, response rows=1, 80x4
@@ -5,4 +5,4 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
› kept draft
gpt-test default
Plan mode (⇧tab to cycle)
Plan mode (⇧tab to cycle)
@@ -1,6 +1,5 @@
---
source: tui/src/app/tests/background_exit_tests.rs
assertion_line: 57
expression: "render_bottom_popup(&app.chat_widget, 90)"
---
Quit and stop running sessions?
+24 -67
View File
@@ -352,7 +352,6 @@ use super::footer::CollaborationModeIndicator;
use super::footer::FooterMode;
use super::footer::FooterProps;
use super::footer::GoalStatusIndicator;
use super::footer::SummaryLeft;
use super::footer::can_show_left_with_context;
use super::footer::context_window_line;
use super::footer::esc_hint_mode;
@@ -367,7 +366,6 @@ use super::footer::render_footer_hint_items;
use super::footer::render_footer_line;
use super::footer::reset_mode_after_activity;
use super::footer::side_conversation_context_line;
use super::footer::single_line_footer_layout;
use super::footer::status_line_right_indicator_line;
use super::footer::toggle_shortcut_mode;
use super::mention_usage::MentionUsage;
@@ -410,6 +408,7 @@ mod history_search;
mod inline_input;
mod mouse;
pub(crate) use mouse::ClipboardPasteTarget;
mod passive_hints;
mod paste_input;
mod popup_state;
mod reconnect;
@@ -4717,22 +4716,30 @@ impl ChatComposer {
}
}
let transcript_hint_area = inset_footer_hint_area(footer_rect);
let warning_notice = self
.warning_notice_layout(transcript_hint_area, options)
.filter(|_| !options.unfocused);
let passive = self.passive_hints(options);
let warning_notice = if !options.unfocused && passive.is_none() {
self.warning_notice_layout(transcript_hint_area, options)
} else {
None
};
let warning_area = warning_notice.as_ref().map(|(area, _)| *area);
match options.footer {
let mut painted_warning = warning_area;
match (options.footer, passive) {
_ if options.unfocused => {}
Some(footer) => {
(_, Some(hints)) => {
let hint_area = passive_hints::hint_area(footer_rect);
painted_warning = hints.render(hint_area, buf);
}
(Some(footer), None) => {
self.render_transcript_footer(transcript_hint_area, buf, footer, warning_area);
}
None if self.shortcuts_above_composer(options) => {
(None, None) if self.shortcuts_above_composer(options) => {
let props = self.hint_footer_props();
super::shortcut_overlay::render(&props, inset_footer_hint_area(popup_rect), buf);
super::shortcut_overlay::close_hint(&props, transcript_hint_area.width)
.render(transcript_hint_area, buf);
}
None => {
(None, None) => {
let hint_rect = warning_notice::before_warning(footer_rect, warning_area);
if let Some(input) = self.draft.textarea.vim_query() {
input.render(inset_footer_hint_area(hint_rect), buf);
@@ -4795,73 +4802,23 @@ impl ChatComposer {
can_show_left_with_context(hint_rect, left_width, right_width);
let has_override =
self.footer.flash_visible() || active_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,
right_width,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
footer_props.key_hints,
))
}
FooterMode::EscHint
| FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay => None,
}
};
let passive_mode = matches!(
footer_props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
);
let show_right = if has_override && status_line_active {
can_show_left_and_context
} else if matches!(
footer_props.mode,
FooterMode::EscHint
| FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
) {
false
} else {
single_line_layout
.as_ref()
.map(|(_, show_context)| *show_context)
.unwrap_or(can_show_left_and_context)
passive_mode && (!has_override || 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,
left_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 self.footer.flash_visible() {
if let Some(flash) = self.footer.flash.as_ref() {
Widget::render(&flash.line, inset_footer_hint_area(hint_rect), buf);
}
} else if let Some(items) = active_footer_hint_override {
render_footer_hint_items(hint_rect, buf, items);
} else {
} else if !passive_mode {
render_footer_from_props(
hint_rect,
buf,
@@ -4878,7 +4835,7 @@ impl ChatComposer {
}
}
}
self.footer.warning_notice_area.set(warning_area);
self.footer.warning_notice_area.set(painted_warning);
if let Some((warning_area, line)) = warning_notice {
line.render(warning_area, buf);
}
@@ -293,36 +293,9 @@ impl super::ChatComposer {
if options.unfocused || self.shortcuts_above_composer(options) {
return 1;
}
if self.footer.status_line_enabled
&& options.footer.is_none()
&& self.custom_footer_height().is_none()
&& matches!(
self.footer_mode(),
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
)
{
let mode = self.footer_mode();
let navigation = mode == FooterMode::ComposerEmpty
&& !self.is_in_paste_burst()
&& (self.agents_navigation_available()
|| self.footer.toggle_shortcuts_key.is_some());
let queue = mode == FooterMode::ComposerHasDraft
&& self.is_task_running
&& self.footer.queue_key.is_some();
return u16::from(
self.warning_notice_layout(
super::inset_footer_hint_area(Rect::new(0, 0, width, 1)),
options,
)
.is_some()
|| navigation
|| queue
|| self
.mode_indicator_line(/*show_cycle_hint*/ false)
.is_some()
|| self.shell_mode_footer_line().is_some()
|| self.footer.side_conversation_context_label.is_some(),
);
if let Some(hints) = self.passive_hints(options) {
return hints
.desired_rows(super::passive_hints::hint_area(Rect::new(0, 0, width, 1)).width);
}
options.footer.map_or_else(
|| {
@@ -0,0 +1,353 @@
//! Fit whole composer hints with navigation and actions on the left and indicators on the right.
//! Request a second row only when a compact input mode (Shell, Vim, Plan) and a
//! current action (queue or active side conversation) fit separately but not together.
//! In two rows, navigation and modes stay above actions and the remaining indicators.
//! Shorten or drop labels in collapse order. If a row still overflows, refill it
//! in interaction priority order, then paint the surviving labels in reading order.
use super::*;
use crate::bottom_pane::footer::goal_status_indicator_line;
use crate::bottom_pane::footer::mode_indicator_line;
use crate::line_truncation::line_width;
use crate::style::secondary_text_style;
use ratatui::style::Styled;
// Declaration order is the reading order within each side.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Kind {
Agents,
Shortcuts,
Action,
Side,
SideNav,
AgentName,
Shell,
Vim,
Plan,
Goal,
Ide,
Context,
Warning,
}
use Kind::*;
impl Kind {
fn is_input_mode(self) -> bool {
matches!(self, Shell | Vim | Plan)
}
fn left(self) -> bool {
matches!(self, Agents | Shortcuts | Action | Side | SideNav)
}
fn row(self, two_rows: bool) -> usize {
usize::from(
two_rows && !self.is_input_mode() && !matches!(self, Agents | Shortcuts | SideNav),
)
}
}
fn joined_width(first: usize, second: usize) -> usize {
first + second + usize::from(first > 0 && second > 0) * 3
}
struct Label {
kind: Kind,
forms: Vec<(usize, Line<'static>)>,
selected: Option<usize>,
}
impl Label {
fn width(&self) -> usize {
self.selected
.and_then(|i| self.forms.get(i))
.map_or(0, |(cells, _)| *cells)
}
}
#[derive(Default)]
pub(super) struct Hints {
labels: Vec<Label>,
}
#[derive(Default, Clone, Copy)]
struct Row {
left: usize,
right: usize,
}
impl Row {
fn width(self) -> usize {
self.left + self.right + usize::from(self.left > 0 && self.right > 0)
}
}
impl Hints {
fn add(&mut self, kind: Kind, forms: impl IntoIterator<Item = Line<'static>>) {
let forms: Vec<_> = forms
.into_iter()
.map(|line| (line_width(&line), line))
.filter(|(cells, _)| *cells > 0)
.collect();
if !forms.is_empty() {
self.labels.push(Label {
kind,
forms,
selected: Some(0),
});
}
}
pub(super) fn desired_rows(&self, width: u16) -> u16 {
let width = usize::from(width);
let mut compact = Row::default();
let mut any_fits = false;
for label in &self.labels {
let Some(&(cells, _)) = label.forms.last() else {
continue;
};
any_fits |= cells <= width;
let group = if label.kind.is_input_mode() {
&mut compact.right
} else if matches!(label.kind, Action | Side) {
&mut compact.left
} else {
continue;
};
let next = joined_width(*group, cells);
if next <= width {
*group = next;
}
}
u16::from(any_fits) + u16::from(compact.width() > width)
}
fn placement(&self, two_rows: bool) -> [Row; 2] {
let mut rows = [Row::default(); 2];
for label in &self.labels {
let row = &mut rows[label.kind.row(two_rows)];
let group = if label.kind.left() {
&mut row.left
} else {
&mut row.right
};
*group = joined_width(*group, label.width());
}
rows
}
fn fit(&mut self, width: usize, two: bool) -> [Row; 2] {
let mut p = self.placement(two);
for (kind, form) in [
(Shortcuts, None),
(Plan, Some(1)),
(Warning, Some(1)),
(Ide, Some(1)),
(Action, Some(1)),
(Warning, Some(2)),
(Agents, None),
] {
if p[kind.row(two)].width() <= width {
continue;
}
let Some(label) = self.labels.iter_mut().find(|label| label.kind == kind) else {
continue;
};
if let Some(i) = form
&& label
.forms
.get(i)
.is_none_or(|(cells, _)| *cells >= label.width())
{
continue;
}
label.selected = form;
p = self.placement(two);
}
let overflow = p.map(|row| row.width() > width);
if !overflow.contains(&true) {
return p;
}
// Refill only overflowing rows in interaction priority order.
for label in &mut self.labels {
if overflow[label.kind.row(two)] {
label.selected = None;
}
}
for kind in [
Action, Side, Shell, Vim, Plan, Goal, Ide, Warning, Context, SideNav, AgentName,
Agents, Shortcuts,
] {
if !overflow[kind.row(two)] {
continue;
}
if let Some(i) = self.labels.iter().position(|label| label.kind == kind) {
self.labels[i].selected = Some(self.labels[i].forms.len() - 1);
if self.placement(two)[kind.row(two)].width() > width {
self.labels[i].selected = None;
}
}
}
self.placement(two)
}
pub(super) fn render(mut self, area: Rect, buf: &mut Buffer) -> Option<Rect> {
if area.is_empty() {
return None;
}
let two = area.height > 1;
let rows = self.fit(usize::from(area.width), two);
self.labels.sort_by_key(|label| label.kind);
let mut cursors = rows.map(|row| [area.x, area.right() - row.right as u16]);
let mut warning = None;
for label in self.labels {
let Some((cells, line)) = label.selected.map(|i| &label.forms[i]) else {
continue;
};
let row = label.kind.row(two);
let x = &mut cursors[row][usize::from(!label.kind.left())];
let y = area.y + row as u16;
// Keep the meter's extra right inset when it is alone on its side and space permits.
let inset = label.kind == Context
&& rows[row].right == *cells
&& rows[row].width() < usize::from(area.width);
let rect = Rect::new(*x - u16::from(inset), y, *cells as u16, 1);
Widget::render(line, rect, buf);
if label.kind == Warning {
warning = Some(rect);
}
*x += *cells as u16;
let end = if label.kind.left() {
area.x + rows[row].left as u16
} else {
area.right()
};
if *x < end {
buf.set_string(*x, y, " · ", secondary_text_style());
*x += 3;
}
}
warning
}
}
fn shortcut(key: key_hint::ShortcutHint, action: &'static str) -> Line<'static> {
let mut line = Line::from(key.spans());
line.push_span(action.set_style(crate::style::secondary_text_style()));
line
}
pub(super) fn hint_area(area: Rect) -> Rect {
let mut area = inset_footer_hint_area(area);
area.width = area.width.saturating_sub(1);
area
}
impl ChatComposer {
pub(super) fn passive_hints(&self, options: ComposerRenderOptions<'_>) -> Option<Hints> {
let mode = self.footer_mode();
if options.footer.is_some()
|| self.custom_footer_height().is_some()
|| !matches!(
mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
)
{
return None;
}
let mut hints = Hints::default();
hints.add(Shell, self.shell_mode_footer_line());
hints.add(Vim, self.vim_mode_indicator_span().map(Line::from));
let can_navigate = self.has_focus && self.draft.input_enabled && !self.popup_active();
let can_type = can_navigate && !self.blocks_direct_input;
let show_cycle = can_type && !self.is_task_running;
if let Some(plan) = self.footer.collaboration_mode_indicator {
hints.add(
Plan,
[
mode_indicator_line(Some(plan), show_cycle),
mode_indicator_line(Some(plan), /*show_cycle_hint*/ false),
]
.into_iter()
.flatten(),
);
} else {
hints.add(
Goal,
goal_status_indicator_line(self.footer.goal_status_indicator.as_ref()),
);
}
if self.footer.ide_context_active {
hints.add(
Ide,
[Line::from("IDE context".cyan()), Line::from("IDE".cyan())],
);
}
if mode == FooterMode::ComposerHasDraft
&& can_type
&& self.is_task_running
&& let Some(key) = self.footer.queue_key
{
hints.add(
Action,
[
shortcut(key, " to queue message"),
shortcut(key, " to queue"),
],
);
}
if let Some(label) = &self.footer.side_conversation_context_label {
let kind = if self.side_conversation_active {
Side
} else {
SideNav
};
hints.add(kind, [side_conversation_context_line(label)]);
}
if !self.footer.status_line_enabled
&& let Some(agent) = &self.footer.active_agent_label
{
hints.add(
AgentName,
[Line::from(agent.clone().set_style(secondary_text_style()))],
);
}
if mode == FooterMode::ComposerEmpty && can_navigate && !self.is_in_paste_burst() {
if self.agents_navigation_available() {
hints.add(
Agents,
[shortcut(
key_hint::plain(KeyCode::Left).into(),
" for agents",
)],
);
}
if let Some(key) = self.footer.toggle_shortcuts_key {
hints.add(Shortcuts, [shortcut(key, " for shortcuts")]);
}
}
let show_warning = self.show_warning_notice(options);
if !self.footer.status_line_enabled
&& !self.footer.context_window_pending
&& self.footer.side_conversation_context_label.is_none()
&& !self.is_bang_shell_command()
{
if hints.labels.is_empty() && !show_warning {
// A lone context meter retains its existing inset and narrow-width clipping.
return None;
}
hints.add(
Context,
[context_window_line(
self.footer.context_window_percent,
self.footer.context_window_used_tokens,
)],
);
}
if show_warning {
hints.add(Warning, self.warning_labels(options.warning_count));
}
Some(hints)
}
}
@@ -0,0 +1,31 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "frames.join(\"\\n---\\n\")"
---
fullscreen 120×2 empty
Build ready
← for agents · ? for shortcuts Vim: Normal · Plan mode (⇧tab to cycle) · IDE context · ⚠ 2 warnings · f2 to view
---
fullscreen 60×2 empty
Build ready
← for agents Vim: Normal · Plan mode · IDE · ⚠ 2 · f2
---
fullscreen 31×3 empty
Build ready
Vim: Normal · Plan mode
---
fullscreen 31×3 draft
Build ready
Vim: Normal · Plan mode
tab to queue message IDE
---
fullscreen 31×2 draft
Build ready
tab to queue Vim: Normal
---
scrollback 31×4 draft
Build ready
tab to queue Vim: Normal
---
scrollback 31×1 draft
tab to queue Vim: Normal
@@ -14,4 +14,4 @@ expression: wide
› Ask Codex
MODEL · ~/project · Context 20% used
? for shortcuts Plan mode (⇧tab to cycle)
? for shortcuts Plan mode (⇧tab to cycle)
@@ -14,7 +14,7 @@ expression: "states.join(\"\\n---\\n\")"
› Ask Codex
MODEL · ~/project · Context 20% used
? for shortcuts Plan mode (⇧tab to cycle)
? for shortcuts Plan mode (⇧tab to cycle)
---
@@ -56,4 +56,4 @@ expression: "states.join(\"\\n---\\n\")"
› Continue the task
MODEL · ~/project · Context 20% used
tab to queue message Plan mode
tab to queue message Plan mode
@@ -3,6 +3,6 @@ source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "frames.join(\"\\n---\\n\")"
---
codex-internal feat/statusline Build 1042 128 tests passed Deploy ready
? for shortcuts Plan mode (⇧tab to cycle) ⚠ 2 warnings · f2 to view
? for shortcuts Plan mode (⇧tab to cycle) · ⚠ 2 warnings · f2 to view
---
? for shortcuts Plan mode (⇧tab to cycle) ⚠ 2 warnings · f2 to view
? for shortcuts Plan mode (⇧tab to cycle) · ⚠ 2 warnings · f2 to view
@@ -7,7 +7,7 @@ height 8
|› [Image #1]draft |
| |
| one… |
| Plan mode (⇧tab to cycle) |
| Plan mode (⇧tab to cycle) |
height 9
| |
@@ -18,4 +18,4 @@ height 9
|› [Image #1]draft |
| |
| one… |
| Plan mode (⇧tab to cycle) |
| Plan mode (⇧tab to cycle) |
@@ -66,11 +66,7 @@ fn extension_status_line_clips_links_to_footer_layout() {
.collect();
assert_eq!(
linked_text,
match (width, separate_status_line) {
(12, false) => "P",
(12, true) => "PR#",
_ => "PR #42",
},
if width == 12 { "PR#" } else { "PR #42" },
"{buffer:?}"
);
assert!(linked.iter().all(|cell| !cell.symbol().contains('…')));
@@ -278,13 +274,17 @@ fn sidebar_focus_collapses_extension_status_to_its_dim_line() {
let frames = [false, true, false].map(|unfocused| {
let options = ComposerRenderOptions {
separate_status_line: true,
warning_count: usize::from(unfocused),
unfocused,
..Default::default()
};
let height = composer.desired_height_with_options(/*width*/ 40, options);
render_status(&composer, Rect::new(0, 0, 40, height), options)
.1
.join("\n")
let (buffer, rows) = render_status(&composer, Rect::new(0, 0, 40, height), options);
if unfocused {
assert_eq!(composer.footer.warning_notice_area.get(), None);
assert!(!buffer.content.iter().any(|cell| cell.symbol() == "⚠"));
}
rows.join("\n")
});
insta::assert_snapshot!(frames.join("\n---\n"), @"
one
@@ -468,7 +468,7 @@ fn extension_status_keeps_its_rows_when_effort_changes() {
if separate_status_line {
vec!["one", "two", "three"]
} else {
vec!["one…"]
vec!["one", "two…"]
}
);
}
@@ -713,11 +713,28 @@ fn fullscreen_plan_indicator_keeps_the_cycle_hint_when_it_fits() {
// Configured items such as git-branch can have no value outside a repository.
composer.set_status_line(/*status_line*/ None);
let (empty_narrow, _) = render(&composer, /*width*/ 24, /*footer*/ None);
assert!(
empty_narrow.lines().last().unwrap().contains("Plan mode"),
"{empty_narrow}"
);
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
..Default::default()
};
let width = 20;
let area = Rect::new(
0,
0,
width,
composer.desired_height_with_options(width, options),
);
let (buf, _) = render_status(&composer, area, options);
let hint: String = (0..width)
.map(|x| buf[(x, area.bottom() - 1)].symbol())
.collect();
assert_eq!(
hint.trim(),
"Plan mode",
"separate_status_line={separate_status_line}"
);
}
let (empty_wide, _) = render(&composer, /*width*/ 100, /*footer*/ None);
assert!(
empty_wide.contains("Plan mode (⇧tab to cycle)"),
@@ -742,21 +759,113 @@ fn fullscreen_plan_indicator_keeps_the_cycle_hint_when_it_fits() {
#[test]
fn footer_layout_in_fullscreen_and_scrollback() {
let mut composer = composer();
composer.set_status_line(Some("Build ready".into()));
composer.command_center_enabled = true;
composer.set_vim_enabled(/*enabled*/ true);
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 31, /*height*/ 4,
);
for separate_status_line in [false, true] {
composer.footer.ide_context_active = true;
composer.set_status_line(Some("Build ready".into()));
let mut frames = Vec::new();
for (fullscreen, width, rows, drafting) in [
(true, 120, 2, false),
(true, 60, 2, false),
(true, 31, 3, false),
(true, 31, 3, true),
(true, 31, 2, true),
(false, 31, 4, true),
(false, 31, 1, true),
] {
if drafting && !composer.is_task_running {
composer.insert_str("draft");
composer.set_task_running(/*running*/ true);
}
let options = ComposerRenderOptions {
separate_status_line,
separate_status_line: fullscreen,
warning_count: 2,
..Default::default()
};
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, 3 + rows);
let layout = composer.layout_with_options(area, options);
assert_eq!((layout.status.height, layout.footer.height), (0, 1));
let (buf, _) = render_status(&composer, area, options);
assert!(frame(&buf).lines().last().unwrap().contains("Plan"));
let mut buf = Buffer::empty(area);
composer.render_with_options(area, &mut buf, /*mask_char*/ None, options);
let lines = (layout.status.y..area.bottom())
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>();
if let Some(hit) = composer.footer.warning_notice_area.get() {
assert_eq!(buf[(hit.x, hit.y)].symbol(), "⚠");
assert_eq!(hit.y, layout.footer.bottom() - 1);
}
frames.push(format!(
"{} {width}×{rows} {}\n{}",
if fullscreen {
"fullscreen"
} else {
"scrollback"
},
if drafting { "draft" } else { "empty" },
lines.join("\n")
));
}
insta::assert_snapshot!(frames.join("\n---\n"));
}
#[test]
fn active_side_action_keeps_plan_expanded_on_the_other_row() {
let mut composer = composer();
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
composer.set_side_conversation_active(/*active*/ true);
let label = "Side from main thread · ctrl+/ to switch · ctrl+c to close";
composer.set_side_conversation_context_label(Some(label.to_string()));
let options = ComposerRenderOptions {
separate_status_line: true,
warning_count: 2,
..Default::default()
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 63, /*height*/ 6,
);
let (buf, _) = render_status(&composer, area, options);
let text = frame(&buf);
let mut rows = text.lines().rev().map(|row| row.trim_matches('|').trim());
assert_eq!(rows.next(), Some(label));
assert!(rows.next().unwrap().contains("Plan mode (⇧tab to cycle)"));
}
#[test]
fn oversized_goal_yields_to_an_eligible_whole_hint_with_status_disabled() {
let mut composer = composer();
composer.set_status_line_enabled(/*enabled*/ false);
composer.set_context_window(Some(100), /*used_tokens*/ None);
let (text, _) = render(&composer, /*width*/ 18, /*footer*/ None);
assert_eq!(text.lines().last().map(str::trim), Some("? for shortcuts"));
let (text, _) = render(&composer, /*width*/ 20, /*footer*/ None);
assert_eq!(
text.lines().last().map(str::trim),
Some("100% context left")
);
composer.set_context_window_pending(/*pending*/ true);
composer.set_goal_status_indicator(Some(GoalStatusIndicator::Paused));
let (text, _) = render(&composer, /*width*/ 18, /*footer*/ None);
assert_eq!(text.lines().last().map(str::trim), Some("? for shortcuts"));
let options = ComposerRenderOptions {
separate_status_line: true,
..Default::default()
};
assert_eq!(composer.footer_hint_height(/*width*/ 18, options), 1);
composer.set_input_enabled(/*enabled*/ false, /*placeholder*/ None);
assert_eq!(composer.footer_hint_height(/*width*/ 18, options), 0);
let (text, _) = render(&composer, /*width*/ 80, /*footer*/ None);
assert_eq!(
text.lines().last().map(str::trim),
Some("Goal paused (/goal resume)")
);
}
#[test]
@@ -64,6 +64,17 @@ impl ChatComposer {
}
pub(super) fn warning_notice(&self, count: usize, width: u16) -> Line<'static> {
let [full, compact, count] = self.warning_labels(count);
if full.width() <= usize::from(width) {
return full;
}
if compact.width() <= usize::from(width) {
return compact;
}
truncate_line_with_ellipsis_if_overflow(count, usize::from(width))
}
pub(super) fn warning_labels(&self, count: usize) -> [Line<'static>; 3] {
let plural = if count == 1 { "" } else { "s" };
let accent = crate::style::warning_notice_style();
let secondary = crate::style::secondary_text_style();
@@ -80,9 +91,6 @@ impl ChatComposer {
.style(secondary);
full.extend(key_hint::key_label_spans(&shortcut));
full.push_span(" to view");
if full.width() <= usize::from(width) {
return full;
}
let mut compact = Line::from(vec![
"⚠ ".into(),
Span::styled(count.to_string(), accent),
@@ -90,13 +98,9 @@ impl ChatComposer {
])
.style(secondary);
compact.extend(key_hint::key_label_spans(&shortcut));
if compact.width() <= usize::from(width) {
return compact;
}
truncate_line_with_ellipsis_if_overflow(
Line::from(vec!["⚠ ".into(), Span::styled(count.to_string(), accent)]).style(secondary),
usize::from(width),
)
let count =
Line::from(vec!["⚠ ".into(), Span::styled(count.to_string(), accent)]).style(secondary);
[full, compact, count]
}
}
@@ -104,7 +104,10 @@ fn warning_notice_respects_shortcuts_and_interactive_hints() {
composer.footer.show_warnings_key = Some(crate::key_hint::plain(KeyCode::F(12)).into());
assert!(text(&render(&composer, /*width*/ 64, /*count*/ 2)).contains("f12 to view"));
composer.footer.show_warnings_key = None;
assert!(text(&render(&composer, /*width*/ 64, /*count*/ 2)).contains("⚠ 2 · /warnings"));
assert!(
text(&render(&composer, /*width*/ 64, /*count*/ 2))
.contains("⚠ 2 warnings · /warnings to view")
);
let interactive = TranscriptFooter {
text: Line::from("Find: needle").into(),
cursor_column: Some(6),
+7 -21
View File
@@ -21,23 +21,7 @@
//! 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. The composer renders the chosen line, the default `FooterProps` mapping, or no left content.
//! The composer fits ordinary navigation, modes, actions, and warnings in `chat_composer::passive_hints`.
pub(crate) mod footer_content;
use self::footer_content::FooterContent;
@@ -292,6 +276,7 @@ pub(crate) fn render_footer_from_props(
.render(area, buf);
}
#[cfg(test)]
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
@@ -302,6 +287,7 @@ enum SummaryHintKind {
None,
Shortcuts,
QueueMessage,
#[cfg(test)]
QueueShort,
}
@@ -338,6 +324,7 @@ fn left_side_line(
line.push_span(" to queue message".set_style(secondary_text_style()));
}
}
#[cfg(test)]
SummaryHintKind::QueueShort => {
if let Some(key) = key_hints.queue {
line.extend(key.spans());
@@ -360,12 +347,14 @@ fn left_side_line(
line
}
#[cfg(test)]
pub(crate) enum SummaryLeft {
Default,
Custom(Line<'static>),
None,
}
#[cfg(test)]
/// 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(
@@ -752,10 +741,7 @@ pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(S
///
/// 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.
/// the default text for base states. The caller owns placement and collapse.
fn footer_from_props_lines(
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
@@ -3,4 +3,4 @@ source: tui/src/bottom_pane/chat_composer_effort_tests.rs
expression: "rows.join(\"\\n\")"
---
gpt-5.4 high · feature-branch
? for shortcuts Plan mode
Plan mode (⇧tab to cycle)
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts · Plan mode (⇧tab to cycle) 100% context left "
" ? for shortcuts Plan mode (⇧tab to cycle) · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" Plan mode (⇧tab to cycle) 100% context left "
" Plan mode (⇧tab to cycle) · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" Plan mode (⇧tab to cycle) "
" Plan mode · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" Plan mode "
" Plan mode "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" tab to queue message · Plan mode 98% context left "
" tab to queue message Plan mode · 98% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" tab to queue message · Plan mode "
" tab to queue Plan mode "
@@ -9,5 +9,5 @@ expression: terminal.backend()
" "
" "
" "
" "
" Plan mode "
" Plan mode "
" tab to queue "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" tab to queue · Plan mode 98% context left "
" tab to queue Plan mode · 98% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" tab to queue · Plan mode "
" tab to queue Plan mode "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" tab to queue 98% context left "
" tab to queue message 98% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" tab to queue message "
" tab to queue "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used "
" Shell mode "
" Shell mode "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5289
expression: terminal.backend()
---
" "
@@ -11,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Normal "
" Vim: Normal · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
"› / "
" "
" 100% context left | Vim: Insert "
" Vim: Insert · 100% context left "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5360
expression: terminal.backend()
---
" "
@@ -11,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Normal "
" Vim: Normal · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts 100% context left | Vim: Insert "
" ? for shortcuts Vim: Insert · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Insert "
" Vim: Insert · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Replace "
" Vim: Replace · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Replace "
" Vim: Replace · 100% context left "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5328
expression: terminal.backend()
---
"› /diff show git diff (including untracked files) "
@@ -11,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Replace "
" Vim: Replace · 100% context left "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5389
expression: terminal.backend()
---
" "
@@ -11,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Normal "
" Vim: Normal · 100% context left "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" 100% context left | Vim: Normal "
" Vim: Normal · 100% context left "
@@ -6,4 +6,4 @@ expression: popup
› /daybreak
profile: work · provider: server-updated
Plan mode (⇧tab to cycle)
Plan mode (⇧tab to cycle)
@@ -9,4 +9,4 @@ expression: snapshot
"› Ask Codex to do anything "
" "
" GPT-5.6-Sol default · /tmp/project "
" ? for shortcuts Goal paused (/goal resume) "
" ? for shortcuts Goal paused (/goal resume) "
@@ -9,4 +9,4 @@ expression: snapshot
"› Ask Codex to do anything "
" "
" GPT-5.6-Sol default · /tmp/project "
" ? for shortcuts Goal paused (/goal resume) "
" ? for shortcuts Goal paused (/goal resume) "
@@ -7,4 +7,4 @@ expression: snapshot
"› Ask Codex to do anything "
" "
" GPT-5.5 max "
" ? for shortcuts Plan mode (⇧tab to cycle) "
" ? for shortcuts Plan mode (⇧tab to cycle) "
@@ -7,4 +7,4 @@ expression: terminal.backend()
"› Ask a follow-up question "
" "
" GPT-5.6-Sol "
" ? for shortcuts Side from main thread · ctrl+/ to switch · ctrl+c to close "
" ? for shortcuts · Side from main thread · ctrl+/ to switch · ctrl+c to close "
@@ -7,4 +7,4 @@ expression: terminal.backend()
"› Ask Codex to do anything "
" "
" GPT-5.6-Sol "
" ? for shortcuts ctrl+/ for side "
" ? for shortcuts · ctrl+/ for side "
@@ -7,4 +7,4 @@ expression: terminal.backend()
"› Ask a follow-up question "
" "
" GPT-5.6-Sol default · /tmp/project "
" Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close "
" Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close "
@@ -9,4 +9,4 @@ expression: normalized_backend_snapshot(terminal.backend())
"› Ask Codex to do anything "
" "
" GPT-5.6-Sol default "
" ? for shortcuts Side starting... "
" ? for shortcuts · Side starting... "
@@ -7,4 +7,4 @@ expression: normalized_backend_snapshot(terminal.backend())
"› Ask Codex to do anything "
" "
" GPT-5.5 "
" ? for shortcuts Pursuing goal (40K / 50K) "
" ? for shortcuts Pursuing goal (40K / 50K) "
@@ -7,4 +7,4 @@ expression: normalized_backend_snapshot(terminal.backend())
"› Ask Codex to do anything "
" "
" GPT-5.5 "
" ? for shortcuts Goal achieved (2d 23h 42m) "
" ? for shortcuts Goal achieved (2d 23h 42m) "
@@ -7,4 +7,4 @@ expression: normalized_backend_snapshot(terminal.backend())
"› Ask Codex to do anything "
" "
" gpt-5.2 medium "
" ? for shortcuts Plan mode (⇧tab to cycle) "
" ? for shortcuts Plan mode (⇧tab to cycle) "
@@ -10,4 +10,3 @@ expression: "setup.replace(&chat.config.cwd.display().to_string(), \"[CWD]\")"
› Input disabled until setup completes.
GPT-5.6-Sol default · [CWD]
? for shortcuts
+16 -6
View File
@@ -341,7 +341,8 @@ async fn side_context_label_shows_hidden_side_snapshot() {
chat.show_welcome_banner = false;
chat.local_settings.tui.status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
chat.set_side_conversation_context_label(Some("ctrl+/ for side".to_string()));
let label = "ctrl+/ for side";
chat.set_side_conversation_context_label(Some(label.to_string()));
let width = 80;
let height = chat.desired_height(width);
@@ -349,13 +350,22 @@ async fn side_context_label_shows_hidden_side_snapshot() {
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw hidden side conversation footer");
let hint_start = width - "ctrl+/ for side".len() as u16 - 2;
let hint = terminal
.backend()
.buffer()
.content()
.windows(label.len())
.find(|cells| {
cells
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
== label
})
.expect("side shortcut");
let key_style = crate::key_hint::ctrl(KeyCode::Char('/')).spans()[0].style;
let secondary_style = crate::style::secondary_text_style();
let styles = [hint_start, hint_start + 4, hint_start + 5, hint_start + 7].map(|x| {
let cell = &terminal.backend().buffer()[(x, height - 1)];
(cell.fg, cell.modifier)
});
let styles = [0, 4, 5, 7].map(|i| (hint[i].fg, hint[i].modifier));
assert_eq!(
styles,
[key_style, key_style, key_style, secondary_style].map(|style| (
@@ -37,10 +37,10 @@ parent-29 │
│child-29
│
│
› parent draft- │› child draft!
│
GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent
│ Side from main thread · ⌃/ to swit
› parent draft- │
│› child draft!
GPT-6.1-Sol default │
│ GPT-6.1-Sol default · Agent
99 columns, reopened
Side chat× +
@@ -60,4 +60,4 @@ child-29
› child draft!
GPT-6.1-Sol default · Agent
Side from main thread · ⌃/ to switch · ⌃c to close
Side from main thread · ⌃/ to switch · ⌃c to close
@@ -20,7 +20,7 @@ Zoomed
› xchild draft!
GPT-6.1-Sol default · Agent
tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close
tab to queue message · Side from main thread · ⌃/ to switch · ⌃c to close
Restored
parent-24 │ Side chat× +
│• unfinished answer
@@ -39,7 +39,7 @@ parent-29
› xparent draft-! │› xchild draft!
second line │
GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent
│ tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close
│ tab to queue message · Side from main thread · ⌃/ to switch · ⌃c to close
Equalized
parent-24 │ Side chat× +
│• unfinished answer
@@ -58,4 +58,4 @@ parent-29
› xparent draft-! │› xchild draft!
second line │
GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent
│ tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close
│ tab to queue message · Side from main thread · ⌃/ to switch · ⌃c to close
@@ -5,4 +5,4 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&chat, 40)"
› Ask Codex to do anything
GPT-5.5 default · /tmp/project
? for shortcuts Plan mode
Plan mode (⇧tab to cycle)
@@ -11,7 +11,6 @@ before:
› draft while loading
after:
>_ OpenAI Codex (v<VERSION>)
@@ -20,7 +19,6 @@ after:
› draft while loading
---
fork lookup fallback:
before:
@@ -31,7 +29,6 @@ before:
› draft while loading
after:
>_ OpenAI Codex (v<VERSION>)
@@ -40,7 +37,6 @@ after:
› draft while loading
---
resume picker cancellation:
before:
@@ -41,7 +41,6 @@ forking (48 columns):
› draft while loading
---
multiline (48 columns):
@@ -52,7 +51,6 @@ multiline (48 columns):
› first startup line
second startup line
---
narrow (18 columns):