Standardize TUI picker presentation and fix wrapping boundaries (#46691)

## Why

Menu layouts and hints vary across TUI flows, and mismatched measurement and rendering widths can clip subsequent choices when descriptions wrap.

## What changed

- Apply shared picker styling across selection menus, with stable columns, descriptions hidden when space is tight, and compact hints derived from configured key bindings.
- Measure rows at their actual rendering width, including caller insets, so wrapped options receive enough space.
- Wrap model menu headers and shortcut capture content, and use the shared panel surface for shortcut capture.
- Indicate clipped headers in non-searchable menus and show the configured `view all` shortcut only when the view supplies one.

## Testing

Add regression tests and snapshots for wrapping boundaries, narrow picker layouts, configured selection and cancellation keys, and clipped approval headers. Verify that copy/export actions retain complete payloads and composer drafts.

GitOrigin-RevId: 8416bcd3c66b6cd15db3f9c1804f5f005b7964c1
This commit is contained in:
Eric Traut
2026-09-19 19:11:54 +00:00
committed by copyberry
parent 132c2be239
commit 79d2144ec6
208 changed files with 2475 additions and 857 deletions
+4 -3
View File
@@ -21,7 +21,7 @@ use crate::app_event::AgentsOverviewThreadRefresh;
use crate::bottom_pane::SelectionDescriptionLayout;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap;
use crate::chatwidget::ThreadInputStateRestoreMode;
use crate::startup_draft::StartupDraftPump;
use codex_app_server_protocol::SessionSource;
@@ -74,6 +74,7 @@ impl App {
if matches!(self.app_server_target, AppServerTarget::Embedded) {
let workload_identity_selected = codex_login::is_workload_identity_selected();
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Shared agents unavailable".to_string()),
subtitle: Some(
if workload_identity_selected {
@@ -91,7 +92,7 @@ impl App {
.dim(),
)
}),
footer_hint: Some(standard_popup_hint_line_for_keymap(&self.keymap.list)),
footer_hint: Some(picker_hint_line_for_keymap(&self.keymap.list)),
items: [
#[cfg(any(unix, windows))]
(!workload_identity_selected).then(|| SelectionItem {
@@ -112,7 +113,7 @@ impl App {
.into_iter()
.flatten()
.collect(),
description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow {
description_layout: SelectionDescriptionLayout::HideWhenNarrow {
min_description_width: 28,
},
..Default::default()
@@ -137,6 +137,7 @@ impl App {
),
};
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(LifecycleHeader(vec![
title.bold().into(),
description.dim().into(),
@@ -307,6 +308,7 @@ impl App {
);
}
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(LifecycleHeader(header)),
items: vec![SelectionItem {
name: "Back to agents".to_string(),
@@ -33,6 +33,7 @@ impl App {
..Default::default()
}];
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(AgentsOverviewErrorHeader(vec![
Line::from("Unable to complete action".bold()),
Line::from(message.clone().dim()),
+7 -5
View File
@@ -79,9 +79,10 @@ impl App {
})
.collect();
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(DaemonMenuHeader(header)),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -93,18 +94,19 @@ impl App {
return;
}
let mut explanation = match source {
DaemonUpdateSource::PublicStable => "Install the latest public stable release (version resolved when the command runs). Production update eligibility will be restored; your automatic-update setting is preserved.".to_string(),
DaemonUpdateSource::PublicStable => "Install the latest public stable release. Restore production updates; keep your automatic-update setting.".to_string(),
DaemonUpdateSource::ThisCli => {
let version = codex_install_context::InstallContext::current()
.package_manifest()
.map_or_else(|| CODEX_CLI_VERSION.to_string(), |manifest| manifest.version.to_string());
format!("Use this CLI package v{version} from {}. The complete local package will be copied and pinned against automatic updates.", executable.display())
format!("Use this CLI package v{version} from {}. Copy the complete package and pin it against automatic updates.", executable.display())
}
};
explanation.push_str("\nThe daemon will restart if needed. Active or queued work may be interrupted.\nCodex will exit and run the update in this terminal, then return to the shell. Relaunch Codex afterward.");
explanation.push_str("\nThis may restart the daemon and interrupt active or queued work.\nCodex exits to update in this terminal. Relaunch it afterward.");
let mut header = vec![Line::from("Update daemon and exit Codex?".bold())];
header.extend(explanation.lines().map(|line| Line::from(line.to_owned())));
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(DaemonMenuHeader(header)),
items: vec![
SelectionItem {
@@ -122,7 +124,7 @@ impl App {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
}
}
+6 -1
View File
@@ -3096,7 +3096,12 @@ impl App {
Ok(runtime_keymap) => runtime_keymap,
Err(err) => {
let params = crate::keymap_setup::build_keymap_conflict_params(
context, action, key, intent, err,
context,
action,
key,
intent,
err,
&self.keymap,
);
self.chat_widget.show_selection_view(params);
return;
+2 -1
View File
@@ -385,6 +385,7 @@ impl App {
let allow_background = running_side_thread_id.is_none()
&& !self.chat_widget.has_queued_follow_up_messages();
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Task is still running".to_string()),
subtitle: Some("Choose what happens to the current task.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
@@ -419,7 +420,7 @@ impl App {
..Default::default()
})
.collect(),
..Default::default()
..SelectionViewParams::picker()
});
return;
}
+2 -1
View File
@@ -213,13 +213,14 @@ impl App {
.collect();
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(AGENT_PICKER_VIEW_ID),
title: Some("Subagents".to_string()),
subtitle: Some(AgentNavigationState::picker_subtitle()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
..SelectionViewParams::picker()
}
}
+2 -1
View File
@@ -144,6 +144,7 @@ impl App {
.is_some()
{
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Unable to resume session".to_string()),
subtitle: Some(message.clone()),
items: vec![SelectionItem {
@@ -151,7 +152,7 @@ impl App {
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
});
}
self.chat_widget.add_error_message(message);
@@ -6,7 +6,8 @@ expression: "render_bottom_popup(&app.chat_widget, 72)"
This stops any running work in this task and its child agents, then
archives them. Their history can be restored from the resume picker.
› 1. Cancel
2. Archive task and child agents
Press enter to confirm or esc to go back
enter select · esc back
@@ -7,6 +7,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
thread id 00000000-0000-0000-0000-000000000001 (code -32600)
Work may have stopped. Resume the task to continue, or retry the action.
› 1. Back to agents
Press enter to confirm or esc to go back
enter select · esc back
@@ -6,7 +6,8 @@ expression: "render_bottom_popup(&app.chat_widget, 72)"
This stops any running work in this task and its child agents, then
permanently deletes their history. This cannot be undone.
› 1. Cancel
2. Permanently delete task and child agents
Press enter to confirm or esc to go back
enter select · esc back
@@ -7,6 +7,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
id 00000000-0000-0000-0000-000000000001 (code -32600)
Work may have stopped. Resume the task to continue, or retry the action.
› 1. Back to agents
Press enter to confirm or esc to go back
enter select · esc back
@@ -3,13 +3,13 @@ source: tui/src/app/daemon_menu_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")"
---
Update daemon and exit Codex?
Use this CLI package v0.0.0 from /x/cli-build/bin/codex. The complete local package will be
copied and pinned against automatic updates.
The daemon will restart if needed. Active or queued work may be interrupted.
Codex will exit and run the update in this terminal, then return to the shell. Relaunch Codex
afterward.
Use this CLI package v0.0.0 from /x/cli-build/bin/codex. Copy the complete package and pin it
against automatic updates.
This may restart the daemon and interrupt active or queued work.
Codex exits to update in this terminal. Relaunch it afterward.
› 1. Cancel
2. Update and exit
Press enter to confirm or esc to go back
enter select · esc back
@@ -5,7 +5,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Daemon
Not connected to the local background server.
› 1. Install latest public stable
2. Use this CLI build
Press enter to confirm or esc to go back
enter select · esc back
@@ -5,7 +5,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Daemon
Running daemon: v0.153.0
› 1. Install latest public stable
2. Use this CLI build
Press enter to confirm or esc to go back
enter select · esc back
@@ -6,7 +6,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Not connected to the local background server.
Run the Codex CLI to manage the daemon from this menu.
› Install latest public stable
Use this CLI build
Press enter to confirm or esc to go back
enter select · esc back
@@ -7,7 +7,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Manage this server on its host. Local daemon updates are unavailable for
remote connections.
› Install latest public stable
Use this CLI build
Press enter to confirm or esc to go back
enter select · esc back
@@ -3,14 +3,13 @@ source: tui/src/app/daemon_menu_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")"
---
Update daemon and exit Codex?
Install the latest public stable release (version resolved when the command
runs). Production update eligibility will be restored; your automatic-update
setting is preserved.
The daemon will restart if needed. Active or queued work may be interrupted.
Codex will exit and run the update in this terminal, then return to the
shell. Relaunch Codex afterward.
Install the latest public stable release. Restore production updates; keep
your automatic-update setting.
This may restart the daemon and interrupt active or queued work.
Codex exits to update in this terminal. Relaunch it afterward.
› 1. Cancel
2. Update and exit
Press enter to confirm or esc to go back
enter select · esc back
@@ -5,8 +5,8 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Daemon
Not connected to the local background server.
› 1. Install latest public stable
Use this CLI build (disabled) disabled: This CLI has no local package to
copy.
Press enter to confirm or esc to go back
› 1. Install latest public stable
Use this CLI build (disabled) This CLI has no local package to copy.
enter select · esc back
@@ -221,16 +221,11 @@ 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());
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
Press enter to confirm or esc to go back
");
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);
}
app.handle_key_event(
&mut tui,
@@ -489,14 +484,15 @@ 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), @r"
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @"
Task is still running
Choose what happens to the current task.
› 1. Cancel task Stop the current task and stay in Codex
2. Exit Stop the current task and exit Codex
Press enter to confirm or esc to go back
enter select · esc back
");
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
@@ -53,6 +53,7 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()>
let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(overview));
app.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
items: vec![SelectionItem {
name: "Stale server action".into(),
actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))],
+1
View File
@@ -496,6 +496,7 @@ async fn physical_dispatch_band_events_are_dropped() -> Result<()> {
async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some("list"),
items: ["First", "Second"]
.into_iter()
@@ -4335,10 +4335,11 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> {
Subagents
Select an agent to watch. ⌥+← previous, ⌥+→ next.
› 1. • Main [default] (current) [root]
2. • /root/worker [child]
Press enter to confirm or esc to go back
enter select · esc back
"
);
assert_eq!(take_backfill_counts(&requests), (0, 0));
@@ -4653,9 +4654,10 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re
Unable to complete action
Couldn't load this conversation. Please try again.
› 1. Return to command center
Press enter to confirm or esc to go back
enter select · esc back
");
}
assert_eq!(app.current_displayed_thread_id(), Some(current_id));
@@ -4,6 +4,7 @@ expression: normalize_snapshot_paths(rendered)
---
Selected model usage exhausted
Add credits to use this model again.
› 1. View usage
Press a number to choose · esc to dismiss · type to continue
@@ -5,6 +5,7 @@ expression: normalize_snapshot_paths(rendered)
Usage limit reached
Your included usage is exhausted. Choose an option below to continue.
› 1. Add credits
Press enter to confirm or esc to go back
enter select · esc back
@@ -0,0 +1,15 @@
---
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
@@ -0,0 +1,13 @@
---
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
@@ -5,6 +5,7 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_wi
Unable to complete action
Wait for permissions to update before starting a session.
› 1. Return to command center
Press enter to confirm or esc to go back
enter select · esc back
@@ -5,6 +5,7 @@ expression: "rendered.replace(&unused.root.display().to_string(), \"<worktree>\"
Unable to complete action
Failed to start session: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603) A checkout was retained at <worktree>; remove it with `git worktree remove <checkout-path>` from the source repository if it is no longer needed.
› 1. Return to command center
Press enter to confirm or esc to go back
enter select · esc back
@@ -6,6 +6,7 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_wi
Failed to start session: thread/start failed during TUI bootstrap: thread/
start failed: replacement unavailable (code -32603)
› 1. Return to command center
Press enter to confirm or esc to go back
enter select · esc back
@@ -7,6 +7,7 @@ expression: "format!(\"{errors}\\n{}\", render_bottom_popup(&app.chat_widget, 80
We couldn’t confirm the agent was interpreting your instructions correctly.
Review what we detected before deciding to continue.
› 1. Review findings
2. New chat
3. Resume another chat
@@ -1,11 +1,11 @@
---
source: tui/src/app/tests/realtime_requests.rs
assertion_line: 1638
expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)"
---
Select voice
Applies to your next voice conversation.
1. juniper
2. maple
3. spruce
@@ -14,5 +14,5 @@ expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_wi
6. breeze
7. arbor
› 8. sol (current)
Press enter to confirm or esc to go back
↓
enter select · esc back
@@ -1,13 +1,13 @@
---
source: tui/src/app/tests/realtime_requests.rs
assertion_line: 1721
expression: popup
---
Select voice
Applies to your next voice conversation.
› 1. maple (current)
2. cove
3. juniper
Press enter to confirm or esc to go back
enter select · esc back
@@ -1,6 +1,5 @@
---
source: tui/src/app/tests/user_verification_routes.rs
assertion_line: 67
source: tui/src/app/tests/user_verification_routes_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 80)"
---
Approve deployment?
@@ -11,6 +10,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Verify the production deployment.
› 1. Verify and approve (y)
2. Cancel this request (c)
@@ -1,5 +1,5 @@
---
source: tui/src/app/tests/user_verification_routes.rs
source: tui/src/app/tests/user_verification_routes_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 80)"
---
Approve deployment?
@@ -10,6 +10,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
Verify the production deployment.
› 1. Verify and approve (y)
2. Cancel this request (c)
+2 -1
View File
@@ -317,6 +317,7 @@ impl App {
},
];
self.chat_widget.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Replace goal?".to_string()),
subtitle: Some(format!(
"New objective: {}",
@@ -324,7 +325,7 @@ impl App {
)),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -153,7 +153,7 @@ impl From<ActionableBanner> for SelectionViewParams {
header: Box::new(BannerContent(lines)),
items: banner.actions,
initial_selected_idx: banner.initial_selected_idx,
..Default::default()
..Self::picker()
}
}
}
@@ -315,6 +315,8 @@ impl ApprovalOverlay {
footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)),
items,
header,
header_view_all_hint: approval_keymap
.primary_hint("open_fullscreen", &approval_keymap.open_fullscreen),
..Default::default()
};
@@ -2508,3 +2510,7 @@ mod tests {
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept));
}
}
#[cfg(test)]
#[path = "approval_overlay/clipping_tests.rs"]
mod clipping_tests;
@@ -0,0 +1,101 @@
//! Clipped approval headers expose the configured route to their complete payload.
use super::*;
use crate::keymap::RuntimeKeymap;
use pretty_assertions::assert_eq;
#[test]
fn clipped_exec_approval_opens_the_complete_command() {
let command = format!(
"{}printf destructive_suffix",
"printf benign\n".repeat(/*n*/ 20)
);
let reason = format!(
"{}reason_suffix",
"A reason requiring review.\n".repeat(/*n*/ 20)
);
let mut snapshots = Vec::new();
for binding in [Some(key_hint::ctrl(KeyCode::Char('g'))), None] {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut keymap = RuntimeKeymap::defaults();
keymap.approval.open_fullscreen = binding.into_iter().collect();
let mut view = ApprovalOverlay::new(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "clipped-approval".into(),
environment_id: Some("local".into()),
command: vec!["sh".into(), "-c".into(), command.clone()],
reason: Some(reason.clone()),
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
}),
AppEventSender::new(tx),
Features::with_defaults(),
keymap.approval,
keymap.list,
);
for (width, height) in [(80, 14), (40, 10)] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
let rows: Vec<String> = (0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect();
let elision = rows.iter().find(|row| row.contains("[…")).unwrap();
assert_eq!(elision.contains("ctrl+g view all"), binding.is_some());
assert!(rows.iter().any(|row| row.contains("esc")));
assert!(!rows.iter().any(|row| row.contains("destructive_suffix")));
assert!(!rows.iter().any(|row| row.contains("reason_suffix")));
assert!(rows.iter().any(|row| row.contains("Yes, proceed")));
snapshots.push(format!(
"bound={} {width}x{height}\n{}",
binding.is_some(),
rows.join("\n")
));
}
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 80,
view.desired_height(/*width*/ 80),
);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
let complete = buffer
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(complete.contains("destructive_suffix"));
assert!(complete.contains("reason_suffix"));
assert!(!complete.contains("[…"));
if binding.is_some() {
view.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL));
let AppEvent::FullScreenApprovalRequest(ApprovalRequest::Exec(request)) =
rx.try_recv().unwrap()
else {
panic!("fullscreen should open the original approval");
};
assert_eq!(
request.command,
vec!["sh".to_owned(), "-c".to_owned(), command.clone()]
);
assert_eq!(request.reason, Some(reason.clone()));
assert!(!view.is_complete());
assert!(rx.try_recv().is_err());
}
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
@@ -0,0 +1,60 @@
---
source: tui/src/bottom_pane/approval_overlay/clipping_tests.rs
assertion_line: 100
expression: "snapshots.join(\"\\n\\n\")"
---
bound=true 80x14
Would you like to run the following command?
Environment: local
Reason: A reason requiring review.A reason requiring review.A reason
requiring review.A reason requiring review.A reason requiring review.A
reason requiring review.A reason requiring review.A reason requiring
[… 34 lines] ctrl+g view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
bound=true 40x10
Would you like to run the following
[… 42 lines] ctrl+g view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cance
bound=false 80x14
Would you like to run the following command?
Environment: local
Reason: A reason requiring review.A reason requiring review.A reason
requiring review.A reason requiring review.A reason requiring review.A
reason requiring review.A reason requiring review.A reason requiring
[… 34 lines]
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
bound=false 40x10
Would you like to run the following
[… 42 lines]
› 1. Yes, proceed (y)
2. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cance
@@ -87,18 +87,17 @@ impl AsyncQuestions {
use ratatui::text::Line;
use ratatui::widgets::Widget;
let row_width = area.width.saturating_add(1);
let input = self.other_input_area(area);
let named_area = Rect::new(area.x, area.y, area.width, input.y.saturating_sub(area.y));
let mut rows = self.option_rows();
rows.pop();
let mut state = ScrollState::default();
while state.scroll_top + 1 < rows.len()
&& measure_rows_height(&rows, &state, rows.len(), row_width) > named_area.height
&& measure_rows_height(&rows, &state, rows.len(), named_area.width) > named_area.height
{
state.scroll_top += 1;
}
if measure_rows_height(&rows, &state, rows.len(), row_width) <= named_area.height {
if measure_rows_height(&rows, &state, rows.len(), named_area.width) <= named_area.height {
self.visible_options
.set((state.scroll_top, rows.len() - state.scroll_top));
}
@@ -196,7 +196,6 @@ impl AsyncQuestions {
if !self.has_options() {
return 0;
}
let row_width = width.saturating_add(1);
let rows = self.option_rows();
if self.other_selected() {
let prefix = self.other_prefix_width(width);
@@ -204,13 +203,13 @@ impl AsyncQuestions {
&rows[..rows.len() - 1],
&ScrollState::default(),
rows.len(),
row_width,
width,
) + self
.composer
.inline_input_height(width.saturating_sub(prefix).max(1))
.clamp(1, 8)
} else {
measure_rows_height(&rows, &ScrollState::default(), rows.len(), row_width)
measure_rows_height(&rows, &ScrollState::default(), rows.len(), width)
}
}
@@ -54,7 +54,6 @@ impl Renderable for AsyncQuestions {
.bold()
.render(sections.question_area, buf);
// The shared measurer reserves a scrollbar column; this renderer uses the full width.
let option_rows = self.option_rows();
if self.other_selected() {
@@ -69,7 +68,7 @@ impl Renderable for AsyncQuestions {
std::slice::from_ref(row),
&super::ScrollState::default(),
/*max_results*/ 1,
sections.options_area.width.saturating_add(1),
sections.options_area.width,
)
})
.collect();
@@ -0,0 +1,23 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: "format!(\"Full height:\\n{}\\n\\nClipped:\\n{}\", buffer_text(&buffer),\nbuffer_text(&clipped),)"
---
Full height:
Second
› 1. abcdefgh
ijklmno
2. pqrstuvw
xyzabcd
3. Other
enter submit
ctrl+] skip
⌥+↓ main prompt
Clipped:
Second
› 1. abcdefgh
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
2. pqrstuvw
› 3. Other
@@ -241,6 +241,62 @@ fn question_other_remains_visible_at_nine_columns_and_distinguishes_named_other(
insta::assert_snapshot!("question_narrow_other", buffer_text(&buffer));
}
#[test]
fn question_options_reserve_wrapped_height_at_the_width_boundary() {
let mut editor = editor();
editor.state.pending.remove(/*index*/ 0);
editor.state.pending[0].question.options =
Some(vec!["abcdefgh ijklmno".into(), "pqrstuvw xyzabcd".into()]);
editor.restore_current_draft();
// The five-column option prefix makes each named choice exactly 21 columns.
assert_eq!(
(
editor.options_required_height(/*width*/ 20),
editor.options_required_height(/*width*/ 21),
),
(5, 3)
);
let width = 24;
let buffer = render_editor(&editor, width, editor.desired_height(width));
assert_eq!(editor.visible_options.get(), (0, 3));
// Two padding rows and the question leave only one line of the selected two-line option.
let clipped = render_editor(&editor, width, /*height*/ 4);
assert_eq!(editor.visible_options.get(), (0, 0));
editor.go_next_or_submit();
assert!(editor.submission.is_none());
insta::assert_snapshot!(
"question_options_width_boundary",
format!(
"Full height:\n{}\n\nClipped:\n{}",
buffer_text(&buffer),
buffer_text(&clipped),
)
);
}
#[test]
fn question_other_excludes_clipped_options_at_the_width_boundary() {
let mut editor = editor();
editor.state.pending.remove(/*index*/ 0);
editor.state.pending[0].question.options =
Some(vec!["abcdefgh ijklmno".into(), "pqrstuvw xyzabcd".into()]);
editor.select_option(/*index*/ 2);
// Other keeps one input row, leaving too little room for a complete named choice.
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 2,
);
let mut buffer = Buffer::empty(area);
editor.render_inline_options(area, &mut buffer);
assert_eq!(editor.visible_options.get(), (0, 0));
insta::assert_snapshot!(
"question_other_clipped_width_boundary",
buffer_text(&buffer)
);
}
#[test]
fn question_wrapped_options_and_other_share_the_text_indent() {
let mut editor = editor();
+51 -2
View File
@@ -21,6 +21,7 @@ use crate::slash_command::SlashCommand;
// `quit` is an alias of `exit`, and `btw` is an alias of `side`, so we skip
// those aliases here.
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Btw];
const COMMAND_LEFT_INSET: u16 = 2;
const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new(
ColumnWidthMode::AutoAllRows,
/*name_column_width*/ None,
@@ -137,7 +138,7 @@ impl CommandPopup {
&rows,
&self.state,
MAX_POPUP_ROWS,
width,
width.saturating_sub(COMMAND_LEFT_INSET),
COMMAND_COLUMN_WIDTH,
)
}
@@ -269,7 +270,10 @@ impl WidgetRef for CommandPopup {
let rows = self.rows_from_matches(self.filtered());
render_rows_with_col_width_mode(
area.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
/*top*/ 0,
COMMAND_LEFT_INSET,
/*bottom*/ 0,
/*right*/ 0,
)),
buf,
&rows,
@@ -370,6 +374,51 @@ mod tests {
);
}
#[test]
fn command_popup_wrap_boundary_preserves_following_choice() {
let mut popup = CommandPopup::new(
CommandPopupFlags {
service_tier_commands_enabled: true,
..CommandPopupFlags::default()
},
vec![
ServiceTierCommand {
id: "priority".to_string(),
name: "tier-one".to_string(),
description: "Use faster inference".to_string(),
},
ServiceTierCommand {
id: "default".to_string(),
name: "tier-two".to_string(),
description: "Keep default speed".to_string(),
},
],
);
popup.on_composer_text_change("/tier".to_string());
// The first description exactly fits at width 33, including the left inset.
// Rendering at the requested height must also retain the second choice
// when the first description wraps one column below that boundary.
let mut snapshots = Vec::new();
for width in [32, 33, 34] {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
popup.calculate_required_height(width),
);
let mut buf = Buffer::empty(area);
popup.render_ref(area, &mut buf);
let snapshot = format!("{buf:?}");
assert!(snapshot.contains("/tier-two"));
assert!(snapshot.contains("Keep default speed"));
snapshots.push(format!("width {width}\n{snapshot}"));
}
insta::assert_snapshot!("command_popup_wrap_boundary", snapshots.join("\n\n"));
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
@@ -421,12 +421,7 @@ impl Renderable for ExperimentalFeaturesView {
let header_height = header.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, rows_width);
let [header_area, _, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
@@ -472,12 +467,7 @@ impl Renderable for ExperimentalFeaturesView {
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, rows_width);
let mut height = self
.header(width.saturating_sub(4))
@@ -1,6 +1,52 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn experimental_features_keep_following_choice_at_wrap_boundary() {
let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel();
let view = ExperimentalFeaturesView::new(
vec![
ExperimentalFeatureItem {
key: "first".into(),
name: "First feature".into(),
description: "A description that fits exactly at this boundary.".into(),
enabled: true,
writable: true,
},
ExperimentalFeatureItem {
key: "last".into(),
name: "Last feature".into(),
description: "Keep this choice visible.".into(),
enabled: false,
writable: true,
},
],
ThreadId::new(),
/*catalog_rx*/ None,
AppEventSender::new(app_tx),
crate::keymap::RuntimeKeymap::defaults().list,
);
let mut snapshots = Vec::new();
for width in [70, 71, 72] {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
view.desired_height(width),
);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
let text = buffer_text(&buffer);
assert!(
text.contains("Last feature"),
"missing last choice at width {width}"
);
snapshots.push(format!("width={width}\n{text}"));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn experimental_features_analytics_plan_history() {
let feature = Feature::AnalyticsPlanHistory;
@@ -225,6 +225,7 @@ pub(crate) struct SelectionViewParams {
/// Rendered left-column width to use for auto-sized rows.
pub name_column_width: Option<usize>,
pub header: Box<dyn Renderable>,
pub header_view_all_hint: Option<ShortcutHint>,
/// Blank rows after the header; defaults to one. Inline banners use zero.
pub header_gap: u16,
pub initial_selected_idx: Option<usize>,
@@ -282,6 +283,7 @@ impl Default for SelectionViewParams {
description_layout: SelectionDescriptionLayout::Columns,
name_column_width: None,
header: Box::new(()),
header_view_all_hint: None,
header_gap: 1,
initial_selected_idx: None,
side_content: Box::new(()),
@@ -328,6 +330,7 @@ pub(crate) struct ListSelectionView {
last_selected_actual_idx: Option<usize>,
rendered_item_count: std::cell::Cell<usize>,
header: Box<dyn Renderable>,
header_view_all_hint: Option<ShortcutHint>,
header_gap: u16,
initial_selected_idx: Option<usize>,
side_content: Box<dyn Renderable>,
@@ -478,6 +481,7 @@ impl ListSelectionView {
last_selected_actual_idx: None,
rendered_item_count: std::cell::Cell::new(0),
header,
header_view_all_hint: params.header_view_all_hint,
header_gap: params.header_gap,
initial_selected_idx: params.initial_selected_idx,
side_content: params.side_content,
@@ -719,7 +723,8 @@ impl ListSelectionView {
.then(|| item.selected_description.clone())
.flatten()
.or_else(|| item.description.clone());
let wrap_indent = description.is_none().then_some(wrap_prefix_width);
let wrap_indent = (description.is_none() && item.disabled_reason.is_none())
.then_some(wrap_prefix_width);
GenericDisplayRow {
selection_style: row_selection_style,
name: name_with_marker,
@@ -1313,7 +1318,7 @@ impl Renderable for ListSelectionView {
self.rows_width(width)
};
// The shared measurement API reserves one column; compensate to match painted rows.
// Measure at the same width as the painted rows, including any side panel.
let rows = self.build_rows();
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width)
.with_description_layout(self.description_layout);
@@ -1322,7 +1327,7 @@ impl Renderable for ListSelectionView {
&rows,
&self.state,
self.max_visible_rows,
effective_rows_width.saturating_add(/*rhs*/ 1),
effective_rows_width,
column_width,
),
SelectionRowDisplay::SingleLine => {
@@ -1465,7 +1470,7 @@ impl Renderable for ListSelectionView {
&rows,
&self.state,
self.max_visible_rows,
effective_rows_width.saturating_add(/*rhs*/ 1),
effective_rows_width,
column_width,
),
SelectionRowDisplay::SingleLine => {
@@ -1541,15 +1546,17 @@ impl Renderable for ListSelectionView {
]
};
// -- Header --
if self.appearance == SelectionAppearance::Legacy && header_area.height < header_height {
if !self.is_searchable && header_area.height < header_height {
let [header_area, elision_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
header.render(header_area, buf);
Paragraph::new(vec![
Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(),
])
.render(elision_area, buf);
let mut spans = vec![format!("[… {header_height} lines]").into()];
if let Some(hint) = &self.header_view_all_hint {
spans.push(" ".into());
spans.extend(hint.spans());
spans.push(" view all".into());
}
Line::from(spans).dim().render(elision_area, buf);
} else {
header.render(header_area, buf);
}
@@ -2983,14 +2990,6 @@ mod tests {
);
}
#[test]
fn snapshot_fixed_col_width_mode_scroll_behavior() {
assert_snapshot!(
"list_selection_col_width_mode_fixed_scroll",
render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, /*width*/ 96)
);
}
#[test]
fn auto_all_rows_col_width_does_not_shift_when_scrolling() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -3026,41 +3025,6 @@ mod tests {
);
}
#[test]
fn fixed_col_width_is_30_70_and_does_not_shift_when_scrolling() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let width = 96;
let mut view = ListSelectionView::new(
SelectionViewParams {
title: Some("Debug".to_string()),
items: make_scrolling_width_items(),
col_width_mode: ColumnWidthMode::Fixed,
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, width);
let before_col = description_col(&before_scroll, "8. Item 8", "desc 8");
let expected_desc_col = ((width.saturating_sub(2) as usize) * 3) / 10;
assert_eq!(
before_col, expected_desc_col,
"fixed mode should place description column at a 30/70 split:\n{before_scroll}"
);
for _ in 0..8 {
view.handle_key_event(KeyEvent::from(KeyCode::Down));
}
let after_scroll = render_lines_with_width(&view, width);
let after_col = description_col(&after_scroll, "8. Item 8", "desc 8");
assert_eq!(
before_col, after_col,
"fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}"
);
}
#[test]
fn side_layout_width_half_uses_exact_split() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -391,12 +391,8 @@ impl Renderable for MemoriesSettingsView {
let header_height = header.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
&rows,
self.active_state(),
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let rows_height =
measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, rows_width);
let [header_area, _, list_area, _, docs_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
@@ -446,12 +442,8 @@ impl Renderable for MemoriesSettingsView {
};
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(
&rows,
self.active_state(),
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let rows_height =
measure_rows_height(&rows, self.active_state(), MAX_POPUP_ROWS, rows_width);
let docs_height = if self.reset_confirmation.is_some() {
0
@@ -475,3 +467,7 @@ fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> {
}
Line::from(spans)
}
#[cfg(test)]
#[path = "memories_settings_view_tests.rs"]
mod tests;
@@ -0,0 +1,45 @@
//! Verify that memory settings reserve enough space for wrapped choices.
use super::*;
#[test]
fn reset_confirmation_keeps_both_choices_at_wrap_boundary() {
let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel();
let mut view = MemoriesSettingsView::new(
/*use_memories*/ true,
/*generate_memories*/ true,
AppEventSender::new(app_tx),
crate::keymap::RuntimeKeymap::defaults().list,
);
view.open_reset_confirmation();
let mut snapshots = Vec::new();
for width in [70, 71, 72] {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
view.desired_height(width),
);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
let text = buffer
.content
.chunks(usize::from(width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains("Go back"),
"missing cancel choice at width {width}"
);
snapshots.push(format!("width={width}\n{text}"));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
+13 -1
View File
@@ -176,11 +176,14 @@ pub(crate) use title_setup::preview_line_for_title_items;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
mod picker_presets;
mod picker_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;
mod selection_row_layout;
mod selection_tabs;
mod startup;
@@ -1335,7 +1338,12 @@ impl BottomPane {
if params.footer_hint.is_none()
|| params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line())
{
params.footer_hint = Some(self.standard_popup_hint_line());
params.footer_hint = Some(match params.appearance {
SelectionAppearance::Picker => {
popup_consts::picker_hint_line_for_keymap(&self.keymap.list)
}
SelectionAppearance::Legacy => self.standard_popup_hint_line(),
});
}
}
@@ -2204,6 +2212,8 @@ impl Renderable for BottomPane {
mod tests {
#[path = "actionable_banner_tests.rs"]
mod actionable_banner_tests;
#[path = "picker_hint_tests.rs"]
mod picker_hint_tests;
use super::*;
use crate::app::app_server_requests::ResolvedAppServerRequest;
@@ -3356,6 +3366,7 @@ mod tests {
pane.set_task_running(/*running*/ true);
pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Agents".to_string()),
items: vec![SelectionItem {
name: "Main".to_string(),
@@ -3459,6 +3470,7 @@ mod tests {
keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))];
pane.set_keymap_bindings(&keymap);
pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Agents".to_string()),
items: vec![SelectionItem {
name: "Main".to_string(),
@@ -0,0 +1,24 @@
//! Shared presentation defaults for production selection menus.
//!
//! Callers retain their actions, filtering, previews, and cancellation policies.
use super::ColumnWidthMode;
use super::PickerSurface;
use super::SelectionDescriptionLayout;
use super::SelectionViewParams;
impl SelectionViewParams {
/// Start a panel picker with stable columns, hiding descriptions when they become too narrow.
/// Titles and subtitles wrap; standard hints follow the active list keymap.
pub(crate) fn picker() -> Self {
Self {
appearance: super::SelectionAppearance::Picker,
picker_surface: PickerSurface::Panel,
col_width_mode: ColumnWidthMode::AutoAllRows,
description_layout: SelectionDescriptionLayout::HideWhenNarrow {
min_description_width: 24,
},
..Self::default()
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub(crate) enum PickerSurface {
Panel,
}
pub(super) fn active_tab_style() -> Style {
pub(crate) fn active_tab_style() -> Style {
let fallback = Style::default()
.fg(Color::Reset)
.bg(Color::Reset)
@@ -33,6 +33,21 @@ pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> L
)
}
/// Compact footer for shared pickers, using only the configured list actions.
pub(crate) fn picker_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> {
let mut spans = Vec::new();
for (action, label) in [(ListAction::Accept, "select"), (ListAction::Cancel, "back")] {
if let Some(hint) = list_keymap.primary_hint(action) {
if !spans.is_empty() {
spans.push(" · ".dim());
}
spans.extend(hint.spans());
spans.push(format!(" {label}").dim());
}
}
spans.into()
}
pub(crate) fn accept_cancel_hint_line(
accept: Option<ShortcutHint>,
accept_label: &'static str,
@@ -1,4 +1,4 @@
//! Opt-in panels retain controls and page by the results that actually fit.
//! Picker panels retain controls and page by the results that actually fit.
use super::*;
use crate::app_event::AppEvent;
@@ -10,7 +10,7 @@ fn browser() -> ListSelectionView {
let (tx, _rx) = unbounded_channel::<AppEvent>();
ListSelectionView::new(
SelectionViewParams {
appearance: SelectionAppearance::Picker,
appearance: crate::bottom_pane::SelectionAppearance::Picker,
picker_surface: PickerSurface::Panel,
max_visible_rows: 24,
title: Some("Keymap".to_owned()),
@@ -59,6 +59,75 @@ fn render_browser(view: &ListSelectionView, width: u16, height: u16) -> (String,
(text, buf)
}
#[test]
fn shared_menu_presentation_at_wide_and_narrow_sizes() {
let keymap = crate::keymap::RuntimeKeymap::defaults().list;
let (tx, _rx) = unbounded_channel();
let view = ListSelectionView::new(
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Choose an option".into()),
subtitle: Some("A menu with descriptions and disabled choices.".into()),
items: vec![
SelectionItem {
name: "Recommended option".into(),
description: Some("The description column is visible when it fits.".into()),
..Default::default()
},
SelectionItem {
name: "A long option label that still wraps at narrow widths".into(),
description: Some("Additional details".into()),
..Default::default()
},
SelectionItem {
name: "Unavailable".into(),
disabled_reason: Some("This option needs a connected server.".into()),
..Default::default()
},
],
footer_hint: Some(super::super::popup_consts::picker_hint_line_for_keymap(
&keymap,
)),
..SelectionViewParams::picker()
},
AppEventSender::new(tx),
keymap,
);
let mut snapshots = Vec::new();
for (width, height) in [(80, 24), (40, 16)] {
let (text, buffer) = render_browser(&view, width, height);
let selected_y = text.lines().position(|line| line.starts_with('›')).unwrap() as u16;
assert_eq!(
(0..width)
.map(|x| buffer[(x, selected_y)].bg)
.collect::<Vec<_>>(),
vec![selection_style().bg.unwrap(); usize::from(width)]
);
assert!(text.contains("enter select · esc back"));
snapshots.push(format!("{width}x{height}\n{text}"));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn tiny_viewports_preserve_query_and_selection_when_enlarged() {
let mut view = browser();
view.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
view.handle_key_event(KeyEvent::from(KeyCode::Down));
let selected = view.selected_actual_idx();
let before = render_browser(&view, /*width*/ 80, /*height*/ 24).0;
for (width, height) in [(0, 0), (1, 1), (4, 1), (12, 3), (40, 8)] {
render_browser(&view, width, height);
assert_eq!(
(view.search_query.as_str(), view.selected_actual_idx()),
("2", selected)
);
}
assert_eq!(render_browser(&view, /*width*/ 80, /*height*/ 24).0, before);
}
#[test]
fn short_browser_keeps_header_tabs_search_and_footer() {
let view = browser();
@@ -23,7 +23,6 @@ use super::scroll_state::ScrollState;
use super::selection_row_layout::SelectionDescriptionLayout;
use super::selection_row_layout::build_full_line;
use super::selection_row_layout::line_to_owned;
use super::selection_row_layout::wrap_stacked_row;
/// Render-ready representation of one row in a selection popup.
///
@@ -39,8 +38,8 @@ pub(crate) struct GenericDisplayRow {
pub display_shortcut: Option<ShortcutHint>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
pub category_tag: Option<String>, // optional right-side category label
pub disabled_reason: Option<String>, // optional disabled message
pub category_tag: Option<String>,
pub disabled_reason: Option<String>, // optional disabled message
pub is_disabled: bool,
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
@@ -50,15 +49,12 @@ pub(crate) struct GenericDisplayRow {
/// Callers should use the same mode for both measurement and rendering, or the
/// popup can reserve the wrong number of lines and clip content.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) enum ColumnWidthMode {
/// Derive column placement from only the visible viewport rows.
#[default]
AutoVisible,
/// Derive column placement from all rows so scrolling does not shift columns.
AutoAllRows,
/// Use a fixed two-column split: 30% left (name), 70% right (description).
Fixed,
}
/// Column-width behavior plus an optional shared left-column width override.
@@ -87,11 +83,6 @@ impl ColumnWidthConfig {
}
}
// Fixed split used by explicitly fixed column mode: 30% label, 70%
// description.
const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3;
const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10;
const MENU_SURFACE_INSET_V: u16 = 1;
const MENU_SURFACE_INSET_H: u16 = 2;
@@ -149,58 +140,42 @@ fn compute_desc_col(
return 0;
}
let max_desc_col = content_width.saturating_sub(1) as usize;
// Reuse the existing fixed split constants to derive the auto cap:
// if fixed mode is 30/70 (label/description), auto mode caps label width
// at 70% to keep at least 30% available for descriptions.
let max_auto_desc_col = max_desc_col.min(
((content_width as usize * (FIXED_LEFT_COLUMN_DENOMINATOR - FIXED_LEFT_COLUMN_NUMERATOR))
/ FIXED_LEFT_COLUMN_DENOMINATOR)
.max(1),
);
match column_width.mode {
ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR)
/ FIXED_LEFT_COLUMN_DENOMINATOR)
.clamp(1, max_desc_col),
ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => {
let max_name_width = match column_width.mode {
ColumnWidthMode::AutoVisible => rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
.map(|(_, row)| {
let mut spans = row.name_prefix_spans.clone();
spans.push(row.name.clone().into());
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
line_width(&Line::from(spans))
})
.max()
.unwrap_or(0),
ColumnWidthMode::AutoAllRows => rows_all
.iter()
.map(|row| {
let mut spans = row.name_prefix_spans.clone();
spans.push(row.name.clone().into());
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
line_width(&Line::from(spans))
})
.max()
.unwrap_or(0),
ColumnWidthMode::Fixed => 0,
};
// Leave at least 30% of the content width available for descriptions.
let max_desc_col = usize::from(content_width.saturating_sub(/*rhs*/ 1));
let max_auto_desc_col =
max_desc_col.min((usize::from(content_width) * 70 / 100).max(/*other*/ 1));
let (start, count) = match column_width.mode {
ColumnWidthMode::AutoVisible => (start_idx, visible_items),
ColumnWidthMode::AutoAllRows => (0, rows_all.len()),
};
let max_name_width = rows_all
.iter()
.skip(start)
.take(count)
.map(|row| {
let mut spans = row.name_prefix_spans.clone();
spans.push(row.name.clone().into());
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
line_width(&Line::from(spans))
})
.max()
.unwrap_or(/*default*/ 0);
column_width
.name_column_width
.map(|width| width.max(max_name_width))
.unwrap_or(max_name_width)
.saturating_add(2)
.min(max_auto_desc_col)
}
let desc_col = column_width
.name_column_width
.map(|width| width.max(max_name_width))
.unwrap_or(max_name_width)
.saturating_add(/*rhs*/ 2)
.min(max_auto_desc_col);
if column_width
.description_layout
.should_hide(content_width, desc_col)
{
0
} else {
desc_col
}
}
@@ -208,7 +183,9 @@ fn compute_desc_col(
fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize {
let max_indent = max_width.saturating_sub(1) as usize;
let indent = row.wrap_indent.unwrap_or_else(|| {
if row.description.is_some() || row.disabled_reason.is_some() {
if desc_col == 0 {
line_width(&Line::from(row.name_prefix_spans.clone()))
} else if row.description.is_some() || row.disabled_reason.is_some() {
desc_col
} else {
0
@@ -219,7 +196,7 @@ fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usiz
fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool {
// This path intentionally targets plain option rows that opt into wrapped
// labels. Styled/fuzzy-matched rows keep the legacy combined-line path.
// labels. Styled/fuzzy-matched rows use the combined-line path.
row.wrap_indent.is_some()
&& row.description.is_some()
&& row.disabled_reason.is_none()
@@ -317,10 +294,7 @@ fn wrap_row_lines(
width: u16,
description_layout: SelectionDescriptionLayout,
) -> Vec<Line<'static>> {
if description_layout.should_stack(width, desc_col) {
return wrap_stacked_row(row, width);
}
if should_wrap_name_in_column(row) {
if desc_col > 0 && should_wrap_name_in_column(row) {
let wrapped = wrap_two_column_row(row, desc_col, width);
if !wrapped.is_empty() {
return wrapped;
@@ -753,11 +727,12 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
/// Compute the number of terminal rows required to render up to `max_results`
/// items from `rows_all` given the current scroll/selection state and the
/// available `width`. Accounts for description wrapping and alignment so the
/// caller can allocate sufficient vertical space.
/// available `width`. Pass the render area's width after applying caller insets;
/// this helper does not reserve a scrollbar column. Accounts for description
/// wrapping and alignment so the caller can allocate sufficient vertical space.
///
/// This function matches [`render_rows`] semantics (`AutoVisible` column
/// sizing). Mixing it with stable or fixed render modes can under- or
/// sizing). Mixing it with stable render modes can under- or
/// over-estimate required height.
pub(crate) fn measure_rows_height(
rows_all: &[GenericDisplayRow],
@@ -798,7 +773,8 @@ fn measure_rows_height_inner(
return 1; // placeholder "no matches" line
}
let content_width = width.saturating_sub(1).max(1);
// Match the renderer's full row width so exact-fit descriptions do not reserve an extra line.
let content_width = width.max(/*other*/ 1);
let visible_items = max_results.min(rows_all.len());
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
@@ -850,6 +826,113 @@ mod tests {
use ratatui::style::Color;
use ratatui::style::Modifier;
#[test]
fn narrow_description_columns_hide_without_stacking() {
let rows = [
GenericDisplayRow {
name: "日本語".into(),
name_prefix_spans: vec!["› 1. ".into()],
description: Some("Details".into()),
..Default::default()
},
GenericDisplayRow {
name: "Blocked".into(),
name_prefix_spans: vec![" 2. ".into()],
disabled_reason: Some("Unavailable".into()),
is_disabled: true,
..Default::default()
},
];
let state = ScrollState::new();
let columns = ColumnWidthConfig::new(
ColumnWidthMode::AutoAllRows,
/*name_column_width*/ None,
)
.with_description_layout(SelectionDescriptionLayout::HideWhenNarrow {
min_description_width: 24,
});
let mut snapshots = Vec::new();
for width in [48, 49, 80] {
let height = measure_rows_height_with_col_width_mode(
&rows, &state, /*max_results*/ 2, width, columns,
);
assert_eq!(height, 2);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut wrapped = Buffer::empty(area);
render_rows_with_col_width_mode(
area,
&mut wrapped,
&rows,
&state,
/*max_results*/ 2,
"",
columns,
);
let mut single_line = Buffer::empty(area);
render_rows_single_line_with_col_width_mode(
area,
&mut single_line,
&rows,
&state,
/*max_results*/ 2,
"",
columns,
);
assert_eq!(wrapped, single_line);
let text = wrapped
.content
.chunks(usize::from(width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(text.contains("Details"), width >= 49);
assert_eq!(text.contains("Unavailable"), width >= 49);
assert!(text.contains("Blocked (disabled)"));
snapshots.push(format!("width={width}\n{text}"));
}
insta::assert_snapshot!("description_column_visibility", snapshots.join("\n\n"));
}
#[test]
fn exact_fit_description_reserves_only_the_rendered_row() {
let rows = [GenericDisplayRow {
name: "/model".to_string(),
description: Some("fits exactly".to_string()),
..Default::default()
}];
let state = ScrollState::new();
let width = 20;
let height = measure_rows_height(&rows, &state, /*max_results*/ 1, width);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
let rendered = render_rows(
area,
&mut buf,
&rows,
&state,
/*max_results*/ 1,
"no matches",
);
assert_eq!(height, rendered);
let text = buf
.content()
.chunks(usize::from(width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(text, @"/model fits exactly");
}
#[test]
fn one_cell_width_falls_back_without_panic_for_wrapped_two_column_rows() {
let row = GenericDisplayRow {
@@ -8,23 +8,20 @@ use unicode_segmentation::UnicodeSegmentation;
use super::selection_popup_common::GenericDisplayRow;
use crate::line_truncation::line_width;
use crate::width::display_width;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
/// Controls whether selection-row descriptions stay in a column or move below
/// their labels when the description column becomes too narrow to read.
/// Controls whether selection-row descriptions remain visible when their column is narrow.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum SelectionDescriptionLayout {
#[default]
Columns,
StackBelowWhenNarrow {
HideWhenNarrow {
min_description_width: u16,
},
}
impl SelectionDescriptionLayout {
pub(super) fn should_stack(self, width: u16, desc_col: usize) -> bool {
let Self::StackBelowWhenNarrow {
pub(super) fn should_hide(self, width: u16, desc_col: usize) -> bool {
let Self::HideWhenNarrow {
min_description_width,
} = self
else {
@@ -60,7 +57,7 @@ fn combined_description(
(None, Some(reason))
if matches!(
description_layout,
SelectionDescriptionLayout::StackBelowWhenNarrow { .. }
SelectionDescriptionLayout::HideWhenNarrow { .. }
) =>
{
Some(reason.clone())
@@ -70,15 +67,6 @@ fn combined_description(
}
}
fn stacked_description(row: &GenericDisplayRow) -> Option<String> {
match (&row.description, &row.disabled_reason) {
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
(Some(desc), None) => Some(desc.clone()),
(None, Some(reason)) => Some(reason.clone()),
(None, None) => None,
}
}
fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec<Span<'static>> {
let mut name_spans = Vec::with_capacity(row.name.len());
let mut used_width = 0usize;
@@ -125,13 +113,6 @@ fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec<Span<'static>>) {
}
}
fn append_category_tag(row: &GenericDisplayRow, spans: &mut Vec<Span<'static>>) {
if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) {
spans.push(" ".into());
spans.push(tag.to_string().dim());
}
}
/// Build the full display line for a row with the description padded to start
/// at `desc_col`.
pub(super) fn build_full_line(
@@ -139,7 +120,9 @@ pub(super) fn build_full_line(
desc_col: usize,
description_layout: SelectionDescriptionLayout,
) -> Line<'static> {
let description = combined_description(row, description_layout);
let description = (desc_col > 0)
.then(|| combined_description(row, description_layout))
.flatten();
let name_prefix_width = line_width(&Line::from(row.name_prefix_spans.clone()));
let name_limit = description
.as_ref()
@@ -158,40 +141,5 @@ pub(super) fn build_full_line(
}
spans.push(description.dim());
}
append_category_tag(row, &mut spans);
Line::from(spans)
}
/// Render a row as a full-width label followed by an indented description.
pub(super) fn wrap_stacked_row(row: &GenericDisplayRow, width: u16) -> Vec<Line<'static>> {
let width = width.max(1);
let prefix_width = line_width(&Line::from(row.name_prefix_spans.clone()))
.min(width.saturating_sub(1) as usize);
let indent = " ".repeat(prefix_width);
let mut label_spans = row.name_prefix_spans.clone();
label_spans.extend(build_name_spans(row, usize::MAX));
append_shortcut(row, &mut label_spans);
append_category_tag(row, &mut label_spans);
let label = Line::from(label_spans);
let label_options = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(indent.clone()));
let mut lines = word_wrap_line(&label, label_options)
.into_iter()
.map(line_to_owned)
.collect::<Vec<_>>();
if let Some(description) = stacked_description(row) {
let description = Line::from(description.dim());
let description_options = RtOptions::new(width as usize)
.initial_indent(Line::from(indent.clone()))
.subsequent_indent(Line::from(indent));
lines.extend(
word_wrap_line(&description, description_options)
.into_iter()
.map(line_to_owned),
);
}
lines
}
@@ -0,0 +1,60 @@
---
source: tui/src/bottom_pane/command_popup.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width 32
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 3 },
content: [
" /tier-one Use faster ",
" inference ",
" /tier-two Keep default speed ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 23, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
width 33
Buffer {
area: Rect { x: 0, y: 0, width: 33, height: 2 },
content: [
" /tier-one Use faster inference",
" /tier-two Keep default speed ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
width 34
Buffer {
area: Rect { x: 0, y: 0, width: 34, height: 2 },
content: [
" /tier-one Use faster inference ",
" /tier-two Keep default speed ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 33, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -0,0 +1,39 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=70
Experimental features
Checked features are configured on. Some experimental features
take effect only in new tasks or after restarting the Codex
server.
› [x] First feature A description that fits exactly at this
boundary.
[ ] Last feature Keep this choice visible.
Press space to select or enter to save
width=71
Experimental features
Checked features are configured on. Some experimental features take
effect only in new tasks or after restarting the Codex server.
› [x] First feature A description that fits exactly at this
boundary.
[ ] Last feature Keep this choice visible.
Press space to select or enter to save
width=72
Experimental features
Checked features are configured on. Some experimental features take
effect only in new tasks or after restarting the Codex server.
› [x] First feature A description that fits exactly at this boundary.
[ ] Last feature Keep this choice visible.
Press space to select or enter to save
@@ -0,0 +1,48 @@
---
source: tui/src/bottom_pane/selection_picker_tests.rs
assertion_line: 103
expression: "snapshots.join(\"\\n\\n\")"
---
80x24
Choose an option
A menu with descriptions and disabled choices.
› 1. Recommended option The description column
is visible when it fits.
2. A long option label that still wraps at narrow wi… Additional details
Unavailable (disabled) This option needs a
connected server.
enter select · esc back
40x16
Choose an option
A menu with descriptions and
disabled choices.
› 1. Recommended option
2. A long option label that still
wraps at narrow widths
Unavailable (disabled)
enter select · esc back
@@ -1,30 +0,0 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)"
---
before scroll:
Debug
› 1. Item 1 desc 1
2. Item 2 desc 2
3. Item 3 desc 3
4. Item 4 desc 4
5. Item 5 desc 5
6. Item 6 desc 6
7. Item 7 desc 7
8. Item 8 desc 8
after scroll:
Debug
2. Item 2 desc 2
3. Item 3 desc 3
4. Item 4 desc 4
5. Item 5 desc 5
6. Item 6 desc 6
7. Item 7 desc 7
8. Item 8 desc 8
› 9. Item 9 with an intent… desc 9
@@ -0,0 +1,38 @@
---
source: tui/src/bottom_pane/memories_settings_view_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=70
Reset all memories?
› Reset all memories Delete local memory files and rollout
summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
width=71
Reset all memories?
› Reset all memories Delete local memory files and rollout
summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
width=72
Reset all memories?
› Reset all memories Delete local memory files and rollout summaries.
Go back Return to memory settings.
Press enter to confirm or esc to go back
@@ -0,0 +1,16 @@
---
source: tui/src/bottom_pane/selection_popup_common.rs
assertion_line: 838
expression: "snapshots.join(\"\\n\\n\")"
---
width=48
› 1. 日 本 語
2. Blocked (disabled)
width=49
› 1. 日 本 語 Details
2. Blocked (disabled) Unavailable
width=80
› 1. 日 本 語 Details
2. Blocked (disabled) Unavailable
@@ -6,11 +6,13 @@ expression: "render_snapshot(&pane, area)"
Usage limit reached
Your included usage is depleted.
Choose an action to continue.
› 1. Open usage settings
2. View usage
3. Extra action
Press a number to choose · esc to dismiss
· type to continue
› Ask Codex to do anything
@@ -6,11 +6,13 @@ expression: "render_snapshot(&pane, area)"
Usage limit reached
Your included usage is depleted.
Choose an action to continue.
› 1. Open usage settings
2. Notify owner
3. Extra action
Press a number to choose · esc to dismiss
· type to continue
› Ask Codex to do anything
@@ -11,10 +11,12 @@ expression: "render_snapshot(&pane, area)"
A long description wraps to fit the
available terminal width.
…
› 1. Open usage settings
2. Notify owner
Press a number to choose · esc to dismiss
· type to continue
› Ask Codex to do anything
@@ -14,6 +14,7 @@ expression: "render_view_lines(&view, 30)"
tion/critical-release-cand
idate
› 1. Verify and approve (y)
2. Cancel this request (c)
@@ -1,25 +1,12 @@
---
source: tui/src/bottom_pane/user_verification_tests.rs
expression: "render_view_lines(&view, 20)"
expression: "render_view_lines(&view, 40, 8)"
---
Production
deployment needs
your approval.
Server:
deployments
Approve
deploying the
reviewed
release?
› 1. Verify and
approve (y)
2. Cancel this
request (c)
Press enter to
confirm or esc to
Production deployment needs your
approval.
[… 7 lines] ctrl+a view all
› 1. Verify and approve (y)
2. Cancel this request (c)
Press enter to confirm or esc to
cancel
@@ -9,6 +9,7 @@ expression: "render_view_lines(&view, 80)"
Approve deploying the reviewed release?
› 1. Verify and approve (y)
2. Cancel this request (c)
@@ -2,6 +2,66 @@ use super::super::actionable_banner::BannerDismissal;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn promoted_banner_uses_picker_hints_and_preserves_the_draft() {
let width = 40;
for confirm in [false, true] {
let (tx, mut rx) = unbounded_channel();
let mut pane = test_pane(AppEventSender::new(tx));
let mut keymap = RuntimeKeymap::defaults();
keymap.list.accept = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 3))];
keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 2))];
pane.set_keymap_bindings(&keymap);
pane.set_composer_text("draft in progress".into(), Vec::new(), Vec::new());
pane.handle_key_event(KeyEvent::from(KeyCode::Left));
let draft = pane.composer.draft_snapshot();
pane.show_actionable_banner(ActionableBanner {
title: "Usage limit reached".into(),
description: "Review your usage or keep working with another model.".into(),
actions: vec![SelectionItem {
name: "View usage".into(),
dismiss_on_select: true,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenUrlInBrowser {
url: "https://example.com/usage".into(),
});
})],
..Default::default()
}],
..Default::default()
});
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
pane.desired_height(width),
);
let rendered = render_snapshot(&pane, area);
assert!(rendered.contains("View usage"), "{rendered}");
assert!(rendered.contains("f3 select · f2 back"), "{rendered}");
if confirm {
assert_snapshot!(format!("promoted_action_banner_{width}"), rendered);
}
assert_eq!(pane.composer.draft_snapshot(), draft);
let key = if confirm {
KeyCode::F(/*n*/ 3)
} else {
KeyCode::F(/*n*/ 2)
};
pane.handle_key_event(KeyEvent::from(key));
assert!(pane.view_stack.is_empty());
assert_eq!(pane.composer.draft_snapshot(), draft);
if confirm {
assert!(
matches!(rx.try_recv(), Ok(AppEvent::OpenUrlInBrowser { url })
if url == "https://example.com/usage")
);
}
assert!(rx.try_recv().is_err());
}
}
#[test]
fn hidden_banner_preserves_input_before_render_and_after_resize() {
let (tx, mut rx) = unbounded_channel();
@@ -0,0 +1,70 @@
//! Picker hints follow runtime bindings while preserving custom and required-choice policy.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn popup_hints_follow_configured_bindings() {
for (name, params) in [
("default", SelectionViewParams::default()),
("picker", SelectionViewParams::picker()),
] {
let (tx, _rx) = unbounded_channel();
let mut pane = test_pane(AppEventSender::new(tx));
let mut keymap = RuntimeKeymap::defaults();
keymap.list.accept = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 3))];
keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 2))];
pane.set_keymap_bindings(&keymap);
pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Choose an action".into()),
items: vec![SelectionItem {
name: "Continue".into(),
..Default::default()
}],
..params
});
let width = 60;
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
pane.desired_height(width),
);
let rendered = render_snapshot(&pane, area);
assert_snapshot!(format!("popup_hint_{name}"), rendered);
assert!(rendered.contains("f3 select · f2 back"), "{rendered}");
pane.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 2)));
assert!(pane.view_stack.is_empty());
}
}
#[test]
fn custom_and_noncancelable_popup_hints_keep_their_policy() {
let (tx, _rx) = unbounded_channel();
let pane = test_pane(AppEventSender::new(tx));
for (allow_cancel, supplied, expected) in [
(
true,
Some(Line::from("custom controls")),
Some(Line::from("custom controls")),
),
(true, Some(Line::default()), Some(Line::default())),
(false, None, None),
(false, Some(popup_consts::standard_popup_hint_line()), None),
(
false,
Some(Line::from("custom controls")),
Some(Line::from("custom controls")),
),
] {
let mut params = SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
allow_cancel,
footer_hint: supplied,
..Default::default()
};
pane.apply_standard_popup_hint(&mut params);
assert_eq!(params.footer_hint, expected);
}
}
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/tests/actionable_banner_tests.rs
expression: rendered
---
Usage limit reached
Review your usage or keep working
with another model.
› 1. View usage
f3 select · f2 back
@@ -0,0 +1,10 @@
---
source: tui/src/bottom_pane/tests/picker_hint_tests.rs
expression: rendered
---
Choose an action
› 1. Continue
f3 select · f2 back
@@ -0,0 +1,10 @@
---
source: tui/src/bottom_pane/tests/picker_hint_tests.rs
expression: rendered
---
Choose an action
› 1. Continue
Press f3 to confirm or f2 to go back
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/tests/picker_hint_tests.rs
expression: rendered
---
Choose an action
› 1. Continue
f3 select · f2 back
@@ -90,6 +90,7 @@ impl UserVerificationView {
.collect();
let header = prompt_header(&request);
let params = SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
footer_note: Some(
accept_cancel_hint_line(
list_keymap.primary_hint(ListAction::Accept),
@@ -101,7 +102,9 @@ impl UserVerificationView {
),
items,
header,
..Default::default()
header_view_all_hint: approval_keymap
.primary_hint("open_fullscreen", &approval_keymap.open_fullscreen),
..SelectionViewParams::picker()
};
Self {
request,
@@ -5,8 +5,7 @@ use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn render_view_lines(view: &UserVerificationView, width: u16) -> String {
let height = view.desired_height(width);
fn render_view_lines(view: &UserVerificationView, width: u16, height: u16) -> String {
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
view.render(Rect::new(0, 0, width, height), &mut buf);
(0..buf.area.height)
@@ -60,16 +59,16 @@ fn prompt_snapshot() {
let (view, _app_rx) = make_view();
assert_snapshot!(
"user_verification_prompt",
render_view_lines(&view, /*width*/ 80)
render_view_lines(&view, /*width*/ 80, view.desired_height(/*width*/ 80))
);
}
#[test]
fn title_wraps_at_narrow_widths() {
fn clipped_prompt_exposes_fullscreen_hint() {
let (view, _app_rx) = make_view();
assert_snapshot!(
"user_verification_narrow",
render_view_lines(&view, /*width*/ 20)
render_view_lines(&view, /*width*/ 40, /*height*/ 8)
);
}
@@ -79,7 +78,7 @@ fn waiting_snapshot_and_duplicate_approval_suppression() {
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_snapshot!(
"user_verification_waiting",
render_view_lines(&view, /*width*/ 80)
render_view_lines(&view, /*width*/ 80, view.desired_height(/*width*/ 80))
);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
@@ -167,7 +166,7 @@ fn long_url_tokens_remain_visible_in_a_narrow_verification_prompt() {
});
assert_snapshot!(
"user_verification_long_urls",
render_view_lines(&view, /*width*/ 30)
render_view_lines(&view, /*width*/ 30, view.desired_height(/*width*/ 30))
);
}
+3 -5
View File
@@ -123,8 +123,6 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::Notifications;
use codex_connectors::AppInfo;
use codex_features::Feature;
#[cfg(test)]
use codex_git_utils::CommitLogEntry;
use codex_git_utils::current_branch_name;
use codex_git_utils::get_git_repo_root;
use codex_git_utils::local_git_branches;
@@ -422,13 +420,12 @@ 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;
mod review_popups;
use self::review::ReviewState;
#[cfg(test)]
pub(crate) use self::review_popups::show_review_commit_picker_with_entries;
mod safety_buffering;
mod service_tiers;
mod settings;
@@ -1092,6 +1089,7 @@ impl ChatWidget {
_ => return,
};
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some(format!("Enable {name}?")),
subtitle: Some(format!("{label} are disabled in this TUI session.")),
footer_note: (feature == Feature::MemoryTool).then(|| {
@@ -1120,7 +1118,7 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
}
+107
View File
@@ -0,0 +1,107 @@
//! Copy targets and bounded previews in the shared picker.
//!
//! Preview truncation never changes the source text or format sent to the clipboard.
use super::*;
use crate::clipboard_copy::CopyFormat;
use crate::text_formatting::truncate_text;
impl ChatWidget {
pub(super) fn show_copy_picker(&mut self) {
let mut choices = Vec::new();
if let Some(status_targets) = &self.transcript.last_status_copy_targets {
choices.push((
"Whole status".to_string(),
Arc::<str>::from(status_targets.handle.copy_text()),
CopyFormat::PlainText,
));
choices.extend(
status_targets
.fields
.iter()
.cloned()
.map(|(label, text)| (label, text, CopyFormat::PlainText)),
);
} else if let Some(markdown) = self
.transcript
.last_agent_markdown
.as_deref()
.filter(|markdown| !markdown.is_empty())
{
choices.push((
"Whole response".to_string(),
Arc::<str>::from(markdown),
CopyFormat::Markdown,
));
let source = self
.transcript
.last_agent_source
.as_deref()
.unwrap_or(markdown);
choices.extend(
crate::markdown::extract_copy_targets(source)
.into_iter()
.filter_map(|target| match target {
crate::markdown::CopyTarget::Code { language, content } => Some((
language.map_or_else(
|| "Code block".to_string(),
|language| format!("{language} code"),
),
content,
CopyFormat::PlainText,
)),
crate::markdown::CopyTarget::Quote(content) => {
let content: String = content
.split_inclusive('\n')
.map(|line| {
crate::git_action_directives::strip_line_directives(line).0
})
.collect();
(!content.trim().is_empty()).then(|| {
(
"Blockquote".to_string(),
Arc::from(content),
CopyFormat::PlainText,
)
})
}
}),
);
}
if choices.is_empty() {
self.copy_last_agent_markdown();
return;
}
let items = choices
.into_iter()
.map(|(label, text, format)| {
let description = text
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| truncate_text(line.trim(), /*max_graphemes*/ 72));
SelectionItem {
name: label.clone(),
description,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::CopySelection {
text: Arc::clone(&text),
label: label.clone(),
format,
});
})],
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Copy to clipboard".into()),
items,
..SelectionViewParams::picker()
});
self.defer_input_until_settings_applied();
}
}
+2 -1
View File
@@ -48,6 +48,7 @@ impl ChatWidget {
});
})];
self.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Resume paused goal?".to_string()),
subtitle: Some(format!("Goal: {objective}")),
footer_hint: Some(standard_popup_hint_line()),
@@ -67,7 +68,7 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -353,104 +353,6 @@ impl ChatWidget {
self.request_redraw();
}
pub(super) fn show_copy_picker(&mut self) {
let mut choices = Vec::new();
if let Some(status_targets) = &self.transcript.last_status_copy_targets {
choices.push((
"Whole status".to_string(),
Arc::<str>::from(status_targets.handle.copy_text()),
CopyFormat::PlainText,
));
choices.extend(
status_targets
.fields
.iter()
.cloned()
.map(|(label, text)| (label, text, CopyFormat::PlainText)),
);
} else if let Some(markdown) = self
.transcript
.last_agent_markdown
.as_deref()
.filter(|markdown| !markdown.is_empty())
{
choices.push((
"Whole response".to_string(),
Arc::<str>::from(markdown),
CopyFormat::Markdown,
));
let source = self
.transcript
.last_agent_source
.as_deref()
.unwrap_or(markdown);
choices.extend(
crate::markdown::extract_copy_targets(source)
.into_iter()
.filter_map(|target| match target {
crate::markdown::CopyTarget::Code { language, content } => Some((
language.map_or_else(
|| "Code block".to_string(),
|language| format!("{language} code"),
),
content,
CopyFormat::PlainText,
)),
crate::markdown::CopyTarget::Quote(content) => {
let content: String = content
.split_inclusive('\n')
.map(|line| {
crate::git_action_directives::strip_line_directives(line).0
})
.collect();
(!content.trim().is_empty()).then(|| {
(
"Blockquote".to_string(),
Arc::from(content),
CopyFormat::PlainText,
)
})
}
}),
);
}
if choices.is_empty() {
self.copy_last_agent_markdown();
return;
}
let items = choices
.into_iter()
.map(|(label, text, format)| {
let description = text
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().chars().take(72).collect());
SelectionItem {
name: label.clone(),
description,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::CopySelection {
text: Arc::clone(&text),
label: label.clone(),
format,
});
})],
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.show_selection_view(SelectionViewParams {
title: Some("Copy to clipboard".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
self.defer_input_until_settings_applied();
}
pub(crate) fn copy_selection(&mut self, text: Arc<str>, label: String, format: CopyFormat) {
self.copy_selection_with(&text, &label, |text| {
crate::clipboard_copy::copy_to_clipboard(text, format)
@@ -1,6 +1,7 @@
//! The Reserve-only /model picker uses normal-model metadata while keeping Reserve routing.
use super::*;
use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap;
use crate::model_catalog::LUNA_MODEL;
use crate::model_catalog::LUNA_RESERVE_MODEL;
@@ -46,9 +47,10 @@ impl ChatWidget {
self.show_model_selection_view(
vec![LUNA_RESERVE_MODEL.to_string()],
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(view_id),
header,
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
footer_hint: Some(picker_hint_line_for_keymap(&self.bottom_pane.list_keymap())),
items: vec![SelectionItem {
name,
description,
@@ -58,7 +60,7 @@ impl ChatWidget {
dismiss_parent_on_child_accept: !single_supported_effort,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
},
);
}
@@ -166,6 +166,7 @@ impl ChatWidget {
);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PRECAUTION_VIEW),
header: Box::new(
Paragraph::new(vec![
@@ -178,7 +179,7 @@ impl ChatWidget {
),
items,
allow_cancel: false,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -203,6 +204,7 @@ impl ChatWidget {
let can_continue = review.continuation_message().is_some();
self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW);
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PRECAUTION_VIEW),
title: Some("Chat paused as a precaution".to_string()),
items: vec![
@@ -223,7 +225,7 @@ impl ChatWidget {
},
],
allow_cancel: false,
..Default::default()
..SelectionViewParams::picker()
});
}
+20 -19
View File
@@ -42,15 +42,14 @@ impl ChatWidget {
pub(super) fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
let title = title.to_string();
let subtitle = subtitle.to_string();
let mut header = ColumnRenderable::new();
header.push(Line::from(title.bold()));
let mut header = vec![Line::from(title.bold())];
if !subtitle.is_empty() {
header.push(Line::from(subtitle.dim()));
}
if let Some(warning) = self.model_menu_warning_line() {
header.push(warning);
}
Box::new(header)
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
}
fn model_menu_warning_line(&self) -> Option<Line<'static>> {
@@ -191,11 +190,11 @@ impl ChatWidget {
self.show_model_selection_view(
model_ids,
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(MODEL_SELECTION_VIEW_ID),
footer_hint: Some(standard_popup_hint_line()),
items,
header,
..Default::default()
..SelectionViewParams::picker()
},
);
}
@@ -284,11 +283,11 @@ impl ChatWidget {
self.show_model_selection_view(
model_ids,
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(view_id),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
header,
..Default::default()
..SelectionViewParams::picker()
},
);
}
@@ -451,9 +450,9 @@ impl ChatWidget {
})];
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()),
subtitle: Some(subtitle),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
SelectionItem {
name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(),
@@ -470,7 +469,7 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
self.notify(Notification::PlanModePrompt {
title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(),
@@ -639,17 +638,17 @@ impl ChatWidget {
});
}
let mut header = ColumnRenderable::new();
header.push(Line::from(
let header = Paragraph::new(Line::from(
format!("Select Reasoning Level for {model_label}").bold(),
));
))
.wrap(Wrap { trim: false });
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -707,14 +706,16 @@ impl ChatWidget {
});
}
let mut header = ColumnRenderable::new();
header.push(Line::from("Advanced Reasoning".bold()));
header.push(Line::from("⚠ Consumes usage limits faster".cyan()));
let header = Paragraph::new(vec![
Line::from("Advanced Reasoning".bold()),
Line::from("⚠ Consumes usage limits faster".cyan()),
])
.wrap(Wrap { trim: false });
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -18,15 +18,15 @@ impl ChatWidget {
let request_id = uuid::Uuid::new_v4();
self.permission_popup_request_id = Some(request_id);
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(VIEW_ID),
title: Some("Update Model Permissions".to_string()),
title: Some("Update Model Permissions".into()),
items: vec![SelectionItem {
name: "Loading permission profiles…".to_string(),
is_disabled: true,
..Default::default()
}],
footer_hint: Some(standard_popup_hint_line()),
..Default::default()
..SelectionViewParams::picker()
});
self.app_event_tx.send(AppEvent::FetchPermissionProfiles {
request_id,
@@ -65,6 +65,7 @@ impl ChatWidget {
self.open_permission_profiles_popup(discovery);
}
Err(message) => self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(VIEW_ID),
title: Some("Update Model Permissions".to_string()),
subtitle: Some(message),
@@ -74,8 +75,7 @@ impl ChatWidget {
dismiss_on_select: true,
..Default::default()
}],
footer_hint: Some(standard_popup_hint_line()),
..Default::default()
..SelectionViewParams::picker()
}),
}
}
@@ -1,10 +1,11 @@
//! Permission and approval popup flows for `ChatWidget`.
//!
//! This module owns the generic permission pickers and confirmation surfaces;
//! This module presents permission choices and confirmations in the shared picker;
//! Windows-specific sandbox prompting lives beside it in
//! `windows_sandbox_prompts`.
use super::*;
use crate::style::accent_color;
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
impl ChatWidget {
@@ -165,19 +166,18 @@ impl ChatWidget {
let footer_note = show_elevate_sandbox_hint.then(|| {
vec![
"The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(),
"/setup-default-sandbox".cyan(),
"/setup-default-sandbox".fg(accent_color()),
".".dim(),
]
.into()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Update Model Permissions".to_string()),
appearance: crate::bottom_pane::SelectionAppearance::Picker,
footer_note,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
..Default::default()
title: Some("Update Model Permissions".into()),
..SelectionViewParams::picker()
});
}
@@ -230,13 +230,19 @@ impl ChatWidget {
);
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Auto-review Denials".to_string()),
subtitle: Some("Select a denied action to approve.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
appearance: crate::bottom_pane::SelectionAppearance::Picker,
header: Box::new(
Paragraph::new(vec![
Line::from("Auto-review Denials".bold()),
Line::from("Select a denied action to approve.".dim()),
])
.wrap(Wrap { trim: false }),
),
items,
is_searchable: true,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
// Denial rationales remain visible before authorizing a retry.
description_layout: crate::bottom_pane::SelectionDescriptionLayout::Columns,
..SelectionViewParams::picker()
});
self.request_redraw();
}
@@ -506,10 +512,10 @@ impl ChatWidget {
];
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
appearance: crate::bottom_pane::SelectionAppearance::Picker,
items,
header: Box::new(header),
..Default::default()
..SelectionViewParams::picker()
});
}
}
@@ -1,4 +1,4 @@
//! Named permission choices from the server catalog, using the existing selection actions.
//! Named permission choices in the shared picker, using the server catalog and existing actions.
use super::*;
use crate::permission_discovery::PermissionDiscovery;
@@ -163,16 +163,15 @@ impl ChatWidget {
));
}
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(super::permission_discovery::VIEW_ID),
items,
title: Some("Update Model Permissions".to_string()),
subtitle: discovery
.profiles
.is_empty()
.then(|| "No permission profiles returned by the server.".to_string()),
title: Some("Update Model Permissions".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
..Default::default()
..SelectionViewParams::picker()
});
}
+2 -1
View File
@@ -294,6 +294,7 @@ impl ChatWidget {
self.pet_picker_preview_state.clear();
self.pet_picker_preview_pet = None;
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(PET_SELECTION_LOADING_VIEW_ID),
title: Some("Loading Pet".to_string()),
subtitle: Some("Preparing the terminal pet.".to_string()),
@@ -302,7 +303,7 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
});
self.pet_selection_load_request_id
}
@@ -75,6 +75,7 @@ pub(super) fn selection_view_params(
);
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()),
subtitle: None,
footer_hint: Some(standard_popup_hint_line()),
@@ -109,6 +110,6 @@ pub(super) fn selection_view_params(
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
}
}
+2 -1
View File
@@ -507,12 +507,13 @@ impl ChatWidget {
];
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID),
title: Some("Approaching rate limits".to_string()),
subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -25,11 +25,12 @@ impl ChatWidget {
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Select voice".to_string()),
subtitle: Some("Applies to your next voice conversation.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
+17 -42
View File
@@ -1,6 +1,10 @@
//! Review preset selection and custom review prompt surfaces.
//! Review presets, searchable Git choices, and instructions in the shared picker.
//!
//! Child cancellation restores the preset menu; accepting a target dismisses both.
use super::*;
use crate::bottom_pane::PickerSurface;
use codex_git_utils::CommitLogEntry;
impl ChatWidget {
pub(crate) fn on_review_started(&mut self) {
@@ -58,10 +62,10 @@ impl ChatWidget {
});
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Select a review preset".into()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -75,7 +79,7 @@ impl ChatWidget {
for option in branches {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
name: branch.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::BaseBranch {
branch: branch.clone(),
@@ -88,8 +92,10 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
picker_surface: PickerSurface::Panel,
title: Some("Select a base branch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
subtitle: Some(format!("Current branch: {current_branch}")),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
@@ -99,7 +105,10 @@ impl ChatWidget {
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
let commits = recent_commits(cwd, /*limit*/ 100).await;
self.show_review_commits(commits);
}
pub(super) fn show_review_commits(&mut self, commits: Vec<CommitLogEntry>) {
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
for entry in commits {
let subject = entry.subject.clone();
@@ -121,8 +130,9 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
appearance: crate::bottom_pane::SelectionAppearance::Picker,
picker_surface: PickerSurface::Panel,
title: Some("Select a commit to review".into()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
@@ -150,38 +160,3 @@ impl ChatWidget {
self.bottom_pane.show_text_prompt(view);
}
}
#[cfg(test)]
pub(crate) fn show_review_commit_picker_with_entries(
chat: &mut ChatWidget,
entries: Vec<CommitLogEntry>,
) {
let mut items: Vec<SelectionItem> = Vec::with_capacity(entries.len());
for entry in entries {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::Commit {
sha: sha.clone(),
title: Some(subject.clone()),
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
@@ -227,12 +227,13 @@ impl ChatWidget {
},
]);
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID),
header: Box::new(SafetyBufferingHeader(header)),
footer_note: Some(Line::from(SAFETY_BUFFERING_FOOTER).dim()),
footer_hint: Some(Line::default()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -258,6 +259,7 @@ impl ChatWidget {
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID),
header: Box::new(SafetyBufferingHeader(vec![
"Stop this attempt and retry?".bold().into(),
@@ -289,7 +291,7 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
}
}
@@ -56,15 +56,15 @@ impl ChatWidget {
let mut hints = Vec::new();
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
let label = if effort == Some(ReasoningEffortConfig::Ultra) {
" to apply · "
" apply · "
} else {
" to set as default · "
" default · "
};
hints.extend([accept.into(), label.into()]);
hints.extend([accept.into(), label.dim()]);
}
hints.extend([key.into(), " for this session".into()]);
hints.extend([key.into(), " session".dim()]);
if let Some(cancel) = keymap.primary_hint(ListAction::Cancel) {
hints.extend([" · ".into(), cancel.into(), " to go back".into()]);
hints.extend([" · ".dim(), cancel.into(), " back".dim()]);
}
let warning = effort
.as_ref()
+2 -1
View File
@@ -49,11 +49,12 @@ impl ChatWidget {
];
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Skills".to_string()),
subtitle: Some("Choose an action".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -206,6 +206,7 @@ impl ChatWidget {
}
SlashCommand::Archive => {
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Archive this session?".to_string()),
subtitle: Some(
"Are you sure? This will archive the current session".to_string(),
@@ -228,12 +229,13 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
self.request_redraw();
}
SlashCommand::Delete => {
self.bottom_pane.show_selection_view(SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Delete this session?".to_string()),
subtitle: Some(
"Cannot be undone. Subagent threads will also be deleted.".to_string(),
@@ -261,7 +263,7 @@ impl ChatWidget {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
self.request_redraw();
}
@@ -1,11 +1,11 @@
---
source: tui/src/chatwidget/realtime_settings_tests.rs
assertion_line: 17
expression: "render_bottom_popup(&chat, 80)"
---
Select voice
Applies to your next voice conversation.
1. juniper
› 2. maple (current)
3. spruce
@@ -14,5 +14,5 @@ expression: "render_bottom_popup(&chat, 80)"
6. breeze
7. arbor
8. sol
Press enter to confirm or esc to go back
↓
enter select · esc back
@@ -1,12 +1,12 @@
---
source: tui/src/chatwidget/realtime_settings_tests.rs
assertion_line: 67
expression: "render_bottom_popup(&chat, 80)"
---
Select voice
Applies to your next voice conversation.
› 1. cove (current)
2. maple
Press enter to confirm or esc to go back
enter select · esc back

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