mirror of
https://github.com/openai/codex.git
synced 2026-10-21 16:38:24 +00:00
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:
@@ -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()),
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+6
-7
@@ -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
|
||||
|
||||
+4
-4
@@ -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)))],
|
||||
|
||||
@@ -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));
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
+13
@@ -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
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
+60
@@ -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();
|
||||
|
||||
+23
@@ -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
|
||||
+6
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+60
@@ -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,
|
||||
]
|
||||
}
|
||||
+39
@@ -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
|
||||
+48
@@ -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
|
||||
-30
@@ -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
|
||||
+38
@@ -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
|
||||
+16
@@ -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
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
|
||||
+7
-20
@@ -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
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
+10
@@ -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
|
||||
+10
@@ -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
|
||||
+11
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user