Stabilize transcript and composer interactions in the TUI (#46749)

## Why

Composer suggestions and transient hints should preserve the draft and transcript context. Submitting a local command while reading older history should reveal its output.

## What changed

- Separate persistent status from transient footer hints in the owned transcript, sharing layout calculations for rendering, measurement, and cursor placement.
- Overlay command and mention suggestions on history without moving the composer; hide retained suggestions while transcript interactions own input.
- Show responsive, grouped shortcut help above the composer and let `Esc` dismiss it before interrupting a task or navigating history.
- Resume transcript following for submissions, slash commands, and last-response copy results while preserving the reading position during background updates.
- Reset transcript interaction state after prompt reverts and refresh retained usage information without rewriting terminal history.

## Testing

Add regression tests and snapshots for popup placement, clipped layouts, footer focus, shortcut dismissal, local-command scrolling, prompt reverts, and pagination while browsing prompts.

GitOrigin-RevId: 890147313d53f050503165a57610c381ffb93ef5
This commit is contained in:
Eric Traut
2026-09-20 02:59:37 +00:00
committed by copyberry
parent 551844b3ef
commit de6ae71ad4
142 changed files with 3856 additions and 1566 deletions
@@ -62,7 +62,13 @@ async fn older_server_notice_is_visible_in_agents_overview() {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"));
insta::assert_snapshot!(
rendered
.lines()
.take(/*n*/ 2)
.collect::<Vec<_>>()
.join("\n")
);
}
#[tokio::test]
@@ -73,14 +79,14 @@ async fn server_version_overview_notice_updates_and_clears() {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"), @" Service v0.151.0 < Codex CLI v0.153.0
insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::<Vec<_>>().join("\n"), @" Service v0.151.0 < Codex CLI v0.153.0
0 need input 0 working 0 ready");
app.update_server_version_overview_notice("0.153.0", /*server_version*/ None);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"), @" Agent command center
insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::<Vec<_>>().join("\n"), @" Agent command center
0 need input 0 working 0 ready");
}
+6 -4
View File
@@ -97,12 +97,14 @@ async fn escape_closes_shortcut_help_before_transcript_backtracking() -> Result<
if search {
app.transcript_view.begin_search();
} else {
app.handle_tui_event(
tui.screen_size_for_event(&TuiEvent::Resize(Size::new(
/*width*/ 80, /*height*/ 40,
)))?;
app.handle_owned_transcript_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
)
.await?;
&TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
)?;
}
assert!(app.transcript_view.has_active_interaction());
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(escape))
+15 -15
View File
@@ -299,14 +299,11 @@ impl App {
.await
{
app_server.cancel_older_history_page(thread_id, &cursor);
if tui.is_owned_screen() && self.chat_widget.thread_id() == Some(thread_id) {
if self.chat_widget.thread_id() == Some(thread_id) {
self.transcript_view.history = TranscriptHistoryState::Failed;
tui.frame_requester().schedule_frame();
}
if self.chat_widget.thread_id() == Some(thread_id)
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
{
overlay.set_history_state(TranscriptHistoryState::Failed);
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
overlay.set_history_state(TranscriptHistoryState::Failed);
}
tui.frame_requester().schedule_frame();
}
tracing::warn!(%thread_id, error = %err, "failed to load older transcript history");
@@ -721,14 +718,15 @@ impl App {
if self.chat_widget.thread_id() == Some(thread_id) {
if let Some(index) = crate::app_backtrack::nth_user_position(&self.transcript_cells, nth_user_message) {
self.transcript_cells.truncate(index);
self.native_history.retain(&self.transcript_cells);
self.transcript_view = crate::transcript_view::TranscriptView::default();
self.native_history.retain(&self.transcript_cells);
}
self.transcript_view = Default::default();
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
self.deferred_history_lines.clear();
self.last_rendered_history_tail = None;
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
self.backtrack_render_pending = true;
self.backtrack_render_pending = !tui.is_owned_screen();
self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ false);
self.chat_widget.emit_prompt_edit_thread_event();
tui.frame_requester().schedule_frame();
@@ -749,6 +747,10 @@ impl App {
self.insert_history_cell(tui, cell);
}
}
AppEvent::FollowTranscript => {
self.transcript_view.jump_to_latest();
tui.frame_requester().schedule_frame();
}
AppEvent::InsertHistoryCell(cell) => {
self.insert_history_cell(tui, cell);
}
@@ -2480,8 +2482,9 @@ impl App {
));
}
}
AppEvent::OpenAgentsOverview => {
self.open_agents_overview(app_server);
AppEvent::OpenAgentsOverview => self.open_agents_overview(app_server),
AppEvent::NewAgentsOverviewSession { cwd } => {
return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await;
}
AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => {
self.apply_agents_overview_thread_refresh(app_server, request_id, result);
@@ -2500,9 +2503,6 @@ impl App {
AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)),
}
}
AppEvent::NewAgentsOverviewSession { cwd } => {
return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await;
}
AppEvent::NewAgentsOverviewWorktree { cwd } => {
Box::pin(self.new_agents_overview_worktree(tui, app_server, cwd)).await;
}
@@ -87,7 +87,7 @@ async fn experimental_features_use_selected_server_profile_and_preserve_task_set
app.transcript_cells
.last()
.unwrap()
.display_lines(/*width*/ 120)
.transcript_lines(/*width*/ 120)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
+20 -9
View File
@@ -1,7 +1,8 @@
//! Terminal history, desktop handoff, and clear-screen UI helpers for the TUI app.
//!
//! This module owns rendering the fresh session header, clearing inline or alternate-screen UI
//! state, and resetting transcript-related app state after `/clear` or Ctrl-L.
//! state, and resetting transcript-related app state after `/clear` or Ctrl-L. Owned-screen sessions
//! keep committed cells as the render source and never enqueue terminal-scrollback rows here.
use super::*;
use crate::terminal_hyperlinks::HyperlinkLine;
@@ -117,7 +118,9 @@ impl App {
return Ok(());
}
if self.overlay.is_some() || self.initial_history_replay_buffer.is_some() {
if !tui.is_owned_screen()
&& (self.overlay.is_some() || self.initial_history_replay_buffer.is_some())
{
self.pending_thread_usage_history_refresh = true;
return Ok(());
}
@@ -125,11 +128,6 @@ impl App {
}
pub(crate) fn refresh_thread_usage_history_tail(&mut self, tui: &mut tui::Tui) -> Result<()> {
if tui.is_owned_screen() {
self.pending_thread_usage_history_refresh = false;
tui.frame_requester().schedule_frame();
return Ok(());
}
let Some(status_history) = self.last_thread_usage_status_cell.as_ref() else {
self.pending_thread_usage_history_refresh = false;
return Ok(());
@@ -160,6 +158,17 @@ impl App {
self.pending_thread_usage_history_refresh = false;
return Ok(());
}
if tui.is_owned_screen() {
// Composite cells are backed by mutable usage state, so the retained view remeasures
// them on its next frame without replacing native terminal history.
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
status_history.lines = updated_lines;
}
self.last_rendered_history_tail = None;
self.pending_thread_usage_history_refresh = false;
tui.frame_requester().schedule_frame();
return Ok(());
}
let Some(rendered_tail) = self.last_rendered_history_tail.as_ref() else {
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
self.last_rendered_history_tail = Some(RenderedHistoryTail {
@@ -280,6 +289,7 @@ impl App {
self.clear_ui_header_cell(version).display_lines(width)
}
/// Share the source-backed header between retained drawing and legacy terminal insertion.
fn clear_ui_header_cell(
&self,
version: &'static str,
@@ -334,7 +344,7 @@ impl App {
// Drop queued history insertions so stale transcript lines cannot be flushed after /clear.
tui.clear_pending_history_lines();
if is_alt_screen_active {
if tui.is_owned_screen() || is_alt_screen_active {
tui.terminal.clear_visible_screen()?;
} else {
// Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and
@@ -365,7 +375,8 @@ impl App {
self.overlay = None;
self.transcript_cells.clear();
self.native_history = Default::default();
self.transcript_view = crate::transcript_view::TranscriptView::default();
self.cancel_pending_key_chord();
self.transcript_view = Default::default();
self.last_rendered_history_tail = None;
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
+31
View File
@@ -1,6 +1,11 @@
//! Desktop handoff output and clear-screen history reset behavior.
use super::*;
use crate::app::tests::make_test_app_with_channels;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use pretty_assertions::assert_eq;
#[test]
fn desktop_thread_opened_history_snapshot() {
@@ -27,3 +32,29 @@ fn render_cell(cell: &impl HistoryCell) -> String {
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn owned_clear_resets_navigation_and_retains_one_fresh_header() -> Result<()> {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
app.insert_history_cell(
&mut tui,
Box::new(PlainHistoryCell::new(vec!["old".into()])),
);
app.transcript_view
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
app.reset_transcript_state_after_clear();
app.queue_clear_ui_header(&mut tui);
app.queue_clear_ui_header(&mut tui);
assert_eq!(app.transcript_cells.len(), 1);
assert_eq!(
app.transcript_cells[0].display_lines(/*width*/ 80),
app.clear_ui_header_lines(/*width*/ 80),
);
assert!(app.transcript_view.is_following());
assert!(tui.pending_history_lines_for_test().is_empty());
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
+21 -5
View File
@@ -68,7 +68,14 @@ impl App {
/*height*/ 1,
));
let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation);
let bottom = chat_widget.bottom_pane_renderable(prompt_footer.as_ref().or(footer.as_ref()));
let bottom = chat_widget.bottom_pane_renderable(
prompt_footer.as_ref().or(footer.as_ref()),
if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
);
let dashboard_visible = chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some();
@@ -131,8 +138,14 @@ impl App {
{
*progress = crate::bottom_pane::footer_hint_items_line(&items);
}
let bottom =
chat_widget.bottom_pane_renderable(prompt_footer.as_ref().or(footer.as_ref()));
let bottom = chat_widget.bottom_pane_renderable(
prompt_footer.as_ref().or(footer.as_ref()),
if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
);
footer_height_changed = !dashboard_visible
&& bottom
.desired_height(screen_size.width)
@@ -207,9 +220,12 @@ impl App {
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
}
if !self.transcript_view.has_active_interaction()
// Visible shortcut help owns Escape before returning a paused viewport to latest.
// A transcript search or selection still owns Escape while it replaces the help footer.
if let TuiEvent::Key(key) = event
&& !self.transcript_view.has_active_interaction()
&& self.chat_widget.shortcut_overlay_visible()
&& matches!(event, TuiEvent::Key(key) if key.code == KeyCode::Esc)
&& crate::key_hint::plain(KeyCode::Esc).is_press(*key)
{
return Ok(false);
}
@@ -142,6 +142,48 @@ async fn empty_enter_returns_to_latest_with_contextual_hints() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn submitting_a_draft_from_history_queues_once_and_follows_immediately() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 80, /*height*/ 12);
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(
(0..30).map(|row| format!("row {row}").into()).collect(),
))];
app.render_owned_transcript(&mut tui, size)?;
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ -10);
app.chat_widget
.apply_external_edit("send this once".to_string());
while events.try_recv().is_ok() {}
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyEvent::from(KeyCode::Enter)),
)
.await?;
let mut follows = 0;
while let Ok(event) = events.try_recv() {
if matches!(event, AppEvent::FollowTranscript) {
follows += 1;
app.handle_event(&mut tui, &mut server, event).await?;
}
}
assert_eq!(follows, 1);
assert!(app.transcript_view.is_following());
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["send this once"]
);
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
server.shutdown().await?;
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn enter_preserves_search_backtrack_and_modal_ownership_while_scrolled() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
+96 -1
View File
@@ -317,7 +317,10 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
let expected_cursor = app
.chat_widget
.bottom_pane_renderable(/*footer*/ None)
.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::Overlay,
)
.cursor_pos(bottom_area)
.expect("composer cursor");
assert_eq!(
@@ -921,6 +924,98 @@ async fn offline_backtrack_keeps_the_preview_and_draft_without_reverting() -> Re
Ok(())
}
#[tokio::test]
async fn slash_picker_overlays_history_without_moving_the_transcript_or_composer() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.chat_widget
.set_status_line(Some("gpt-test default · /tmp/project".into()));
app.local_settings.tui.animations = false;
app.chat_widget
.set_footer_hint_override(Some(vec![("model".to_string(), "high · fast".to_string())]));
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=40)
.map(|row| format!("Transcript row {row:02}: content behind the menu").into())
.collect(),
))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
for (width, height) in [(80, 14), (32, 14), (80, 7), (80, 5)] {
let size = Size::new(width, height);
tui.terminal.resize(size)?;
app.chat_widget.apply_external_edit("/m".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.render_owned_transcript(&mut tui, size)?;
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ -3);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
let before =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
// Changing the token reopens the menu dismissed above.
app.chat_widget.apply_external_edit("/mo".to_string());
app.chat_widget.apply_external_edit("/m".to_string());
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
let open =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
let open_text = buffer_text(&open);
if bottom.y > 0 {
let menu_y = open_text
.lines()
.position(|line| line.contains("› /model"))
.expect("available rows show command matches");
let prefix_len = menu_y * usize::from(width);
assert_eq!(
&open.content()[..prefix_len],
&before.content()[..prefix_len]
);
assert_ne!(open, before);
}
let composer_start = open.index_of(bottom.x, bottom.y);
assert_eq!(
&open.content()[composer_start..],
&before.content()[composer_start..]
);
if (width, height) == (80, 14) {
insta::assert_snapshot!("slash_picker_overlays_history", open_text);
}
app.chat_widget.apply_external_edit("/mo".to_string());
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
let filtered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
assert_eq!(
filtered
.lines()
.take(usize::from(bottom.y))
.any(|line| line.contains("› /model")),
bottom.y > 0,
"a single command remains visible when the menu has room",
);
app.chat_widget.apply_external_edit("/m".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
assert_eq!(
(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
tui.terminal.last_known_cursor_pos
),
(&before, cursor),
);
}
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn double_escape_browses_prompts_without_reverting_and_explains_editing() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
+1 -1
View File
@@ -771,7 +771,7 @@ impl App {
self.reset_transcript_state_after_clear();
tui.clear_pending_history_lines();
if tui.is_owned_screen() {
tui.terminal.clear_visible_screen()?;
tui.terminal.clear()?;
} else {
Self::clear_terminal_for_thread_switch(&mut tui.terminal)?;
}
@@ -1,9 +1,9 @@
---
source: tui/src/app/composer_hints_tests.rs
assertion_line: 37
expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join(\"\\n\\n\"))"
---
80 columns
› question
Tip: f12 searches this conversation
@@ -11,9 +11,9 @@ expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapsho
› Ask Codex to do anything
GPT-5.6-Sol default · /tmp/project
? for shortcuts
32 columns
› question
@@ -21,3 +21,4 @@ expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapsho
› Ask Codex to do anything
GPT-5.6-Sol default · /tmp/pr…
? for shortcuts
@@ -1,10 +1,9 @@
---
source: tui/src/app/owned_transcript_input_tests.rs
assertion_line: 257
assertion_line: 299
expression: "format!(\"before\\n{before}\\n\\nafter\\n{}\", screen(&tui))"
---
before
row 13
row 14
row 15
row 16
@@ -15,10 +14,10 @@ row 19
› draft preserved
GPT-5.6-Sol default · /tmp/project
New activity · esc latest
after
row 25
row 26
row 27
row 28
@@ -4,7 +4,6 @@ assertion_line: 220
expression: "snapshots.join(\"\\n\\n\")"
---
Latest
Transcript row 34
Transcript row 35
Transcript row 36
Transcript row 37
@@ -17,39 +16,40 @@ Transcript row 40
gpt-test default · /tmp/project
Reading
Transcript row 28
Transcript row 29
Transcript row 30
Transcript row 31
Transcript row 32
Transcript row 33
Transcript row 34
Transcript row 35
↓ Back to bottom · esc
› draft
gpt-test default · /tmp/project
esc latest
Resized multiline composer
Transcript row 28
Transcript row 29
Transcript row 30
Transcript row 31
Transcript row 32
Transcript row 33
↓ Back to bottom · esc
› draft
second line
third line
gpt-test default · /tmp/project
esc latest
Tiny terminal
↓ Back to bottom · esc
› draft
gpt-test default · /tmp/project
esc latest
Detailed
@@ -59,9 +59,9 @@ Detailed
↓ Back to bottom · esc
› preserved draft
gpt-test default · /tmp/project
esc latest
@@ -1,6 +1,6 @@
---
source: tui/src/app/owned_transcript_tests.rs
assertion_line: 1077
assertion_line: 1074
expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)))"
---
@@ -15,7 +15,7 @@ expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_s
› third question
› Ask Codex to do anything
gpt-test default · /tmp/project
Browsing · ↑↓/jk scroll · ←→/hl prompts · ctrl+t details · ↵ rewind · esc back
@@ -0,0 +1,18 @@
---
source: tui/src/app/owned_transcript_tests.rs
expression: open_text
---
Transcript row 30: content behind the menu
Transcript row 31: content behind the menu
Transcript row 32: content behind the menu
Transcript row 33: content behind the menu
Transcript row 34: content behind the menu
› /model choose what model and reasoning effort to use
/memories configure memory use and generation
/mention mention a file
/mcp list configured MCP tools; use /mcp verbose for details
› /m
gpt-test default · /tmp/project
model high · fast
@@ -0,0 +1,21 @@
---
source: tui/src/app/tests.rs
assertion_line: 8342
expression: transcript
---
/permissions - choose what Codex is allowed to do
/model - choose what model and reasoning effort to use
/review - review any changes and find issues
› retained prompt
[Image #1]
[Image #2]
› selected prompt [Image #1]
• Conversation reverted to this point. File changes are unchanged.
+1 -1
View File
@@ -750,8 +750,8 @@ See the Codex keymap documentation for supported actions and examples."
keymap: runtime_keymap,
key_chord_matcher: KeyChordMatcher::default(),
transcript_cells: Vec::new(),
transcript_view: Default::default(),
native_history: Default::default(),
transcript_view: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
+1 -1
View File
@@ -456,7 +456,7 @@ mod tests {
let mut rendered = Vec::new();
while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() {
rendered.extend(
cell.display_lines(/*width*/ 120)
cell.transcript_lines(/*width*/ 120)
.iter()
.map(render_line_text),
);
+1 -1
View File
@@ -37,8 +37,8 @@ pub(super) async fn make_test_app() -> App {
pending_server_profiles: HashMap::new(),
file_search,
transcript_cells: Vec::new(),
transcript_view: Default::default(),
native_history: Default::default(),
transcript_view: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
+94 -14
View File
@@ -21,6 +21,8 @@ mod backend_banner_startup_tests;
mod background_exit_tests;
#[path = "tests/background_task_defaults_tests.rs"]
mod background_task_defaults_tests;
#[path = "tests/browsing_pagination_tests.rs"]
mod browsing_pagination_tests;
#[path = "tests/buffered_replay.rs"]
mod buffered_replay;
#[path = "tests/connector_policy.rs"]
@@ -33,6 +35,8 @@ mod fork_workspace_roots_tests;
mod fresh_sparkle_tests;
#[path = "tests/key_chords.rs"]
mod key_chords;
#[path = "tests/local_command_scroll_tests.rs"]
mod local_command_scroll_tests;
#[path = "tests/luna_reserve_recovery_tests.rs"]
mod luna_reserve_recovery_tests;
#[path = "tests/mcp_startup.rs"]
@@ -80,7 +84,6 @@ mod stream_animation_tests;
mod thread_usage;
#[path = "tests/transcript_composer.rs"]
mod transcript_composer;
#[path = "tests/transcript_selection.rs"]
mod transcript_selection;
#[path = "tests/turn_submission.rs"]
@@ -457,7 +460,7 @@ fn bypass_hook_trust_startup_warning_snapshot() {
"`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."
.to_string(),
)
.display_lines(/*width*/ 80),
.transcript_lines(/*width*/ 80),
);
assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered);
@@ -481,7 +484,7 @@ async fn cyber_model_auto_review_notice_snapshot() -> Result<()> {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
let rendered = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
assert_app_snapshot!("cyber_model_auto_review_notice", rendered);
Ok(())
}
@@ -5096,11 +5099,11 @@ async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> {
);
assert_snapshot!(
format!("{input_footer}\n{approval_footer}\n{cleared_footer}"),
@"
Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close
Side from main thread · main needs approval · ctrl+/ to switch · ctrl+c to close
Side from main thread · ctrl+/ to switch · ctrl+c to close
"
@r"
Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close
Side from main thread · main needs approval · ctrl+/ to switch · ctrl+c to close
Side from main thread · ctrl+/ to switch · ctrl+c to close
"
);
Ok(())
@@ -5975,8 +5978,8 @@ async fn make_test_app() -> App {
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
transcript_view: crate::transcript_view::TranscriptView::default(),
native_history: Default::default(),
transcript_view: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
@@ -6078,8 +6081,8 @@ pub(super) async fn make_test_app_with_channels() -> (
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
transcript_view: crate::transcript_view::TranscriptView::default(),
native_history: Default::default(),
transcript_view: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
@@ -8147,8 +8150,11 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
// Leave the oldest prompt on an unloaded page.
// Bound fixture hydration independently from the owned viewport exercised below.
// Inline hydration's row cap leaves the oldest prompt on an unloaded page.
let mut local_settings = crate::local_settings::LocalSettings::from(&config);
local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never;
local_settings.tui.terminal_resize_reflow_max_rows = Some(2);
let started = app_server
.resume_thread(
@@ -8211,6 +8217,48 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
.await?
.goal;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 80, /*height*/ 24);
app.render_owned_transcript(&mut tui, size)?;
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ -1);
app.render_owned_transcript(&mut tui, size)?;
// Freeze a visible selection before the same thread's authoritative history changes.
let selected_row = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
.content()
.chunks(usize::from(size.width))
.position(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.contains("selected prompt")
})
.expect("selected prompt should be visible") as u16;
for (kind, column) in [
(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
2,
),
(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
8,
),
] {
app.transcript_view.handle_mouse(
crossterm::event::MouseEvent {
kind,
column,
row: selected_row,
modifiers: KeyModifiers::NONE,
},
&app.transcript_cells,
);
}
assert!(
app.transcript_view
.selected_text(&app.transcript_cells)
.is_some()
);
let prompt = crate::chatwidget::UserMessage {
text: "selected prompt [Image #1]".to_string(),
local_images: vec![crate::bottom_pane::LocalImageAttachment {
@@ -8260,8 +8308,9 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
assert_eq!(
app_server
.thread_read(source_thread_id, /*include_turns*/ true)
.await?,
before
.await?
.turns,
before.turns
);
}
app.app_server_target = crate::AppServerTarget::Embedded;
@@ -8303,6 +8352,32 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
_ => {}
}
}
assert_eq!(
(
app.transcript_view.is_following(),
app.transcript_view.selected_text(&app.transcript_cells)
),
(true, None),
);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let transcript = buffer
.content()
.chunks(usize::from(size.width))
.take(usize::from(bottom.y.saturating_sub(/*rhs*/ 1)))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
let stable_cwd = "/tmp/project";
let cwd = test_path_buf(stable_cwd).display().to_string();
let transcript = transcript.replace(&cwd, &format!("{stable_cwd:<width$}", width = cwd.len()));
insta::assert_snapshot!("owned_prompt_revert_retained_history", transcript);
assert!(Arc::ptr_eq(
&child,
&app.thread_event_channels[&child_id].store
@@ -8352,7 +8427,9 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
.iter()
.any(|line| line.contains("Thread forked from"))
);
// Editing the first visible prompt leaves the unloaded prefix intact.
// Editing the first visible prompt also discards search state from the removed window.
app.transcript_view.begin_search();
assert!(app.transcript_view.is_search_active());
app.chat_widget.restore_thread_input_state(
/*input_state*/ None,
crate::chatwidget::ThreadInputStateRestoreMode {
@@ -8376,6 +8453,8 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
}
}
assert!(!app.transcript_view.is_search_active());
assert!(app.transcript_view.is_following());
assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id));
assert_eq!(
app.chat_widget.composer_text_with_pending(),
@@ -8422,6 +8501,7 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res
app_server.thread_goal_get(source_thread_id).await?.goal,
Some(goal)
);
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
@@ -3,6 +3,8 @@
use super::*;
use crate::app::reconnect::ReconnectPresentation;
use crate::app::reconnect::reconnect;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::net::TcpListener;
@@ -24,6 +26,9 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
let pending_profile = !recovered_queue && resume_error_code == -32600;
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
app.local_settings.tui.show_server_version_notice = notice_enabled;
// Reconstructed widgets should use the same hint on every test terminal.
app.local_settings.tui.keymap.chat.edit_queued_message =
Some(KeybindingsSpec::One(KeybindingSpec("alt-up".into())));
let id = ThreadId::new();
let cwd = app.config.cwd.clone();
app.config.model = Some("gpt-test".into());
@@ -732,7 +737,7 @@ pub(super) async fn drain_history(
Ok(lines_to_single_string(
&app.transcript_cells
.iter()
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.flat_map(|cell| cell.transcript_lines(/*width*/ 80))
.collect::<Vec<_>>(),
))
}
@@ -221,11 +221,17 @@ async fn daemon_ctrl_c_shows_background_exit_menu_and_escape_dismisses_it() -> R
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(!app.chat_widget.no_modal_or_popup_active());
for width in [40, 90] {
let rendered = render_bottom_popup(&app.chat_widget, width);
assert!(rendered.contains("enter select · esc back"), "{rendered}");
insta::assert_snapshot!(format!("running_task_exit_picker_{width}"), rendered);
}
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r"
Task is still running
Choose what happens to the current task.
› 1. Cancel task Stop the current task and stay in Codex
2. Run in background Exit Codex and leave the task running
3. Exit Stop the current task and exit Codex
enter select · esc back
");
app.handle_key_event(
&mut tui,
@@ -484,7 +490,7 @@ async fn daemon_ctrl_c_hides_background_exit_for_running_background_side_thread(
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @"
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r"
Task is still running
Choose what happens to the current task.
@@ -0,0 +1,157 @@
//! Prompt browsing pages past a partial answer without losing its selection or exit origin.
use super::pagination_completion_tests::completed_history_app;
use super::session_lifecycle_requests::start_recording_app_server;
use super::*;
use crate::history_cell::PlainHistoryCell;
use crate::pager_overlay::TranscriptHistoryState;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn browsing_loads_before_the_oldest_prompt_after_a_partial_answer() -> Result<()> {
for cancel_before_completion in [false, true] {
let (mut app, _codex_home, thread_id) =
completed_history_app(&["Oldest", "Middle", "Newest"]).await?;
let (mut app_server, _requests, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let started = app_server
.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
// A transport page may begin inside the preceding answer, well before its first prompt.
app.transcript_cells = (0..30)
.map(|index| {
Arc::new(PlainHistoryCell::new(vec![
format!("Earlier answer fragment {index}").into(),
])) as Arc<dyn HistoryCell>
})
.collect();
app.transcript_cells
.extend(crate::thread_transcript::thread_items_to_transcript_cells(
Some(thread_id),
&app.config.cwd,
started.turns.iter().flat_map(|turn| turn.items.clone()),
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(&app.config),
));
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app.scrollback_has_older_history = app_server.has_older_history(thread_id);
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
app.transcript_view.history = TranscriptHistoryState::Partial;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = tui.terminal.size()?;
app.render_owned_transcript(&mut tui, size)?;
for _ in 0..2 {
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Esc.into()),
)
.await?;
}
assert_eq!(
(
app.backtrack.overlay_preview_active,
app.backtrack.nth_user_message
),
(true, 0)
);
assert!(!app.transcript_view.needs_history(&app.transcript_cells));
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
.await?;
assert_eq!(
app.transcript_view.history,
TranscriptHistoryState::LoadingOlder
);
let first_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), event_rx.recv())
.await?
.expect("older history completion");
let AppEvent::OlderThreadHistoryLoaded { cursor, .. } = first_event else {
panic!("expected an older history completion")
};
// Repeated navigation while loading must keep a single request in flight.
for code in [KeyCode::Left, KeyCode::Char('h')] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(code.into()))
.await?;
}
assert!(app_server.is_older_history_page_pending(thread_id, &cursor));
assert!(event_rx.try_recv().is_err());
// Complete only an answer item first: browsing must continue to a page with a prompt.
let first_page = app_server
.thread_items_page(
thread_id,
/*turn_id*/ None,
Some(cursor.clone()),
/*limit*/ 1,
)
.await?;
let next_cursor = first_page.next_cursor.clone().expect("older prompt page");
if cancel_before_completion {
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Esc.into()),
)
.await?;
}
app.handle_older_history_page(
&mut tui,
&mut app_server,
thread_id,
&cursor,
Ok(first_page),
)
.await?;
assert_eq!(
app_server.is_older_history_page_pending(thread_id, &next_cursor),
!cancel_before_completion
);
if cancel_before_completion {
assert_eq!(
(
app.backtrack.overlay_preview_active,
app.transcript_view.is_following()
),
(false, true)
);
} else {
let final_event =
tokio::time::timeout(Duration::from_secs(/*secs*/ 5), event_rx.recv())
.await?
.expect("older prompt completion");
Box::pin(app.handle_event(&mut tui, &mut app_server, final_event)).await?;
assert_eq!(
(app.backtrack.nth_user_message, app.transcript_view.history),
(2, TranscriptHistoryState::Complete)
);
for (code, selected) in [(KeyCode::Left, 1), (KeyCode::Char('h'), 0)] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(code.into()))
.await?;
assert_eq!(app.backtrack.nth_user_message, selected);
}
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let text = buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(text.contains("Oldest prompt"), "{text}");
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
proxy.await??;
}
Ok(())
}
@@ -226,7 +226,7 @@ async fn commands_can_precede_the_sparkle_but_inserted_or_typed_drafts_cannot()
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT));
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.contains("customize shortcuts with /keymap")
.contains("Keyboard shortcuts")
);
}
"commands" => {
@@ -0,0 +1,258 @@
//! Explicit local commands return to their output without making background updates
//! steal the reader's position in the owned transcript.
use super::*;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use pretty_assertions::assert_eq;
fn hold_older_history(app: &mut App, tui: &mut crate::tui::Tui) {
app.insert_history_cell(
tui,
Box::new(PlainHistoryCell::new(
(0..6)
.map(|index| Line::from(format!("older {index}")))
.collect(),
)),
);
app.transcript_view
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
assert!(!app.transcript_view.is_following());
}
fn transcript_buffer(app: &mut App) -> Buffer {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3,
);
let mut buffer = Buffer::empty(area);
app.transcript_view
.render(area, &mut buffer, &app.transcript_cells);
buffer
}
fn submit_local_command(app: &mut App, command: &str) {
app.chat_widget.apply_external_edit(command.to_owned());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
// Closing the slash popup can require one Enter before submitting. Stop as
// soon as the command is consumed so a newly opened picker stays untouched.
for _ in 0..2 {
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if app.chat_widget.composer_text_with_pending().is_empty() {
break;
}
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
}
#[tokio::test]
async fn local_command_and_inline_error_reveal_their_history_output() -> Result<()> {
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = start_config_write_test_app_server(&app).await?;
tui.set_owned_screen(/*owned*/ true)?;
for (command, output) in [
("/status", "/status"),
("/keymap invalid", "Usage: /keymap [debug]"),
] {
hold_older_history(&mut app, &mut tui);
while events.try_recv().is_ok() {}
submit_local_command(&mut app, command);
let follow = events.try_recv().expect("command follow event");
assert_matches!(&follow, AppEvent::FollowTranscript);
app.handle_event(&mut tui, &mut server, follow).await?;
let mut inserted = false;
while let Ok(event) = events.try_recv() {
if matches!(&event, AppEvent::InsertHistoryCell(_)) {
app.handle_event(&mut tui, &mut server, event).await?;
inserted = true;
}
}
assert!(inserted, "{command} should emit local history output");
assert!(app.transcript_view.is_following());
let cell = app.transcript_cells.last().expect("local command output");
assert!(lines_to_single_string(&cell.display_lines(/*width*/ 80)).contains(output));
}
let visible = transcript_buffer(&mut app)
.content
.chunks(/*chunk_size*/ 80)
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(visible, @"
older 5
■ Usage: /keymap [debug]
");
tui.set_owned_screen(/*owned*/ false)?;
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn copy_shortcut_reveals_its_feedback_without_changing_the_draft() -> Result<()> {
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = start_config_write_test_app_server(&app).await?;
tui.set_owned_screen(/*owned*/ true)?;
hold_older_history(&mut app, &mut tui);
app.chat_widget
.apply_external_edit("draft preserved".to_string());
while events.try_recv().is_ok() {}
// No response avoids the host clipboard while exercising the real shortcut route.
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)),
)
.await?;
assert!(!app.transcript_view.is_following());
// The default copy shortcut is a chord; its prefix alone must not move the viewport.
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyCode::Char('o').into()),
)
.await?;
while let Ok(event) = events.try_recv() {
app.handle_event(&mut tui, &mut server, event).await?;
}
assert!(app.transcript_view.is_following());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"draft preserved"
);
let visible = transcript_buffer(&mut app)
.content
.chunks(/*chunk_size*/ 80)
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(visible, @"
older 5
■ No agent response to copy
");
tui.set_owned_screen(/*owned*/ false)?;
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn dynamic_service_tier_command_returns_to_latest() -> Result<()> {
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
set_fast_mode_test_catalog(&mut app.chat_widget);
app.chat_widget.set_model("gpt-5.4");
app.chat_widget
.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = start_config_write_test_app_server(&app).await?;
tui.set_owned_screen(/*owned*/ true)?;
hold_older_history(&mut app, &mut tui);
while events.try_recv().is_ok() {}
submit_local_command(&mut app, "/fast");
let follow = events.try_recv().expect("service tier follow event");
assert_matches!(&follow, AppEvent::FollowTranscript);
app.handle_event(&mut tui, &mut server, follow).await?;
assert!(app.transcript_view.is_following());
assert!(
std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection { service_tier: Some(tier) }
if tier == ServiceTier::Fast.request_value()
))
);
tui.set_owned_screen(/*owned*/ false)?;
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn usage_picker_opens_analytics_without_moving_the_background_transcript() -> Result<()> {
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
set_chatgpt_auth(&mut app.chat_widget);
let startup_request = app.chat_widget.start_rate_limit_reset_startup_check();
assert!(app.chat_widget.finish_rate_limit_reset_hint_refresh(
startup_request,
Vec::new(),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None,
}),
));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = start_config_write_test_app_server(&app).await?;
tui.set_owned_screen(/*owned*/ true)?;
hold_older_history(&mut app, &mut tui);
while events.try_recv().is_ok() {}
submit_local_command(&mut app, "/usage");
let follow = events.try_recv().expect("usage command follow event");
assert_matches!(&follow, AppEvent::FollowTranscript);
app.handle_event(&mut tui, &mut server, follow).await?;
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("View analytics"));
while events.try_recv().is_ok() {}
app.transcript_view
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let open = events.try_recv().expect("usage picker action");
assert_matches!(&open, AppEvent::OpenAnalytics { view: None });
let held = transcript_buffer(&mut app);
app.handle_event(&mut tui, &mut server, open).await?;
assert!(matches!(app.overlay.as_ref(), Some(Overlay::Analytics(_))));
assert_eq!(transcript_buffer(&mut app), held);
assert!(!app.transcript_view.is_following());
tui.set_owned_screen(/*owned*/ false)?;
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn background_history_insertion_keeps_the_visible_reading_rows() -> Result<()> {
let (mut app, _events, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = start_config_write_test_app_server(&app).await?;
tui.set_owned_screen(/*owned*/ true)?;
hold_older_history(&mut app, &mut tui);
let held = transcript_buffer(&mut app);
app.handle_event(
&mut tui,
&mut server,
AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![
"background task finished".into(),
]))),
)
.await?;
assert_eq!(transcript_buffer(&mut app), held);
assert!(!app.transcript_view.is_following());
assert_eq!(app.transcript_cells.len(), 2);
tui.set_owned_screen(/*owned*/ false)?;
server.shutdown().await?;
Ok(())
}
@@ -166,7 +166,7 @@ async fn model_default_saves_report_server_outcomes_and_target_server_profile()
let messages = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 80)))
Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
}
_ => None,
})
@@ -1927,6 +1927,9 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
#[tokio::test]
async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> {
let (mut app, codex_home) = make_history_test_app().await?;
// The inline scrollback row cap fixes the page boundary around the review marker.
app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
app.local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100);
let thread_id = create_fake_paginated_rollout(
codex_home.path(),
@@ -2123,7 +2126,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() ->
}
#[tokio::test]
async fn transcript_home_loads_every_older_history_page() -> Result<()> {
async fn transcript_alt_beginning_loads_every_older_history_page() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
@@ -2260,7 +2263,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> {
app.handle_backtrack_overlay_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::ALT)),
)
.await?;
while app_server.has_older_history(thread_id) {
@@ -2279,7 +2282,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> {
.any(|line| line.to_string().contains("history output 0"))
}));
let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else {
panic!("expected transcript overlay after Home navigation");
panic!("expected transcript overlay after beginning navigation");
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12,
@@ -2542,6 +2545,9 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs());
// Keep initial hydration within the inline scrollback budget so refill has work to do.
app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
app.local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(8);
let thread_id = create_history_rollout(
&app.config,
@@ -2675,7 +2681,10 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
assert!(!partial.contains("This is a test announcement"));
assert!(!partial.contains('%'));
overlay.set_history_state(crate::pager_overlay::TranscriptHistoryState::LoadingOlder);
overlay.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
)?;
let loading = render_overlay(overlay);
assert!(loading.contains("Loading earlier messages…"));
assert!(!loading.contains("OpenAI Codex"));
@@ -3810,7 +3819,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook
AppEvent::InsertHistoryCell(cell) => Some(cell),
_ => None,
})
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 200)))
.map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 200)))
.collect::<Vec<_>>()
};
let change = |thread_id, path: &str| AppEvent::ChangeWorkingDirectory {
@@ -4334,16 +4343,16 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> {
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.replace(&root_thread_id.to_string(), "[root]")
.replace(&child_thread_id.to_string(), "[child]"),
@"
Subagents
Select an agent to watch. ⌥+← previous, ⌥+→ next.
@r###"
Subagents
Select an agent to watch. ⌥+← previous, ⌥+→ next.
› 1. • Main [default] (current) [root]
2. • /root/worker [child]
› 1. • Main [default] (current) [root]
2. • /root/worker [child]
enter select · esc back
"
enter select · esc back
"###
);
assert_eq!(take_backfill_counts(&requests), (0, 0));
tokio::time::timeout(Duration::from_secs(5), started_rx).await??;
@@ -4631,9 +4640,11 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re
let current_id = current.session.thread_id;
app.enqueue_primary_thread_session(current.session, current.turns)
.await?;
let thread = owner
let mut thread = owner
.thread_read(thread_id, /*include_turns*/ false)
.await?;
// Keep the age label stable while the server exercises failure recovery.
thread.updated_at = chrono::Utc::now().timestamp() - 7 * 24 * 60 * 60;
let mut view = app.agents_overview_view(vec![thread], Some(thread_id));
view.handle_paste("Keep this draft".into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
@@ -1,15 +0,0 @@
---
source: tui/src/app/tests/background_exit_tests.rs
assertion_line: 227
expression: rendered
---
Task is still running
Choose what happens to the current
task.
› 1. Cancel task
2. Run in background
3. Exit
enter select · esc back
@@ -1,13 +0,0 @@
---
source: tui/src/app/tests/background_exit_tests.rs
expression: rendered
---
Task is still running
Choose what happens to the current task.
› 1. Cancel task Stop the current task and stay in Codex
2. Run in background Exit Codex and leave the task running
3. Exit Stop the current task and exit Codex
enter select · esc back
+3 -3
View File
@@ -1182,7 +1182,7 @@ async fn fresh_startup_notice_follows_session_attachment() {
})
.collect::<Vec<_>>();
assert!(cells.len() > 1, "session history should precede the notice");
insta::assert_snapshot!(lines_to_single_string(&cells.last().unwrap().display_lines(/*width*/ 80)), @"⚠ Older server notice");
insta::assert_snapshot!(lines_to_single_string(&cells.last().unwrap().transcript_lines(/*width*/ 80)), @"⚠ Older server notice");
assert_eq!(app.pending_server_version_notice, None);
}
@@ -1215,7 +1215,7 @@ async fn remote_overview_startup_hides_disabled_older_server_notice() -> Result<
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"), @" Agent command center
insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::<Vec<_>>().join("\n"), @" Agent command center
0 need input 0 working 0 ready");
app.chat_widget.remote_connection =
crate::status::remote_connection::remote_connection_status_value(
@@ -1233,7 +1233,7 @@ async fn remote_overview_startup_hides_disabled_older_server_notice() -> Result<
app.local_settings.tui.show_server_version_notice = true;
app.refresh_server_version_overview_notice("2.1.0");
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"), @" Service v2.0.0 < Codex CLI v2.1.0
insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::<Vec<_>>().join("\n"), @" Service v2.0.0 < Codex CLI v2.1.0
0 need input 0 working 0 ready");
app.pending_server_version_notice =
Some(crate::status::remote_connection::ServerVersionNotice {
@@ -283,7 +283,7 @@ async fn run_verification_rpc_scenario(scenario: RpcScenario) -> color_eyre::Res
panic!("expected warning history cell");
};
let rendered = cell
.display_lines(/*width*/ 80)
.transcript_lines(/*width*/ 80)
.iter()
.map(|line| {
line.spans
+9 -5
View File
@@ -275,8 +275,12 @@ pub(crate) enum AppEvent {
ReviewMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
ContinueMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
CloseMisalignmentReview,
/// Open the daemon-wide overview of recent and locally retained root sessions.
/// Open the live command center for recent and locally retained root sessions.
OpenAgentsOverview,
/// Create an empty thread from the command center.
NewAgentsOverviewSession {
cwd: Option<AbsolutePathBuf>,
},
/// Update the daemon-wide overview after a background thread listing finishes.
AgentsOverviewThreadsLoaded {
request_id: Uuid,
@@ -286,10 +290,6 @@ pub(crate) enum AppEvent {
SelectAgentsOverviewThread {
thread_id: ThreadId,
},
/// Open an empty session in the selected checkout.
NewAgentsOverviewSession {
cwd: Option<AbsolutePathBuf>,
},
/// Create an empty session in a worktree from the selected project's default branch.
NewAgentsOverviewWorktree {
cwd: Option<AbsolutePathBuf>,
@@ -1069,6 +1069,10 @@ pub(crate) enum AppEvent {
/// resize-reflow tail renderer.
BeginThreadSwitchHistoryReplayBuffer,
/// Resume following the transcript after an explicit local command submission.
/// Background output and later refreshes must preserve the user's reading position.
FollowTranscript,
InsertHistoryCell(Box<dyn HistoryCell>),
/// Move visible completed voice captions into history in one app event.
@@ -227,6 +227,7 @@ impl BottomPane {
pub(super) fn handle_inline_banner_key(&mut self, key: KeyEvent) -> bool {
// Draft input, completion menus, and paste bursts retain all of their normal keys.
if !self.composer_is_empty()
|| (key.code == KeyCode::Esc && self.composer.shortcut_overlay_visible())
|| self.composer.popup_active()
|| self.composer.is_in_paste_burst()
|| self.composer_should_handle_vim_insert_escape(key)
+135 -142
View File
@@ -1,11 +1,16 @@
//! The chat composer is the bottom-pane text input state machine.
//! A borrowed transcript Find footer supplies its own caret without changing the draft cursor.
//!
//! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes
//! completed slash commands to atomic elements, and handles Enter submission/newlines.
//! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts
//! from raw key streams, particularly on Windows.
//! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible.
//! Owned transcripts keep persistent status below the composer and hints on a separate final row.
//! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row
//! so input and persistent status stay anchored when help opens or closes.
//! Escape dismisses visible shortcut help before editing, transcript backtracking, or interruption.
//! Transcript interactions borrow the hint footer through `ComposerRenderOptions`;
//! its resolved presentation drives height, painting, and the focused cursor together.
//!
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
//! submit ordinary text without enabling shell mode.
@@ -29,6 +34,14 @@
//!
//! # Completion and Popup Dismissal
//!
//! All completion suggestions render above the composer and preserve its footer.
//! Owned transcript frames overlay suggestions with blank rows above and below, without reserving
//! layout space. Those rows show scroll arrows when suggestions extend beyond the visible menu.
//! Transcript interactions hide suggestions while retaining their query and selection;
//! closing the interaction restores the same draft and completion state.
//! Measurement, painting, and cursor placement share that layout, including clipped views.
//! Unified mention tabs retain their position across filters; Left/Right also works at a bare `@`.
//!
//! Popup targeting resolves an editable token range around the cursor and treats atomic text
//! elements as hard boundaries. When that range begins immediately after an atomic element, the
//! insertion path first adds a horizontal separator and shifts the range so the completion cannot
@@ -297,6 +310,7 @@ 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;
@@ -355,12 +369,15 @@ mod reconnect;
pub(crate) use reconnect::RestrictedInputMode;
mod slash_input;
mod sparkle;
mod status_surface;
mod vim_history;
mod vim_search;
use self::attachment_state::AttachmentState;
use self::draft_state::ComposerMentionBinding;
use self::draft_state::DraftState;
pub(crate) use self::footer_state::CommandPopupPlacement;
use self::footer_state::ComposerLayout;
pub(crate) use self::footer_state::ComposerRenderOptions;
use self::footer_state::FooterState;
pub(crate) use self::footer_state::TranscriptFooter;
@@ -647,8 +664,6 @@ pub(crate) struct ComposerDraftSnapshot {
pub(crate) sparkle_draft: sparkle::SparkleDraft,
}
const FOOTER_SPACING_HEIGHT: u16 = 0;
impl ChatComposer {
fn slash_input(&self) -> SlashInput<'_> {
SlashInput::new(
@@ -722,6 +737,8 @@ impl ChatComposer {
.primary_hint(KeymapContext::Global, "open_external_editor"),
show_transcript_key: default_keymap
.primary_hint(KeymapContext::Global, "open_transcript"),
find_transcript_key: default_keymap
.primary_hint(KeymapContext::Global, "find_transcript"),
insert_newline_key: footer_insert_newline_key(
&default_keymap.editor.insert_newline,
use_shift_enter_hint,
@@ -977,6 +994,12 @@ impl ChatComposer {
self.voice_command_enabled = enabled;
}
/// Refresh the event destination when a retained editor survives reconnection.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn set_app_event_sender(&mut self, app_event_tx: AppEventSender) {
self.app_event_tx = app_event_tx;
}
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
///
/// Submit and queue bindings are cached here because composer dispatch must
@@ -996,6 +1019,8 @@ impl ChatComposer {
keymap.primary_hint(KeymapContext::Global, "open_external_editor");
self.footer.show_transcript_key =
keymap.primary_hint(KeymapContext::Global, "open_transcript");
self.footer.find_transcript_key =
keymap.primary_hint(KeymapContext::Global, "find_transcript");
self.footer.insert_newline_key =
match keymap.primary_hint(KeymapContext::Editor, "insert_newline") {
hint @ Some(ShortcutHint::Chord { .. }) => hint,
@@ -1069,62 +1094,6 @@ impl ChatComposer {
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
self.windows_degraded_sandbox_active = enabled;
}
fn layout_areas(&self, area: Rect) -> [Rect; 4] {
self.layout_areas_with_options(area, ComposerRenderOptions::default())
}
fn layout_areas_with_options(
&self,
area: Rect,
options: ComposerRenderOptions<'_>,
) -> [Rect; 4] {
let options = self.resolve_render_options(options);
let textarea_right_reserve = options.textarea_right_reserve;
let footer_hint_height = self.footer_hint_height(options);
let footer_total_height = footer_hint_height + Self::footer_spacing(footer_hint_height);
let popup_height = self
.popups
.active
.required_height(area.width, footer_total_height);
let popup_constraint = Constraint::Max(popup_height);
let voice_rows = if self.voice_strip.is_some() { 3 } else { 0 };
let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(3 + voice_rows), popup_constraint]).areas(area);
// Keep the draft visible when clipped.
let voice_rows = voice_rows * u16::from(composer_rect.height >= 6);
let mut textarea_rect = composer_rect.inset(Insets::tlbr(
/*top*/ 1 + voice_rows,
LIVE_PREFIX_COLS,
/*bottom*/ 1,
/*right*/ 1u16.saturating_add(textarea_right_reserve),
));
let remote_images_height = self
.attachments
.remote_image_lines()
.len()
.try_into()
.unwrap_or(u16::MAX)
.min(textarea_rect.height.saturating_sub(1));
let remote_images_separator = u16::from(remote_images_height > 0);
let consumed = remote_images_height.saturating_add(remote_images_separator);
let remote_images_rect = Rect {
x: textarea_rect.x,
y: textarea_rect.y,
width: textarea_rect.width,
height: remote_images_height,
};
textarea_rect.y = textarea_rect.y.saturating_add(consumed);
textarea_rect.height = textarea_rect.height.saturating_sub(consumed);
[composer_rect, remote_images_rect, textarea_rect, popup_rect]
}
fn footer_spacing(footer_hint_height: u16) -> u16 {
if footer_hint_height == 0 {
0
} else {
FOOTER_SPACING_HEIGHT
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_options(area, ComposerRenderOptions::default())
@@ -1914,9 +1883,14 @@ impl ChatComposer {
}
/// Clear the "press again to quit" hint immediately.
///
/// Key routing calls this before dispatching ordinary input, so unrelated footer modes
/// such as shortcut help must remain available to their own toggle handlers.
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
self.footer.quit_shortcut_expires_at = None;
self.footer.mode = reset_mode_after_activity(self.footer.mode);
if self.footer.mode == FooterMode::QuitShortcutReminder {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
self.set_has_focus(has_focus);
}
@@ -2304,7 +2278,7 @@ impl ChatComposer {
return (InputResult::None, true);
}
self.footer.mode = reset_mode_after_activity(self.footer.mode);
let can_switch_search_mode = self.current_editable_at_token().is_some();
let can_switch_search_mode = self.current_mentions_v2_token_range().is_some();
let ActivePopup::MentionV2(popup) = &mut self.popups.active else {
unreachable!();
@@ -2710,15 +2684,6 @@ impl ChatComposer {
self.current_editable_prefixed_token_range('@', allow_empty)
}
fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option<String> {
self.current_editable_at_token_range_with_options(allow_empty)
.map(|(_, token)| token)
}
fn current_editable_at_token(&self) -> Option<String> {
self.current_editable_at_token_with_options(/*allow_empty*/ false)
}
fn current_mentions_v2_token_range(&self) -> Option<(Range<usize>, String)> {
if !self.mentions_v2_enabled {
return None;
@@ -3808,13 +3773,17 @@ impl ChatComposer {
.remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea);
}
/// Handle empty-prompt agents navigation and the shortcut-overlay toggle.
/// Handle empty-prompt agents navigation and shortcut-overlay toggling or dismissal.
///
/// This only toggles when the composer is empty and no paste burst is in
/// progress, so typing/pasting `?` still inserts text instead of opening
/// help. The bound key list intentionally supports terminal-variant
/// modifier reporting (for example `?` vs `shift-?`).
fn handle_empty_prompt_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(*key_event) {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
return true;
}
if key_event.kind != KeyEventKind::Press {
return false;
}
@@ -3866,7 +3835,6 @@ impl ChatComposer {
FooterProps {
mode,
esc_backtrack_hint: self.footer.esc_backtrack_hint,
use_shift_enter_hint: self.footer.use_shift_enter_hint,
is_task_running: self.is_task_running,
queue_submissions: self.queue_submissions,
quit_shortcut_key: self.footer.quit_shortcut_key,
@@ -3884,6 +3852,7 @@ impl ChatComposer {
external_editor: self.footer.external_editor_key,
edit_previous: Some(key_hint::plain(KeyCode::Esc).into()),
show_transcript: self.footer.show_transcript_key,
find_transcript: self.footer.find_transcript_key,
history_search: self.footer.history_search_key,
reasoning_down: self.footer.reasoning_down_key,
reasoning_up: self.footer.reasoning_up_key,
@@ -4599,36 +4568,6 @@ impl ChatComposer {
self.luna_reserve_active = active;
true
}
pub(crate) fn desired_height_with_options(
&self,
width: u16,
options: ComposerRenderOptions<'_>,
) -> u16 {
let options = self.resolve_render_options(options);
let textarea_right_reserve = options.textarea_right_reserve;
let footer_hint_height = self.footer_hint_height(options);
let footer_total_height = footer_hint_height + Self::footer_spacing(footer_hint_height);
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let inner_width =
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve));
let remote_images_height: u16 = self
.attachments
.remote_image_lines()
.len()
.try_into()
.unwrap_or(u16::MAX);
let remote_images_separator = u16::from(remote_images_height > 0);
self.draft.textarea.desired_height(inner_width)
+ remote_images_height
+ remote_images_separator
+ 2
+ if self.voice_strip.is_some() { 3 } else { 0 }
+ self
.popups
.active
.required_height(width, footer_total_height)
}
}
impl ChatComposer {
@@ -4643,25 +4582,70 @@ impl ChatComposer {
mask_char: Option<char>,
options: ComposerRenderOptions<'_>,
) {
let options = self.resolve_render_options(options);
let [composer_rect, remote_images_rect, textarea_rect, popup_rect] =
self.layout_areas_with_options(area, options);
match &self.popups.active {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
let ComposerLayout {
status,
composer: composer_rect,
remote_images: remote_images_rect,
textarea: textarea_rect,
popup: popup_rect,
footer: footer_rect,
} = self.layout_with_options(area, options);
self.render_status_surface(status, buf);
if self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::Hidden
{
let popup_rect = if options.command_popup_placement == CommandPopupPlacement::Overlay {
let height = self
.popups
.active
.required_height(composer_rect.width, /*footer_total_height*/ 0)
.min(composer_rect.y.saturating_sub(buf.area.y));
// Command and unified mention menus can render in a single row.
let height = if matches!(
self.popups.active,
ActivePopup::Command(_) | ActivePopup::MentionV2(_)
) || height > 2
{
height
} else {
0
};
Rect::new(
composer_rect.x,
composer_rect.y - height,
composer_rect.width,
height,
)
} else {
popup_rect
};
// Overlay rows must erase both glyphs and styles from the transcript underneath.
ratatui::widgets::Clear.render(popup_rect, buf);
match &self.popups.active {
ActivePopup::Command(popup) => popup.render_ref(popup_rect, buf),
ActivePopup::MentionV2(popup) => popup.render_ref(popup_rect, buf),
ActivePopup::File(popup) => popup.render_ref(popup_rect, buf),
ActivePopup::Skill(popup) => popup.render_ref(popup_rect, buf),
ActivePopup::None => {
unreachable!("only suggestion menus use above-composer placement")
}
}
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
}
let transcript_hint_area = inset_footer_hint_area(footer_rect);
match options.footer {
Some(footer) => {
self.render_transcript_footer(transcript_hint_area, buf, footer);
}
ActivePopup::Skill(popup) => {
popup.render_ref(popup_rect, buf);
None if self.shortcuts_above_composer(options) => {
let props = self.hint_footer_props(options);
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);
}
ActivePopup::MentionV2(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let footer_props = self.footer_props();
let show_cycle_hint = !footer_props.is_task_running
None => {
let footer_props = self.hint_footer_props(options);
let show_cycle_hint = self.status_surface_height(options) == 0
&& !footer_props.is_task_running
&& self.footer.collaboration_mode_indicator.is_some();
let show_shortcuts_hint = match footer_props.mode {
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
@@ -4679,11 +4663,8 @@ impl ChatComposer {
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let hint_rect = self.footer_hint_area(popup_rect, options);
if let Some(footer) = options.footer {
Paragraph::new(footer.text.clone())
.render(inset_footer_hint_area(hint_rect), buf);
} else if let Some(input) = self.draft.textarea.vim_query() {
let hint_rect = footer_rect;
if let Some(input) = self.draft.textarea.vim_query() {
input.render(inset_footer_hint_area(hint_rect), buf);
} else if let Some(line) = self.history_search_footer_line() {
render_footer_line(hint_rect, buf, line);
@@ -4722,11 +4703,12 @@ impl ChatComposer {
} else {
None
};
let left_mode_indicator = if status_line_active {
None
} else {
self.footer.collaboration_mode_indicator
};
let left_mode_indicator =
if status_line_active || self.status_surface_height(options) > 0 {
None
} else {
self.footer.collaboration_mode_indicator
};
let active_footer_hint_override = self.footer.hint_override.as_ref();
let mut left_width = if self.footer.flash_visible() {
self.footer
@@ -4755,7 +4737,7 @@ impl ChatComposer {
Some(side_conversation_context_line(label))
} else if let Some(line) = self.shell_mode_footer_line() {
Some(line)
} else if transition_active {
} else if self.status_surface_height(options) > 0 || transition_active {
None
} else if status_line_active {
let full = self.mode_indicator_line(show_cycle_hint);
@@ -5013,6 +4995,9 @@ impl ChatComposer {
self.cursor_pos_with_options(area, options),
buf,
);
if options.footer.is_some_and(|footer| footer.is_interactive) {
buf.set_style(composer_rect, Style::default().dim());
}
}
}
@@ -5036,6 +5021,10 @@ mod paste_tests;
#[path = "chat_composer/snapshot_tests.rs"]
mod snapshot_tests;
#[cfg(test)]
#[path = "chat_composer/mentions_layout_tests.rs"]
mod mentions_layout_tests;
#[cfg(test)]
mod tests {
use super::attachment_state::AttachedImage;
@@ -5054,7 +5043,6 @@ mod tests {
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::footer::footer_height;
use crate::bottom_pane::textarea::TextArea;
use codex_protocol::models::local_image_label_text;
use tokio::sync::mpsc::UnboundedReceiver;
@@ -5305,9 +5293,8 @@ mod tests {
);
setup(&mut composer);
let footer_props = composer.footer_props();
let footer_lines = footer_height(&footer_props);
let footer_spacing = ChatComposer::footer_spacing(footer_lines);
let height = footer_lines + footer_spacing + 8;
let footer_lines = footer_height(&footer_props, width);
let height = footer_lines + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
@@ -9424,16 +9411,22 @@ mod tests {
// Type "/mo" humanlike so paste-burst doesn’t interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
for (height, snapshot, cursor) in [
(3, "slash_popup_mo_clipped", (5, 1)),
(5, "slash_popup_mo", (5, 2)),
] {
let mut terminal =
Terminal::new(TestBackend::new(/*width*/ 60, height)).expect("create terminal");
terminal
.draw(|f| {
composer.render(f.area(), f.buffer_mut());
assert_eq!(composer.cursor_pos(f.area()), Some(cursor));
})
.expect("draw composer");
// Visual snapshot should show the slash popup with /model as the first entry.
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
// The picker sits above the draft; clipping must keep the draft and cursor visible.
insta::assert_snapshot!(snapshot, terminal.backend());
}
}
#[test]
@@ -1,6 +1,6 @@
//! Reserves popup and voice-control space around the editable draft.
//! Shared composer geometry keeps rendering, measurement, and the cursor aligned.
use super::ChatComposer;
use super::*;
use crate::bottom_pane::voice_strip::VoiceStrip;
use crate::bottom_pane::voice_strip::VoiceStripState;
use crate::render::renderable::Renderable;
@@ -34,3 +34,171 @@ impl ChatComposer {
}
}
}
impl ChatComposer {
pub(super) fn layout_with_options(
&self,
area: Rect,
options: ComposerRenderOptions<'_>,
) -> ComposerLayout {
let footer_hint_height = self.footer_hint_height(area.width, options);
// Preserve input and hints before allocating the persistent status row.
let status_height = self.status_surface_height(options).min(
area.height
.saturating_sub(footer_hint_height.saturating_add(/*rhs*/ 3)),
);
let status = Rect {
y: area
.bottom()
.saturating_sub(footer_hint_height)
.saturating_sub(status_height)
.max(area.y),
height: status_height,
..area
};
let area = Rect {
height: area.height - status_height,
..area
};
let popup_height = if self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::AboveComposer
{
footer_hint_height
} else {
self.popups
.active
.required_height(area.width, footer_hint_height)
};
let voice_rows = if self.voice_strip.is_some() { 3 } else { 0 };
let shortcuts_above = self.shortcuts_above_composer(options);
let (composer_rect, popup_rect, mut footer_rect) = if shortcuts_above {
let [shortcuts, composer, footer] = Layout::vertical([
Constraint::Max(footer_height(&self.hint_footer_props(options), area.width)),
Constraint::Min(3 + voice_rows),
Constraint::Length(footer_hint_height),
])
.areas(area);
(composer, shortcuts, footer)
} else if self.popups.active.is_above_composer() {
// Preserve the draft first, then its footer, and use remaining space for suggestions.
let [mut composer, mut popup, footer] = Layout::vertical([
Constraint::Min(3 + voice_rows),
Constraint::Max(popup_height.saturating_sub(footer_hint_height)),
Constraint::Length(footer_hint_height),
])
.areas(area);
popup.y = area.y;
composer.y = popup.bottom();
(composer, popup, footer)
} else {
let [composer, popup] = Layout::vertical([
Constraint::Min(3 + voice_rows),
Constraint::Max(popup_height),
])
.areas(area);
(composer, popup, popup)
};
footer_rect.y = footer_rect.y.saturating_add(status.height);
// Keep the draft visible when clipped.
let voice_rows = voice_rows * u16::from(composer_rect.height >= 6);
let mut textarea_rect = composer_rect.inset(Insets::tlbr(
/*top*/ 1 + voice_rows,
LIVE_PREFIX_COLS,
/*bottom*/ 1,
/*right*/ 1u16.saturating_add(options.textarea_right_reserve),
));
let remote_images_height = self
.attachments
.remote_image_lines()
.len()
.try_into()
.unwrap_or(u16::MAX)
.min(textarea_rect.height.saturating_sub(1));
let remote_images_separator = u16::from(remote_images_height > 0);
let consumed = remote_images_height.saturating_add(remote_images_separator);
let remote_images_rect = Rect {
x: textarea_rect.x,
y: textarea_rect.y,
width: textarea_rect.width,
height: remote_images_height,
};
textarea_rect.y = textarea_rect.y.saturating_add(consumed);
textarea_rect.height = textarea_rect.height.saturating_sub(consumed);
ComposerLayout {
status,
composer: composer_rect,
remote_images: remote_images_rect,
textarea: textarea_rect,
popup: popup_rect,
footer: footer_rect,
}
}
pub(crate) fn cursor_pos_with_options(
&self,
area: Rect,
options: ComposerRenderOptions<'_>,
) -> Option<(u16, u16)> {
let layout = self.layout_with_options(area, options);
if let Some(footer) = options.footer.filter(|footer| footer.is_interactive) {
let area = inset_footer_hint_area(layout.footer);
return footer
.cursor_column
.filter(|column| *column < area.width && area.height > 0)
.map(|column| (area.x + column, area.y));
}
if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() {
return None;
}
if let Some(pos) = self
.vim_search_cursor_pos(layout.footer)
.or_else(|| self.history_search_cursor_pos(layout.footer))
{
return Some(pos);
}
let state = *self.draft.textarea_state.borrow();
self.draft
.textarea
.cursor_pos_with_state(layout.textarea, state)
}
pub(crate) fn desired_height_with_options(
&self,
width: u16,
options: ComposerRenderOptions<'_>,
) -> u16 {
let footer_hint_height = self.footer_hint_height(width, options);
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let inner_width =
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(options.textarea_right_reserve));
let remote_images_height: u16 = self
.attachments
.remote_image_lines()
.len()
.try_into()
.unwrap_or(u16::MAX);
let remote_images_separator = u16::from(remote_images_height > 0);
self.draft.textarea.desired_height(inner_width)
+ remote_images_height
+ remote_images_separator
+ self.status_surface_height(options)
+ if self.shortcuts_above_composer(options) {
footer_height(&self.hint_footer_props(options), width)
} else {
0
}
+ 2
+ if self.voice_strip.is_some() { 3 } else { 0 }
+ if self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::AboveComposer
{
footer_hint_height
} else {
self.popups
.active
.required_height(width, footer_hint_height)
}
}
}
@@ -1,122 +1,155 @@
//! Footer and status-row presentation state for the chat composer.
//! Owners schedule flash expiry redraws; replacing a draft clears its flash.
//! Borrowed transcript feedback uses one resolved presentation for height, paint, and cursor.
//! One resolved layout supplies the status, input, popup, and hint rectangles to each consumer.
//! Slash-command and unified mention suggestions preserve passive footer content while keeping input.
//! Interactive transcript footers keep focus over nonempty composer hints. Their owner projects
//! actual pending chord hints; unrelated hints resume when the transcript interaction closes.
//! While the transcript owns input, the composer is dimmed and yields its cursor to the footer.
//! Hidden suggestions retain their query and selection until their input owner returns.
//! A pending quit contributes a derived release hint without changing stored footer state.
//! Shortcut help occupies the space above the composer and keeps its close hint on the final row.
//! Passive transcript hints retain the shortcuts entry when it fits beside the complete hint.
use std::time::Instant;
use super::super::footer::footer_height;
use super::super::footer::shows_passive_footer_line;
use super::ActivePopup;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use crate::bottom_pane::footer::CollaborationModeIndicator;
use crate::bottom_pane::footer::FooterMode;
use crate::bottom_pane::footer::GoalStatusIndicator;
use crate::bottom_pane::footer::inset_footer_hint_area;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use std::time::Duration;
/// Per-frame transcript feedback rendered by the composer footer owner.
/// Resolved rectangles shared by painting and cursor placement, without retained layout state.
pub(super) struct ComposerLayout {
pub(super) status: Rect,
pub(super) composer: Rect,
pub(super) remote_images: Rect,
pub(super) textarea: Rect,
pub(super) popup: Rect,
pub(super) footer: Rect,
}
/// Per-frame transcript content; the composer remains the only footer layout owner.
pub(crate) struct TranscriptFooter {
pub(crate) text: ratatui::text::Text<'static>,
/// Caret in the first footer row, measured from its inset content area.
pub(crate) text: Text<'static>,
pub(crate) cursor_column: Option<u16>,
pub(crate) is_interactive: bool,
}
/// Borrowed presentation shared by composer measurement, painting, and cursor placement.
/// Whether composer suggestions reserve space, cover transcript rows, or stay hidden.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum CommandPopupPlacement {
#[default]
AboveComposer,
Overlay,
/// Retain suggestions while another surface owns input above the draft.
Hidden,
}
/// A borrowed presentation shared by measurement, painting, and cursor placement.
#[derive(Clone, Copy, Default)]
pub(crate) struct ComposerRenderOptions<'a> {
pub(crate) textarea_right_reserve: u16,
/// Keep configured status below the composer while hints occupy the final row.
pub(crate) separate_status_line: bool,
pub(crate) command_popup_placement: CommandPopupPlacement,
pub(crate) footer: Option<&'a TranscriptFooter>,
}
impl super::ChatComposer {
pub(crate) fn cursor_pos_with_options(
&self,
area: Rect,
options: ComposerRenderOptions<'_>,
) -> Option<(u16, u16)> {
let options = self.resolve_render_options(options);
if let Some(footer) = options.footer.filter(|footer| footer.is_interactive) {
let [_, _, _, popup_rect] = self.layout_areas_with_options(area, options);
let area = inset_footer_hint_area(self.footer_hint_area(popup_rect, options));
return footer
.cursor_column
.filter(|column| *column < area.width && area.height > 0)
.map(|column| (area.x + column, area.y));
}
if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() {
return None;
}
if let Some(pos) = self
.vim_search_cursor_pos(area)
.or_else(|| self.history_search_cursor_pos(area))
{
return Some(pos);
}
let [_, _, textarea_rect, _] = self.layout_areas_with_options(area, options);
let state = *self.draft.textarea_state.borrow();
self.draft
.textarea
.cursor_pos_with_state(textarea_rect, state)
}
pub(super) fn resolve_render_options<'a>(
pub(crate) fn resolve_render_options<'a>(
&self,
mut options: ComposerRenderOptions<'a>,
) -> ComposerRenderOptions<'a> {
options.footer = options.footer.filter(|footer| {
matches!(self.popups.active, super::ActivePopup::None)
&& self.history_search.is_none()
&& self.draft.textarea.vim_query().is_none()
&& !self.quit_shortcut_hint_visible()
&& (footer.is_interactive
|| (!self.footer.flash_visible()
&& self.footer.hint_override.is_none()
&& matches!(
self.footer_mode(),
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
)))
if (self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::Hidden
&& footer.is_interactive)
|| self
.footer
.hint_override
.as_ref()
.is_some_and(|items| items.is_empty() || !footer.is_interactive)
|| self.history_search.is_some()
|| self.draft.textarea.vim_query().is_some()
|| self.quit_shortcut_hint_visible()
{
return false;
}
footer.is_interactive || shows_passive_footer_line(&self.footer_props())
});
options
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn shortcut_overlay_visible(&self) -> bool {
self.footer_mode() == FooterMode::ShortcutOverlay
&& matches!(self.popups.active, super::ActivePopup::None)
&& matches!(self.popups.active, ActivePopup::None)
&& self.custom_footer_height().is_none()
}
pub(super) fn footer_hint_height(&self, options: ComposerRenderOptions<'_>) -> u16 {
options.footer.map_or_else(
|| {
self.custom_footer_height()
.unwrap_or_else(|| super::super::footer::footer_height(&self.footer_props()))
},
|footer| footer.text.height().try_into().unwrap_or(u16::MAX),
)
pub(super) fn shortcuts_above_composer(&self, options: ComposerRenderOptions<'_>) -> bool {
self.shortcut_overlay_visible() && options.footer.is_none()
}
pub(super) fn footer_hint_area(
&self,
popup_rect: ratatui::layout::Rect,
options: ComposerRenderOptions<'_>,
) -> ratatui::layout::Rect {
let footer_hint_height = self.footer_hint_height(options);
let footer_spacing = Self::footer_spacing(footer_hint_height);
if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Length(footer_spacing),
ratatui::layout::Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
pub(super) fn footer_hint_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 {
if self.shortcuts_above_composer(options) {
return 1;
}
options
.footer
.map_or_else(
|| {
self.custom_footer_height()
.unwrap_or_else(|| footer_height(&self.hint_footer_props(options), width))
},
|footer| footer.text.height().try_into().unwrap_or(u16::MAX),
)
.max(u16::from(options.separate_status_line))
}
pub(super) fn render_transcript_footer(
&self,
hint_area: Rect,
buf: &mut Buffer,
footer: &TranscriptFooter,
) {
let mut text = footer.text.clone();
if footer.cursor_column.is_none()
&& self.footer.flash_visible()
&& let Some(flash) = &self.footer.flash
&& let Some(line) = text.lines.last_mut()
{
*line = flash.line.clone();
}
if !footer.is_interactive
&& !self.footer.flash_visible()
&& self.footer_mode() == FooterMode::ComposerEmpty
&& !self.is_in_paste_burst()
&& let Some(key) = self.footer.toggle_shortcuts_key
&& let Some(line) = text.lines.last_mut()
{
let mut shortcuts = Line::default();
if line.width() > 0 {
shortcuts.push_span(" · ".dim());
}
shortcuts.extend(key.spans());
shortcuts.push_span(" shortcuts".dim());
if line.width() + shortcuts.width() <= usize::from(hint_area.width) {
line.extend(shortcuts.spans);
}
}
Paragraph::new(text).render(hint_area, buf);
}
pub(crate) fn footer_flash_delay(&self) -> Option<Duration> {
@@ -152,6 +185,7 @@ pub(super) struct FooterState {
pub(super) active_agent_label: Option<String>,
pub(super) external_editor_key: Option<ShortcutHint>,
pub(super) show_transcript_key: Option<ShortcutHint>,
pub(super) find_transcript_key: Option<ShortcutHint>,
pub(super) insert_newline_key: Option<ShortcutHint>,
pub(super) queue_key: Option<ShortcutHint>,
pub(super) toggle_shortcuts_key: Option<ShortcutHint>,
@@ -190,3 +224,7 @@ impl FooterState {
})
}
}
#[cfg(test)]
#[path = "footer_state_tests.rs"]
mod tests;
@@ -0,0 +1,148 @@
//! Footer replacement keeps the existing layout and the focused editor's cursor together.
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::footer::inset_footer_hint_area;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn composer() -> ChatComposer {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(tx),
/*enhanced_keys_supported*/ false,
"Ask Codex".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("MODEL STATUS")));
composer
}
fn render(
composer: &ChatComposer,
footer: Option<&TranscriptFooter>,
) -> (Buffer, Option<(u16, u16)>) {
let options = composer.resolve_render_options(ComposerRenderOptions {
footer,
..ComposerRenderOptions::default()
});
let width = 60;
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
composer.desired_height_with_options(width, options),
);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
(buffer, composer.cursor_pos_with_options(area, options))
}
fn text(buffer: &Buffer) -> String {
buffer
.content
.chunks(usize::from(buffer.area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn retained_command_suggestions_yield_paint_and_cursor_to_find() {
let mut composer = composer();
composer.draft.textarea.insert_str("/");
composer.sync_popups();
let original = render(&composer, /*footer*/ None);
let footer = TranscriptFooter {
text: vec![
Line::from("Find: needle"),
Line::from("Enter next · Esc close"),
]
.into(),
cursor_column: Some(8),
is_interactive: true,
};
let options = composer.resolve_render_options(ComposerRenderOptions {
footer: Some(&footer),
command_popup_placement: CommandPopupPlacement::Hidden,
..ComposerRenderOptions::default()
});
let mut frames = Vec::new();
for (width, height) in [(60, 8), (24, 8), (12, 3)] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
let cursor = composer.cursor_pos_with_options(area, options);
let footer_area =
inset_footer_hint_area(composer.layout_with_options(area, options).footer);
assert_eq!(
cursor,
(footer_area.width > 8 && footer_area.height > 0)
.then_some((footer_area.x + 8, footer_area.y))
);
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
assert_eq!(composer.draft.textarea.text(), "/");
frames.push(format!(
"{width}x{height}, cursor {cursor:?}\n{}",
text(&buffer)
));
}
insta::assert_snapshot!(
"find_hides_retained_command_suggestions",
frames.join("\n\n")
);
assert_eq!(render(&composer, /*footer*/ None), original);
}
#[test]
fn copying_from_find_preserves_the_query_above_selection_feedback() {
let mut composer = composer();
let footer = TranscriptFooter {
text: vec![
Line::from("Find: needle"),
Line::from("Ctrl+C copy · Esc clear selection"),
]
.into(),
cursor_column: None,
is_interactive: true,
};
composer.show_footer_flash(
"Copied selection to clipboard".into(),
Duration::from_secs(/*secs*/ 3),
);
let (buffer, cursor) = render(&composer, Some(&footer));
assert_eq!(cursor, None);
insta::assert_snapshot!("transcript_find_copy_footer", text(&buffer));
}
#[test]
fn interactive_query_keeps_focus_over_underlying_hints_but_empty_override_wins() {
let mut composer = composer();
let footer = TranscriptFooter {
text: vec![
Line::from("Find: needle"),
Line::from("Enter next · Esc close"),
]
.into(),
cursor_column: Some(12),
is_interactive: true,
};
let expected = render(&composer, Some(&footer));
composer.set_footer_hint_override(Some(vec![("ctrl+c".to_string(), "quit".to_string())]));
assert_eq!(render(&composer, Some(&footer)), expected);
composer.set_footer_hint_override(Some(Vec::new()));
assert_eq!(
render(&composer, Some(&footer)),
render(&composer, /*footer*/ None)
);
}
@@ -25,8 +25,6 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
@@ -34,7 +32,6 @@ use ratatui::text::Span;
use super::super::chat_composer_history::HistorySearchDirection;
use super::super::chat_composer_history::HistorySearchResult;
use super::super::footer::footer_height;
use super::super::footer::reset_mode_after_activity;
use super::super::textarea::VimPersistentState;
use super::ActivePopup;
@@ -476,28 +473,8 @@ impl ChatComposer {
/// The cursor tracks the end of the footer query rather than the textarea preview. If the
/// footer area is collapsed or too narrow, the x coordinate is clamped inside the hint rect so
/// terminal backends do not receive an off-screen cursor position.
pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
pub(super) fn history_search_cursor_pos(&self, hint_rect: Rect) -> Option<(u16, u16)> {
self.history_search.as_ref()?;
let [_, _, _, popup_rect] = self.layout_areas(area);
if popup_rect.is_empty() {
return None;
}
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(&footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(footer_spacing),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
if hint_rect.is_empty() {
return None;
}
@@ -999,7 +976,9 @@ mod tests {
}
let area = Rect::new(0, 0, 60, 8);
let [_, _, textarea_rect, _] = composer.layout_areas(area);
let textarea_rect = composer
.layout_with_options(area, Default::default())
.textarea;
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let x = textarea_rect.x;
@@ -1018,7 +997,9 @@ mod tests {
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area);
let accepted_textarea_rect = composer
.layout_with_options(area, Default::default())
.textarea;
let mut accepted_buf = Buffer::empty(area);
composer.render(area, &mut accepted_buf);
for offset in 0..3 {
@@ -2,6 +2,7 @@
use super::super::super::chat_composer_history::HistoryEntry;
use super::super::ChatComposer;
use super::super::ComposerRenderOptions;
use super::super::InputResult;
use super::super::LARGE_PASTE_CHAR_THRESHOLD;
use crate::app_event_sender::AppEventSender;
@@ -71,7 +72,10 @@ fn history_search_large_paste_clamps_cursor() {
composer.handle_paste("x".repeat(usize::from(u16::MAX) + 1));
let terminal = render_composer(&composer, /*width*/ 80);
let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into());
let cursor = composer.cursor_pos_with_options(
terminal.size().unwrap().into(),
ComposerRenderOptions::default(),
);
assert_eq!(cursor, Some((79, 4)));
insta::assert_snapshot!(
"history_search_large_paste_cursor",
@@ -115,7 +119,10 @@ fn history_search_paste_shows_separators_and_matches_original_query() {
assert_eq!(composer.draft.textarea.text(), "draft");
let terminal = render_composer(&composer, /*width*/ 70);
let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into());
let cursor = composer.cursor_pos_with_options(
terminal.size().unwrap().into(),
ComposerRenderOptions::default(),
);
assert_eq!(cursor, Some((31, 4)));
insta::assert_snapshot!(
"history_search_pasted_separators",
@@ -0,0 +1,241 @@
//! Production mention overlays retain the draft, status footer and editor cursor across scopes.
use super::*;
use pretty_assertions::assert_eq;
use ratatui::layout::Size;
use tokio::sync::mpsc::unbounded_channel;
fn composer() -> ChatComposer {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(tx),
/*enhanced_keys_supported*/ false,
"Ask Codex".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some("MODEL STATUS".into()));
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_plugin_mentions(Some(
(0..12)
.map(|index| PluginCapabilitySummary {
config_name: format!("plugin{index:02}@test"),
display_name: format!("Plugin{index:02}"),
plugin_namespace: None,
description: Some(format!("Plugin description {index:02}")),
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
})
.collect(),
));
composer.set_text_content("@".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@".len());
composer
}
fn render_overlay(
composer: &ChatComposer,
size: Size,
footer: &TranscriptFooter,
) -> (Buffer, Rect, Option<(u16, u16)>) {
let options = composer.resolve_render_options(ComposerRenderOptions {
command_popup_placement: CommandPopupPlacement::Overlay,
footer: Some(footer),
..Default::default()
});
let mut buffer = Buffer::empty(Rect::new(
/*x*/ 0,
/*y*/ 0,
size.width,
size.height,
));
let height = composer
.desired_height_with_options(size.width, options)
.min(size.height);
let area = Rect::new(/*x*/ 0, size.height - height, size.width, height);
for y in 0..area.y {
Line::from(format!("Transcript row {y:02}: behind the menu").magenta())
.render(Rect::new(/*x*/ 0, y, size.width, /*height*/ 1), &mut buffer);
}
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
(
buffer,
area,
composer.cursor_pos_with_options(area, options),
)
}
fn buffer_text(buffer: &Buffer) -> String {
buffer
.content
.chunks(usize::from(buffer.area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn mention_scopes_overlay_history_without_moving_composer_or_footer() {
let footer = TranscriptFooter {
text: "TRANSCRIPT STATUS".into(),
cursor_column: None,
is_interactive: false,
};
for (width, height) in [(80, 32), (40, 32), (80, 12), (80, 7), (80, 6), (80, 5)] {
let mut composer = composer();
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let size = Size::new(width, height);
let (closed, composer_area, cursor) = render_overlay(&composer, size, &footer);
let composer_start = closed.index_of(composer_area.x, composer_area.y);
composer.popups.dismissed_mention_token = None;
composer.sync_popups();
let mut menu_row = None;
for scope in ["all", "filesystem", "plugins"] {
let (open, area, open_cursor) = render_overlay(&composer, size, &footer);
assert_eq!((area, open_cursor), (composer_area, cursor));
assert_eq!(
&open.content[composer_start..],
&closed.content[composer_start..],
);
if (1..=2).contains(&composer_area.y) && scope != "filesystem" {
assert!(buffer_text(&open).contains("Plugin00"));
}
if height == 32 {
assert_eq!(
&open.content[..usize::from(width)],
&closed.content[..usize::from(width)]
);
let current_menu_row = (0..composer_area.y).find(|y| {
(0..width)
.map(|x| open[(x, *y)].symbol())
.collect::<String>()
.contains("Mentions")
});
assert!(current_menu_row.is_some());
if let Some(previous) = menu_row {
assert_eq!(current_menu_row, Some(previous));
}
menu_row = current_menu_row;
// The popup owns exactly its measured height, including both overflow rows.
let ActivePopup::MentionV2(popup) = &composer.popups.active else {
panic!("expected unified mentions");
};
let first_popup_row = composer_area.y - popup.calculate_required_height(width);
assert!(current_menu_row.is_some_and(|row| row >= first_popup_row));
assert!((first_popup_row..composer_area.y).all(|y| {
!(0..width)
.map(|x| open[(x, y)].symbol())
.collect::<String>()
.contains("Transcript row")
}));
}
if (width, height) == (80, 32) && scope == "all" {
insta::assert_snapshot!("mention_scopes_overlay_history", buffer_text(&open));
}
composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
}
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
render_overlay(&composer, size, &footer),
(closed, composer_area, cursor)
);
}
}
#[test]
fn mention_menu_reserves_height_above_composer_and_owns_cursor_over_interactive_footer() {
let composer = composer();
let footer = TranscriptFooter {
text: "Find: needle".into(),
cursor_column: Some(6),
is_interactive: true,
};
let options = composer.resolve_render_options(ComposerRenderOptions {
footer: Some(&footer),
..Default::default()
});
assert!(options.footer.is_none());
let width = 80;
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
composer.desired_height_with_options(width, options),
);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
let ComposerLayout {
composer: surface,
textarea,
popup,
footer,
..
} = composer.layout_with_options(area, options);
assert_eq!((popup.bottom(), surface.bottom()), (surface.y, footer.y));
assert_eq!(
composer.cursor_pos_with_options(area, options),
Some((3, textarea.y))
);
insta::assert_snapshot!("mention_menu_above_composer", buffer_text(&buffer));
}
#[test]
fn fallback_completions_keep_the_draft_cursor_and_footer_anchored() {
let footer = TranscriptFooter {
text: "TRANSCRIPT STATUS".into(),
cursor_column: None,
is_interactive: false,
};
for (width, height) in [(80, 24), (40, 16), (20, 5)] {
for token in ["$", "@"] {
let mut composer = composer();
composer.popups.active = ActivePopup::None;
composer.draft.textarea.set_text_clearing_elements(token);
let size = Size::new(width, height);
let (closed, composer_area, cursor) = render_overlay(&composer, size, &footer);
composer.popups.active = if token == "$" {
ActivePopup::Skill(SkillPopup::new(vec![MentionItem {
display_name: "Example skill".into(),
description: Some("Review this checkout".into()),
insert_text: "$example".into(),
search_terms: vec!["example".into()],
path: None,
category_tag: None,
sort_rank: 1,
}]))
} else {
let mut popup = FileSearchPopup::new();
popup.set_matches(
"",
vec![codex_file_search::FileMatch {
score: 1,
path: "src/example.rs".into(),
root: "/repo".into(),
match_type: codex_file_search::MatchType::File,
indices: None,
}],
);
ActivePopup::File(popup)
};
let (open, area, open_cursor) = render_overlay(&composer, size, &footer);
assert_eq!((area, open_cursor), (composer_area, cursor));
let start = open.index_of(area.x, area.y);
assert_eq!(&open.content[start..], &closed.content[start..]);
if height > 5 {
assert!(buffer_text(&open).contains(if token == "$" {
"Example skill"
} else {
"src/example.rs"
}));
}
}
}
}
@@ -113,13 +113,18 @@ pub(super) enum ActivePopup {
}
impl ActivePopup {
/// Shared suggestion menus render above the draft and retain its status footer.
pub(super) fn is_above_composer(&self) -> bool {
!matches!(self, Self::None)
}
pub(super) fn required_height(&self, width: u16, footer_total_height: u16) -> u16 {
match self {
Self::None => footer_total_height,
Self::Command(popup) => popup.calculate_required_height(width),
Self::File(popup) => popup.calculate_required_height(),
Self::Skill(popup) => popup.calculate_required_height(width),
Self::MentionV2(popup) => popup.calculate_required_height(width),
Self::Command(popup) => popup.calculate_required_height(width) + footer_total_height,
Self::File(popup) => popup.calculate_required_height() + footer_total_height,
Self::Skill(popup) => popup.calculate_required_height(width) + footer_total_height,
Self::MentionV2(popup) => popup.calculate_required_height(width) + footer_total_height,
}
}
}
@@ -0,0 +1,28 @@
---
source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs
assertion_line: 231
expression: "frames.join(\"\\n\\n\")"
---
60x8, cursor Some((10, 6))
› /
Find: needle
Enter next · Esc close
24x8, cursor Some((10, 6))
› /
Find: needle
Enter next · Esc close
12x3, cursor None
› /
@@ -0,0 +1,9 @@
---
source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs
expression: text(&buffer)
---
› Ask Codex
Find: needle
Copied selection to clipboard
@@ -0,0 +1,25 @@
---
source: tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs
expression: buffer_text(&buffer)
---
Mentions
Files, directories, plugins, skills and tasks
All Results Filesystem Only Plugins
Type to search mentions
› Plugin00 Plugin description 00 Plugin
Plugin01 Plugin description 01 Plugin
Plugin02 Plugin description 02 Plugin
Plugin03 Plugin description 03 Plugin
Plugin04 Plugin description 04 Plugin
Plugin05 Plugin description 05 Plugin
Plugin06 Plugin description 06 Plugin
Plugin07 Plugin description 07 Plugin
↓
enter/tab insert · esc close · ↑/↓ select · ←/→ filter
› @
MODEL STATUS
@@ -0,0 +1,36 @@
---
source: tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs
expression: buffer_text(&open)
---
Transcript row 00: behind the menu
Transcript row 01: behind the menu
Transcript row 02: behind the menu
Transcript row 03: behind the menu
Transcript row 04: behind the menu
Transcript row 05: behind the menu
Transcript row 06: behind the menu
Transcript row 07: behind the menu
Transcript row 08: behind the menu
Transcript row 09: behind the menu
Transcript row 10: behind the menu
Mentions
Files, directories, plugins, skills and tasks
All Results Filesystem Only Plugins
Type to search mentions
› Plugin00 Plugin description 00 Plugin
Plugin01 Plugin description 01 Plugin
Plugin02 Plugin description 02 Plugin
Plugin03 Plugin description 03 Plugin
Plugin04 Plugin description 04 Plugin
Plugin05 Plugin description 05 Plugin
Plugin06 Plugin description 06 Plugin
Plugin07 Plugin description 07 Plugin
↓
enter/tab insert · esc close · ↑/↓ select · ←/→ filter
› @
TRANSCRIPT STATUS
@@ -1,13 +1,15 @@
---
source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs
assertion_line: 561
expression: "format!(\"command during startup:\\n{}\\n\\nafter command:\\n{}\", text(&pending),\ntext(&completed))"
---
command during startup:
› /status show current session configuration and token usage
/statusline configure which items appear in the status line
› /status
› /status show current session configuration and token usage
/statusline configure which items appear in the status line
100% context left
after command:
⠈ ⢀ ⠁ ⠂
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs
assertion_line: 452
expression: "snapshots.join(\"\\n\\n\")"
---
expires during help: false
@@ -9,18 +10,27 @@ initial:
⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀
? for shortcuts 100% context left
shortcut help:
Keyboard shortcuts
Compose Session
/ Commands tab Send message
@ Mention files ⌥+, Less reasoning
! Shell command ⌥+. More reasoning
ctrl+j New line ctrl+c Quit
ctrl+v Paste image
ctrl+g External editor Transcript (open first)
ctrl+r Search history ctrl+t Open transcript
esc esc Edit last message f3 Find text
pgup / pgdn Scroll
ctrl+space Start selection
ctrl+home / ctrl+end Top / latest
shift+⌥+, / shift+⌥+. Top / latest
/keymap customize
› Ask Codex to do anything
/ for commands ! for shell commands
ctrl+j for newline tab to submit message
@ for file paths ctrl+v to paste images
ctrl+g to edit in external editor esc esc to edit previous message
ctrl+r search history ctrl+c to exit
⌥+, reasoning down ⌥+. reasoning up
ctrl+t to view transcript
customize shortcuts with /keymap
? / esc close
after closing:
⠁ ⡀ ⠄ ⠄ ⠄
› Ask Codex to do anything ⠈
@@ -34,18 +44,27 @@ initial:
⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀
? for shortcuts 100% context left
shortcut help:
Keyboard shortcuts
Compose Session
/ Commands tab Send message
@ Mention files ⌥+, Less reasoning
! Shell command ⌥+. More reasoning
ctrl+j New line ctrl+c Quit
ctrl+v Paste image
ctrl+g External editor Transcript (open first)
ctrl+r Search history ctrl+t Open transcript
esc esc Edit last message f3 Find text
pgup / pgdn Scroll
ctrl+space Start selection
ctrl+home / ctrl+end Top / latest
shift+⌥+, / shift+⌥+. Top / latest
/keymap customize
› Ask Codex to do anything
/ for commands ! for shell commands
ctrl+j for newline tab to submit message
@ for file paths ctrl+v to paste images
ctrl+g to edit in external editor esc esc to edit previous message
ctrl+r search history ctrl+c to exit
⌥+, reasoning down ⌥+. reasoning up
ctrl+t to view transcript
customize shortcuts with /keymap
? / esc close
after closing:
› Ask Codex to do anything
@@ -1,20 +1,30 @@
---
source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs
assertion_line: 365
expression: "snapshots.join(\"\\n\\n\")"
---
shortcut help:
Keyboard shortcuts
Compose Session
/ Commands tab Send message
@ Mention files ⌥+, Less reasoning
! Shell command ⌥+. More reasoning
ctrl+j New line ctrl+c Quit
ctrl+v Paste image
ctrl+g External editor Transcript (open first)
ctrl+r Search history ctrl+t Open transcript
esc esc Edit last message f3 Find text
pgup / pgdn Scroll
ctrl+space Start selection
ctrl+home / ctrl+end Top / latest
shift+⌥+, / shift+⌥+. Top / latest
/keymap customize
› Ask Codex to do anything
/ for commands ! for shell commands
ctrl+j for newline tab to submit message
@ for file paths ctrl+v to paste images
ctrl+g to edit in external editor esc esc to edit previous message
ctrl+r search history ctrl+c to exit
⌥+, reasoning down ⌥+. reasoning up
ctrl+t to view transcript
customize shortcuts with /keymap
? / esc close
after /model selects Astra:
⠈ ⢀ ⠁ ⠂
@@ -0,0 +1,59 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "states.join(\"\\n---\\n\")"
---
› Ask Codex
MODEL · ~/project · Context 20% used Plan mode
? for shortcuts
---
› Ask Codex
MODEL · ~/project · Context 20% used Plan mode
Copied selection
---
› Ask Codex
MODEL · ~/project · Context 20% used Plan mode
Ctrl+X waiting for chord
---
› Continue the task
MODEL · ~/project · Context 20% used Plan mode
tab to queue message
@@ -0,0 +1,44 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: text
---
Keyboard shortcuts
Compose
/ Commands
@ Mention files
! Shell command
ctrl+j New line
ctrl+v Paste image
ctrl+g External editor
ctrl+r Search history
esc esc Edit last message
Session
tab Send message
⌥+, Less reasoning
⌥+. More reasoning
ctrl+c Quit
Transcript (open first)
ctrl+t Open transcript
f3 Find text
pgup / pgdn Scroll
ctrl+space Start selection
ctrl+home / ctrl+end Top / latest
shift+⌥+, / shift+⌥+. Top / latest
/keymap customize
› Ask Codex
MODEL · ~/project · Context 20% used
? / esc close
@@ -0,0 +1,30 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: text
---
Keyboard shortcuts
Compose Session Transcript (open first)
/ Commands tab Send message ctrl+t Open transcript
@ Mention files ⌥+, Less reasoning f3 Find text
! Shell command ⌥+. More reasoning pgup / pgdn Scroll
ctrl+j New line ctrl+c Quit ctrl+space Start selection
ctrl+v Paste image ctrl+home / ctrl+end Top / latest
ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest
ctrl+r Search history
esc esc Edit last message
/keymap customize
› Ask Codex
MODEL · ~/project · Context 20% used
? / esc close
@@ -0,0 +1,39 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
assertion_line: 344
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 8 },
content: [
" /model choose what model and reasoning effort to use ",
"› /memories configure memory use and generation ",
" /mention mention a file ",
" /mcp list configured MCP tools; use /mcp verbose for details ",
" ",
"› /m ",
" ",
" model · high · fast ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 58, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 48, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 68, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 1, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -55,19 +55,19 @@ fn draw(composer: &ChatComposer, width: u16, now: Instant) -> (Buffer, Rect) {
width,
composer.desired_height(width),
);
let [padding, _, textarea, _] = composer.layout_areas(area);
let layout = composer.layout_with_options(area, Default::default());
let phase = composer.sparkle.phase.replace(Phase::Unarmed);
let mut buffer = Buffer::empty(area);
composer.render(area, &mut buffer);
composer.sparkle.phase.set(phase);
composer.render_sparkle_at(
padding,
textarea,
layout.composer,
layout.textarea,
composer.cursor_pos(area),
now,
&mut buffer,
);
(buffer, textarea)
(buffer, layout.textarea)
}
fn text(buffer: &Buffer) -> String {
@@ -0,0 +1,69 @@
//! Separate persistent transcript context from the composer's transient hint row.
//! Measurement, rendering, and cursor placement share the same reserved status rectangle.
//! Shortcut help grows above the input, preserving status and close rows below it even when clipped.
use super::*;
impl ChatComposer {
pub(super) fn status_surface_height(&self, options: ComposerRenderOptions<'_>) -> u16 {
u16::from(options.separate_status_line && self.footer.status_line_enabled)
}
pub(super) fn hint_footer_props(&self, options: ComposerRenderOptions<'_>) -> FooterProps {
let mut props = self.footer_props();
if self.status_surface_height(options) > 0 {
props.status_line_enabled = false;
props.status_line_value = None;
props.active_agent_label = None;
}
props
}
pub(super) fn render_status_surface(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let mut props = self.footer_props();
// Persistent context is independent of help, queue, search, and quit modes.
props.mode = FooterMode::ComposerHasDraft;
props.is_task_running = false;
let line = passive_footer_status_line(&props);
let right = self.mode_indicator_line(/*show_cycle_hint*/ false);
let right_width = right
.as_ref()
.map_or(/*default*/ 0, |line| line.width() as u16);
let width = max_left_width_for_right(area, right_width)
.unwrap_or_else(|| inset_footer_hint_area(area).width);
let transition = self
.effort_status_line_transition
.as_ref()
.filter(|transition| !transition.is_finished());
let line = if let Some(transition) = transition {
transition.render_line(line.as_ref(), width)
} else {
line
};
if let Some(line) = line {
render_footer_line(
area,
buf,
truncate_line_with_ellipsis_if_overflow(line, usize::from(width)),
);
}
if let Some(right) = right {
render_context_right(area, buf, &right);
}
if let Some(url) = self.footer.status_line_hyperlink_url.as_deref() {
mark_underlined_hyperlink(buf, area, url);
}
if transition.is_some()
&& let Some(frame_requester) = &self.frame_requester
{
frame_requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
}
}
}
#[cfg(test)]
#[path = "status_surface_tests.rs"]
mod tests;
@@ -0,0 +1,364 @@
//! Persistent status survives transient footer modes and suggestion overlays.
use super::*;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn composer() -> ChatComposer {
let (tx, _rx) = unbounded_channel();
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(tx),
/*enhanced_keys_supported*/ false,
"Ask Codex".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("MODEL · ~/project · Context 20% used")));
composer
}
fn render(
composer: &ChatComposer,
width: u16,
footer: Option<&TranscriptFooter>,
) -> (String, Option<(u16, u16)>) {
render_with_height(composer, width, footer, /*height*/ None)
}
fn render_with_height(
composer: &ChatComposer,
width: u16,
footer: Option<&TranscriptFooter>,
height: Option<u16>,
) -> (String, Option<(u16, u16)>) {
let options = composer.resolve_render_options(ComposerRenderOptions {
separate_status_line: true,
command_popup_placement: CommandPopupPlacement::Overlay,
footer,
..ComposerRenderOptions::default()
});
let height = height.unwrap_or_else(|| composer.desired_height_with_options(width, options));
let area = Rect::new(/*x*/ 0, /*y*/ 8, width, height);
let mut buf = Buffer::empty(Rect::new(/*x*/ 0, /*y*/ 0, width, area.bottom()));
composer.render_with_options(area, &mut buf, /*mask_char*/ None, options);
let text = buf
.content
.chunks(usize::from(width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
(text, composer.cursor_pos_with_options(area, options))
}
#[test]
fn passive_activity_keeps_shortcuts_only_when_the_complete_hint_fits() {
let mut composer = composer();
let mut line = Line::from(key_hint::key_label_spans("ctrl+o t/f4"));
line.push_span(" inspect activity".dim());
let mut footer = TranscriptFooter {
text: line.into(),
cursor_column: None,
is_interactive: false,
};
for (width, expected) in [
(80, "ctrl+o t/f4 inspect activity · ? shortcuts"),
(40, "ctrl+o t/f4 inspect activity"),
] {
let (text, _) = render(&composer, width, Some(&footer));
assert_eq!(text.lines().last().map(str::trim), Some(expected));
}
footer.is_interactive = true;
let (text, _) = render(&composer, /*width*/ 80, Some(&footer));
assert_eq!(
text.lines().last().map(str::trim),
Some("ctrl+o t/f4 inspect activity")
);
footer.is_interactive = false;
composer.footer.toggle_shortcuts_key = None;
let (text, _) = render(&composer, /*width*/ 80, Some(&footer));
assert_eq!(
text.lines().last().map(str::trim),
Some("ctrl+o t/f4 inspect activity")
);
}
#[test]
fn shortcut_help_stays_above_the_prompt_and_bottom_status() {
let mut composer = composer();
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
for (width, height, snapshot) in [
(130, None, Some("shortcut_help_above_wide")),
(80, None, None),
(40, None, Some("shortcut_help_above_narrow")),
(80, Some(12), None),
] {
let (text, cursor) = render_with_height(&composer, width, /*footer*/ None, height);
let rows: Vec<_> = text.lines().collect();
let help_row = rows
.iter()
.position(|line| line.contains("Keyboard shortcuts"))
.unwrap();
let prompt_row = rows
.iter()
.position(|line| line.contains("Ask Codex"))
.unwrap();
assert!(help_row < prompt_row);
assert!(
rows[..prompt_row]
.iter()
.any(|line| line.trim() == "/keymap customize")
);
assert_eq!(text.matches("? / esc close").count(), 1);
assert_eq!(
(
rows.last().map(|line| line.trim()),
rows[rows.len() - 2].trim(),
cursor,
),
(
Some("? / esc close"),
"MODEL · ~/project · Context 20% used",
Some((2, prompt_row as u16))
),
);
assert_eq!(prompt_row + 3, rows.len() - 1);
if let Some(name) = snapshot {
insta::assert_snapshot!(name, text);
}
}
}
#[test]
fn shortcut_help_keeps_bottom_anchored_composer_status_and_cursor_stable() {
let (width, available_height) = (40, 12);
let mut composer = composer();
let mut geometry = Vec::new();
for event in [
None,
Some(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)),
Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
] {
if let Some(event) = event {
composer.handle_key_event(event);
}
let options = composer.resolve_render_options(ComposerRenderOptions {
separate_status_line: true,
..ComposerRenderOptions::default()
});
let height = composer
.desired_height_with_options(width, options)
.min(available_height);
let area = Rect::new(/*x*/ 3, 80 - height, width, height);
let ComposerLayout {
composer: surface,
footer,
status,
..
} = composer.layout_with_options(area, options);
geometry.push((
surface,
status,
footer,
composer.cursor_pos_with_options(area, options),
));
}
assert_eq!(geometry, vec![geometry[0]; 3]);
}
#[test]
fn shortcut_help_keeps_input_and_close_hint_visible_when_height_is_tiny() {
for status_enabled in [true, false] {
let mut composer = composer();
composer.set_status_line_enabled(status_enabled);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let options = composer.resolve_render_options(ComposerRenderOptions {
separate_status_line: true,
..ComposerRenderOptions::default()
});
for height in 0..=7 {
let area = Rect::new(/*x*/ 3, /*y*/ 5, /*width*/ 40, height);
let mut buf = Buffer::empty(area);
composer.render_with_options(area, &mut buf, /*mask_char*/ None, options);
if let Some((x, y)) = composer.cursor_pos_with_options(area, options) {
assert!(x >= area.x && x < area.right() && y >= area.y && y < area.bottom());
}
if height >= 4 {
let ComposerLayout {
composer: surface,
footer,
..
} = composer.layout_with_options(area, options);
assert_eq!(
(surface.height, footer.height, footer.bottom()),
(3, 1, area.bottom())
);
let last_row = (area.x..area.right())
.map(|x| buf[(x, area.bottom() - 1)].symbol())
.collect::<String>();
assert_eq!(last_row.trim(), "? / esc close");
}
}
}
}
#[test]
fn shortcut_help_close_row_uses_runtime_binding_and_narrow_fallback() {
let chord = Some(key_hint::ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::ctrl(KeyCode::Char('h')),
});
for (width, binding, expected) in [
(80, chord, "ctrl+x ctrl+h / esc close"),
(22, chord, "esc close"),
(80, None, "esc close"),
] {
let mut composer = composer();
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.footer.toggle_shortcuts_key = binding;
let (text, _) = render_with_height(
&composer,
width,
/*footer*/ None,
/*height*/ Some(8),
);
assert_eq!(text.lines().last().map(str::trim), Some(expected));
assert_eq!(text.matches("close").count(), 1);
assert_eq!(text.matches("/keymap customize").count(), 1);
}
}
#[test]
fn status_survives_hints_feedback_and_queue() {
let mut composer = composer();
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
let mut states = Vec::new();
states.push(render(&composer, /*width*/ 64, /*footer*/ None).0);
composer.footer.mode = FooterMode::ShortcutOverlay;
composer.show_footer_flash("Copied selection".into(), Duration::from_secs(/*secs*/ 3));
states.push(render(&composer, /*width*/ 64, /*footer*/ None).0);
composer.footer.flash = None;
composer.footer.mode = FooterMode::ComposerEmpty;
composer.set_footer_hint_override(Some(vec![("Ctrl+X".into(), "waiting for chord".into())]));
states.push(render(&composer, /*width*/ 64, /*footer*/ None).0);
composer.set_footer_hint_override(/*items*/ None);
composer.set_text_content("Continue the task".into(), Vec::new(), Vec::new());
composer.set_task_running(/*running*/ true);
states.push(render(&composer, /*width*/ 64, /*footer*/ None).0);
for popup in [
ActivePopup::File(FileSearchPopup::new()),
ActivePopup::Skill(SkillPopup::new(Vec::new())),
] {
composer.popups.active = popup;
let (text, _) = render(&composer, /*width*/ 64, /*footer*/ None);
assert!(text.contains("MODEL · ~/project · Context 20% used"));
assert_eq!(text.matches("Plan mode").count(), 1);
}
for state in &states {
assert_eq!(state.matches("Plan mode").count(), 1);
assert!(state.contains("MODEL · ~/project · Context 20% used"));
}
insta::assert_snapshot!("persistent_status_and_messages", states.join("\n---\n"));
}
#[test]
fn transcript_search_keeps_status_and_query_cursor() {
let composer = composer();
let footer = TranscriptFooter {
text: vec![
Line::from("Find: needle"),
Line::from("Enter next · Esc close"),
]
.into(),
cursor_column: Some(8),
is_interactive: true,
};
let (text, cursor) = render(&composer, /*width*/ 48, Some(&footer));
assert_eq!(cursor, Some((10, text.lines().count() as u16 - 2)));
assert!(text.contains("MODEL · ~/project · Context 20% used"));
}
#[test]
fn input_history_search_uses_the_final_row() {
let mut composer = composer();
composer.begin_history_search();
let (text, cursor) = render(&composer, /*width*/ 48, /*footer*/ None);
assert_eq!(
cursor.map(|(_, y)| y),
Some(text.lines().count() as u16 - 1)
);
}
#[test]
fn slash_popup_preserves_footer_and_selection_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(tx),
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some("model · high · fast".into()));
for ch in ['/', 'm'] {
composer.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
for (width, clipped) in [(80, false), (32, false), (80, true)] {
let height = if clipped {
6
} else {
composer.desired_height(width)
};
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let ComposerLayout {
composer: surface,
textarea,
popup,
footer,
..
} = composer.layout_with_options(area, ComposerRenderOptions::default());
assert_eq!(popup.bottom(), surface.y);
assert_eq!(surface.bottom(), footer.y);
assert_eq!(composer.cursor_pos(area), Some((4, textarea.y)));
let footer_text: String = (0..width).map(|x| buf[(x, height - 1)].symbol()).collect();
assert!(footer_text.contains("model · high · fast"));
if !clipped && width == 80 {
insta::assert_snapshot!("slash_popup_footer_wide", format!("{buf:?}"));
}
}
let footer = TranscriptFooter {
text: "transcript status".into(),
cursor_column: None,
is_interactive: false,
};
let options = composer.resolve_render_options(ComposerRenderOptions {
footer: Some(&footer),
..Default::default()
});
assert!(options.footer.is_some());
let width = 80;
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
composer.desired_height_with_options(width, options),
);
let mut buf = Buffer::empty(area);
composer.render_with_options(area, &mut buf, /*mask_char*/ None, options);
let footer_text: String = (0..width)
.map(|x| buf[(x, area.bottom() - 1)].symbol())
.collect();
assert!(footer_text.contains("transcript status"));
}
@@ -13,9 +13,8 @@ impl ChatComposer {
canceled
}
pub(super) fn vim_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
pub(super) fn vim_search_cursor_pos(&self, mut footer: Rect) -> Option<(u16, u16)> {
let query = self.draft.textarea.vim_query()?;
let [_, _, _, mut footer] = self.layout_areas(area);
footer.y = footer.bottom().saturating_sub(1);
footer.height = footer.height.min(1);
query.cursor_pos(inset_footer_hint_area(footer))
+19 -481
View File
@@ -5,6 +5,9 @@
//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like
//! `ChatWidget` (which decides when quit/interrupt is allowed).
//!
//! The shortcut reference lives in `shortcut_overlay`; its responsive layout is shared by
//! footer measurement and rendering so resizing does not clip a differently measured layout.
//!
//! 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.
@@ -41,6 +44,7 @@
//! In short: `single_line_footer_layout` chooses *what* best fits, and the two
//! render helpers choose whether to draw the chosen line or the default
//! `FooterProps` mapping.
use super::shortcut_overlay;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
@@ -69,7 +73,6 @@ use ratatui::widgets::Widget;
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
pub(crate) esc_backtrack_hint: bool,
pub(crate) use_shift_enter_hint: bool,
pub(crate) is_task_running: bool,
pub(crate) queue_submissions: bool,
pub(crate) collaboration_modes_enabled: bool,
@@ -116,6 +119,7 @@ pub(crate) struct FooterKeyHints {
pub(crate) external_editor: Option<ShortcutHint>,
pub(crate) edit_previous: Option<ShortcutHint>,
pub(crate) show_transcript: Option<ShortcutHint>,
pub(crate) find_transcript: Option<ShortcutHint>,
pub(crate) history_search: Option<ShortcutHint>,
pub(crate) reasoning_down: Option<ShortcutHint>,
pub(crate) reasoning_up: Option<ShortcutHint>,
@@ -132,6 +136,7 @@ impl FooterKeyHints {
external_editor: Some(key_hint::ctrl(KeyCode::Char('g')).into()),
edit_previous: Some(key_hint::plain(KeyCode::Esc).into()),
show_transcript: Some(key_hint::ctrl(KeyCode::Char('t')).into()),
find_transcript: Some(key_hint::plain(KeyCode::F(3)).into()),
history_search: Some(key_hint::ctrl(KeyCode::Char('r')).into()),
reasoning_down: Some(key_hint::alt(KeyCode::Char(',')).into()),
reasoning_up: Some(key_hint::alt(KeyCode::Char('.')).into()),
@@ -225,7 +230,11 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
}
}
pub(crate) fn footer_height(props: &FooterProps) -> u16 {
pub(crate) fn footer_height(props: &FooterProps, width: u16) -> u16 {
if props.mode == FooterMode::ShortcutOverlay {
return shortcut_overlay::lines(props, width.saturating_sub(FOOTER_INDENT_COLS as u16)).len()
as u16;
}
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => false,
@@ -278,6 +287,10 @@ pub(crate) fn render_footer_from_props(
show_shortcuts_hint: bool,
show_queue_hint: bool,
) {
if props.mode == FooterMode::ShortcutOverlay {
shortcut_overlay::render(props, inset_footer_hint_area(area), buf);
return;
}
Paragraph::new(prefix_lines(
footer_from_props_lines(
props,
@@ -780,18 +793,7 @@ fn footer_from_props_lines(
key_hints,
)]
}
FooterMode::ShortcutOverlay => {
let state = ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
queue_submissions: props.queue_submissions,
is_wsl: props.is_wsl,
collaboration_modes_enabled: props.collaboration_modes_enabled,
key_hints,
};
shortcut_overlay_lines(state)
}
FooterMode::ShortcutOverlay => shortcut_overlay::lines(props, u16::MAX),
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
FooterMode::ComposerHasDraft => {
let state = LeftSideState {
@@ -922,17 +924,6 @@ pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static
Line::from(spans)
}
#[derive(Clone, Copy, Debug)]
struct ShortcutsState {
use_shift_enter_hint: bool,
esc_backtrack_hint: bool,
is_task_running: bool,
queue_submissions: bool,
is_wsl: bool,
collaboration_modes_enabled: bool,
key_hints: FooterKeyHints,
}
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
let mut line = Line::from(key.spans());
line.push_span(" again to quit".set_style(secondary_text_style()));
@@ -954,129 +945,6 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
}
}
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = Line::from("");
let mut shell_commands = Line::from("");
let mut newline = Line::from("");
let mut queue_message_tab = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut external_editor = Line::from("");
let mut edit_previous = Line::from("");
let mut history_search = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
let mut change_mode = Line::from("");
let mut reasoning_down = Line::from("");
let mut reasoning_up = Line::from("");
for descriptor in SHORTCUTS {
if let Some(text) = descriptor.overlay_entry(state) {
match descriptor.id {
ShortcutId::Commands => commands = text,
ShortcutId::ShellCommands => shell_commands = text,
ShortcutId::InsertNewline => newline = text,
ShortcutId::QueueMessageTab => queue_message_tab = text,
ShortcutId::FilePaths => file_paths = text,
ShortcutId::PasteImage => paste_image = text,
ShortcutId::ExternalEditor => external_editor = text,
ShortcutId::EditPrevious => edit_previous = text,
ShortcutId::HistorySearch => history_search = text,
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
ShortcutId::ChangeMode => change_mode = text,
ShortcutId::ReasoningDown => reasoning_down = text,
ShortcutId::ReasoningUp => reasoning_up = text,
}
}
}
let mut ordered = vec![
commands,
shell_commands,
newline,
queue_message_tab,
file_paths,
paste_image,
external_editor,
edit_previous,
history_search,
quit,
reasoning_down,
reasoning_up,
];
if change_mode.width() > 0 {
ordered.push(change_mode);
}
ordered.push(show_transcript);
let mut lines = build_columns(ordered);
if let Some(key) = state.key_hints.agents {
lines.push(Line::from(vec![
key.display_label()
.fg(crate::style::accent_color_on(/*background*/ None))
.not_bold()
.not_dim(),
" for agents (empty prompt)".set_style(secondary_text_style()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
"customize shortcuts with ".set_style(secondary_text_style()),
"/keymap"
.fg(crate::style::accent_color_on(/*background*/ None))
.not_bold()
.not_dim(),
]));
lines
}
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
if entries.is_empty() {
return Vec::new();
}
const COLUMNS: usize = 2;
const COLUMN_PADDING: [usize; COLUMNS] = [4, 4];
const COLUMN_GAP: usize = 4;
let rows = entries.len().div_ceil(COLUMNS);
let target_len = rows * COLUMNS;
let mut entries = entries;
if entries.len() < target_len {
entries.extend(std::iter::repeat_n(
Line::from(""),
target_len - entries.len(),
));
}
let mut column_widths = [0usize; COLUMNS];
for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.width());
}
for (idx, width) in column_widths.iter_mut().enumerate() {
*width += COLUMN_PADDING[idx];
}
entries
.chunks(COLUMNS)
.map(|chunk| {
let mut line = Line::from("");
for (col, entry) in chunk.iter().enumerate() {
line.extend(entry.spans.clone());
if col < COLUMNS - 1 {
let target_width = column_widths[col];
let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP;
line.push_span(Span::from(" ".repeat(padding)));
}
}
line.set_style(secondary_text_style())
})
.collect()
}
pub(crate) fn context_window_line(percent: Option<i64>, used_tokens: Option<i64>) -> Line<'static> {
if let Some(percent) = percent {
let percent = percent.clamp(0, 100);
@@ -1097,273 +965,6 @@ pub(crate) fn context_window_line(percent: Option<i64>, used_tokens: Option<i64>
])
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShortcutId {
Commands,
ShellCommands,
InsertNewline,
QueueMessageTab,
FilePaths,
PasteImage,
ExternalEditor,
EditPrevious,
HistorySearch,
Quit,
ShowTranscript,
ChangeMode,
ReasoningDown,
ReasoningUp,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ShortcutBinding {
key: KeyBinding,
condition: DisplayCondition,
}
impl ShortcutBinding {
fn matches(&self, state: ShortcutsState) -> bool {
self.condition.matches(state)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DisplayCondition {
Always,
WhenShiftEnterHint,
WhenNotShiftEnterHint,
WhenUnderWSL,
WhenCollaborationModesEnabled,
}
impl DisplayCondition {
fn matches(self, state: ShortcutsState) -> bool {
match self {
DisplayCondition::Always => true,
DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint,
DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint,
DisplayCondition::WhenUnderWSL => state.is_wsl,
DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled,
}
}
}
struct ShortcutDescriptor {
id: ShortcutId,
bindings: &'static [ShortcutBinding],
prefix: &'static str,
label: &'static str,
}
impl ShortcutDescriptor {
fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> {
self.bindings.iter().find(|binding| binding.matches(state))
}
fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
let key = match self.id {
ShortcutId::InsertNewline => state.key_hints.insert_newline,
ShortcutId::QueueMessageTab => state.key_hints.queue,
ShortcutId::ExternalEditor => state.key_hints.external_editor,
ShortcutId::EditPrevious => state.key_hints.edit_previous,
ShortcutId::ShowTranscript => state.key_hints.show_transcript,
ShortcutId::HistorySearch => state.key_hints.history_search,
ShortcutId::ReasoningDown => state.key_hints.reasoning_down,
ShortcutId::ReasoningUp => state.key_hints.reasoning_up,
ShortcutId::Commands
| ShortcutId::ShellCommands
| ShortcutId::FilePaths
| ShortcutId::PasteImage
| ShortcutId::Quit
| ShortcutId::ChangeMode => self
.binding_for(state)
.map(|binding| ShortcutHint::Single(binding.key)),
}?;
let reference_key = key
.display_label()
.fg(crate::style::accent_color_on(/*background*/ None))
.not_bold()
.not_dim();
let mut line = Line::from(vec![self.prefix.into(), reference_key.clone()]);
match self.id {
ShortcutId::QueueMessageTab => {
if state.is_task_running || state.queue_submissions {
line.push_span(" to queue message");
} else {
line.push_span(" to submit message");
}
}
ShortcutId::EditPrevious => {
if state.esc_backtrack_hint {
line.push_span(" again to edit previous message");
} else {
line.extend(vec![
" ".into(),
reference_key,
" to edit previous message".into(),
]);
}
}
ShortcutId::Quit => {
if state.is_task_running {
line.push_span(" to interrupt");
} else {
line.push_span(" to exit");
}
}
_ => line.push_span(self.label),
};
Some(line)
}
}
const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::Commands,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Char('/')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for commands",
},
ShortcutDescriptor {
id: ShortcutId::ShellCommands,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Char('!')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for shell commands",
},
ShortcutDescriptor {
id: ShortcutId::InsertNewline,
bindings: &[
ShortcutBinding {
key: key_hint::shift(KeyCode::Enter),
condition: DisplayCondition::WhenShiftEnterHint,
},
ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('j')),
condition: DisplayCondition::WhenNotShiftEnterHint,
},
],
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::QueueMessageTab,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Tab),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to queue message",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Char('@')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for file paths",
},
ShortcutDescriptor {
id: ShortcutId::PasteImage,
// Show Ctrl+Alt+V when running under WSL (terminals often intercept plain
// Ctrl+V); otherwise fall back to Ctrl+V.
bindings: &[
ShortcutBinding {
key: key_hint::ctrl_alt(KeyCode::Char('v')),
condition: DisplayCondition::WhenUnderWSL,
},
ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('v')),
condition: DisplayCondition::Always,
},
],
prefix: "",
label: " to paste images",
},
ShortcutDescriptor {
id: ShortcutId::ExternalEditor,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('g')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to edit in external editor",
},
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Esc),
condition: DisplayCondition::Always,
}],
prefix: "",
label: "",
},
ShortcutDescriptor {
id: ShortcutId::HistorySearch,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('r')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " search history",
},
ShortcutDescriptor {
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('c')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to exit",
},
ShortcutDescriptor {
id: ShortcutId::ShowTranscript,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('t')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to view transcript",
},
ShortcutDescriptor {
id: ShortcutId::ChangeMode,
bindings: &[ShortcutBinding {
key: key_hint::shift(KeyCode::Tab),
condition: DisplayCondition::WhenCollaborationModesEnabled,
}],
prefix: "",
label: " to change mode",
},
ShortcutDescriptor {
id: ShortcutId::ReasoningDown,
bindings: &[ShortcutBinding {
key: key_hint::alt(KeyCode::Char(',')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " reasoning down",
},
ShortcutDescriptor {
id: ShortcutId::ReasoningUp,
bindings: &[ShortcutBinding {
key: key_hint::alt(KeyCode::Char('.')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " reasoning up",
},
];
#[cfg(test)]
#[path = "footer_typography_tests.rs"]
mod typography_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1626,7 +1227,7 @@ mod tests {
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
context_line: Line<'static>,
) {
let height = footer_height(props).max(1);
let height = footer_height(props, width).max(/*other*/ 1);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
draw_footer_frame(
&mut terminal,
@@ -1645,7 +1246,7 @@ mod tests {
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
context_line: Line<'static>,
) -> String {
let height = footer_height(props).max(1);
let height = footer_height(props, width).max(/*other*/ 1);
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
draw_footer_frame(
&mut terminal,
@@ -1665,7 +1266,7 @@ mod tests {
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
ide_context_active: bool,
) {
let height = footer_height(props).max(1);
let height = footer_height(props, width).max(/*other*/ 1);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
draw_footer_frame(
&mut terminal,
@@ -1685,7 +1286,6 @@ mod tests {
FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1703,7 +1303,6 @@ mod tests {
FooterProps {
mode: FooterMode::ShortcutOverlay,
esc_backtrack_hint: true,
use_shift_enter_hint: true,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1724,7 +1323,6 @@ mod tests {
FooterProps {
mode: FooterMode::ShortcutOverlay,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -1742,7 +1340,6 @@ mod tests {
FooterProps {
mode: FooterMode::ShortcutOverlay,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1760,7 +1357,6 @@ mod tests {
FooterProps {
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1778,7 +1374,6 @@ mod tests {
FooterProps {
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1796,7 +1391,6 @@ mod tests {
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1814,7 +1408,6 @@ mod tests {
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: true,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1832,7 +1425,6 @@ mod tests {
FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1852,7 +1444,6 @@ mod tests {
FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1872,7 +1463,6 @@ mod tests {
FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1888,7 +1478,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -1917,7 +1506,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -1939,7 +1527,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1956,7 +1543,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1973,7 +1559,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -1990,7 +1575,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -2021,7 +1605,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -2044,7 +1627,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -2068,7 +1650,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -2093,7 +1674,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -2110,7 +1690,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: false,
@@ -2130,7 +1709,6 @@ mod tests {
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
queue_submissions: false,
collaboration_modes_enabled: true,
@@ -2165,44 +1743,4 @@ mod tests {
"status line should be truncated with ellipsis to keep mode indicator"
);
}
#[test]
fn paste_image_shortcut_prefers_ctrl_alt_v_under_wsl() {
let descriptor = SHORTCUTS
.iter()
.find(|descriptor| descriptor.id == ShortcutId::PasteImage)
.expect("paste image shortcut");
let is_wsl = {
#[cfg(target_os = "linux")]
{
crate::clipboard_paste::is_probably_wsl()
}
#[cfg(not(target_os = "linux"))]
{
false
}
};
let expected_key = if is_wsl {
key_hint::ctrl_alt(KeyCode::Char('v'))
} else {
key_hint::ctrl(KeyCode::Char('v'))
};
let actual_key = descriptor
.binding_for(ShortcutsState {
use_shift_enter_hint: false,
esc_backtrack_hint: false,
is_task_running: false,
queue_submissions: false,
is_wsl,
collaboration_modes_enabled: false,
key_hints: FooterKeyHints::default_bindings(),
})
.expect("shortcut binding")
.key;
assert_eq!(actual_key, expected_key);
}
}
@@ -1,88 +0,0 @@
//! Legacy help keeps accent keys while ordinary footer keys clear inherited emphasis.
use super::*;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
#[test]
fn help_reference_and_footer_preserve_distinct_key_styles() {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (32, 32, 32),
bg: (255, 255, 255),
},
|| {
let help = shortcut_overlay_lines(ShortcutsState {
use_shift_enter_hint: false,
esc_backtrack_hint: false,
is_task_running: false,
queue_submissions: false,
is_wsl: false,
collaboration_modes_enabled: false,
key_hints: FooterKeyHints::default_bindings(),
});
let help_height = help.len() as u16;
let footer = footer_hint_items_line(&[
("ctrl+o".into(), "then".into()),
("o".into(), "copy".into()),
]);
let mut terminal = Terminal::new(TestBackend::new(
/*width*/ 80,
/*height*/ help_height + 1,
))
.unwrap();
terminal
.draw(|frame| {
let inherited = Style::default().fg(Color::Green).bold().dim();
frame.render_widget(
Paragraph::new(help).style(inherited),
Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 80, help_height),
);
frame.render_widget(
Paragraph::new(footer).style(inherited),
Rect::new(
/*x*/ 0,
help_height,
/*width*/ 80,
/*height*/ 1,
),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let styles = [
(0, 0),
(2, 0),
(0, help_height),
(4, help_height),
(7, help_height),
]
.map(|position| {
let cell = &buffer[position];
(cell.fg, cell.modifier)
});
let key_color = key_hint::ctrl(KeyCode::Char('o')).spans()[0]
.style
.fg
.unwrap();
let secondary_color = secondary_text_style().fg.unwrap();
assert_eq!(
styles,
[
(
crate::style::accent_color_on(/*background*/ None),
Modifier::empty()
),
(secondary_color, Modifier::empty()),
(key_color, Modifier::BOLD),
(key_color, Modifier::BOLD),
(secondary_color, Modifier::empty()),
]
);
},
);
}
+104 -40
View File
@@ -15,6 +15,11 @@
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
//! Inline banners sit above the composer. Number shortcuts apply only to an empty, idle composer;
//! drafts, paste bursts, and active dialogs keep their normal input routing.
pub(crate) use chat_composer::CommandPopupPlacement;
pub(crate) use chat_composer::ComposerRenderOptions;
pub(crate) use chat_composer::TranscriptFooter;
pub(crate) use footer::footer_hint_items_line;
pub(crate) use footer::inset_footer_hint_area;
use std::collections::VecDeque;
use std::path::PathBuf;
@@ -39,8 +44,6 @@ use crate::terminal_palette::effective_stdout_color_level;
use crate::tui::FrameRequester;
pub(crate) use bottom_pane_view::BottomPaneView;
pub(crate) use bottom_pane_view::ViewCompletion;
pub(crate) use chat_composer::ComposerRenderOptions;
pub(crate) use chat_composer::TranscriptFooter;
use codex_app_server_protocol::SkillMetadata;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_features::Features;
@@ -134,7 +137,14 @@ mod footer;
mod list_selection_view;
mod memories_settings_view;
mod mentions_v2;
mod picker_presets;
mod picker_rows;
mod picker_style;
mod shortcut_overlay;
pub(crate) use picker_style::active_tab_style;
pub(crate) use picker_style::selection_style;
pub(crate) mod prompt_args;
mod selection_picker_layout;
mod skill_popup;
mod skills_toggle_view;
pub(crate) mod slash_commands;
@@ -164,9 +174,6 @@ pub(crate) use feedback_view::feedback_disabled_params;
pub(crate) use feedback_view::feedback_selection_params;
pub(crate) use feedback_view::feedback_success_cell;
pub(crate) use feedback_view::feedback_upload_consent_params;
pub(crate) use footer::footer_hint_items_line;
pub(crate) use footer::inset_footer_hint_area;
pub(crate) use picker_style::selection_style;
pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
@@ -181,15 +188,10 @@ mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
mod picker_option;
mod picker_presets;
mod picker_rows;
pub(crate) use picker_option::picker_option_list;
pub(crate) use picker_option::picker_option_row;
mod picker_style;
pub(crate) use picker_style::active_tab_style;
pub(crate) mod popup_consts;
mod scroll_state;
mod selection_picker_layout;
mod selection_popup_common;
pub(crate) use selection_popup_common::menu_surface_padding_height;
pub(crate) use selection_popup_common::render_menu_surface;
@@ -1111,11 +1113,6 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) {
self.composer.show_footer_flash(line, duration);
self.request_redraw();
}
pub(crate) fn set_voice_strip(&mut self, state: Option<VoiceStripState>) {
self.composer
.set_voice_strip(state, self.frame_requester.clone());
@@ -1627,6 +1624,8 @@ impl BottomPane {
.is_some_and(|(name, _, _)| matches!(name, "agents" | "subagents"));
self.keymap.chat.interrupt_turn.is_pressed(key_event)
&& !(self.shortcut_overlay_visible()
&& key_hint::plain(KeyCode::Esc).is_press(key_event))
&& self.is_task_running
&& !(is_agent_command && key_event.code == KeyCode::Esc)
&& self.no_modal_or_popup_active()
@@ -1663,20 +1662,18 @@ impl BottomPane {
/// use Esc-Esc for backtracking from the main view.
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running
&& !self.questions.as_ref().is_some_and(|q| q.expanded)
&& self.view_stack.is_empty()
&& !self.composer.popup_active()
&& !self.shortcut_overlay_visible()
&& self.can_launch_external_editor()
&& !self.inline_banner_accepts_dismissal()
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn shortcut_overlay_visible(&self) -> bool {
self.no_modal_or_popup_active() && self.composer.shortcut_overlay_visible()
}
/// Return true when no popups or modal views are active, regardless of task state.
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.view_stack.is_empty()
!self.has_active_view()
&& !self.composer.popup_active()
&& !self.questions.as_ref().is_some_and(|q| q.expanded)
}
@@ -1994,7 +1991,7 @@ impl BottomPane {
}
pub(crate) fn attach_image(&mut self, path: PathBuf) {
if self.view_stack.is_empty() {
if !self.has_active_view() {
self.composer.attach_image(path);
self.request_redraw();
}
@@ -2020,22 +2017,12 @@ impl BottomPane {
}
fn as_renderable(&'_ self) -> RenderableItem<'_> {
self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0)
}
pub(crate) fn as_renderable_with_composer_right_reserve(
&'_ self,
composer_right_reserve: u16,
) -> RenderableItem<'_> {
self.as_renderable_with_options(ComposerRenderOptions {
textarea_right_reserve: composer_right_reserve,
..ComposerRenderOptions::default()
})
self.as_renderable_with_options(ComposerRenderOptions::default())
}
pub(crate) fn as_renderable_with_options<'a>(
&'a self,
options: ComposerRenderOptions<'a>,
mut options: ComposerRenderOptions<'a>,
) -> RenderableItem<'a> {
if (self.is_task_running || !self.view_stack.is_empty())
&& let Some(banner) = &self.inline_banner
@@ -2120,7 +2107,17 @@ impl BottomPane {
{
flex.push(/*flex*/ 0, RenderableItem::Owned("".into()));
}
if let Some(summary) = self.question_summary(Instant::now()) {
let question_summary = self.question_summary(Instant::now());
// History overlays must not clear the status and previews above this composer.
if options.command_popup_placement == CommandPopupPlacement::Overlay
&& (has_status_or_footer
|| has_inline_previews
|| self.inline_banner.is_some()
|| question_summary.is_some())
{
options.command_popup_placement = CommandPopupPlacement::AboveComposer;
}
if let Some(summary) = question_summary {
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(Paragraph::new(summary))),
@@ -2130,12 +2127,16 @@ impl BottomPane {
flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into()));
let composer: RenderableItem<'_> = if let Some(questions) = question_editor {
RenderableItem::Borrowed(questions.as_ref())
} else if options.textarea_right_reserve == 0 && options.footer.is_none() {
} else if options.textarea_right_reserve == 0
&& options.footer.is_none()
&& !options.separate_status_line
&& options.command_popup_placement == CommandPopupPlacement::AboveComposer
{
RenderableItem::Borrowed(&self.composer)
} else {
RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable {
RenderableItem::Owned(Box::new(ChatComposerPresentation {
composer: &self.composer,
options,
options: self.composer.resolve_render_options(options),
}))
};
flex2.push(/*flex*/ 0, composer);
@@ -2143,6 +2144,11 @@ impl BottomPane {
}
}
pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) {
self.composer.show_footer_flash(line, duration);
self.request_redraw();
}
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
if self.composer.set_status_line(status_line) {
self.request_redraw();
@@ -2184,12 +2190,12 @@ impl BottomPane {
}
}
struct ChatComposerRightReserveRenderable<'a> {
struct ChatComposerPresentation<'a> {
composer: &'a chat_composer::ChatComposer,
options: ComposerRenderOptions<'a>,
}
impl Renderable for ChatComposerRightReserveRenderable<'_> {
impl Renderable for ChatComposerPresentation<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.composer
.render_with_options(area, buf, /*mask_char*/ None, self.options);
@@ -2205,7 +2211,15 @@ impl Renderable for ChatComposerRightReserveRenderable<'_> {
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.composer.cursor_style(area)
if self
.options
.footer
.is_some_and(|footer| footer.is_interactive)
{
crossterm::cursor::SetCursorStyle::SteadyBar
} else {
self.composer.cursor_style(area)
}
}
}
@@ -2389,6 +2403,35 @@ mod tests {
assert!(pane.is_normal_backtrack_mode());
}
#[test]
fn escape_closes_shortcut_help_before_inline_banner() {
let (tx, _rx) = unbounded_channel();
let mut pane = test_pane(AppEventSender::new(tx));
pane.set_inline_banner(Some(ActionableBanner {
title: "Continue working".into(),
description: "Keep this notice while dismissing shortcut help".into(),
..Default::default()
}));
let width = 80;
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
pane.desired_height(width),
);
let _ = render_snapshot(&pane, area);
assert_eq!(pane.inline_banner_lifecycle(), (true, false));
pane.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(pane.shortcut_overlay_visible());
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!pane.shortcut_overlay_visible());
assert_eq!(pane.inline_banner_lifecycle(), (true, false));
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(pane.inline_banner_lifecycle(), (true, true));
}
#[test]
fn backend_banner_snapshots_and_numbered_actions() {
for (kind, action, label) in [
@@ -3308,6 +3351,27 @@ mod tests {
"expected command popup after typing `/rev`"
);
// Owned transcript mode must reserve the popup's rows while task status is visible.
let overlay = pane.as_renderable_with_options(ComposerRenderOptions {
command_popup_placement: CommandPopupPlacement::Overlay,
..Default::default()
});
let inline = pane.as_renderable();
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 60,
inline.desired_height(/*width*/ 60),
);
let mut expected = Buffer::empty(area);
let mut actual = Buffer::empty(area);
inline.render(area, &mut expected);
overlay.render(area, &mut actual);
assert_eq!(overlay.desired_height(/*width*/ 60), area.height);
assert_eq!(actual, expected);
assert_eq!(overlay.cursor_pos(area), inline.cursor_pos(area));
drop((overlay, inline));
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
while let Ok(ev) = rx.try_recv() {
@@ -0,0 +1,257 @@
//! A compact shortcut reference with shared width-aware measurement and rendering.
//!
//! Groups flow into three, two, or one column without changing key routing. Runtime hints
//! remain authoritative for remapped, chorded, and disabled bindings. A clipped reference
//! keeps customization visible; the composer reserves a separate bottom row for the close hint.
use super::footer::FooterProps;
use crate::key_hint;
use crate::key_hint::ShortcutHint;
use crate::style::accent_color;
use crate::style::secondary_text_style;
use crate::wrapping::word_wrap_lines;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
const COLUMN_GAP: usize = 4;
struct Shortcut {
key: String,
action: &'static str,
}
impl Shortcut {
fn new(key: impl Into<ShortcutHint>, action: &'static str) -> Self {
Self {
key: key.into().display_label(),
action,
}
}
}
struct Group {
title: &'static str,
entries: Vec<Shortcut>,
}
impl Group {
fn push(&mut self, key: Option<ShortcutHint>, action: &'static str) {
if let Some(key) = key {
self.entries.push(Shortcut::new(key, action));
}
}
fn lines(&self) -> Vec<Line<'static>> {
let key_width = self
.entries
.iter()
.map(|entry| Span::raw(&entry.key).width())
.max()
.unwrap_or(/*default*/ 0);
let mut lines = vec![self.title.bold().into()];
for entry in &self.entries {
let key = entry.key.clone().fg(accent_color());
let padding = " ".repeat(key_width.saturating_sub(key.width()) + 2);
lines.push(vec![key, padding.into(), entry.action.into()].into());
}
lines
}
}
pub(super) fn lines(props: &FooterProps, width: u16) -> Vec<Line<'static>> {
let hints = props.key_hints;
let mut compose = Group {
title: "Compose",
entries: vec![
Shortcut::new(key_hint::plain(KeyCode::Char('/')), "Commands"),
Shortcut::new(key_hint::plain(KeyCode::Char('@')), "Mention files"),
Shortcut::new(key_hint::plain(KeyCode::Char('!')), "Shell command"),
],
};
compose.push(hints.insert_newline, "New line");
compose.entries.push(Shortcut::new(
if props.is_wsl {
key_hint::ctrl_alt(KeyCode::Char('v'))
} else {
key_hint::ctrl(KeyCode::Char('v'))
},
"Paste image",
));
compose.push(hints.external_editor, "External editor");
compose.push(hints.history_search, "Search history");
if let Some(key) = hints.edit_previous {
let label = key.display_label();
compose.entries.push(Shortcut {
key: if props.esc_backtrack_hint {
label
} else {
format!("{label} {label}")
},
action: "Edit last message",
});
}
let mut session = Group {
title: "Session",
entries: Vec::new(),
};
session.push(
hints.queue,
if props.is_task_running || props.queue_submissions {
"Queue message"
} else {
"Send message"
},
);
if props.collaboration_modes_enabled {
session
.entries
.push(Shortcut::new(key_hint::shift(KeyCode::Tab), "Change mode"));
}
session.push(hints.reasoning_down, "Less reasoning");
session.push(hints.reasoning_up, "More reasoning");
session.push(hints.agents, "Agents (empty prompt)");
session.entries.push(Shortcut::new(
key_hint::ctrl(KeyCode::Char('c')),
if props.is_task_running {
"Interrupt"
} else {
"Quit"
},
));
let mut transcript = Group {
title: "Transcript (open first)",
entries: Vec::new(),
};
transcript.push(hints.show_transcript, "Open transcript");
transcript.push(hints.find_transcript, "Find text");
transcript.entries.extend([
Shortcut {
key: "pgup / pgdn".into(),
action: "Scroll",
},
Shortcut::new(key_hint::ctrl(KeyCode::Char(' ')), "Start selection"),
Shortcut {
key: "ctrl+home / ctrl+end".into(),
action: "Top / latest",
},
// Keep both jump alternatives: the terminal may be on another OS over SSH.
Shortcut {
key: format!(
"{} / {}",
key_hint::KeyBinding::new(
KeyCode::Char(','),
KeyModifiers::ALT | KeyModifiers::SHIFT
)
.display_label(),
key_hint::KeyBinding::new(
KeyCode::Char('.'),
KeyModifiers::ALT | KeyModifiers::SHIFT
)
.display_label()
),
action: "Top / latest",
},
]);
let groups = [compose.lines(), session.lines(), transcript.lines()];
let widths: Vec<usize> = groups
.iter()
.map(|group| group.iter().map(Line::width).max().unwrap_or(/*default*/ 0))
.collect();
let width = usize::from(width.max(/*other*/ 1));
let mut result = vec![Line::from("Keyboard shortcuts").bold(), Line::default()];
if widths.iter().sum::<usize>() + COLUMN_GAP * 2 <= width {
result.extend(columns(&groups, &widths));
} else if widths[0] + COLUMN_GAP + widths[1].max(widths[2]) <= width {
let mut right = groups[1].clone();
right.push(Line::default());
right.extend(groups[2].clone());
result.extend(columns(
&[groups[0].clone(), right],
&[widths[0], widths[1].max(widths[2])],
));
} else {
for (index, group) in groups.into_iter().enumerate() {
if index > 0 {
result.push(Line::default());
}
result.extend(group);
}
}
result.push(Line::default());
result.extend(footer_lines(width));
word_wrap_lines(&result, width)
}
pub(super) fn close_hint(props: &FooterProps, width: u16) -> Line<'static> {
let mut line = Line::default();
if let Some(key) = props.key_hints.toggle_shortcuts {
line.extend(key.spans());
line.push_span(" / ".set_style(secondary_text_style()));
}
line.extend(key_hint::plain(KeyCode::Esc).spans());
line.push_span(" close".set_style(secondary_text_style()));
if line.width() > usize::from(width) {
line = Line::from(key_hint::plain(KeyCode::Esc).spans());
line.push_span(" close".set_style(secondary_text_style()));
}
line
}
fn footer_lines(width: usize) -> Vec<Line<'static>> {
word_wrap_lines(
[Line::from(vec![
"/keymap".fg(accent_color()),
" customize".set_style(secondary_text_style()),
])],
width,
)
}
fn columns(groups: &[Vec<Line<'static>>], widths: &[usize]) -> Vec<Line<'static>> {
let height = groups.iter().map(Vec::len).max().unwrap_or(/*default*/ 0);
(0..height)
.map(|row| {
let mut line = Line::default();
for (column, group) in groups.iter().enumerate() {
let entry = group.get(row).cloned().unwrap_or_default();
let padding = widths[column].saturating_sub(entry.width()) + COLUMN_GAP;
line.extend(entry.spans);
if column + 1 < groups.len() {
line.push_span(" ".repeat(padding));
}
}
line
})
.collect()
}
pub(super) fn render(props: &FooterProps, area: Rect, buf: &mut Buffer) {
let mut lines = lines(props, area.width);
let height = usize::from(area.height);
if lines.len() > height && height > 0 {
let mut footer = footer_lines(usize::from(area.width.max(/*other*/ 1)));
footer.truncate(height);
let body_height = height.saturating_sub(footer.len());
lines.truncate(body_height.saturating_sub(/*rhs*/ 1));
if body_height > 0 {
lines.push(Line::from("… resize to see all").dim());
}
lines.extend(footer);
}
Paragraph::new(lines).render(area, buf);
}
#[cfg(test)]
#[path = "shortcut_overlay_tests.rs"]
mod tests;
@@ -0,0 +1,103 @@
use super::lines;
use crate::bottom_pane::footer::FooterKeyHints;
use crate::bottom_pane::footer::FooterMode;
use crate::bottom_pane::footer::FooterProps;
use crate::bottom_pane::footer::footer_height;
use crate::bottom_pane::footer::render_footer_from_props;
use crate::key_hint;
use crate::key_hint::ShortcutHint;
use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn props() -> FooterProps {
FooterProps {
mode: FooterMode::ShortcutOverlay,
esc_backtrack_hint: false,
is_task_running: true,
queue_submissions: false,
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
status_line_value: None,
status_line_enabled: false,
key_hints: FooterKeyHints {
agents: Some(key_hint::plain(KeyCode::Left).into()),
..FooterKeyHints::default_bindings()
},
active_agent_label: None,
}
}
fn snapshot(name: &str, width: u16, height: Option<u16>, props: &FooterProps) {
let inner_width = width - FOOTER_INDENT_COLS as u16;
let measured_lines = lines(props, inner_width);
assert!(
measured_lines
.iter()
.all(|line| line.width() <= usize::from(inner_width))
);
let measured_height = footer_height(props, width);
assert_eq!(usize::from(measured_height), measured_lines.len());
let height = height.unwrap_or(measured_height);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
render_footer_from_props(
frame.area(),
frame.buffer_mut(),
props,
/*collaboration_mode_indicator*/ None,
/*show_cycle_hint*/ false,
/*show_shortcuts_hint*/ false,
/*show_queue_hint*/ false,
);
})
.unwrap();
let painted_height = terminal
.backend()
.buffer()
.content
.chunks(usize::from(width))
.rposition(|row| row.iter().any(|cell| cell.symbol() != " "))
.map_or(/*default*/ 0, |row| row + 1);
assert_eq!(painted_height, usize::from(height));
insta::assert_snapshot!(name, terminal.backend());
}
#[test]
fn shortcut_overlay_uses_runtime_bindings_and_wsl_paste() {
let mut props = props();
props.is_task_running = false;
props.is_wsl = true;
props.esc_backtrack_hint = true;
props.key_hints.external_editor = Some(ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::ctrl(KeyCode::Char('e')),
});
props.key_hints.toggle_shortcuts = Some(ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(KeyCode::Char('h')),
});
props.key_hints.insert_newline = Some(key_hint::shift(KeyCode::Enter).into());
props.key_hints.history_search = None;
props.key_hints.reasoning_down = None;
snapshot(
"shortcut_overlay_customized_wsl",
/*width*/ 80,
/*height*/ None,
&props,
);
}
#[test]
fn shortcut_overlay_clipping_preserves_customization_in_small_areas() {
snapshot(
"shortcut_overlay_customize_only",
/*width*/ 28,
/*height*/ Some(1),
&props(),
);
}
@@ -1,7 +1,26 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5294
expression: terminal.backend()
---
" Keyboard shortcuts "
" "
" Compose Session "
" / Commands tab Send message "
" @ Mention files ⌥+, Less reasoning "
" ! Shell command ⌥+. More reasoning "
" ctrl+j New line ← Agents (empty prompt) "
" ctrl+v Paste image ctrl+c Quit "
" ctrl+g External editor "
" ctrl+r Search history Transcript (open first) "
" esc esc Edit last message ctrl+t Open transcript "
" f3 Find text "
" pgup / pgdn Scroll "
" ctrl+space Start selection "
" ctrl+home / ctrl+end Top / latest "
" shift+⌥+, / shift+⌥+. Top / latest "
" "
" /keymap customize "
" "
"› Ask Codex to do anything "
" "
@@ -9,14 +28,4 @@ expression: terminal.backend()
" "
" "
" "
" "
" / for commands ! for shell commands "
" ctrl+j for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" ← for agents (empty prompt) "
" "
" customize shortcuts with /keymap "
" ? / esc close "
@@ -1,14 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5348
assertion_line: 5328
expression: terminal.backend()
---
" "
"› @sa "
" "
" Mentions "
" Files, directories, plugins, skills and tasks "
" All Results Filesystem Only Plugins "
" sa "
"› Sample Plugin Plugin with skills and an MCP server Plugin"
" enter/tab insert · esc close · ↑/↓ select · ←/→ filter "
" "
"› @sa "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" loading... "
" "
" "
"› @src $_ "
" "
" "
" "
" "
" "
" loading... "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" loading... "
" "
" "
"› @src $1_suffix "
" "
" "
" "
" "
" "
" loading... "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
" loading... "
" "
" "
"› @src $1plugin:d "
" "
" "
" "
" "
" "
" loading... "
" "
" 100% context left "
@@ -1,7 +1,25 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5620
expression: terminal.backend()
---
" Keyboard shortcuts "
" "
" Compose Session "
" / Commands tab Send message "
" @ Mention files ⌥+, Less reasoning "
" ! Shell command ⌥+. More reasoning "
" ctrl+x enter New line ctrl+c Quit "
" ctrl+v Paste image "
" ctrl+g ctrl+g External editor Transcript (open first) "
" ctrl+r Search history ctrl+t Open transcript "
" esc esc Edit last message f3 Find text "
" pgup / pgdn Scroll "
" ctrl+space Start selection "
" ctrl+home / ctrl+end Top / latest "
" shift+⌥+, / shift+⌥+. Top / latest "
" "
" /keymap customize "
" "
"› Ask Codex to do anything "
" "
@@ -9,13 +27,4 @@ expression: terminal.backend()
" "
" "
" "
" "
" / for commands ! for shell commands "
" ctrl+x enter for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
" ? / esc close "
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5609
expression: terminal.backend()
---
" "
@@ -10,4 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" esc esc to edit previous message "
" ? for shortcuts 100% context left "
@@ -1,7 +1,21 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5294
expression: terminal.backend()
---
" Keyboard shortcuts "
" "
" Compose Session Transcript (open first) "
" / Commands tab Send message ctrl+t Open transcript"
" @ Mention files ⌥+, Less reasoning f3 Find text "
" ! Shell command ⌥+. More reasoning pgup / pgdn Scroll "
" shift+enter New line ctrl+c Quit ctrl+space Start selection"
" ctrl+v Paste image ctrl+home / ctrl+end Top / latest "
" ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest "
" ctrl+r Search history "
" esc Edit last message "
" "
" /keymap customize "
" "
"› Ask Codex to do anything "
" "
@@ -9,13 +23,4 @@ expression: terminal.backend()
" "
" "
" "
" "
" / for commands ! for shell commands "
" shift+enter for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc again to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
" ? / esc close "
@@ -1,7 +1,21 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5294
expression: terminal.backend()
---
" Keyboard shortcuts "
" "
" Compose Session Transcript (open first) "
" / Commands tab Queue message ctrl+t Open transcript"
" @ Mention files ⌥+, Less reasoning f3 Find text "
" ! Shell command ⌥+. More reasoning pgup / pgdn Scroll "
" shift+enter New line ctrl+c Quit ctrl+space Start selection"
" ctrl+v Paste image ctrl+home / ctrl+end Top / latest "
" ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest "
" ctrl+r Search history "
" esc esc Edit last message "
" "
" /keymap customize "
" "
"› Ask Codex to do anything "
" "
@@ -9,13 +23,4 @@ expression: terminal.backend()
" "
" "
" "
" "
" / for commands ! for shell commands "
" shift+enter for newline tab to queue message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
" ? / esc close "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $goog "
" "
" "
" "
"› Google Calendar [Skill] Find availability and plan event changes "
" Google Calendar [App] Look up events and availability "
" "
" enter insert · esc close "
" "
"› $goog "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $sa "
" "
" "
" "
" "
"› Sample Plugin [Plugin] Plugin that includes the Figma MCP server and Skills for common workflows "
" "
" enter insert · esc close "
" "
"› $sa "
" "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $1p "
" "
" "
" "
" "
"› 1password [Skill] Example skill used in tests. "
" "
" enter insert · esc close "
" "
"› $1p "
" "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $left $bound/path "
" "
" "
" "
" "
"› left [Skill] Example skill used in tests. "
" "
" enter insert · esc close "
" "
"› $left $bound/path "
" "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $1x $plugin:d "
" "
" "
" "
" "
"› deploy (plugin) [Skill] Example skill used in tests. "
" "
" enter insert · esc close "
" "
"› $1x $plugin:d "
" "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $unbound-skill $bound-skill continue "
" "
" "
" "
" "
"› unbound-skill [Skill] Example skill used in tests. "
" "
" enter insert · esc close "
" "
"› $unbound-skill $bound-skill continue "
" "
" "
" 100% context left "
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› $bound-skill$unbound-skill "
" "
" "
" "
" "
"› unbound-skill [Skill] Example skill used in tests. "
" "
" enter insert · esc close "
" "
"› $bound-skill$unbound-skill "
" "
" "
" 100% context left "
@@ -2,8 +2,8 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"› /archive archive this session "
" "
"› /ar "
" "
" "
"› /archive archive this session "
" 100% context left "
@@ -2,8 +2,8 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"› /btw start a side conversation in an ephemeral fork "
" "
"› /bt "
" "
" "
"› /btw start a side conversation in an ephemeral fork "
" 100% context left "
@@ -2,8 +2,8 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"› /model choose what model and reasoning effort to use "
" "
"› /mo "
" "
" "
"› /model choose what model and reasoning effort to use "
" 100% context left "
@@ -0,0 +1,7 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› /mo "
" "
@@ -2,8 +2,8 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"› /pets choose or hide the terminal pet "
" "
"› /pet "
" "
" "
"› /pets choose or hide the terminal pet "
" 100% context left "
@@ -1,10 +1,11 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 9511
expression: terminal.backend()
---
"› /resume resume a saved chat "
" "
"› /res "
" "
" "
" "
"› /resume resume a saved chat "
" 100% context left "
@@ -2,8 +2,8 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"› /side start a side conversation in an ephemeral fork "
" "
"› /si "
" "
" "
"› /side start a side conversation in an ephemeral fork "
" 100% context left "
@@ -1,14 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5348
assertion_line: 5328
expression: terminal.backend()
---
" "
"› @migr "
" "
" Mentions "
" Files, directories, plugins, skills and tasks "
" All Results Filesystem Only Plugins "
" migr "
"› Review database migration /workspace/project Task "
" enter/tab insert · esc close · ↑/↓ select · ←/→ filter "
" "
"› @migr "
" "
" 100% context left "
@@ -1,14 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5348
assertion_line: 5328
expression: terminal.backend()
---
" "
"› @left @bound "
" "
" Mentions "
" Files, directories, plugins, skills and tasks "
" All Results Filesystem Only Plugins "
" left "
"› left Plugin used to test bound mention fallback. Plugin"
" enter/tab insert · esc close · ↑/↓ select · ←/→ filter "
" "
"› @left @bound "
" "
" 100% context left "
@@ -1,7 +1,9 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5328
expression: terminal.backend()
---
"› /diff show git diff (including untracked files) "
" "
"› /dide tail "
" "
@@ -9,5 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" "
"› /diff show git diff (including untracked files) "
" 100% context left | Vim: Replace "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/command_popup.rs
assertion_line: 523
expression: commands
---
/model - choose what model and reasoning effort to use
@@ -25,7 +24,7 @@ expression: commands
/init - create an AGENTS.md file with instructions for Codex
/compact - summarize conversation to prevent hitting the context limit
/recap - summarize the current conversation now
/agents - view and switch between all active agent sessions
/agents - open the agent command center
/side - start a side conversation in an ephemeral fork
/copy - copy the last response or part of it
/export - export the conversation as markdown
@@ -1,13 +1,23 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1240
expression: terminal.backend()
---
" / for commands ! for shell commands "
" ctrl+j for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" shift+tab to change mode ctrl+t to view transcript "
" Keyboard shortcuts "
" "
" customize shortcuts with /keymap "
" Compose Session "
" / Commands tab Send message "
" @ Mention files shift+tab Change mode "
" ! Shell command ⌥+, Less reasoning "
" ctrl+j New line ⌥+. More reasoning "
" ctrl+v Paste image ctrl+c Quit "
" ctrl+g External editor "
" ctrl+r Search history Transcript (open first) "
" esc esc Edit last message ctrl+t Open transcript "
" f3 Find text "
" pgup / pgdn Scroll "
" ctrl+space Start selection "
" ctrl+home / ctrl+end Top / latest "
" shift+⌥+, / shift+⌥+. Top / latest "
" "
" /keymap customize "
@@ -1,13 +1,22 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1240
expression: terminal.backend()
---
" / for commands ! for shell commands "
" ctrl+j for newline tab to queue message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to interrupt "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" Keyboard shortcuts "
" "
" customize shortcuts with /keymap "
" Compose Session "
" / Commands tab Queue message "
" @ Mention files ⌥+, Less reasoning "
" ! Shell command ⌥+. More reasoning "
" ctrl+j New line ctrl+c Interrupt "
" ctrl+v Paste image "
" ctrl+g External editor Transcript (open first) "
" ctrl+r Search history ctrl+t Open transcript "
" esc esc Edit last message f3 Find text "
" pgup / pgdn Scroll "
" ctrl+space Start selection "
" ctrl+home / ctrl+end Top / latest "
" shift+⌥+, / shift+⌥+. Top / latest "
" "
" /keymap customize "
@@ -1,13 +1,22 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1240
expression: terminal.backend()
---
" / for commands ! for shell commands "
" shift+enter for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc again to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" Keyboard shortcuts "
" "
" customize shortcuts with /keymap "
" Compose Session "
" / Commands tab Send message "
" @ Mention files ⌥+, Less reasoning "
" ! Shell command ⌥+. More reasoning "
" shift+enter New line ctrl+c Quit "
" ctrl+v Paste image "
" ctrl+g External editor Transcript (open first) "
" ctrl+r Search history ctrl+t Open transcript "
" esc Edit last message f3 Find text "
" pgup / pgdn Scroll "
" ctrl+space Start selection "
" ctrl+home / ctrl+end Top / latest "
" shift+⌥+, / shift+⌥+. Top / latest "
" "
" /keymap customize "
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/shortcut_overlay_tests.rs
assertion_line: 67
expression: terminal.backend()
---
" /keymap customize "
@@ -0,0 +1,22 @@
---
source: tui/src/bottom_pane/shortcut_overlay_tests.rs
expression: terminal.backend()
---
" Keyboard shortcuts "
" "
" Compose Session "
" / Commands tab Send message "
" @ Mention files shift+tab Change mode "
" ! Shell command ⌥+. More reasoning "
" shift+enter New line ← Agents (empty prompt) "
" ctrl+⌥+v Paste image ctrl+c Quit "
" ctrl+x ctrl+e External editor "
" esc Edit last message Transcript (open first) "
" ctrl+t Open transcript "
" f3 Find text "
" pgup / pgdn Scroll "
" ctrl+space Start selection "
" ctrl+home / ctrl+end Top / latest "
" shift+⌥+, / shift+⌥+. Top / latest "
" "
" /keymap customize "
+7 -8
View File
@@ -285,7 +285,6 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::collaboration_modes;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCell;
use crate::exec_cell::new_active_exec_command;
use crate::exec_command::split_command_string;
@@ -322,6 +321,7 @@ mod command_lifecycle;
mod connector_mentions;
mod connectors;
mod constructor;
mod dynamic_activity;
pub(crate) use self::connectors::ConnectorScopeGeneration;
use self::connectors::ConnectorsState;
mod exec_state;
@@ -359,6 +359,7 @@ mod pets;
mod session_flow;
mod session_header;
use self::session_header::SessionHeader;
mod copy_picker;
mod hook_lifecycle;
mod hooks;
mod interaction;
@@ -421,7 +422,6 @@ pub(crate) use realtime::tests::activate_voice_for_thread;
pub(crate) use realtime::tests::commit_realtime_history_events;
mod reasoning_shortcuts;
use self::realtime::RealtimeConversationUiState;
mod copy_picker;
mod rendering;
mod replay;
mod review;
@@ -445,7 +445,6 @@ use self::status_surfaces::CachedProjectRootName;
mod thread_title_status;
mod thread_usage;
pub(crate) use self::thread_usage::ThreadUsageOutcome;
mod dynamic_activity;
mod tool_lifecycle;
mod tool_requests;
mod transcript;
@@ -835,7 +834,8 @@ enum CodexOpTarget {
/// it cheaply decide when to recompute that tail as the active cell evolves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ActiveCellTranscriptKey {
/// External mutable sources must refresh even when the explicit revision is unchanged.
/// Whether unchanged keys may reuse the live layout across redraws.
/// External state can change a cell without advancing the widget's revision.
pub(crate) cacheable: bool,
/// Cache-busting revision for in-place updates.
///
@@ -1980,13 +1980,12 @@ impl ChatWidget {
/// Returns a cache key describing the current in-flight cells for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
/// current active and asynchronous usage cells, and the overlay caches that tail; this
/// current active, realtime, and rate-limit cells, and the overlay caches that tail; this
/// key is what it uses to decide whether it must recompute. When there are no live cells, this
/// returns `None` so the overlay can drop the tail entirely.
///
/// If callers mutate the active cell's transcript output without bumping the revision (or
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
/// the main viewport updates.
/// Sources backed by external mutable state disable reuse even when revision and animation
/// tick are unchanged. Stable sources still require a revision bump when their content changes.
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
let cell = self.transcript.active_cell.as_ref();
let mut realtime_cells = self.realtime_conversation.live_transcript_cells();
@@ -5,37 +5,6 @@ use super::*;
use crate::transcript_view::ActivityTranscriptLines;
impl ChatWidget {
pub(super) fn active_cell_hyperlink_lines_with(
&self,
width: u16,
render: impl Fn(&dyn HistoryCell, u16) -> Vec<HyperlinkLine>,
) -> Option<Vec<HyperlinkLine>> {
let mut lines = Vec::new();
if let Some(cell) = self.transcript.active_cell.as_ref() {
lines.extend(render(cell.as_ref(), width));
}
for cell in self
.realtime_conversation
.pending_history_cells
.iter()
.chain(self.realtime_conversation.live_transcript_cells())
{
let realtime_lines = render(cell.as_ref(), width);
if !realtime_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(realtime_lines);
}
if let Some(rate_limit_reset_hint) = self.pending_rate_limit_reset_hint() {
let hint_lines = render(rate_limit_reset_hint, width);
if !hint_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(hint_lines);
}
(!lines.is_empty()).then_some(lines)
}
pub(crate) fn active_activity_ids(&self) -> Vec<String> {
self.transcript
.active_cell
@@ -4,6 +4,7 @@
//! exec-cell grouping and unified exec wait state.
use super::*;
use crate::exec_cell::CommandOutput;
impl ChatWidget {
pub(super) fn flush_unified_exec_wait_streak(&mut self) {
+14 -2
View File
@@ -3,6 +3,7 @@
//! The queue data itself lives in `input_queue`; this module owns the app-level
//! effects around taking composer input, submitting user turns, draining queued
//! follow-ups, and restoring draft state across interrupts or thread switches.
//! Composer submissions resume transcript following before dispatch or startup queueing.
use super::*;
use crate::bottom_pane::prompt_args::parse_slash_name;
@@ -21,6 +22,14 @@ impl ChatWidget {
input_result: InputResult,
had_modal_or_popup: bool,
) {
if matches!(
&input_result,
InputResult::Command(_)
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(..)
) {
self.app_event_tx.send(AppEvent::FollowTranscript);
}
match input_result {
InputResult::Submitted {
text,
@@ -33,6 +42,7 @@ impl ChatWidget {
{
return;
}
self.app_event_tx.send(AppEvent::FollowTranscript);
let should_submit_now = self.is_session_configured()
&& !self.is_plan_streaming_in_tui()
&& !self.input_queue.suppress_queue_autosend
@@ -64,7 +74,9 @@ impl ChatWidget {
pending_pastes,
} => {
let user_message = self.user_message_from_submission(text, text_elements);
self.queue_user_message_with_options(user_message, action, pending_pastes);
if self.queue_user_message_with_options(user_message, action, pending_pastes) {
self.app_event_tx.send(AppEvent::FollowTranscript);
}
}
InputResult::Command(cmd) => {
self.handle_slash_command_dispatch(cmd);
@@ -113,7 +125,7 @@ impl ChatWidget {
.set_queue_submissions(queue && !self.is_session_configured());
}
pub(super) fn queue_user_message_with_options(
pub(crate) fn queue_user_message_with_options(
&mut self,
user_message: UserMessage,
action: QueuedInputAction,
+19 -13
View File
@@ -43,6 +43,11 @@ impl ChatWidget {
return;
}
if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) {
self.bottom_pane.handle_key_event(key_event);
return;
}
if (self.chat_keymap.interrupt_turn.is_pressed(key_event)
|| key_hint::ctrl(KeyCode::Char('c')).is_press(key_event))
&& self.bottom_pane.no_modal_or_popup_active()
@@ -303,7 +308,6 @@ impl ChatWidget {
.replace_selection_view_if_present(view_id, params)
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn shortcut_overlay_visible(&self) -> bool {
self.bottom_pane.shortcut_overlay_visible()
}
@@ -341,19 +345,22 @@ impl ChatWidget {
&mut self,
copy_fn: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyOutcome, String>,
) {
// The shortcut bypasses composer submission, which normally reveals local feedback.
self.app_event_tx.send(AppEvent::FollowTranscript);
match self.transcript.last_agent_markdown.clone() {
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
Ok(outcome) => {
let status = outcome.store(&mut self.clipboard_lease);
self.add_to_history(history_cell::new_info_event(
status.message("last message"),
/*hint*/ None,
));
Some(markdown) if !markdown.is_empty() => {
match self.write_clipboard(&markdown, copy_fn) {
Ok(status) => {
self.add_to_history(history_cell::new_info_event(
status.message("last message"),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
},
}
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
)),
@@ -373,7 +380,6 @@ impl ChatWidget {
}
/// The owned viewport renders its own copy feedback above the composer.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) fn copy_transcript_selection_with(
&mut self,
text: &str,
@@ -34,6 +34,7 @@ pub(super) fn selection_view_params(
Some(mask) => {
let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::FollowTranscript);
tx.send(AppEvent::SubmitUserMessageWithMode {
text: user_text.clone(),
collaboration_mode: mask.clone(),
+72 -8
View File
@@ -1,4 +1,4 @@
//! Render composition for the main chat widget surface.
//! Shared composer and live-history composition for inline and owned transcript surfaces.
use super::transcript::ActiveCellLayoutCache;
use super::transcript::ActiveCellLayoutCacheKey;
@@ -122,9 +122,10 @@ impl ChatWidget {
.selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
return self
.bottom_pane
.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0);
return self.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::AboveComposer,
);
}
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
@@ -183,20 +184,27 @@ impl ChatWidget {
})),
);
}
let bottom = self.bottom_pane_renderable(/*footer*/ None);
flex.push(
/*flex*/ 0,
bottom.inset(Insets::tlbr(
self.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::AboveComposer,
)
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(flex))
}
/// Render only the composer and its local surfaces beneath an owned transcript.
/// Returns the composer, footer, and active modal without the live transcript above it.
///
/// Both transcript surfaces use this composition so read-only notices and cursor placement
/// remain consistent. The caller owns any spacing between the transcript and this surface.
pub(crate) fn bottom_pane_renderable<'a>(
&'a self,
footer: Option<&'a crate::bottom_pane::TranscriptFooter>,
command_popup_placement: crate::bottom_pane::CommandPopupPlacement,
) -> RenderableItem<'a> {
if self.external_writer_view && !self.bottom_pane.has_active_view() {
RenderableItem::Owned(Box::new(ExternalWriterNotice {
@@ -204,14 +212,70 @@ impl ChatWidget {
transcript_hint: self.bottom_pane.transcript_shortcut_hint(),
}))
} else {
let right_reserve = if self
.bottom_pane
.selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
0
} else {
self.ambient_pet_wrap_reserved_cols()
};
self.bottom_pane
.as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions {
textarea_right_reserve: self.ambient_pet_wrap_reserved_cols(),
textarea_right_reserve: right_reserve,
separate_status_line: command_popup_placement
!= crate::bottom_pane::CommandPopupPlacement::AboveComposer,
command_popup_placement,
footer,
})
}
}
/// Returns compact live-history lines using the current rich or raw presentation.
#[cfg(test)]
pub(crate) fn active_cell_display_hyperlink_lines(
&self,
width: u16,
) -> Option<Vec<HyperlinkLine>> {
self.active_cell_hyperlink_lines_with(width, |cell, width| {
cell.display_hyperlink_lines_for_mode(width, self.history_render_mode())
})
}
/// Combines the same live cells for compact and detailed transcript rendering.
pub(super) fn active_cell_hyperlink_lines_with(
&self,
width: u16,
render_cell: impl Fn(&dyn HistoryCell, u16) -> Vec<HyperlinkLine>,
) -> Option<Vec<HyperlinkLine>> {
let cells = self
.transcript
.active_cell
.as_deref()
.into_iter()
.chain(
self.realtime_conversation
.pending_history_cells
.iter()
.chain(self.realtime_conversation.live_transcript_cells())
.map(AsRef::as_ref),
)
.chain(
self.pending_rate_limit_reset_hint()
.map(|cell| cell as &dyn HistoryCell),
);
let mut lines = Vec::new();
for cell in cells {
let cell_lines = render_cell(cell, width);
if !cell_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(cell_lines);
}
(!lines.is_empty()).then_some(lines)
}
pub(crate) fn note_rendered_width(&self, width: u16) {
self.last_rendered_width.set(Some(width));
}

Some files were not shown because too many files have changed in this diff Show More