mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<_>>()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+18
@@ -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
|
||||
+21
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
-15
@@ -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
|
||||
-13
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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
|
||||
|
||||
› /
|
||||
+9
@@ -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
|
||||
+25
@@ -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
|
||||
+36
@@ -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
|
||||
+4
-2
@@ -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:
|
||||
⠈ ⢀ ⠁ ⠂
|
||||
|
||||
+37
-18
@@ -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
|
||||
|
||||
+19
-9
@@ -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:
|
||||
⠈ ⢀ ⠁ ⠂
|
||||
|
||||
+59
@@ -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
|
||||
+44
@@ -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
|
||||
+30
@@ -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
|
||||
+39
@@ -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))
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
+20
-11
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+4
-4
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
" "
|
||||
"› @src $_ "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
" 100% context left "
|
||||
|
||||
+4
-4
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
" "
|
||||
"› @src $1_suffix "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
" 100% context left "
|
||||
|
||||
+4
-4
@@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
" "
|
||||
"› @src $1plugin:d "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" loading... "
|
||||
" "
|
||||
" 100% context left "
|
||||
|
||||
+19
-10
@@ -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 "
|
||||
|
||||
+2
-1
@@ -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 "
|
||||
|
||||
+15
-10
@@ -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 "
|
||||
|
||||
+15
-10
@@ -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 "
|
||||
|
||||
+4
-4
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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
-2
@@ -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
-2
@@ -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
-2
@@ -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 "
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /mo "
|
||||
" "
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
+3
-2
@@ -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
-2
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+5
-5
@@ -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 "
|
||||
|
||||
+3
-2
@@ -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
-2
@@ -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
|
||||
|
||||
+18
-8
@@ -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 "
|
||||
|
||||
+17
-8
@@ -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 "
|
||||
|
||||
+17
-8
@@ -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 "
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/shortcut_overlay_tests.rs
|
||||
assertion_line: 67
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" /keymap customize "
|
||||
+22
@@ -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 "
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user