| 1 | //! #6500: the model picker's ⇧P / ⇧F / ⇧D after a search, driven through the |
| 2 | //! open view stack, the event handler, disk, and the rendered picker. |
| 3 | |
| 4 | use super::*; |
| 5 | use crate::tui::model_picker::ModelPickerView; |
| 6 | use crate::tui::ui::handlers::{toggle_model_picker_fleet, toggle_model_picker_pin}; |
| 7 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 8 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 9 | |
| 10 | /// An app on a keyed DeepSeek route with `/model` open, under a temp home. |
| 11 | #[cfg(test)] |
| 12 | fn app_with_open_picker(root: &std::path::Path) -> (App, Config) { |
| 13 | let workspace = root.join("workspace"); |
| 14 | std::fs::create_dir_all(&workspace).unwrap(); |
| 15 | let mut config = Config::default(); |
| 16 | config |
| 17 | .set_provider_api_key_override( |
| 18 | &config.test_identity_for_kind(ProviderKind::Deepseek), |
| 19 | Some("fixture-key".into()), |
| 20 | ) |
| 21 | .unwrap(); |
| 22 | let mut app = App::new(crate::test_support::test_tui_options(&workspace), &config); |
| 23 | app.workspace = workspace; |
| 24 | let picker = ModelPickerView::new(&app, &config); |
| 25 | app.view_stack.push(picker); |
| 26 | (app, config) |
| 27 | } |
| 28 | |
| 29 | fn type_query(app: &mut App, query: &str) { |
| 30 | for ch in query.chars() { |
| 31 | let events = app |
| 32 | .view_stack |
| 33 | .handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 34 | assert!(events.is_empty(), "typing {ch:?} emitted {events:?}"); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | fn shifted(ch: char) -> KeyEvent { |
| 39 | KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT) |
| 40 | } |
| 41 | |
| 42 | fn render_picker(app: &mut App) -> String { |
| 43 | assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker)); |
| 44 | let view = app.view_stack.pop().unwrap(); |
| 45 | let area = Rect::new(0, 0, 140, 40); |
| 46 | let mut buf = Buffer::empty(area); |
| 47 | view.render(area, &mut buf); |
| 48 | app.view_stack.push_boxed(view); |
| 49 | (0..area.height) |
| 50 | .map(|y| { |
| 51 | (0..area.width) |
| 52 | .map(|x| buf[(x, y)].symbol()) |
| 53 | .collect::<String>() |
| 54 | }) |
| 55 | .collect::<Vec<_>>() |
| 56 | .join("\n") |
| 57 | } |
| 58 | |
| 59 | /// The picker line that lists `model` as a row (not the query title or the |
| 60 | /// receipt line). |
| 61 | fn row_line<'a>(screen: &'a str, model: &str) -> &'a str { |
| 62 | screen |
| 63 | .lines() |
| 64 | .find(|line| { |
| 65 | line.contains(model) && !line.contains("Model:") && !line.contains(&format!("/{model}")) |
| 66 | }) |
| 67 | .unwrap_or_else(|| panic!("no row for {model}:\n{screen}")) |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn shift_p_after_a_search_pins_the_highlighted_row_and_says_so_in_the_picker() { |
| 72 | let _lock = crate::test_support::lock_test_env(); |
| 73 | let root = tempfile::tempdir().unwrap(); |
| 74 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 75 | let (mut app, config) = app_with_open_picker(root.path()); |
| 76 | let model = app.model.clone(); |
| 77 | type_query(&mut app, &model); |
| 78 | |
| 79 | let events = app.view_stack.handle_key(shifted('P')); |
| 80 | let [ |
| 81 | ViewEvent::ModelPickerTogglePin { |
| 82 | provider, |
| 83 | provider_id, |
| 84 | model: picked, |
| 85 | }, |
| 86 | ] = events.as_slice() |
| 87 | else { |
| 88 | panic!("⇧P while searching must pin the highlighted row, got {events:?}"); |
| 89 | }; |
| 90 | assert_eq!(picked, &model); |
| 91 | let provider_key = provider_id |
| 92 | .clone() |
| 93 | .unwrap_or_else(|| provider.as_str().to_string()); |
| 94 | toggle_model_picker_pin(&mut app, &config, &provider_key, picked); |
| 95 | |
| 96 | let saved = crate::settings::Settings::load_persisted().unwrap(); |
| 97 | assert!( |
| 98 | saved |
| 99 | .pinned_models |
| 100 | .iter() |
| 101 | .any(|pin| pin.provider == provider_key && pin.model == model), |
| 102 | "pin not persisted: {:?}", |
| 103 | saved.pinned_models |
| 104 | ); |
| 105 | let screen = render_picker(&mut app); |
| 106 | assert!( |
| 107 | screen.contains(&format!("Pinned {provider_key}/{model}")), |
| 108 | "receipt must be visible inside the picker:\n{screen}" |
| 109 | ); |
| 110 | assert!( |
| 111 | row_line(&screen, &model).contains("pinned"), |
| 112 | "row must carry its pin:\n{screen}" |
| 113 | ); |
| 114 | assert!( |
| 115 | screen.contains(&format!("Model: {model}")) && !screen.contains(&format!("{model}P")), |
| 116 | "⇧P must not become query text:\n{screen}" |
| 117 | ); |
| 118 | |
| 119 | // The same chord on the same row unpins it, on disk and on screen. |
| 120 | let events = app.view_stack.handle_key(shifted('P')); |
| 121 | assert!(matches!( |
| 122 | events.as_slice(), |
| 123 | [ViewEvent::ModelPickerTogglePin { model: again, .. }] if again == &model |
| 124 | )); |
| 125 | toggle_model_picker_pin(&mut app, &config, &provider_key, &model); |
| 126 | let saved = crate::settings::Settings::load_persisted().unwrap(); |
| 127 | assert!(saved.pinned_models.is_empty(), "{:?}", saved.pinned_models); |
| 128 | let screen = render_picker(&mut app); |
| 129 | assert!( |
| 130 | screen.contains(&format!("Unpinned {provider_key}/{model}")), |
| 131 | "{screen}" |
| 132 | ); |
| 133 | assert!(!row_line(&screen, &model).contains("pinned"), "{screen}"); |
| 134 | } |
| 135 | |
| 136 | #[test] |
| 137 | fn shift_f_after_a_search_adds_the_highlighted_row_to_fleet_and_says_so_in_the_picker() { |
| 138 | let _lock = crate::test_support::lock_test_env(); |
| 139 | let root = tempfile::tempdir().unwrap(); |
| 140 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 141 | let (mut app, config) = app_with_open_picker(root.path()); |
| 142 | let model = app.model.clone(); |
| 143 | type_query(&mut app, &model); |
| 144 | |
| 145 | let events = app.view_stack.handle_key(shifted('F')); |
| 146 | let [ |
| 147 | ViewEvent::ModelPickerToggleFleet { |
| 148 | provider, |
| 149 | provider_id, |
| 150 | model: picked, |
| 151 | }, |
| 152 | ] = events.as_slice() |
| 153 | else { |
| 154 | panic!("⇧F while searching must toggle Fleet for the highlighted row, got {events:?}"); |
| 155 | }; |
| 156 | assert_eq!(picked, &model); |
| 157 | let provider_key = provider_id |
| 158 | .clone() |
| 159 | .unwrap_or_else(|| provider.as_str().to_string()); |
| 160 | toggle_model_picker_fleet(&mut app, &config, &provider_key, picked); |
| 161 | |
| 162 | let fleet = crate::fleet::members::fleet_models(&app.workspace).unwrap(); |
| 163 | assert!( |
| 164 | fleet |
| 165 | .iter() |
| 166 | .any(|member| member.matches(&provider_key, &model)), |
| 167 | "route not in the selected fleet: {fleet:?}" |
| 168 | ); |
| 169 | assert!(app.fleet_roster_stale, "the engine roster must be resynced"); |
| 170 | let screen = render_picker(&mut app); |
| 171 | assert!( |
| 172 | screen.contains(&format!("{provider_key}/{model}")), |
| 173 | "receipt must be visible inside the picker:\n{screen}" |
| 174 | ); |
| 175 | assert!( |
| 176 | row_line(&screen, &model).contains("fleet · "), |
| 177 | "row must carry its Fleet membership:\n{screen}" |
| 178 | ); |
| 179 | |
| 180 | // Toggling again removes the shortlist row it added. |
| 181 | let events = app.view_stack.handle_key(shifted('F')); |
| 182 | assert!(matches!( |
| 183 | events.as_slice(), |
| 184 | [ViewEvent::ModelPickerToggleFleet { model: again, .. }] if again == &model |
| 185 | )); |
| 186 | toggle_model_picker_fleet(&mut app, &config, &provider_key, &model); |
| 187 | let fleet = crate::fleet::members::fleet_models(&app.workspace).unwrap(); |
| 188 | assert!( |
| 189 | !fleet |
| 190 | .iter() |
| 191 | .any(|member| member.matches(&provider_key, &model)), |
| 192 | "{fleet:?}" |
| 193 | ); |
| 194 | let screen = render_picker(&mut app); |
| 195 | assert!(!row_line(&screen, &model).contains("fleet · "), "{screen}"); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn shift_d_while_searching_is_query_text_not_a_provider_auth_detour() { |
| 200 | let _lock = crate::test_support::lock_test_env(); |
| 201 | let root = tempfile::tempdir().unwrap(); |
| 202 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 203 | let (mut app, _config) = app_with_open_picker(root.path()); |
| 204 | let model = app.model.clone(); |
| 205 | type_query(&mut app, &model); |
| 206 | assert!( |
| 207 | !render_picker(&mut app).contains("⇧D"), |
| 208 | "⇧D is not advertised while it would be query text" |
| 209 | ); |
| 210 | |
| 211 | let events = app.view_stack.handle_key(shifted('D')); |
| 212 | assert!( |
| 213 | events.is_empty(), |
| 214 | "⇧D mid-search used to emit a lock explanation / auth hand-off: {events:?}" |
| 215 | ); |
| 216 | assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker)); |
| 217 | assert!(render_picker(&mut app).contains(&format!("Model: {model}D"))); |
| 218 | } |
| 219 | |
| 220 | /// #6523 review: a failed mutation renders in the theme's failure slot, not |
| 221 | /// warning ink, and success in the outcome slot — the typed toast level is |
| 222 | /// carried into the picker's receipt. |
| 223 | #[test] |
| 224 | fn picker_receipt_renders_its_semantic_level_through_the_palette() { |
| 225 | let _lock = crate::test_support::lock_test_env(); |
| 226 | let root = tempfile::tempdir().unwrap(); |
| 227 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 228 | let (mut app, config) = app_with_open_picker(root.path()); |
| 229 | let theme = app.ui_theme; |
| 230 | |
| 231 | let receipt_fg = |app: &mut App, text: &str| { |
| 232 | let view = app.view_stack.pop().unwrap(); |
| 233 | let area = Rect::new(0, 0, 140, 40); |
| 234 | let mut buf = Buffer::empty(area); |
| 235 | view.render(area, &mut buf); |
| 236 | app.view_stack.push_boxed(view); |
| 237 | let first = text.chars().next().unwrap().to_string(); |
| 238 | (0..area.height) |
| 239 | .find_map(|y| { |
| 240 | let line: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect(); |
| 241 | let col = line.find(text)?; |
| 242 | let x = line[..col].chars().count() as u16; |
| 243 | assert_eq!(buf[(x, y)].symbol(), first); |
| 244 | Some(buf[(x, y)].fg) |
| 245 | }) |
| 246 | .unwrap_or_else(|| panic!("receipt {text:?} not rendered")) |
| 247 | }; |
| 248 | |
| 249 | let failure = "reorder refused"; |
| 250 | super::super::handlers::refresh_open_model_picker( |
| 251 | &mut app, |
| 252 | &config, |
| 253 | Some(( |
| 254 | failure.to_string(), |
| 255 | crate::tui::app::StatusToastLevel::Error, |
| 256 | )), |
| 257 | ); |
| 258 | assert_eq!(receipt_fg(&mut app, failure), theme.error_fg); |
| 259 | |
| 260 | let success = "order saved"; |
| 261 | super::super::handlers::refresh_open_model_picker( |
| 262 | &mut app, |
| 263 | &config, |
| 264 | Some(( |
| 265 | success.to_string(), |
| 266 | crate::tui::app::StatusToastLevel::Success, |
| 267 | )), |
| 268 | ); |
| 269 | assert_eq!(receipt_fg(&mut app, success), theme.status_working); |
| 270 | } |
| 271 | |
| 272 | /// #6523 review: while a custom model id is typed there is no highlighted |
| 273 | /// catalog route, ⇧P / ⇧F are query text, so the footer must not offer them. |
| 274 | #[test] |
| 275 | fn pin_and_fleet_hints_hide_when_no_catalog_route_is_highlighted() { |
| 276 | let _lock = crate::test_support::lock_test_env(); |
| 277 | let root = tempfile::tempdir().unwrap(); |
| 278 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 279 | let (mut app, _config) = app_with_open_picker(root.path()); |
| 280 | let model = app.model.clone(); |
| 281 | type_query(&mut app, &model); |
| 282 | let screen = render_picker(&mut app); |
| 283 | assert!( |
| 284 | screen.contains("⇧P") && screen.contains("⇧F"), |
| 285 | "a highlighted route offers its verbs:\n{screen}" |
| 286 | ); |
| 287 | |
| 288 | let mut app_custom = { |
| 289 | let (app, _config) = app_with_open_picker(root.path()); |
| 290 | app |
| 291 | }; |
| 292 | type_query(&mut app_custom, "zz-no-such-model-xyz"); |
| 293 | let screen = render_picker(&mut app_custom); |
| 294 | assert!( |
| 295 | !screen.contains("⇧P") && !screen.contains("⇧F"), |
| 296 | "no highlighted route, no Pin/Fleet hints:\n{screen}" |
| 297 | ); |
| 298 | let events = app_custom.view_stack.handle_key(shifted('P')); |
| 299 | assert!(events.is_empty(), "{events:?}"); |
| 300 | } |
| 301 |