| 1 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; |
| 2 | use ratatui::{ |
| 3 | buffer::Buffer, |
| 4 | layout::{Position, Rect}, |
| 5 | style::{Modifier, Style}, |
| 6 | text::{Line, Span}, |
| 7 | widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}, |
| 8 | }; |
| 9 | use std::borrow::Cow; |
| 10 | use std::cell::{Cell, RefCell}; |
| 11 | use std::fmt; |
| 12 | use unicode_width::UnicodeWidthStr; |
| 13 | |
| 14 | use crate::config::{ApiProvider, ApprovalPolicyControl, Config}; |
| 15 | use crate::features::{FEATURES, Stage}; |
| 16 | use crate::settings::Settings; |
| 17 | use crate::tools::UserInputResponse; |
| 18 | use crate::tools::subagent::{ |
| 19 | FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus, localized_whale_display_names, |
| 20 | }; |
| 21 | use crate::tui::app::App; |
| 22 | use crate::tui::approval::{ElevationOption, ReviewDecision}; |
| 23 | use crate::tui::focus_texture::FocusTextureMode; |
| 24 | use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output}; |
| 25 | use crate::tui::menu_style; |
| 26 | use crate::tui::tideline::{SettingApplySemantics, SettingAuthority, SettingFact, UiSnapshot}; |
| 27 | use crate::tui::widgets::agent_card::AgentLifecycle; |
| 28 | use codewhale_localization::{ |
| 29 | Locale, MessageId, configured_locale_is_partial_pack, normalize_configured_locale, tr, tr_key, |
| 30 | }; |
| 31 | use codewhale_palette as palette; |
| 32 | |
| 33 | pub mod automations; |
| 34 | pub mod extensions; |
| 35 | pub mod fleet_detail; |
| 36 | pub mod fleet_list; |
| 37 | pub mod fleet_roster; |
| 38 | pub mod fleet_setup; |
| 39 | pub mod mode_picker; |
| 40 | pub mod route_save_prompt; |
| 41 | pub mod skills_manager; |
| 42 | pub mod status_picker; |
| 43 | pub mod workflows_manager; |
| 44 | |
| 45 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 46 | pub enum ModalKind { |
| 47 | PetHabitat, |
| 48 | Approval, |
| 49 | Elevation, |
| 50 | UserInput, |
| 51 | CommandPalette, |
| 52 | Help, |
| 53 | SubAgents, |
| 54 | Pager, |
| 55 | LiveTranscript, |
| 56 | SessionPicker, |
| 57 | Config, |
| 58 | ModelPicker, |
| 59 | ProviderPicker, |
| 60 | ModePicker, |
| 61 | FleetRoster, |
| 62 | FleetSetup, |
| 63 | FleetList, |
| 64 | FleetDetail, |
| 65 | HotbarSetup, |
| 66 | SetupWizard, |
| 67 | FilePicker, |
| 68 | StatusPicker, |
| 69 | FeedbackPicker, |
| 70 | ThemePicker, |
| 71 | ContextMenu, |
| 72 | ContextInspector, |
| 73 | SkillsManager, |
| 74 | /// Unified, read-only extensions inventory. Mutations delegate to the |
| 75 | /// existing Hooks / Plugins / Skills / MCP command controllers. |
| 76 | Extensions, |
| 77 | /// Native git worktree manager (list / create / switch / compare). |
| 78 | WorktreeManager, |
| 79 | /// Live workflow **run** dashboard (`/workflows`): active and retained |
| 80 | /// runs from the journal, with host-side cancel. |
| 81 | WorkflowsManager, |
| 82 | /// The scheduled-automation room (`/automation`): every automation the |
| 83 | /// person owns, with pause / resume / run / cancel / delete. |
| 84 | Automations, |
| 85 | /// "Resume this session?" over the launch card. Resuming replaces the |
| 86 | /// whole session context, so it asks first. |
| 87 | LaunchResumeConfirm, |
| 88 | } |
| 89 | |
| 90 | /// Clear and paint a modal popup with an opaque surface. |
| 91 | /// |
| 92 | /// Older modals often called `Clear` only, which left reset-background blank |
| 93 | /// cells that could read as translucent on terminals with a non-default app |
| 94 | /// background. This helper makes the popup area explicit and keeps the small |
| 95 | /// shadow from inheriting stale transcript glyphs. |
| 96 | pub(crate) fn render_modal_surface(area: Rect, popup_area: Rect, buf: &mut Buffer) { |
| 97 | let shadow_x = popup_area.x.saturating_add(1); |
| 98 | let shadow_y = popup_area.y.saturating_add(1); |
| 99 | let shadow_right = area.x.saturating_add(area.width); |
| 100 | let shadow_bottom = area.y.saturating_add(area.height); |
| 101 | let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x)); |
| 102 | let shadow_height = popup_area |
| 103 | .height |
| 104 | .min(shadow_bottom.saturating_sub(shadow_y)); |
| 105 | |
| 106 | if shadow_width > 0 && shadow_height > 0 { |
| 107 | Block::default() |
| 108 | .style(Style::default().bg(palette::SURFACE_ELEVATED)) |
| 109 | .render( |
| 110 | Rect { |
| 111 | x: shadow_x, |
| 112 | y: shadow_y, |
| 113 | width: shadow_width, |
| 114 | height: shadow_height, |
| 115 | }, |
| 116 | buf, |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | Clear.render(popup_area, buf); |
| 121 | Block::default() |
| 122 | .style(Style::default().bg(palette::WHALE_BG)) |
| 123 | .render(popup_area, buf); |
| 124 | } |
| 125 | |
| 126 | /// Paint a full-screen underwater instrument surface and return its body. |
| 127 | /// |
| 128 | /// Secondary rooms use one title hairline and one bottom action rail instead |
| 129 | /// of a centered generic card. A one-cell outer margin is retained when the |
| 130 | /// terminal can afford it; compact panes use every cell. |
| 131 | pub(crate) fn render_underwater_surface( |
| 132 | area: Rect, |
| 133 | buf: &mut Buffer, |
| 134 | title: impl Into<String>, |
| 135 | ) -> Rect { |
| 136 | let margin_x = u16::from(area.width >= 44); |
| 137 | let margin_y = u16::from(area.height >= 24); |
| 138 | let surface = Rect { |
| 139 | x: area.x.saturating_add(margin_x), |
| 140 | y: area.y.saturating_add(margin_y), |
| 141 | width: area.width.saturating_sub(margin_x.saturating_mul(2)), |
| 142 | height: area.height.saturating_sub(margin_y.saturating_mul(2)), |
| 143 | }; |
| 144 | Clear.render(area, buf); |
| 145 | Block::default() |
| 146 | .style(Style::default().bg(palette::WHALE_BG)) |
| 147 | .render(area, buf); |
| 148 | // Ratatui clips long block titles at the border edge without signalling |
| 149 | // that anything is missing. Reserve the corner cells and semantic-ellipsis |
| 150 | // the title so compact terminals still read as intentional instruments. |
| 151 | let title_width = usize::from(surface.width.saturating_sub(4)); |
| 152 | let title = crate::tui::ui_text::semantic_truncate(&title.into(), title_width); |
| 153 | let block = Block::default() |
| 154 | .title(Line::from(Span::styled( |
| 155 | format!(" {title} "), |
| 156 | Style::default() |
| 157 | .fg(palette::WHALE_ACTION) |
| 158 | .add_modifier(Modifier::BOLD), |
| 159 | ))) |
| 160 | .borders(Borders::TOP | Borders::BOTTOM) |
| 161 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 162 | .style(Style::default().bg(palette::WHALE_BG)) |
| 163 | .padding(Padding::new(1, 1, u16::from(area.height >= 24), 0)); |
| 164 | let inner = block.inner(surface); |
| 165 | block.render(surface, buf); |
| 166 | inner |
| 167 | } |
| 168 | |
| 169 | /// Paint a scrollbar on the exact right edge of the panel it controls and |
| 170 | /// return the content rect with that rail reserved. Nothing is drawn when all |
| 171 | /// rows fit, so narrow surfaces do not spend a column on a fictional control. |
| 172 | pub(crate) fn render_panel_scroll_rail( |
| 173 | area: Rect, |
| 174 | buf: &mut Buffer, |
| 175 | total_rows: usize, |
| 176 | offset: usize, |
| 177 | visible_rows: usize, |
| 178 | focused: bool, |
| 179 | ) -> Rect { |
| 180 | if area.width < 2 || area.height == 0 || total_rows <= visible_rows.max(1) { |
| 181 | return area; |
| 182 | } |
| 183 | let rail_x = area.right().saturating_sub(1); |
| 184 | let rail_height = usize::from(area.height); |
| 185 | let visible = visible_rows.max(1).min(total_rows); |
| 186 | let thumb_height = ((rail_height * visible).div_ceil(total_rows)).clamp(1, rail_height); |
| 187 | let max_offset = total_rows.saturating_sub(visible); |
| 188 | let travel = rail_height.saturating_sub(thumb_height); |
| 189 | let thumb_top = travel |
| 190 | .saturating_mul(offset.min(max_offset)) |
| 191 | .checked_div(max_offset) |
| 192 | .unwrap_or(0); |
| 193 | let thumb_color = if focused { |
| 194 | palette::TEXT_MUTED |
| 195 | } else { |
| 196 | palette::TEXT_DIM |
| 197 | }; |
| 198 | for local_y in 0..area.height { |
| 199 | let y = area.y.saturating_add(local_y); |
| 200 | let local = usize::from(local_y); |
| 201 | let is_thumb = local >= thumb_top && local < thumb_top + thumb_height; |
| 202 | buf[(rail_x, y)] |
| 203 | .set_symbol(if is_thumb { "█" } else { "│" }) |
| 204 | .set_style(Style::default().fg(if is_thumb { |
| 205 | thumb_color |
| 206 | } else { |
| 207 | palette::BORDER_COLOR |
| 208 | })); |
| 209 | } |
| 210 | Rect { |
| 211 | width: area.width.saturating_sub(1), |
| 212 | ..area |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | fn render_modal_backdrop(area: Rect, buf: &mut Buffer) { |
| 217 | for y in area.top()..area.bottom() { |
| 218 | for x in area.left()..area.right() { |
| 219 | buf[(x, y)] |
| 220 | .set_symbol(" ") |
| 221 | .set_style(Style::default().bg(palette::WHALE_BG)); |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /// Compute a centered, responsive popup rect for a modal. |
| 227 | /// |
| 228 | /// The size starts from `preferred_*`, but is clamped so it never exceeds the |
| 229 | /// frame (leaving a small breathing-room margin when there is space) and never |
| 230 | /// drops below `min_*` unless the frame itself is smaller. Centering the result |
| 231 | /// inside `area` replaces the repeated, error-prone |
| 232 | /// `N.min(area.width.saturating_sub(..))` arithmetic scattered across modals so |
| 233 | /// every overlay sizes itself the same way at 80x24, 100x30, 120x32, 160x40, |
| 234 | /// and beyond. See #3732. |
| 235 | pub(crate) fn centered_modal_area( |
| 236 | area: Rect, |
| 237 | preferred_width: u16, |
| 238 | preferred_height: u16, |
| 239 | min_width: u16, |
| 240 | min_height: u16, |
| 241 | ) -> Rect { |
| 242 | // Keep a 2-cell margin on each axis when the frame can spare it so the |
| 243 | // backdrop stays visible around the card; otherwise fill the frame. |
| 244 | let avail_width = area.width.saturating_sub(2).max(1); |
| 245 | let avail_height = area.height.saturating_sub(2).max(1); |
| 246 | let width = preferred_width.clamp(min_width.min(avail_width), avail_width); |
| 247 | let height = preferred_height.clamp(min_height.min(avail_height), avail_height); |
| 248 | Rect { |
| 249 | x: area.x + area.width.saturating_sub(width) / 2, |
| 250 | y: area.y + area.height.saturating_sub(height) / 2, |
| 251 | width, |
| 252 | height, |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | /// A single key/label hint shown in a modal's action footer. |
| 257 | /// |
| 258 | /// Footers built from `ActionHint`s are laid out by [`action_footer_lines`], |
| 259 | /// which wraps to additional rows instead of letting an action run off the |
| 260 | /// right edge of the modal — the core overflow bug behind #3732. Use this for |
| 261 | /// action/navigation hints; truncate only identifiers/paths/hashes elsewhere. |
| 262 | pub(crate) struct ActionHint { |
| 263 | key: Cow<'static, str>, |
| 264 | label: Cow<'static, str>, |
| 265 | } |
| 266 | |
| 267 | impl ActionHint { |
| 268 | pub(crate) fn new( |
| 269 | key: impl Into<Cow<'static, str>>, |
| 270 | label: impl Into<Cow<'static, str>>, |
| 271 | ) -> Self { |
| 272 | Self { |
| 273 | key: key.into(), |
| 274 | label: label.into(), |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | /// Display columns this hint occupies: ` key ` (key padded by a space on |
| 279 | /// each side) followed by the label. |
| 280 | fn width(&self) -> usize { |
| 281 | UnicodeWidthStr::width(self.key.as_ref()) + 2 + UnicodeWidthStr::width(self.label.as_ref()) |
| 282 | } |
| 283 | |
| 284 | fn spans(&self) -> [Span<'static>; 2] { |
| 285 | [ |
| 286 | Span::styled( |
| 287 | format!(" {} ", self.key), |
| 288 | Style::default() |
| 289 | .fg(palette::WHALE_ACTION) |
| 290 | .add_modifier(Modifier::BOLD), |
| 291 | ), |
| 292 | Span::styled( |
| 293 | self.label.clone().into_owned(), |
| 294 | Style::default().fg(palette::TEXT_MUTED), |
| 295 | ), |
| 296 | ] |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /// Lay out action hints into one or more lines that each fit within `width`. |
| 301 | /// |
| 302 | /// Hints are packed greedily; when the next hint would overflow the current row |
| 303 | /// the layout starts a new row rather than truncating. No action is ever |
| 304 | /// dropped or clipped (a single hint wider than `width` is emitted alone, which |
| 305 | /// only happens at degenerate widths below the modal minimums). This is the |
| 306 | /// shared replacement for the single-line `title_bottom` footers that silently |
| 307 | /// pushed actions off-screen. |
| 308 | pub(crate) fn action_footer_lines(hints: &[ActionHint], width: u16) -> Vec<Line<'static>> { |
| 309 | let width = usize::from(width); |
| 310 | if hints.is_empty() || width == 0 { |
| 311 | return Vec::new(); |
| 312 | } |
| 313 | const GAP: usize = 1; |
| 314 | let mut lines: Vec<Line<'static>> = Vec::new(); |
| 315 | let mut current: Vec<Span<'static>> = Vec::new(); |
| 316 | let mut current_width = 0usize; |
| 317 | for hint in hints { |
| 318 | let hint_width = hint.width(); |
| 319 | let needed = if current.is_empty() { |
| 320 | hint_width |
| 321 | } else { |
| 322 | current_width + GAP + hint_width |
| 323 | }; |
| 324 | if !current.is_empty() && needed > width { |
| 325 | lines.push(Line::from(std::mem::take(&mut current))); |
| 326 | current_width = 0; |
| 327 | } |
| 328 | if !current.is_empty() { |
| 329 | current.push(Span::raw(" ".repeat(GAP))); |
| 330 | current_width += GAP; |
| 331 | } |
| 332 | current.extend(hint.spans()); |
| 333 | current_width += hint_width; |
| 334 | } |
| 335 | if !current.is_empty() { |
| 336 | lines.push(Line::from(current)); |
| 337 | } |
| 338 | lines |
| 339 | } |
| 340 | |
| 341 | /// Reserve `lines` worth of rows at the bottom of `inner`, paint them, and |
| 342 | /// return the content area that remains above. Shared by the action-hint and |
| 343 | /// free-text modal footers. |
| 344 | fn place_footer_lines( |
| 345 | inner: Rect, |
| 346 | buf: &mut Buffer, |
| 347 | lines: Vec<Line<'static>>, |
| 348 | quiet_gutter: bool, |
| 349 | ) -> Rect { |
| 350 | if lines.is_empty() || inner.height == 0 { |
| 351 | return inner; |
| 352 | } |
| 353 | let footer_height = u16::try_from(lines.len()) |
| 354 | .unwrap_or(u16::MAX) |
| 355 | .min(inner.height); |
| 356 | // Opted-in overlays keep one quiet row between scrollable body copy and |
| 357 | // the action rail. Degenerate heights keep every row for content. |
| 358 | let gutter_height = u16::from(quiet_gutter && inner.height >= footer_height.saturating_add(4)); |
| 359 | let footer_area = Rect { |
| 360 | x: inner.x, |
| 361 | y: inner.y + inner.height - footer_height, |
| 362 | width: inner.width, |
| 363 | height: footer_height, |
| 364 | }; |
| 365 | Paragraph::new(lines).render(footer_area, buf); |
| 366 | Rect { |
| 367 | x: inner.x, |
| 368 | y: inner.y, |
| 369 | width: inner.width, |
| 370 | height: inner |
| 371 | .height |
| 372 | .saturating_sub(footer_height.saturating_add(gutter_height)), |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | /// Render a wrapping action footer anchored to the bottom of `inner` and |
| 377 | /// return the content area that remains above it. |
| 378 | /// |
| 379 | /// Modals call this after painting their block so the footer reserves exactly |
| 380 | /// as many rows as it needs (bounded by the available height) and the body |
| 381 | /// fills the rest. Centralizing it keeps every modal's action row visible and |
| 382 | /// reachable at narrow widths. |
| 383 | pub(crate) fn render_modal_footer(inner: Rect, buf: &mut Buffer, hints: &[ActionHint]) -> Rect { |
| 384 | let lines = action_footer_lines(hints, inner.width); |
| 385 | place_footer_lines(inner, buf, lines, false) |
| 386 | } |
| 387 | |
| 388 | /// Render a modal action footer with one quiet body-to-footer row when the |
| 389 | /// caller's responsive layout has explicitly budgeted for it. |
| 390 | pub(crate) fn render_modal_footer_with_gutter( |
| 391 | inner: Rect, |
| 392 | buf: &mut Buffer, |
| 393 | hints: &[ActionHint], |
| 394 | ) -> Rect { |
| 395 | let lines = action_footer_lines(hints, inner.width); |
| 396 | place_footer_lines(inner, buf, lines, true) |
| 397 | } |
| 398 | |
| 399 | /// Word-wrap a free-form footer string into styled lines that each fit `width`. |
| 400 | /// |
| 401 | /// For footers that are pre-composed prose/sentences (e.g. localized config |
| 402 | /// hints) rather than discrete key/label hints. Wrapping on whitespace keeps |
| 403 | /// every word visible instead of clipping the tail at the modal edge. |
| 404 | pub(crate) fn wrapped_footer_lines(text: &str, width: u16, style: Style) -> Vec<Line<'static>> { |
| 405 | let width = usize::from(width); |
| 406 | if text.trim().is_empty() || width == 0 { |
| 407 | return Vec::new(); |
| 408 | } |
| 409 | let mut lines: Vec<Line<'static>> = Vec::new(); |
| 410 | let mut current = String::new(); |
| 411 | let mut current_width = 0usize; |
| 412 | for word in text.split_whitespace() { |
| 413 | let word_width = UnicodeWidthStr::width(word); |
| 414 | let needed = if current.is_empty() { |
| 415 | word_width |
| 416 | } else { |
| 417 | current_width + 1 + word_width |
| 418 | }; |
| 419 | if !current.is_empty() && needed > width { |
| 420 | lines.push(Line::from(Span::styled( |
| 421 | std::mem::take(&mut current), |
| 422 | style, |
| 423 | ))); |
| 424 | current_width = 0; |
| 425 | } |
| 426 | if !current.is_empty() { |
| 427 | current.push(' '); |
| 428 | current_width += 1; |
| 429 | } |
| 430 | current.push_str(word); |
| 431 | current_width += word_width; |
| 432 | } |
| 433 | if !current.is_empty() { |
| 434 | lines.push(Line::from(Span::styled(current, style))); |
| 435 | } |
| 436 | lines |
| 437 | } |
| 438 | |
| 439 | /// Render a wrapping free-text footer anchored to the bottom of `inner` and |
| 440 | /// return the content area above it. The prose counterpart to |
| 441 | /// [`render_modal_footer`]. |
| 442 | pub(crate) fn render_modal_text_footer( |
| 443 | inner: Rect, |
| 444 | buf: &mut Buffer, |
| 445 | text: &str, |
| 446 | style: Style, |
| 447 | ) -> Rect { |
| 448 | let lines = wrapped_footer_lines(text, inner.width, style); |
| 449 | // Free-text status footers are already separated semantically from their |
| 450 | // table body and can carry the last visible receipt themselves. Do not |
| 451 | // spend another row here; action-rail layouts can opt into that gutter. |
| 452 | place_footer_lines(inner, buf, lines, false) |
| 453 | } |
| 454 | |
| 455 | /// Shared list/detail geometry for modal managers and pickers. |
| 456 | /// |
| 457 | /// Wide modals get a stable left list and a right detail pane. Narrow modals |
| 458 | /// stack the list over the detail so neither side becomes unreadably thin. |
| 459 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 460 | pub(crate) struct ListDetailLayout { |
| 461 | pub(crate) list: Rect, |
| 462 | pub(crate) detail: Rect, |
| 463 | pub(crate) stacked: bool, |
| 464 | } |
| 465 | |
| 466 | impl ListDetailLayout { |
| 467 | #[must_use] |
| 468 | pub(crate) fn split(area: Rect, min_detail_width: u16) -> Self { |
| 469 | if area.width == 0 || area.height == 0 { |
| 470 | return Self { |
| 471 | list: area, |
| 472 | detail: area, |
| 473 | stacked: true, |
| 474 | }; |
| 475 | } |
| 476 | |
| 477 | let gap = 1; |
| 478 | let min_list_width = 30.min(area.width); |
| 479 | let can_split = area.width >= 96 |
| 480 | && area |
| 481 | .width |
| 482 | .saturating_sub(gap) |
| 483 | .saturating_sub(min_list_width) |
| 484 | >= min_detail_width; |
| 485 | if can_split { |
| 486 | let max_list_width = area.width.saturating_sub(gap + min_detail_width); |
| 487 | let preferred = area.width.saturating_mul(42) / 100; |
| 488 | let list_width = preferred.clamp(min_list_width, max_list_width.min(52)); |
| 489 | let detail_width = area.width.saturating_sub(list_width + gap); |
| 490 | return Self { |
| 491 | list: Rect { |
| 492 | x: area.x, |
| 493 | y: area.y, |
| 494 | width: list_width, |
| 495 | height: area.height, |
| 496 | }, |
| 497 | detail: Rect { |
| 498 | x: area.x + list_width + gap, |
| 499 | y: area.y, |
| 500 | width: detail_width, |
| 501 | height: area.height, |
| 502 | }, |
| 503 | stacked: false, |
| 504 | }; |
| 505 | } |
| 506 | |
| 507 | let gap = if area.height >= 8 { 1 } else { 0 }; |
| 508 | let min_detail_height = 4.min(area.height); |
| 509 | let max_list_height = area.height.saturating_sub(gap + min_detail_height); |
| 510 | let preferred = area.height.saturating_mul(3) / 5; |
| 511 | let list_height = preferred.clamp(1, max_list_height.max(1)); |
| 512 | let detail_height = area.height.saturating_sub(list_height + gap); |
| 513 | Self { |
| 514 | list: Rect { |
| 515 | x: area.x, |
| 516 | y: area.y, |
| 517 | width: area.width, |
| 518 | height: list_height, |
| 519 | }, |
| 520 | detail: Rect { |
| 521 | x: area.x, |
| 522 | y: area.y + list_height + gap, |
| 523 | width: area.width, |
| 524 | height: detail_height, |
| 525 | }, |
| 526 | stacked: true, |
| 527 | } |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | /// Plain empty-state copy for modal list/detail bodies. |
| 532 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 533 | pub(crate) struct EmptyState { |
| 534 | title: Cow<'static, str>, |
| 535 | body: Cow<'static, str>, |
| 536 | primary_action: Option<(Cow<'static, str>, Cow<'static, str>)>, |
| 537 | secondary_action: Option<(Cow<'static, str>, Cow<'static, str>)>, |
| 538 | } |
| 539 | |
| 540 | impl EmptyState { |
| 541 | pub(crate) fn new( |
| 542 | title: impl Into<Cow<'static, str>>, |
| 543 | body: impl Into<Cow<'static, str>>, |
| 544 | ) -> Self { |
| 545 | Self { |
| 546 | title: title.into(), |
| 547 | body: body.into(), |
| 548 | primary_action: None, |
| 549 | secondary_action: None, |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | #[must_use] |
| 554 | pub(crate) fn primary_action( |
| 555 | mut self, |
| 556 | key: impl Into<Cow<'static, str>>, |
| 557 | label: impl Into<Cow<'static, str>>, |
| 558 | ) -> Self { |
| 559 | self.primary_action = Some((key.into(), label.into())); |
| 560 | self |
| 561 | } |
| 562 | |
| 563 | #[must_use] |
| 564 | pub(crate) fn secondary_action( |
| 565 | mut self, |
| 566 | key: impl Into<Cow<'static, str>>, |
| 567 | label: impl Into<Cow<'static, str>>, |
| 568 | ) -> Self { |
| 569 | self.secondary_action = Some((key.into(), label.into())); |
| 570 | self |
| 571 | } |
| 572 | |
| 573 | pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) { |
| 574 | let mut lines = vec![ |
| 575 | Line::from(Span::styled( |
| 576 | self.title.clone().into_owned(), |
| 577 | Style::default() |
| 578 | .fg(palette::TEXT_PRIMARY) |
| 579 | .add_modifier(Modifier::BOLD), |
| 580 | )), |
| 581 | Line::from(""), |
| 582 | Line::from(Span::styled( |
| 583 | self.body.clone().into_owned(), |
| 584 | Style::default().fg(palette::TEXT_MUTED), |
| 585 | )), |
| 586 | ]; |
| 587 | if self.primary_action.is_some() || self.secondary_action.is_some() { |
| 588 | lines.push(Line::from("")); |
| 589 | } |
| 590 | for (key, label) in [self.primary_action.as_ref(), self.secondary_action.as_ref()] |
| 591 | .into_iter() |
| 592 | .flatten() |
| 593 | { |
| 594 | let hint = ActionHint::new(key.clone(), label.clone()); |
| 595 | lines.push(Line::from(hint.spans().to_vec())); |
| 596 | } |
| 597 | Paragraph::new(lines) |
| 598 | .style(Style::default().fg(palette::TEXT_PRIMARY)) |
| 599 | .wrap(Wrap { trim: true }) |
| 600 | .render(area, buf); |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | #[derive(Debug, Clone)] |
| 605 | pub enum CommandPaletteAction { |
| 606 | ExecuteCommand { command: String }, |
| 607 | InsertText { text: String }, |
| 608 | OpenTextPager { title: String, content: String }, |
| 609 | } |
| 610 | |
| 611 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 612 | pub enum ContextMenuAction { |
| 613 | CopySelection, |
| 614 | OpenSelection, |
| 615 | ClearSelection, |
| 616 | CopyCell { |
| 617 | cell_index: usize, |
| 618 | }, |
| 619 | OpenDetails { |
| 620 | cell_index: usize, |
| 621 | }, |
| 622 | Paste, |
| 623 | OpenCommandPalette, |
| 624 | OpenContextInspector, |
| 625 | OpenHelp, |
| 626 | /// Open the selected file:line in the user's editor. |
| 627 | OpenFileAtLine { |
| 628 | cell_index: usize, |
| 629 | }, |
| 630 | /// Hide a transcript cell. Adds the cell's index to `collapsed_cells`. |
| 631 | HideCell { |
| 632 | cell_index: usize, |
| 633 | }, |
| 634 | /// Show a previously hidden cell (when right-clicking near it). |
| 635 | ShowCell { |
| 636 | cell_index: usize, |
| 637 | }, |
| 638 | /// Show all currently hidden cells. |
| 639 | ShowAllHidden, |
| 640 | /// Execute a slash command associated with a contextual UI row. |
| 641 | ExecuteCommand { |
| 642 | command: String, |
| 643 | }, |
| 644 | /// Copy a pre-resolved text payload (e.g. a sidebar row's full text) |
| 645 | /// to the clipboard. |
| 646 | CopyText { |
| 647 | text: String, |
| 648 | }, |
| 649 | /// Pin/unpin the host terminal window (normal window ↔ always-on-top |
| 650 | /// mini window). Windows only; no-op elsewhere. |
| 651 | ToggleWindowPin, |
| 652 | } |
| 653 | |
| 654 | #[derive(Debug, Clone)] |
| 655 | pub enum ViewEvent { |
| 656 | CommandPaletteSelected { |
| 657 | action: CommandPaletteAction, |
| 658 | }, |
| 659 | OpenTextPager { |
| 660 | title: String, |
| 661 | content: String, |
| 662 | }, |
| 663 | ApprovalDecision { |
| 664 | tool_id: String, |
| 665 | tool_name: String, |
| 666 | decision: ReviewDecision, |
| 667 | timed_out: bool, |
| 668 | /// Exact-argument fingerprint, used to scope *denials* (#1617). |
| 669 | approval_key: String, |
| 670 | /// Lossy / arity-aware fingerprint, used to scope *approvals*. |
| 671 | approval_grouping_key: String, |
| 672 | /// Permission rules to append when the decision approves. |
| 673 | persistent_rules: Vec<codewhale_config::ToolAskRule>, |
| 674 | }, |
| 675 | ElevationDecision { |
| 676 | tool_id: String, |
| 677 | tool_name: String, |
| 678 | option: ElevationOption, |
| 679 | }, |
| 680 | UserInputSubmitted { |
| 681 | tool_id: String, |
| 682 | response: UserInputResponse, |
| 683 | }, |
| 684 | UserInputCancelled { |
| 685 | tool_id: String, |
| 686 | }, |
| 687 | ConfigUpdated { |
| 688 | key: String, |
| 689 | value: String, |
| 690 | persist: bool, |
| 691 | }, |
| 692 | /// The canonical `/theme` picker's selection. Preview, rollback, and |
| 693 | /// persist travel in one event so the theme never changes by half. |
| 694 | ThemeSelectionUpdated { |
| 695 | theme: String, |
| 696 | persist: bool, |
| 697 | }, |
| 698 | SubAgentsRefresh, |
| 699 | SidebarAgentCancel { |
| 700 | agent_id: String, |
| 701 | }, |
| 702 | /// An agent row activation (Work strip, sidebar dossier, `/agents`) or |
| 703 | /// Alt+V from Agent Details, Enter/click on any agent row, and Enter in the |
| 704 | /// `/agents` register all request the agent's transcript — since v0.9.7's |
| 705 | /// "one agent, one destination" inversion that is the in-place focus. |
| 706 | OpenAgentTranscript { |
| 707 | agent_id: String, |
| 708 | }, |
| 709 | /// Agent Details was popped with Esc/q/Left. The Work surface uses this |
| 710 | /// to release only its detail-open owner while retaining selection. |
| 711 | AgentDetailsClosed { |
| 712 | agent_id: String, |
| 713 | }, |
| 714 | /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a |
| 715 | /// candidate. The handler should insert `@<path>` at the composer's cursor |
| 716 | /// position. |
| 717 | FilePickerSelected { |
| 718 | path: String, |
| 719 | }, |
| 720 | SessionSelected { |
| 721 | session_id: String, |
| 722 | }, |
| 723 | SessionRenamed { |
| 724 | metadata: Box<crate::session_manager::SessionMetadata>, |
| 725 | }, |
| 726 | /// A session's archive flag was flipped (#2934 / #4397). |
| 727 | /// |
| 728 | /// Distinct from `SessionRenamed` so the receipt can say what actually |
| 729 | /// happened; reusing rename would report "Renamed session …" for an |
| 730 | /// archive, which is exactly the kind of small lie that erodes trust in |
| 731 | /// every other receipt. |
| 732 | SessionArchived { |
| 733 | metadata: Box<crate::session_manager::SessionMetadata>, |
| 734 | }, |
| 735 | SessionDeleted { |
| 736 | session_id: String, |
| 737 | title: String, |
| 738 | }, |
| 739 | /// Emitted by the `/model` picker on Enter or Shift+D. Carries both the |
| 740 | /// chosen model id and reasoning effort tier so the UI handler can update |
| 741 | /// App state and forward `Op::SetModel` to the running engine. |
| 742 | /// `save_as_startup_default` is true only for the explicit Shift+D action; |
| 743 | /// ordinary Enter remains a session-local route change. `previous_*` |
| 744 | /// fields let the handler skip work when nothing changed and craft a clear |
| 745 | /// status message. |
| 746 | ModelPickerApplied { |
| 747 | model: String, |
| 748 | provider: Option<crate::config::ApiProvider>, |
| 749 | /// Exact named custom route key when the selected provider enum is |
| 750 | /// `Custom`; built-in routes leave this unset. |
| 751 | provider_id: Option<String>, |
| 752 | effort: crate::reasoning_preference::ReasoningEffort, |
| 753 | previous_model: String, |
| 754 | previous_effort: crate::reasoning_preference::ReasoningEffort, |
| 755 | save_as_startup_default: bool, |
| 756 | }, |
| 757 | /// Emitted by the `/model` picker on Esc so the next open can restore |
| 758 | /// the browsing context — view mode and highlighted row (#4109 / #4115). |
| 759 | ModelPickerDismissed { |
| 760 | /// True when the dismissed view browses beyond configured providers |
| 761 | /// (Catalog / Recent / Coding / Cheap / Long context). |
| 762 | catalog_view: bool, |
| 763 | /// Named view key (`configured`, `catalog`, `recent`, `coding`, |
| 764 | /// `cheap`, `long_context`) for reopen restore (#4115). |
| 765 | view: String, |
| 766 | selected_row_id: Option<String>, |
| 767 | }, |
| 768 | /// Enter on a locked (unauthenticated) model: explain why selection is |
| 769 | /// blocked and open the provider auth/setup path when possible. |
| 770 | /// Re-resolve readiness + rebuild catalog rows for the open model picker. |
| 771 | ModelPickerRefresh, |
| 772 | ModelPickerTogglePin { |
| 773 | provider: crate::config::ApiProvider, |
| 774 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 775 | provider_id: Option<String>, |
| 776 | model: String, |
| 777 | }, |
| 778 | ModelPickerMovePin { |
| 779 | provider: crate::config::ApiProvider, |
| 780 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 781 | provider_id: Option<String>, |
| 782 | model: String, |
| 783 | delta: isize, |
| 784 | }, |
| 785 | /// `⇧F` in the picker: add the row's exact route to the team (the |
| 786 | /// selected saved team), or remove it when it is already there (design §10 F1). |
| 787 | ModelPickerToggleFleet { |
| 788 | provider: crate::config::ApiProvider, |
| 789 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 790 | provider_id: Option<String>, |
| 791 | model: String, |
| 792 | }, |
| 793 | /// Enter on a Fleet editor row: open the standard `/model` picker for |
| 794 | /// that row (the editor stays underneath) instead of the editor's own |
| 795 | /// inline route list. |
| 796 | FleetProfileRoutePickRequested { |
| 797 | editor_id: uuid::Uuid, |
| 798 | }, |
| 799 | FleetProfileRoutePicked { |
| 800 | editor_id: uuid::Uuid, |
| 801 | provider: crate::config::ApiProvider, |
| 802 | provider_id: Option<String>, |
| 803 | model: String, |
| 804 | reasoning: Option<crate::reasoning_preference::ReasoningEffort>, |
| 805 | }, |
| 806 | FleetProfileRouteCommitRequested { |
| 807 | editor_id: uuid::Uuid, |
| 808 | }, |
| 809 | FleetAssignmentPickerDismissed { |
| 810 | editor_id: uuid::Uuid, |
| 811 | }, |
| 812 | FleetRosterOpenCoordinatorRequested, |
| 813 | FleetDetailRoutePickRequested { |
| 814 | target: crate::tui::views::fleet_detail::FleetRouteTarget, |
| 815 | editor_id: uuid::Uuid, |
| 816 | }, |
| 817 | /// The `/model` picker, opened for a Fleet editor row, resolved a route. |
| 818 | /// Carries the row's absolute route — never a diff against the session — |
| 819 | /// and the host applies and saves it on the editor still on the stack. |
| 820 | /// The picker's `auto` row means "inherit the session route". |
| 821 | FleetRoutePicked { |
| 822 | target: crate::tui::views::fleet_detail::FleetRouteTarget, |
| 823 | editor_id: uuid::Uuid, |
| 824 | provider: crate::config::ApiProvider, |
| 825 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 826 | provider_id: Option<String>, |
| 827 | model: String, |
| 828 | reasoning: Option<crate::reasoning_preference::ReasoningEffort>, |
| 829 | }, |
| 830 | ModelPickerNeedsAuth { |
| 831 | provider: crate::config::ApiProvider, |
| 832 | model: String, |
| 833 | reason: String, |
| 834 | }, |
| 835 | /// Transient status toast from a modal (e.g. locked-model explanation). |
| 836 | StatusMessage { |
| 837 | message: String, |
| 838 | }, |
| 839 | /// The Tideline topbar's route segment requested the normal `/provider` |
| 840 | /// surface. It carries no catalog, readiness, or selected-route payload: |
| 841 | /// those facts remain owned by the provider picker and its apply path. |
| 842 | TopbarRoutePickerRequested, |
| 843 | /// The info line's model field requested the normal `/model` surface. |
| 844 | /// Same rule as the route segment: an entry point carrying no catalog. |
| 845 | TopbarModelPickerRequested, |
| 846 | /// Emitted by the `/provider` picker on Esc so the next open can restore |
| 847 | /// the browsing context — view mode and highlighted row. |
| 848 | ProviderPickerDismissed { |
| 849 | catalog_view: bool, |
| 850 | selected_provider_id: Option<String>, |
| 851 | }, |
| 852 | /// Emitted by the `/provider` picker when the user selects a provider |
| 853 | /// that already has credentials — the handler should perform the same |
| 854 | /// switch as `AppAction::SwitchProvider`. |
| 855 | ProviderPickerApplied { |
| 856 | provider: crate::config::ApiProvider, |
| 857 | provider_id: Option<String>, |
| 858 | }, |
| 859 | /// Emitted by the `/provider` picker after the user types an API key |
| 860 | /// inline for a provider that lacked one. The handler validates the key |
| 861 | /// live; on success it reopens the guided flow at the model-pick stage |
| 862 | /// without persisting yet (#3875). |
| 863 | ProviderPickerApiKeySubmitted { |
| 864 | provider: crate::config::ApiProvider, |
| 865 | provider_id: Option<String>, |
| 866 | api_key: String, |
| 867 | /// Endpoint chosen in the wizard's billing-route stage, applied to the |
| 868 | /// verification config only — nothing is written until confirm (#4526). |
| 869 | base_url: Option<String>, |
| 870 | }, |
| 871 | /// Emitted by the `/provider` guided setup confirm stage after the user |
| 872 | /// accepted provider + model. The handler persists the key (and model) |
| 873 | /// via the comment-preserving config path, then performs the switch. |
| 874 | ProviderPickerSetupConfirmed { |
| 875 | provider: crate::config::ApiProvider, |
| 876 | provider_id: Option<String>, |
| 877 | api_key: String, |
| 878 | model: String, |
| 879 | context_window: Option<u32>, |
| 880 | /// Endpoint the key was verified against, persisted to the provider's |
| 881 | /// own `base_url` before the key is saved (#4526). |
| 882 | base_url: Option<String>, |
| 883 | }, |
| 884 | /// Emitted by the `/provider` picker after the custom provider form is |
| 885 | /// completed. The handler persists a named OpenAI-compatible provider |
| 886 | /// table and switches to it without storing raw secrets. |
| 887 | ProviderPickerCustomProviderSubmitted { |
| 888 | provider_id: String, |
| 889 | base_url: String, |
| 890 | model: Option<String>, |
| 891 | api_key_env: Option<String>, |
| 892 | }, |
| 893 | /// Emitted by provider/setup UI when xAI device-code OAuth is requested. |
| 894 | ProviderPickerXaiOAuthRequested, |
| 895 | /// Emitted by provider/setup UI when native ChatGPT PKCE sign-in is requested. |
| 896 | ProviderPickerChatgptOAuthRequested, |
| 897 | /// Emitted only after the picker showed owner, exact path, and the full |
| 898 | /// read-only side-effect contract and the user explicitly confirmed it. |
| 899 | ProviderPickerExternalConsentConfirmed { |
| 900 | provider: crate::config::ApiProvider, |
| 901 | consent_provider: codewhale_config::ProviderKind, |
| 902 | source: codewhale_config::ExternalCredentialSource, |
| 903 | path: std::path::PathBuf, |
| 904 | }, |
| 905 | /// One-step revocation from a provider row that currently has consent. |
| 906 | ProviderPickerExternalConsentRevoked { |
| 907 | provider: crate::config::ApiProvider, |
| 908 | }, |
| 909 | /// Emitted by the `/provider` picker (the `M` action) to jump straight to |
| 910 | /// the `/model` picker pre-filtered to the highlighted provider (#3083). |
| 911 | ProviderPickerOpenModels { |
| 912 | provider: crate::config::ApiProvider, |
| 913 | provider_id: Option<String>, |
| 914 | }, |
| 915 | /// Emitted by `/provider` `T`: probe `/models` and refresh readiness |
| 916 | /// without treating a 2xx as model-ready (#5350). |
| 917 | ProviderPickerTestConnection { |
| 918 | provider: crate::config::ApiProvider, |
| 919 | provider_id: Option<String>, |
| 920 | /// Restore Catalog vs Configured after the probe. Must not force |
| 921 | /// the all-providers catalog if the user was on configured-only. |
| 922 | catalog_view: bool, |
| 923 | }, |
| 924 | /// Emitted by the `/mode` picker when the user chooses a mode. |
| 925 | ModeSelected { |
| 926 | mode: codewhale_config::AppMode, |
| 927 | }, |
| 928 | /// Emitted by the `/statusline` picker every time the user toggles an |
| 929 | /// item (live preview) and once more on Enter (final). The handler |
| 930 | /// updates `app.status_items` immediately and persists on `final_save` |
| 931 | /// so the footer animates without a write per keystroke. |
| 932 | StatusItemsUpdated { |
| 933 | items: Vec<crate::config::StatusItem>, |
| 934 | final_save: bool, |
| 935 | }, |
| 936 | /// Emitted by the `/hotbar` setup wizard when the user saves the draft |
| 937 | /// bindings. The host updates live config state; disk persistence is |
| 938 | /// handled by the follow-up persistence slice. |
| 939 | HotbarSetupSaved { |
| 940 | bindings: Vec<codewhale_config::HotbarBindingToml>, |
| 941 | }, |
| 942 | /// Emitted by the constitution-first setup shell when a staged setup-state |
| 943 | /// record should be committed atomically to `$CODEWHALE_HOME/setup_state.json`. |
| 944 | SetupStateCommitRequested { |
| 945 | state: codewhale_config::SetupState, |
| 946 | message: String, |
| 947 | }, |
| 948 | /// Emitted by the constitution-first setup shell when accepting a guided |
| 949 | /// structured user-global constitution. The host commits the constitution |
| 950 | /// and matching setup-state record together. |
| 951 | SetupConstitutionCommitRequested { |
| 952 | constitution: codewhale_config::UserConstitution, |
| 953 | state: codewhale_config::SetupState, |
| 954 | message: String, |
| 955 | }, |
| 956 | /// Emitted by the setup Constitution card (`A`, provider route ready) to |
| 957 | /// ask the user's first configured model to draft the constitution from |
| 958 | /// the guided answers plus an optional bounded own-words note. The host |
| 959 | /// performs the one-shot call, pushes the sanitized/bounded draft back into the wizard, and opens the |
| 960 | /// ratification preview; on any failure it reports why and leaves the |
| 961 | /// deterministic guided draft standing. Nothing is persisted by this |
| 962 | /// event — saving still goes through the ratify keypress and |
| 963 | /// [`SetupConstitutionCommitRequested`](Self::SetupConstitutionCommitRequested). |
| 964 | SetupConstitutionModelDraftRequested { |
| 965 | draft: crate::tui::setup::GuidedConstitutionDraft, |
| 966 | freeform_note: Option<String>, |
| 967 | locale: codewhale_localization::Locale, |
| 968 | }, |
| 969 | /// Emitted by the fleet setup Review step (`m`) to ask the configured |
| 970 | /// model to draft the agent profile the wizard describes. The host |
| 971 | /// performs the one-shot call, pushes the sanitized/bounded draft back |
| 972 | /// into the wizard, and opens the rendered-TOML preview; on failure it |
| 973 | /// reports why and the manual authoring flow stands. Nothing is |
| 974 | /// persisted by this event. |
| 975 | FleetProfileModelDraftRequested { |
| 976 | role: String, |
| 977 | /// Target model for the worker: a concrete model id, or "inherit". |
| 978 | model: String, |
| 979 | /// Canonical provider id for a concrete cross-provider route pick, or |
| 980 | /// `None` for `inherit` (#4093). Carried so the model-drafted profile |
| 981 | /// keeps the picked provider instead of collapsing to an ambiguous, |
| 982 | /// provider-scoped profile — the exact bug #4093 fixes. |
| 983 | provider: Option<String>, |
| 984 | /// Canonical reasoning tier selected by the wizard, or `None` for |
| 985 | /// inherit (#4137). Carried with the async draft for the same reason |
| 986 | /// as `provider`: the ratified profile must preserve the operator's |
| 987 | /// explicit choice, not whatever the model echoed. |
| 988 | reasoning_effort: Option<String>, |
| 989 | locale: codewhale_localization::Locale, |
| 990 | }, |
| 991 | /// Emitted by the `/fleet` roster view (`s` / Enter) to edit a member. |
| 992 | /// The host routes a selected v2 Fleet to its exact editor and uses the |
| 993 | /// legacy profile wizard only when no named Fleet is selected. |
| 994 | FleetRosterOpenSetupRequested { |
| 995 | /// Exact Fleet member id; roles are not unique and therefore cannot |
| 996 | /// identify which row the operator selected. |
| 997 | member_id: String, |
| 998 | }, |
| 999 | /// Open the live workers tab from the unified Fleet surface. |
| 1000 | FleetRosterOpenWorkersRequested, |
| 1001 | |
| 1002 | /// The roster asks the host to open the secondary named-Fleet switcher |
| 1003 | /// (`/fleet fleets`; `/fleet fleets` remains compatible). Editing stays on |
| 1004 | /// setup; this is pick/select only. |
| 1005 | FleetRosterOpenFleetsRequested, |
| 1006 | |
| 1007 | /// The Fleet list view asks the host to open a saved Fleet's detail view. |
| 1008 | FleetListOpenDetailRequested { |
| 1009 | name: String, |
| 1010 | scope: crate::fleet::store::FleetScope, |
| 1011 | }, |
| 1012 | /// A Fleet store mutation happened (select/save/delete/rename/copy). |
| 1013 | /// The message is the exact receipt; the host refreshes roster state. |
| 1014 | FleetStoreChanged { |
| 1015 | message: String, |
| 1016 | }, |
| 1017 | /// Emitted by the fleet setup Review step after the user previewed a |
| 1018 | /// model-drafted profile and pressed the explicit ratify key. The host |
| 1019 | /// renders TOML deterministically from the validated draft and persists it |
| 1020 | /// atomically in the explicitly selected project or personal scope. |
| 1021 | FleetProfileDraftCommitRequested { |
| 1022 | draft: Box<crate::fleet::profile::FleetProfileDraft>, |
| 1023 | scope: crate::fleet::profile::FleetProfileScope, |
| 1024 | }, |
| 1025 | /// Emitted by the Fleet setup Model step when the user selects a route that |
| 1026 | /// has structurally valid external-consent credentials but is not the |
| 1027 | /// active session provider. The host performs a route-scoped validation |
| 1028 | /// (minting the read capability only for this exact provider/source/path) |
| 1029 | /// and records the result in the session health snapshot so the same row |
| 1030 | /// becomes selectable on the next render. The parent session provider and |
| 1031 | /// model are never changed. |
| 1032 | FleetSetupExternalConsentActivationRequested { |
| 1033 | provider_id: String, |
| 1034 | model: String, |
| 1035 | }, |
| 1036 | /// Emitted by the setup Runtime Posture card after the user has previewed |
| 1037 | /// and confirmed an explicit preset/config diff. |
| 1038 | SetupRuntimePresetApplyRequested { |
| 1039 | preset: crate::tui::setup::SetupRuntimePreset, |
| 1040 | state: codewhale_config::SetupState, |
| 1041 | message: String, |
| 1042 | }, |
| 1043 | /// Emitted by the setup Provider/Model readiness card to hand off to the |
| 1044 | /// existing provider manager instead of duplicating provider auth UI. |
| 1045 | SetupOpenProviderRequested, |
| 1046 | /// Emitted by the setup Provider/Model readiness card to hand off to the |
| 1047 | /// existing provider-qualified model route picker. |
| 1048 | SetupOpenModelRequested, |
| 1049 | /// Emitted by the setup Operate/Fleet readiness card to hand off to the |
| 1050 | /// existing Fleet setup wizard without writing Fleet config itself. |
| 1051 | SetupOpenFleetRequested, |
| 1052 | /// Emitted by the setup Hotbar card to hand off to the existing Hotbar |
| 1053 | /// setup wizard without rewriting bindings itself. |
| 1054 | SetupOpenHotbarRequested, |
| 1055 | /// Emitted by the setup Runtime Posture card to hand off to the existing |
| 1056 | /// work-mode picker. |
| 1057 | SetupOpenModeRequested, |
| 1058 | /// Emitted by the setup Runtime Posture card to hand off to the existing |
| 1059 | /// config view for approval/sandbox/network details. |
| 1060 | SetupOpenConfigRequested, |
| 1061 | /// Emitted by the progressive setup guide to start the same account-owned |
| 1062 | /// web remote-control flow as `/rc`. Setup never duplicates enrollment. |
| 1063 | SetupOpenRemoteControlRequested, |
| 1064 | /// Emitted by the `/hotbar` setup wizard when the user chooses "Disable |
| 1065 | /// Hotbar". The host persists `hotbar = []` and hides the panel. |
| 1066 | HotbarDisableRequested, |
| 1067 | /// Emitted by the live-transcript overlay while in backtrack preview |
| 1068 | /// mode (#133) when the user steps the highlighted user message with |
| 1069 | /// Left or Right. The handler advances `app.backtrack`, refreshes the |
| 1070 | /// overlay's `selected_idx`, and pins scroll near the new highlight. |
| 1071 | BacktrackStep { |
| 1072 | direction: crate::tui::backtrack::Direction, |
| 1073 | }, |
| 1074 | /// Emitted by the live-transcript overlay when the user presses Enter |
| 1075 | /// in backtrack preview mode (#133). The handler calls |
| 1076 | /// `app.backtrack.confirm()`, trims `app.history`/`api_messages` to |
| 1077 | /// the selected user message, populates the composer with the |
| 1078 | /// dropped user text, and closes the overlay. |
| 1079 | BacktrackConfirm, |
| 1080 | /// Emitted by the live-transcript overlay when the user presses Esc |
| 1081 | /// in backtrack preview mode (#133). The handler resets |
| 1082 | /// `app.backtrack` and closes the overlay without trimming. |
| 1083 | BacktrackCancel, |
| 1084 | ContextMenuSelected { |
| 1085 | action: ContextMenuAction, |
| 1086 | }, |
| 1087 | /// Emitted by the pager (`c` / `y`) to copy its body to the system |
| 1088 | /// clipboard. The host handler writes via `app.clipboard` and surfaces a |
| 1089 | /// status message — modal views cannot reach `app` directly. `label` is |
| 1090 | /// the noun shown in the success / failure status (e.g. "Pager content"). |
| 1091 | CopyToClipboard { |
| 1092 | text: String, |
| 1093 | label: String, |
| 1094 | }, |
| 1095 | /// Emitted by the skills manager when the user confirms an install / |
| 1096 | /// import / update / remove / trust action. The host runs the mutation |
| 1097 | /// controller and rebuilds the open manager view. |
| 1098 | SkillMutationRequested { |
| 1099 | request: crate::skills::mutation::SkillMutationRequest, |
| 1100 | }, |
| 1101 | /// Toggle owned-only vs compatible audit scan inside the skills manager. |
| 1102 | SkillsManagerToggleCompatible, |
| 1103 | /// The launch card's resume confirmation was accepted. The host resumes |
| 1104 | /// the named session through the same path the card's own Enter uses. |
| 1105 | LaunchResumeConfirmed { |
| 1106 | session_id: String, |
| 1107 | }, |
| 1108 | /// A slash command an Extensions row activated in place: the panel stays |
| 1109 | /// open, the host runs the command through the normal command path, then |
| 1110 | /// hands the panel a fresh snapshot so every row re-reads live state. |
| 1111 | /// When `pager_title` is set, the command's text output renders in a |
| 1112 | /// pager stacked on the panel rather than landing in the transcript. |
| 1113 | ExecutePanelCommand { |
| 1114 | command: String, |
| 1115 | pager_title: Option<String>, |
| 1116 | }, |
| 1117 | /// The open Extensions panel's bounded poll: the host rebuilds the read |
| 1118 | /// model only when the MCP snapshot generation or the initializing flag |
| 1119 | /// moved past what the panel's snapshot last saw. |
| 1120 | RefreshExtensions { |
| 1121 | mcp_generation: u64, |
| 1122 | mcp_initializing: bool, |
| 1123 | }, |
| 1124 | } |
| 1125 | |
| 1126 | #[derive(Debug, Clone)] |
| 1127 | pub enum ViewAction { |
| 1128 | None, |
| 1129 | Close, |
| 1130 | Emit(ViewEvent), |
| 1131 | EmitAndClose(ViewEvent), |
| 1132 | } |
| 1133 | |
| 1134 | pub trait ModalView: std::any::Any { |
| 1135 | fn kind(&self) -> ModalKind; |
| 1136 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction; |
| 1137 | /// Returns `true` if the modal consumed the paste; `false` to let the |
| 1138 | /// host route the text elsewhere (e.g. drop it because a modal is open, |
| 1139 | /// or insert it into the composer when no modal wants it). The default |
| 1140 | /// is `false` so modals that don't care about paste don't silently |
| 1141 | /// swallow Cmd-V. |
| 1142 | fn handle_paste(&mut self, _text: &str) -> bool { |
| 1143 | false |
| 1144 | } |
| 1145 | |
| 1146 | fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction { |
| 1147 | ViewAction::None |
| 1148 | } |
| 1149 | fn render(&self, area: Rect, buf: &mut Buffer); |
| 1150 | /// The region this modal actually paints within the full frame `area`. |
| 1151 | /// |
| 1152 | /// Defaults to the whole frame, which is the legacy full-screen overlay |
| 1153 | /// behaviour every picker/menu still relies on. Inline modals (the |
| 1154 | /// approval prompt) override this to return a bottom-anchored band so the |
| 1155 | /// backdrop only dims their strip and the transcript above stays visible. |
| 1156 | /// The returned rect MUST match the region the modal renders into, or the |
| 1157 | /// dim and the painted content will disagree. |
| 1158 | fn occupied_region(&self, area: Rect) -> Rect { |
| 1159 | area |
| 1160 | } |
| 1161 | fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool { |
| 1162 | false |
| 1163 | } |
| 1164 | fn tick(&mut self) -> ViewAction { |
| 1165 | ViewAction::None |
| 1166 | } |
| 1167 | /// Erased downcast hook for views that need a typed reference back from |
| 1168 | /// the boxed trait object (e.g. the live transcript overlay needs `&mut` |
| 1169 | /// access from outside the trait so it can refresh its snapshot of the |
| 1170 | /// app's transcript state right before render). |
| 1171 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any; |
| 1172 | |
| 1173 | /// The approval tool id this view decides, when this view is an approval |
| 1174 | /// card. Enables identity-aware dismissal: a remote decision must close |
| 1175 | /// its own card, not whichever approval happens to be on top. |
| 1176 | fn approval_request_id(&self) -> Option<&str> { |
| 1177 | None |
| 1178 | } |
| 1179 | } |
| 1180 | |
| 1181 | #[derive(Default)] |
| 1182 | pub struct ViewStack { |
| 1183 | views: Vec<Box<dyn ModalView>>, |
| 1184 | /// Focus-context texture prototype mode (#4823). `Off` by default, which |
| 1185 | /// keeps the render output byte-identical to the pre-prototype path. |
| 1186 | focus_texture: FocusTextureMode, |
| 1187 | /// Theme snapshot for the texture pass, set alongside the mode each |
| 1188 | /// frame. `None` (e.g. tests that never opt in) disables the texture. |
| 1189 | focus_texture_theme: Option<codewhale_palette::UiTheme>, |
| 1190 | } |
| 1191 | |
| 1192 | impl ViewStack { |
| 1193 | pub fn new() -> Self { |
| 1194 | Self { |
| 1195 | views: Vec::new(), |
| 1196 | focus_texture: FocusTextureMode::Off, |
| 1197 | focus_texture_theme: None, |
| 1198 | } |
| 1199 | } |
| 1200 | |
| 1201 | /// Set the focus-context texture mode and theme for subsequent renders |
| 1202 | /// (#4823 prototype). Called once per frame from the UI render path with |
| 1203 | /// the parsed setting; a plain enum/theme copy, no allocation. |
| 1204 | pub fn set_focus_texture(&mut self, mode: FocusTextureMode, theme: codewhale_palette::UiTheme) { |
| 1205 | self.focus_texture = mode; |
| 1206 | self.focus_texture_theme = Some(theme); |
| 1207 | } |
| 1208 | |
| 1209 | pub fn is_empty(&self) -> bool { |
| 1210 | self.views.is_empty() |
| 1211 | } |
| 1212 | |
| 1213 | pub fn top_kind(&self) -> Option<ModalKind> { |
| 1214 | self.views.last().map(|view| view.kind()) |
| 1215 | } |
| 1216 | |
| 1217 | /// Whether the top view is the approval card deciding exactly `gate`. |
| 1218 | /// Identity-aware: a web-mirror dismissal closes its own card, never an |
| 1219 | /// unrelated approval that happens to be on top. |
| 1220 | pub fn top_matches_approval_gate(&self, gate: &str) -> bool { |
| 1221 | self.views.last().is_some_and(|view| { |
| 1222 | crate::remote_control::view_is_approval_for_gate(view.as_ref(), gate) |
| 1223 | }) |
| 1224 | } |
| 1225 | |
| 1226 | pub fn contains_kind(&self, kind: ModalKind) -> bool { |
| 1227 | self.views.iter().any(|view| view.kind() == kind) |
| 1228 | } |
| 1229 | |
| 1230 | /// Close the named view and any child modal opened above it. This keeps a |
| 1231 | /// shell-global toggle from stacking a duplicate parent behind its picker. |
| 1232 | pub fn pop_through_kind(&mut self, kind: ModalKind) -> bool { |
| 1233 | while let Some(view) = self.pop() { |
| 1234 | if view.kind() == kind { |
| 1235 | return true; |
| 1236 | } |
| 1237 | } |
| 1238 | false |
| 1239 | } |
| 1240 | |
| 1241 | pub fn top_occupied_region(&self, area: Rect) -> Option<Rect> { |
| 1242 | self.views.last().map(|view| view.occupied_region(area)) |
| 1243 | } |
| 1244 | |
| 1245 | pub fn push<V: ModalView + 'static>(&mut self, view: V) { |
| 1246 | let kind = view.kind(); |
| 1247 | self.views.push(Box::new(view)); |
| 1248 | tracing::debug!(target: "codewhale_tui::view_stack", action = "push", kind = ?kind, depth = self.views.len(), "view pushed"); |
| 1249 | } |
| 1250 | |
| 1251 | /// Push an already-boxed view back onto the stack. Used by call sites |
| 1252 | /// that pop a view, mutate it externally, and need to restore it without |
| 1253 | /// the generic `push` re-boxing dance. |
| 1254 | pub fn push_boxed(&mut self, view: Box<dyn ModalView>) { |
| 1255 | let kind = view.kind(); |
| 1256 | self.views.push(view); |
| 1257 | tracing::debug!(target: "codewhale_tui::view_stack", action = "push_boxed", kind = ?kind, depth = self.views.len(), "view pushed"); |
| 1258 | } |
| 1259 | |
| 1260 | pub fn pop(&mut self) -> Option<Box<dyn ModalView>> { |
| 1261 | let popped = self.views.pop(); |
| 1262 | if let Some(view) = popped.as_ref() { |
| 1263 | tracing::debug!(target: "codewhale_tui::view_stack", action = "pop", kind = ?view.kind(), depth = self.views.len(), "view popped"); |
| 1264 | } |
| 1265 | popped |
| 1266 | } |
| 1267 | |
| 1268 | pub fn render(&self, area: Rect, buf: &mut Buffer) { |
| 1269 | // Focus-context texture prototype (#4823): runs over the already |
| 1270 | // rendered background BEFORE any backdrop or view paint, so the |
| 1271 | // focused modal is painted afterwards at full strength and the |
| 1272 | // texture can never overwrite it. `Off` (the default) leaves the |
| 1273 | // buffer untouched, keeping output byte-identical to the |
| 1274 | // pre-prototype path. |
| 1275 | if self.focus_texture != FocusTextureMode::Off |
| 1276 | && let (Some(focus), Some(theme)) = |
| 1277 | (self.top_occupied_region(area), self.focus_texture_theme) |
| 1278 | { |
| 1279 | crate::tui::focus_texture::apply_focus_texture( |
| 1280 | area, |
| 1281 | buf, |
| 1282 | focus, |
| 1283 | &theme, |
| 1284 | self.focus_texture, |
| 1285 | crate::tui::color_compat::ascii_safe_enabled(), |
| 1286 | ); |
| 1287 | } |
| 1288 | // Dim each view's own occupied region rather than the whole frame, so |
| 1289 | // an inline modal (the approval prompt) leaves the transcript above it |
| 1290 | // visible instead of blacking out the screen. Full-screen modals keep |
| 1291 | // the default `occupied_region` of the entire frame, so their backdrop |
| 1292 | // is unchanged. |
| 1293 | for view in &self.views { |
| 1294 | let region = view.occupied_region(area); |
| 1295 | crate::tui::osc8::overlay_frame_links(region, Vec::new()); |
| 1296 | render_modal_backdrop(region, buf); |
| 1297 | view.render(area, buf); |
| 1298 | } |
| 1299 | } |
| 1300 | |
| 1301 | pub fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool { |
| 1302 | let mut updated = false; |
| 1303 | for view in &mut self.views { |
| 1304 | updated |= view.update_subagents(agents); |
| 1305 | } |
| 1306 | updated |
| 1307 | } |
| 1308 | |
| 1309 | pub fn handle_key(&mut self, key: KeyEvent) -> Vec<ViewEvent> { |
| 1310 | let action = self |
| 1311 | .views |
| 1312 | .last_mut() |
| 1313 | .map(|view| view.handle_key(key)) |
| 1314 | .unwrap_or(ViewAction::None); |
| 1315 | self.apply_action(action) |
| 1316 | } |
| 1317 | |
| 1318 | pub fn handle_paste(&mut self, text: &str) -> bool { |
| 1319 | self.views |
| 1320 | .last_mut() |
| 1321 | .map(|view| view.handle_paste(text)) |
| 1322 | .unwrap_or(false) |
| 1323 | } |
| 1324 | |
| 1325 | pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec<ViewEvent> { |
| 1326 | let action = self |
| 1327 | .views |
| 1328 | .last_mut() |
| 1329 | .map(|view| view.handle_mouse(mouse)) |
| 1330 | .unwrap_or(ViewAction::None); |
| 1331 | self.apply_action(action) |
| 1332 | } |
| 1333 | |
| 1334 | pub fn tick(&mut self) -> Vec<ViewEvent> { |
| 1335 | let action = self |
| 1336 | .views |
| 1337 | .last_mut() |
| 1338 | .map(|view| view.tick()) |
| 1339 | .unwrap_or(ViewAction::None); |
| 1340 | self.apply_action(action) |
| 1341 | } |
| 1342 | |
| 1343 | fn apply_action(&mut self, action: ViewAction) -> Vec<ViewEvent> { |
| 1344 | let mut events = Vec::new(); |
| 1345 | match action { |
| 1346 | ViewAction::None => {} |
| 1347 | ViewAction::Close => { |
| 1348 | if let Some(view) = self.views.pop() { |
| 1349 | tracing::debug!(target: "codewhale_tui::view_stack", action = "close", kind = ?view.kind(), depth = self.views.len(), "view closed via action"); |
| 1350 | } |
| 1351 | } |
| 1352 | ViewAction::Emit(event) => { |
| 1353 | events.push(event); |
| 1354 | } |
| 1355 | ViewAction::EmitAndClose(event) => { |
| 1356 | events.push(event); |
| 1357 | if let Some(view) = self.views.pop() { |
| 1358 | tracing::debug!(target: "codewhale_tui::view_stack", action = "emit_and_close", kind = ?view.kind(), depth = self.views.len(), "view closed via action"); |
| 1359 | } |
| 1360 | } |
| 1361 | } |
| 1362 | events |
| 1363 | } |
| 1364 | |
| 1365 | /// Whether the Extensions panel is the top view. |
| 1366 | pub fn extensions_is_top(&self) -> bool { |
| 1367 | self.views |
| 1368 | .last() |
| 1369 | .is_some_and(|view| view.kind() == ModalKind::Extensions) |
| 1370 | } |
| 1371 | |
| 1372 | /// Hand a freshly-built read model to the open Extensions panel, when it |
| 1373 | /// is on top. A pager or another modal stacked above it means the user is |
| 1374 | /// looking at something else — the rebuild is skipped and the next poll |
| 1375 | /// retries. |
| 1376 | pub fn refresh_extensions(&mut self, snapshot: extensions::ExtensionsSnapshot) { |
| 1377 | if let Some(view) = self.views.last_mut() |
| 1378 | && let Some(panel) = view |
| 1379 | .as_any_mut() |
| 1380 | .downcast_mut::<extensions::ExtensionsView>() |
| 1381 | { |
| 1382 | panel.refresh_snapshot(snapshot); |
| 1383 | } |
| 1384 | } |
| 1385 | } |
| 1386 | |
| 1387 | impl fmt::Debug for ViewStack { |
| 1388 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1389 | f.debug_struct("ViewStack") |
| 1390 | .field("len", &self.views.len()) |
| 1391 | .field("top", &self.top_kind()) |
| 1392 | .finish() |
| 1393 | } |
| 1394 | } |
| 1395 | |
| 1396 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1397 | enum ConfigScope { |
| 1398 | Session, |
| 1399 | Saved, |
| 1400 | } |
| 1401 | |
| 1402 | impl ConfigScope { |
| 1403 | fn label(self, locale: Locale) -> Cow<'static, str> { |
| 1404 | tr( |
| 1405 | locale, |
| 1406 | match self { |
| 1407 | ConfigScope::Session => MessageId::ConfigScopeSession, |
| 1408 | ConfigScope::Saved => MessageId::ConfigScopeSaved, |
| 1409 | }, |
| 1410 | ) |
| 1411 | } |
| 1412 | |
| 1413 | fn persist(self) -> bool { |
| 1414 | matches!(self, ConfigScope::Saved) |
| 1415 | } |
| 1416 | } |
| 1417 | |
| 1418 | #[derive(Debug, Clone)] |
| 1419 | struct ConfigRow { |
| 1420 | key: String, |
| 1421 | value: String, |
| 1422 | editable: bool, |
| 1423 | scope: ConfigScope, |
| 1424 | /// Typed facts decided when the row is built from `App`, `Settings`, and |
| 1425 | /// `Config`; the shell never re-derives them from the key at render time. |
| 1426 | facts: ConfigRowFacts, |
| 1427 | } |
| 1428 | |
| 1429 | impl ConfigRow { |
| 1430 | fn edit_value(&self) -> &str { |
| 1431 | if self.key.starts_with("notifications.") { |
| 1432 | self.facts.effective.as_deref().unwrap_or(&self.value) |
| 1433 | } else { |
| 1434 | &self.value |
| 1435 | } |
| 1436 | } |
| 1437 | |
| 1438 | /// The schema declaration behind this row. `None` means the key is not |
| 1439 | /// declared, and the row is dropped before the view is built. |
| 1440 | fn schema(&self) -> Option<&'static codewhale_config::SettingDef> { |
| 1441 | codewhale_config::setting(&self.key) |
| 1442 | } |
| 1443 | |
| 1444 | /// The row's `ui` block. `None` means "declared, but not shown". |
| 1445 | fn ui(&self) -> Option<&'static codewhale_config::SettingUi> { |
| 1446 | self.schema().and_then(|def| def.ui.as_ref()) |
| 1447 | } |
| 1448 | |
| 1449 | /// Section heading, from the schema's group id. |
| 1450 | fn section(&self) -> ConfigSection { |
| 1451 | self.ui() |
| 1452 | .and_then(|ui| ConfigSection::from_id(ui.group)) |
| 1453 | .unwrap_or(ConfigSection::Experimental) |
| 1454 | } |
| 1455 | } |
| 1456 | |
| 1457 | /// What a row *is*: a persisted or session setting fact, an action that opens |
| 1458 | /// another surface, or a read-only receipt observed from the running app. |
| 1459 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1460 | enum ConfigRowKind { |
| 1461 | Setting, |
| 1462 | Action, |
| 1463 | Diagnostic, |
| 1464 | } |
| 1465 | |
| 1466 | /// Which [`UiSnapshot`] fact a row projects, when it projects one. |
| 1467 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1468 | enum SnapshotLane { |
| 1469 | Provider, |
| 1470 | Model, |
| 1471 | } |
| 1472 | |
| 1473 | /// Which durable store a saved row persists to — independent of which |
| 1474 | /// authority currently wins the *effective* decision. An environment or |
| 1475 | /// terminal override (NO_ANIMATIONS, a legacy console host, …) relabels the |
| 1476 | /// row's authority without repairing or breaking its store, so store-error |
| 1477 | /// marking must key on this field, never on `authority`. |
| 1478 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1479 | enum SettingStore { |
| 1480 | /// `settings.toml` (user settings). |
| 1481 | UserSettings, |
| 1482 | /// `config.toml` (workspace configuration). |
| 1483 | WorkspaceConfig, |
| 1484 | /// Session-owned, diagnostic, or otherwise not persisted from this surface. |
| 1485 | None, |
| 1486 | } |
| 1487 | |
| 1488 | impl SettingStore { |
| 1489 | /// The store an authority implies when a row is *built* under it. Only |
| 1490 | /// meaningful at construction: an override authority applied later must |
| 1491 | /// keep the row's original store. |
| 1492 | fn for_authority(authority: SettingAuthority) -> Self { |
| 1493 | match authority { |
| 1494 | SettingAuthority::UserSettings => SettingStore::UserSettings, |
| 1495 | SettingAuthority::WorkspaceConfiguration => SettingStore::WorkspaceConfig, |
| 1496 | _ => SettingStore::None, |
| 1497 | } |
| 1498 | } |
| 1499 | } |
| 1500 | |
| 1501 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1502 | struct ConfigRowFacts { |
| 1503 | kind: ConfigRowKind, |
| 1504 | authority: SettingAuthority, |
| 1505 | /// The store this row's saved/startup lanes come from (#5730 Windows CI: |
| 1506 | /// an override-authority row still has a store that can fail to load). |
| 1507 | store: SettingStore, |
| 1508 | apply: SettingApplySemantics, |
| 1509 | /// Value observed in force from an explicit `App` field. `None` means |
| 1510 | /// unobserved; it is never inferred from the persisted value. |
| 1511 | effective: Option<String>, |
| 1512 | /// Snapshot lane supplying the live fact for this row. |
| 1513 | snapshot: Option<SnapshotLane>, |
| 1514 | /// Slash command that activation runs, with the localized verb for it. |
| 1515 | command: Option<(&'static str, MessageId)>, |
| 1516 | /// The concrete environment/terminal token when `authority` is an |
| 1517 | /// override (`NO_ANIMATIONS`, `TERM_PROGRAM=vscode`, …). |
| 1518 | authority_detail: Option<&'static str>, |
| 1519 | /// The load error of the row's store when it could not be read; the saved |
| 1520 | /// and startup lanes are then unavailable, never a default in disguise. |
| 1521 | store_error: Option<String>, |
| 1522 | } |
| 1523 | |
| 1524 | impl ConfigRowFacts { |
| 1525 | /// A `settings.toml` value edited here and applied on save. |
| 1526 | fn saved_setting() -> Self { |
| 1527 | Self { |
| 1528 | kind: ConfigRowKind::Setting, |
| 1529 | authority: SettingAuthority::UserSettings, |
| 1530 | store: SettingStore::UserSettings, |
| 1531 | apply: SettingApplySemantics::Immediate, |
| 1532 | effective: None, |
| 1533 | snapshot: None, |
| 1534 | command: None, |
| 1535 | authority_detail: None, |
| 1536 | store_error: None, |
| 1537 | } |
| 1538 | } |
| 1539 | |
| 1540 | /// The effective value is forced by an environment or terminal override. |
| 1541 | fn overridden(self, environment: bool, detail: &'static str) -> Self { |
| 1542 | Self { |
| 1543 | authority: if environment { |
| 1544 | SettingAuthority::Environment |
| 1545 | } else { |
| 1546 | SettingAuthority::Terminal |
| 1547 | }, |
| 1548 | authority_detail: Some(detail), |
| 1549 | ..self |
| 1550 | } |
| 1551 | } |
| 1552 | |
| 1553 | /// The row's store failed to load: no saved or startup value is known. |
| 1554 | /// The error is folded onto one line so it reads in a single lane. |
| 1555 | fn unavailable(self, error: &str) -> Self { |
| 1556 | Self { |
| 1557 | store_error: Some(error.split_whitespace().collect::<Vec<_>>().join(" ")), |
| 1558 | ..self |
| 1559 | } |
| 1560 | } |
| 1561 | |
| 1562 | /// A value the live session owns; its row value *is* the observed value. |
| 1563 | fn session_setting() -> Self { |
| 1564 | Self { |
| 1565 | authority: SettingAuthority::Session, |
| 1566 | store: SettingStore::None, |
| 1567 | apply: SettingApplySemantics::EffectiveNow, |
| 1568 | ..Self::saved_setting() |
| 1569 | } |
| 1570 | } |
| 1571 | |
| 1572 | /// A persisted setting shown but not editable from this surface. |
| 1573 | fn read_only_setting(authority: SettingAuthority) -> Self { |
| 1574 | Self { |
| 1575 | store: SettingStore::for_authority(authority), |
| 1576 | authority, |
| 1577 | apply: SettingApplySemantics::ReadOnly, |
| 1578 | ..Self::saved_setting() |
| 1579 | } |
| 1580 | } |
| 1581 | |
| 1582 | /// A receipt observed from the running app, never a persisted fact. |
| 1583 | fn diagnostic(authority: SettingAuthority) -> Self { |
| 1584 | Self { |
| 1585 | kind: ConfigRowKind::Diagnostic, |
| 1586 | store: SettingStore::None, |
| 1587 | authority, |
| 1588 | apply: SettingApplySemantics::ReadOnly, |
| 1589 | ..Self::saved_setting() |
| 1590 | } |
| 1591 | } |
| 1592 | |
| 1593 | /// A row whose activation opens another surface; not a persisted fact. |
| 1594 | fn action(command: &'static str, verb: MessageId) -> Self { |
| 1595 | Self { |
| 1596 | kind: ConfigRowKind::Action, |
| 1597 | authority: SettingAuthority::Session, |
| 1598 | store: SettingStore::None, |
| 1599 | apply: SettingApplySemantics::EffectiveNow, |
| 1600 | command: Some((command, verb)), |
| 1601 | ..Self::saved_setting() |
| 1602 | } |
| 1603 | } |
| 1604 | |
| 1605 | fn authority(self, authority: SettingAuthority) -> Self { |
| 1606 | // Callers that relabel a row's authority through this builder are |
| 1607 | // describing the row's store (e.g. a config.toml row), so the store |
| 1608 | // follows. An *override* never goes through this builder — it wins |
| 1609 | // the effective decision while the store stays what it was. |
| 1610 | let store = SettingStore::for_authority(authority); |
| 1611 | Self { |
| 1612 | authority, |
| 1613 | store, |
| 1614 | ..self |
| 1615 | } |
| 1616 | } |
| 1617 | |
| 1618 | fn apply(self, apply: SettingApplySemantics) -> Self { |
| 1619 | Self { apply, ..self } |
| 1620 | } |
| 1621 | |
| 1622 | fn effective(self, value: impl Into<String>) -> Self { |
| 1623 | Self { |
| 1624 | effective: Some(value.into()), |
| 1625 | ..self |
| 1626 | } |
| 1627 | } |
| 1628 | |
| 1629 | fn snapshot(self, lane: SnapshotLane) -> Self { |
| 1630 | Self { |
| 1631 | snapshot: Some(lane), |
| 1632 | ..self |
| 1633 | } |
| 1634 | } |
| 1635 | |
| 1636 | /// A setting fact whose activation opens a picker instead of an editor. |
| 1637 | fn opens(self, command: &'static str, verb: MessageId) -> Self { |
| 1638 | Self { |
| 1639 | command: Some((command, verb)), |
| 1640 | ..self |
| 1641 | } |
| 1642 | } |
| 1643 | } |
| 1644 | |
| 1645 | /// Editor behavior for one Settings entry. This is intentionally independent |
| 1646 | /// from where the value is stored: category/scope describe ownership, while |
| 1647 | /// kind determines the interaction and validation surface. |
| 1648 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1649 | enum SettingKind { |
| 1650 | Boolean, |
| 1651 | Choice, |
| 1652 | Integer, |
| 1653 | Text, |
| 1654 | Action, |
| 1655 | ReadOnly, |
| 1656 | } |
| 1657 | |
| 1658 | #[derive(Debug, Clone)] |
| 1659 | struct SettingMeta { |
| 1660 | kind: SettingKind, |
| 1661 | category: ConfigSection, |
| 1662 | choices: Option<Vec<String>>, |
| 1663 | } |
| 1664 | |
| 1665 | #[derive(Debug, Clone)] |
| 1666 | struct SettingsRegistry { |
| 1667 | provider: ApiProvider, |
| 1668 | base_url: String, |
| 1669 | model: String, |
| 1670 | auto_model: bool, |
| 1671 | } |
| 1672 | |
| 1673 | impl SettingsRegistry { |
| 1674 | fn new(view: &ConfigView) -> Self { |
| 1675 | Self { |
| 1676 | provider: view.api_provider, |
| 1677 | base_url: view.route_base_url.clone(), |
| 1678 | model: view.route_model.clone(), |
| 1679 | auto_model: view.auto_model, |
| 1680 | } |
| 1681 | } |
| 1682 | |
| 1683 | fn reasoning_effort_choices(&self) -> Vec<String> { |
| 1684 | let mut values = vec!["default".to_string()]; |
| 1685 | for effort in crate::tui::model_picker::picker_efforts_for_route( |
| 1686 | self.provider, |
| 1687 | &self.base_url, |
| 1688 | &self.model, |
| 1689 | self.auto_model, |
| 1690 | ) { |
| 1691 | let label = if self.provider == ApiProvider::OpenaiCodex { |
| 1692 | effort.display_label_for_provider(self.provider) |
| 1693 | } else { |
| 1694 | effort.as_setting() |
| 1695 | }; |
| 1696 | if !values.iter().any(|value| value == label) { |
| 1697 | values.push(label.to_string()); |
| 1698 | } |
| 1699 | } |
| 1700 | values |
| 1701 | } |
| 1702 | |
| 1703 | fn meta(&self, row: &ConfigRow) -> SettingMeta { |
| 1704 | let choices = if row.key == "reasoning_effort" { |
| 1705 | Some(self.reasoning_effort_choices()) |
| 1706 | } else { |
| 1707 | config_choice_values(&row.key) |
| 1708 | }; |
| 1709 | let kind = if !row.editable { |
| 1710 | SettingKind::ReadOnly |
| 1711 | } else if row.facts.command.is_some() { |
| 1712 | SettingKind::Action |
| 1713 | } else if config_boolean_key(&row.key) { |
| 1714 | SettingKind::Boolean |
| 1715 | } else if choices.is_some() { |
| 1716 | SettingKind::Choice |
| 1717 | } else if config_integer_key(&row.key) { |
| 1718 | SettingKind::Integer |
| 1719 | } else { |
| 1720 | SettingKind::Text |
| 1721 | }; |
| 1722 | SettingMeta { |
| 1723 | kind, |
| 1724 | category: row.section(), |
| 1725 | choices, |
| 1726 | } |
| 1727 | } |
| 1728 | } |
| 1729 | |
| 1730 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1731 | enum ConfigSection { |
| 1732 | Provider, |
| 1733 | Model, |
| 1734 | Permissions, |
| 1735 | Network, |
| 1736 | Display, |
| 1737 | Composer, |
| 1738 | Sidebar, |
| 1739 | History, |
| 1740 | Mcp, |
| 1741 | Fleet, |
| 1742 | /// Workflow orchestration (`/workflow`). Kept out of Fleet: a Fleet is |
| 1743 | /// *who*, a Workflow is *what order* the work follows over it. |
| 1744 | Workflow, |
| 1745 | /// Session-scoped drivers such as `/goal`. |
| 1746 | Session, |
| 1747 | /// Explicitly legacy compatibility settings that are not a live choice — |
| 1748 | /// e.g. the DeepSeek-only `default_model` fallback (#4751). |
| 1749 | Legacy, |
| 1750 | Experimental, |
| 1751 | } |
| 1752 | |
| 1753 | /// The seven Tideline settings categories in rail order |
| 1754 | /// (`docs/design/tideline-redesign.html`, "Settings categories"). |
| 1755 | /// |
| 1756 | /// A category is a projection over the existing [`ConfigRow`] store: rows keep |
| 1757 | /// their fine-grained [`ConfigSection`]; the category follows the section |
| 1758 | /// unless the row's typed facts file it elsewhere (motion keys, telemetry, |
| 1759 | /// low-level receipts). |
| 1760 | /// |
| 1761 | /// This enum is the single taxonomy: the `ConfigView` rail/strip and the |
| 1762 | /// Tideline settings stage scaffold both iterate [`ConfigCategory::ALL`]. |
| 1763 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1764 | pub(crate) enum ConfigCategory { |
| 1765 | Appearance, |
| 1766 | ModelsProviders, |
| 1767 | Work, |
| 1768 | ToolsMcp, |
| 1769 | Trust, |
| 1770 | Motion, |
| 1771 | Advanced, |
| 1772 | } |
| 1773 | |
| 1774 | impl ConfigCategory { |
| 1775 | /// The schema tab id this category renders. |
| 1776 | fn id(self) -> &'static str { |
| 1777 | match self { |
| 1778 | ConfigCategory::Appearance => codewhale_config::settings_schema::TAB_APPEARANCE, |
| 1779 | ConfigCategory::ModelsProviders => codewhale_config::settings_schema::TAB_MODELS, |
| 1780 | ConfigCategory::Work => codewhale_config::settings_schema::TAB_WORK, |
| 1781 | ConfigCategory::ToolsMcp => codewhale_config::settings_schema::TAB_TOOLS, |
| 1782 | ConfigCategory::Trust => codewhale_config::settings_schema::TAB_TRUST, |
| 1783 | ConfigCategory::Motion => codewhale_config::settings_schema::TAB_MOTION, |
| 1784 | ConfigCategory::Advanced => codewhale_config::settings_schema::TAB_ADVANCED, |
| 1785 | } |
| 1786 | } |
| 1787 | |
| 1788 | fn from_id(id: &str) -> Option<Self> { |
| 1789 | Self::ALL.into_iter().find(|category| category.id() == id) |
| 1790 | } |
| 1791 | |
| 1792 | const ALL: [ConfigCategory; 7] = [ |
| 1793 | ConfigCategory::Appearance, |
| 1794 | ConfigCategory::ModelsProviders, |
| 1795 | ConfigCategory::Work, |
| 1796 | ConfigCategory::ToolsMcp, |
| 1797 | ConfigCategory::Trust, |
| 1798 | ConfigCategory::Motion, |
| 1799 | ConfigCategory::Advanced, |
| 1800 | ]; |
| 1801 | |
| 1802 | fn label(self, locale: Locale) -> Cow<'static, str> { |
| 1803 | tr( |
| 1804 | locale, |
| 1805 | match self { |
| 1806 | ConfigCategory::Appearance => MessageId::ConfigCategoryAppearance, |
| 1807 | ConfigCategory::ModelsProviders => MessageId::ConfigCategoryModelsProviders, |
| 1808 | ConfigCategory::Work => MessageId::ConfigCategoryWork, |
| 1809 | ConfigCategory::ToolsMcp => MessageId::ConfigCategoryToolsMcp, |
| 1810 | ConfigCategory::Trust => MessageId::ConfigCategoryTrust, |
| 1811 | ConfigCategory::Motion => MessageId::ConfigCategoryMotion, |
| 1812 | ConfigCategory::Advanced => MessageId::ConfigCategoryAdvanced, |
| 1813 | }, |
| 1814 | ) |
| 1815 | } |
| 1816 | |
| 1817 | /// The rail category the schema files this row under. |
| 1818 | fn for_row(row: &ConfigRow) -> Self { |
| 1819 | row.ui() |
| 1820 | .and_then(|ui| Self::from_id(ui.tab)) |
| 1821 | .unwrap_or(ConfigCategory::Advanced) |
| 1822 | } |
| 1823 | |
| 1824 | fn contains(self, row: &ConfigRow) -> bool { |
| 1825 | Self::for_row(row) == self |
| 1826 | } |
| 1827 | |
| 1828 | fn position(self) -> usize { |
| 1829 | Self::ALL |
| 1830 | .iter() |
| 1831 | .position(|category| *category == self) |
| 1832 | .unwrap_or(0) |
| 1833 | } |
| 1834 | |
| 1835 | fn next(self) -> Self { |
| 1836 | Self::ALL[(self.position() + 1) % Self::ALL.len()] |
| 1837 | } |
| 1838 | |
| 1839 | fn prev(self) -> Self { |
| 1840 | Self::ALL[(self.position() + Self::ALL.len() - 1) % Self::ALL.len()] |
| 1841 | } |
| 1842 | } |
| 1843 | |
| 1844 | impl ConfigSection { |
| 1845 | const ALL: [ConfigSection; 14] = [ |
| 1846 | ConfigSection::Provider, |
| 1847 | ConfigSection::Model, |
| 1848 | ConfigSection::Permissions, |
| 1849 | ConfigSection::Network, |
| 1850 | ConfigSection::Display, |
| 1851 | ConfigSection::Composer, |
| 1852 | ConfigSection::Sidebar, |
| 1853 | ConfigSection::History, |
| 1854 | ConfigSection::Mcp, |
| 1855 | ConfigSection::Fleet, |
| 1856 | ConfigSection::Workflow, |
| 1857 | ConfigSection::Session, |
| 1858 | ConfigSection::Legacy, |
| 1859 | ConfigSection::Experimental, |
| 1860 | ]; |
| 1861 | |
| 1862 | /// The schema group id this section heads. |
| 1863 | fn id(self) -> &'static str { |
| 1864 | match self { |
| 1865 | ConfigSection::Provider => "provider", |
| 1866 | ConfigSection::Model => "model", |
| 1867 | ConfigSection::Permissions => "permissions", |
| 1868 | ConfigSection::Network => "network", |
| 1869 | ConfigSection::Display => "display", |
| 1870 | ConfigSection::Composer => "composer", |
| 1871 | ConfigSection::Sidebar => "workbar", |
| 1872 | ConfigSection::History => "history", |
| 1873 | ConfigSection::Mcp => "mcp", |
| 1874 | ConfigSection::Fleet => "fleet", |
| 1875 | ConfigSection::Workflow => "workflow", |
| 1876 | ConfigSection::Session => "session", |
| 1877 | ConfigSection::Legacy => "legacy", |
| 1878 | ConfigSection::Experimental => "experimental", |
| 1879 | } |
| 1880 | } |
| 1881 | |
| 1882 | fn from_id(id: &str) -> Option<Self> { |
| 1883 | Self::ALL.into_iter().find(|section| section.id() == id) |
| 1884 | } |
| 1885 | |
| 1886 | fn label(self, locale: Locale) -> Cow<'static, str> { |
| 1887 | tr( |
| 1888 | locale, |
| 1889 | match self { |
| 1890 | ConfigSection::Provider => MessageId::ConfigSectionProvider, |
| 1891 | ConfigSection::Model => MessageId::ConfigSectionModel, |
| 1892 | ConfigSection::Permissions => MessageId::ConfigSectionPermissions, |
| 1893 | ConfigSection::Network => MessageId::ConfigSectionNetwork, |
| 1894 | ConfigSection::Display => MessageId::ConfigSectionDisplay, |
| 1895 | ConfigSection::Composer => MessageId::ConfigSectionComposer, |
| 1896 | ConfigSection::Sidebar => MessageId::ConfigSectionSidebar, |
| 1897 | ConfigSection::History => MessageId::ConfigSectionHistory, |
| 1898 | ConfigSection::Mcp => MessageId::ConfigSectionMcp, |
| 1899 | ConfigSection::Fleet => MessageId::ConfigSectionFleet, |
| 1900 | ConfigSection::Workflow => MessageId::ConfigSectionWorkflow, |
| 1901 | ConfigSection::Session => MessageId::ConfigSectionSession, |
| 1902 | ConfigSection::Legacy => MessageId::ConfigSectionLegacy, |
| 1903 | ConfigSection::Experimental => MessageId::ConfigSectionExperimental, |
| 1904 | }, |
| 1905 | ) |
| 1906 | } |
| 1907 | } |
| 1908 | |
| 1909 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1910 | enum ConfigListItem { |
| 1911 | Section(ConfigSection), |
| 1912 | Row(usize), |
| 1913 | } |
| 1914 | |
| 1915 | /// Clickable editor controls; keyboard Enter/Esc do the same thing. |
| 1916 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1917 | enum EditorControl { |
| 1918 | Apply, |
| 1919 | Cancel, |
| 1920 | } |
| 1921 | |
| 1922 | /// Clickable overflow markers of the category strip. |
| 1923 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1924 | pub(crate) enum NavStep { |
| 1925 | Previous, |
| 1926 | Next, |
| 1927 | } |
| 1928 | |
| 1929 | #[derive(Debug, Clone)] |
| 1930 | struct ConfigEdit { |
| 1931 | key: String, |
| 1932 | original_value: String, |
| 1933 | buffer: Vec<char>, |
| 1934 | cursor: usize, |
| 1935 | select_all: bool, |
| 1936 | scope: ConfigScope, |
| 1937 | choices: Option<Vec<String>>, |
| 1938 | selected_choice: usize, |
| 1939 | } |
| 1940 | |
| 1941 | pub struct ConfigView { |
| 1942 | rows: Vec<ConfigRow>, |
| 1943 | selected: usize, |
| 1944 | scroll: usize, |
| 1945 | editing: Option<ConfigEdit>, |
| 1946 | filter: String, |
| 1947 | status: Option<String>, |
| 1948 | locale: Locale, |
| 1949 | last_visible_rows: Cell<usize>, |
| 1950 | /// Selection-anchored scroll actually used by the last render; keeps the |
| 1951 | /// panel scroll rail truthful when the stored scroll predates a resize. |
| 1952 | last_render_scroll: Cell<usize>, |
| 1953 | /// Exact painted cells of each list row; a click selects a row only when |
| 1954 | /// it lands inside one of these rects. |
| 1955 | last_row_hitboxes: RefCell<Vec<(Rect, usize)>>, |
| 1956 | /// Exact painted cells of each visible editor choice. |
| 1957 | last_choice_hitboxes: RefCell<Vec<(Rect, usize)>>, |
| 1958 | /// Exact painted cells of the editor's Apply / Cancel controls. |
| 1959 | last_editor_controls: RefCell<Vec<(Rect, EditorControl)>>, |
| 1960 | /// Exact painted cells of each category in the rail or strip. |
| 1961 | last_rail_hitboxes: RefCell<Vec<(Rect, ConfigCategory)>>, |
| 1962 | /// Exact painted cells of the strip's ‹ / › overflow markers. |
| 1963 | last_nav_controls: RefCell<Vec<(Rect, NavStep)>>, |
| 1964 | last_mouse_selected: Option<usize>, |
| 1965 | /// Pointer hover state, repainted from the shared hover style. Hover |
| 1966 | /// never moves the keyboard selection; it only tints what the pointer |
| 1967 | /// is over so every clickable element answers visibly. |
| 1968 | hovered_row: Option<usize>, |
| 1969 | hovered_rail: Option<ConfigCategory>, |
| 1970 | hovered_nav: Option<NavStep>, |
| 1971 | hovered_editor: Option<EditorControl>, |
| 1972 | hovered_choice: Option<usize>, |
| 1973 | api_provider: ApiProvider, |
| 1974 | route_base_url: String, |
| 1975 | route_model: String, |
| 1976 | auto_model: bool, |
| 1977 | /// Selected rail category of the Tideline settings shell. |
| 1978 | category: ConfigCategory, |
| 1979 | /// Read-only session projection for the provider/model facts; the detail |
| 1980 | /// pane shows its lanes verbatim instead of guessing a saved default. |
| 1981 | snapshot: UiSnapshot, |
| 1982 | } |
| 1983 | |
| 1984 | const CONFIG_MIN_KEY_COLUMN_WIDTH: usize = 19; |
| 1985 | const CONFIG_VALUE_COLUMN_WIDTH: usize = 44; |
| 1986 | const CONFIG_MIN_VALUE_COLUMN_WIDTH: usize = 10; |
| 1987 | const CONFIG_SCOPE_COLUMN_WIDTH: usize = 7; |
| 1988 | const CONFIG_ROW_PREFIX_WIDTH: usize = 2; |
| 1989 | /// The two two-column gaps painted between key, value, and affordance. |
| 1990 | const CONFIG_COLUMN_GAPS_WIDTH: usize = 4; |
| 1991 | /// Affordance glyph column (`[x]`, `‹ ›`, `✎`, `›`, `⊘`) plus its gap. |
| 1992 | const CONFIG_AFFORDANCE_COLUMN_WIDTH: usize = 5; |
| 1993 | /// List width below which the scope badge sheds in favour of the value. |
| 1994 | const CONFIG_SCOPE_BADGE_MIN_WIDTH: usize = 60; |
| 1995 | |
| 1996 | impl ConfigView { |
| 1997 | pub fn new_for_app(app: &App) -> Self { |
| 1998 | // A store that fails to load yields no saved facts. The defaults below |
| 1999 | // only shape the row list; every row backed by a failed store is |
| 2000 | // marked unavailable before the view is returned. |
| 2001 | let (settings, settings_error) = match Settings::load_persisted() { |
| 2002 | Ok(settings) => { |
| 2003 | let error = settings.load_error.clone(); |
| 2004 | (settings, error) |
| 2005 | } |
| 2006 | Err(error) => (Settings::default(), Some(error.to_string())), |
| 2007 | }; |
| 2008 | let (config, config_error) = |
| 2009 | match Config::load(app.config_path.clone(), app.config_profile.as_deref()) { |
| 2010 | Ok(config) => (config, None), |
| 2011 | Err(error) => (Config::default(), Some(error.to_string())), |
| 2012 | }; |
| 2013 | let motion_override = crate::settings::detect_low_motion_override(); |
| 2014 | let motion_facts = |facts: ConfigRowFacts| match motion_override { |
| 2015 | Some(source) => facts.overridden(source.is_environment(), source.label()), |
| 2016 | None => facts, |
| 2017 | }; |
| 2018 | let permission_control = config.approval_policy_control( |
| 2019 | app.config_path.as_deref(), |
| 2020 | app.config_profile.as_deref(), |
| 2021 | &app.workspace, |
| 2022 | ); |
| 2023 | let saved_permission_row = match permission_control { |
| 2024 | ApprovalPolicyControl::Unset => ConfigRow { |
| 2025 | key: "permission_posture".to_string(), |
| 2026 | value: settings |
| 2027 | .permission_posture |
| 2028 | .as_deref() |
| 2029 | .unwrap_or("ask") |
| 2030 | .to_string(), |
| 2031 | editable: true, |
| 2032 | scope: ConfigScope::Saved, |
| 2033 | facts: ConfigRowFacts::saved_setting(), |
| 2034 | }, |
| 2035 | ApprovalPolicyControl::RootConfig => ConfigRow { |
| 2036 | key: "approval_policy".to_string(), |
| 2037 | value: config |
| 2038 | .approval_policy |
| 2039 | .as_deref() |
| 2040 | .unwrap_or("ask") |
| 2041 | .to_string(), |
| 2042 | editable: permission_control.editable_root(), |
| 2043 | scope: ConfigScope::Saved, |
| 2044 | facts: ConfigRowFacts::saved_setting() |
| 2045 | .authority(SettingAuthority::WorkspaceConfiguration), |
| 2046 | }, |
| 2047 | source => ConfigRow { |
| 2048 | key: "managed_approval_policy".to_string(), |
| 2049 | value: format!( |
| 2050 | "{} · {}", |
| 2051 | app.approval_mode.permission_chip_label(), |
| 2052 | source.label() |
| 2053 | ), |
| 2054 | editable: false, |
| 2055 | scope: ConfigScope::Saved, |
| 2056 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy), |
| 2057 | }, |
| 2058 | }; |
| 2059 | let approval_session_editable = matches!(permission_control, ApprovalPolicyControl::Unset); |
| 2060 | let shell_control = config.allow_shell_control( |
| 2061 | app.config_path.as_deref(), |
| 2062 | app.config_profile.as_deref(), |
| 2063 | &app.workspace, |
| 2064 | ); |
| 2065 | let shell_row = if shell_control.editable_root() { |
| 2066 | ConfigRow { |
| 2067 | key: "allow_shell".to_string(), |
| 2068 | value: app.allow_shell.to_string(), |
| 2069 | editable: true, |
| 2070 | scope: ConfigScope::Saved, |
| 2071 | facts: ConfigRowFacts::saved_setting() |
| 2072 | .authority(SettingAuthority::WorkspaceConfiguration), |
| 2073 | } |
| 2074 | } else { |
| 2075 | ConfigRow { |
| 2076 | key: "managed_allow_shell".to_string(), |
| 2077 | value: format!("{} · {}", app.allow_shell, shell_control.label()), |
| 2078 | editable: false, |
| 2079 | scope: ConfigScope::Saved, |
| 2080 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy), |
| 2081 | } |
| 2082 | }; |
| 2083 | let (active_route_provider, _) = app.effective_route_display(); |
| 2084 | let (active_provider_identity, active_route_model) = app.effective_route_identity_display(); |
| 2085 | let mut rows = vec![ |
| 2086 | ConfigRow { |
| 2087 | key: "provider".to_string(), |
| 2088 | value: active_provider_identity.clone(), |
| 2089 | editable: true, |
| 2090 | scope: ConfigScope::Session, |
| 2091 | facts: ConfigRowFacts::session_setting() |
| 2092 | .snapshot(SnapshotLane::Provider) |
| 2093 | .opens("/provider", MessageId::ConfigActionOpenProvider), |
| 2094 | }, |
| 2095 | ConfigRow { |
| 2096 | key: config_base_url_row_key(active_route_provider).to_string(), |
| 2097 | value: config_base_url_row_value(app), |
| 2098 | // An endpoint is a route receipt, not a loose global knob. |
| 2099 | // `/provider` owns changing the credential, model, and endpoint |
| 2100 | // together; this row must not pretend that editing a live |
| 2101 | // receipt can mutate an already-running client. |
| 2102 | editable: false, |
| 2103 | scope: ConfigScope::Session, |
| 2104 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 2105 | }, |
| 2106 | ConfigRow { |
| 2107 | key: "context_window".to_string(), |
| 2108 | value: config |
| 2109 | .context_window_for_provider_config(active_route_provider) |
| 2110 | .map_or_else(|| "(not set)".to_string(), |tokens| tokens.to_string()), |
| 2111 | editable: false, |
| 2112 | scope: ConfigScope::Saved, |
| 2113 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration), |
| 2114 | }, |
| 2115 | ConfigRow { |
| 2116 | key: "effective_context_window".to_string(), |
| 2117 | value: format!( |
| 2118 | "{} tokens · {}", |
| 2119 | crate::route_budget::route_context_window_tokens( |
| 2120 | app.api_provider, |
| 2121 | app.effective_model_for_budget(), |
| 2122 | app.active_route_limits, |
| 2123 | ), |
| 2124 | app.active_context_window_source.display_label() |
| 2125 | ), |
| 2126 | editable: false, |
| 2127 | scope: ConfigScope::Session, |
| 2128 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 2129 | }, |
| 2130 | ConfigRow { |
| 2131 | key: "model".to_string(), |
| 2132 | // `·` keeps the row unambiguous when a provider display name |
| 2133 | // itself contains `/` (e.g. `Zhipu AI / Z.ai`). |
| 2134 | value: format!("{active_provider_identity} · {active_route_model}"), |
| 2135 | editable: true, |
| 2136 | scope: ConfigScope::Session, |
| 2137 | facts: ConfigRowFacts::session_setting() |
| 2138 | .snapshot(SnapshotLane::Model) |
| 2139 | .opens("/model", MessageId::ConfigActionOpenModel), |
| 2140 | }, |
| 2141 | // DeepSeek-only legacy fallback: hide on non-DeepSeek providers so |
| 2142 | // it is not misread as an active setting (#4717). Keep the field |
| 2143 | // and routing behavior; surface the row only for DeepSeek routes |
| 2144 | // (or when an explicit value is set and the operator needs to see it). |
| 2145 | // Built below after provider check so non-DeepSeek menus stay clean. |
| 2146 | ConfigRow { |
| 2147 | key: "reasoning_effort".to_string(), |
| 2148 | value: settings.reasoning_effort.as_deref().map_or_else( |
| 2149 | || tr(app.ui_locale, MessageId::ConfigDefaultReasoning).to_string(), |
| 2150 | |value| { |
| 2151 | crate::reasoning_preference::ReasoningEffort::from_setting_for_provider( |
| 2152 | value, |
| 2153 | app.api_provider, |
| 2154 | ) |
| 2155 | .as_setting_for_provider(app.api_provider) |
| 2156 | .to_string() |
| 2157 | }, |
| 2158 | ), |
| 2159 | editable: true, |
| 2160 | scope: ConfigScope::Saved, |
| 2161 | facts: ConfigRowFacts::saved_setting(), |
| 2162 | }, |
| 2163 | ConfigRow { |
| 2164 | key: "approval_mode".to_string(), |
| 2165 | value: app.approval_mode.permission_chip_label().to_string(), |
| 2166 | editable: approval_session_editable, |
| 2167 | scope: ConfigScope::Session, |
| 2168 | facts: ConfigRowFacts::session_setting(), |
| 2169 | }, |
| 2170 | saved_permission_row, |
| 2171 | ConfigRow { |
| 2172 | key: "default_mode".to_string(), |
| 2173 | value: settings.default_mode.clone(), |
| 2174 | editable: true, |
| 2175 | scope: ConfigScope::Saved, |
| 2176 | // The startup mode is read once when a session begins. |
| 2177 | facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession), |
| 2178 | }, |
| 2179 | shell_row, |
| 2180 | ConfigRow { |
| 2181 | key: "telemetry".to_string(), |
| 2182 | value: crate::telemetry_notice::saved_preference_enabled(&config).to_string(), |
| 2183 | editable: true, |
| 2184 | scope: ConfigScope::Saved, |
| 2185 | // Telemetry is a trust decision, not a network knob. |
| 2186 | facts: ConfigRowFacts::saved_setting() |
| 2187 | .authority(SettingAuthority::WorkspaceConfiguration), |
| 2188 | }, |
| 2189 | ConfigRow { |
| 2190 | key: "stream_chunk_timeout_secs".to_string(), |
| 2191 | value: app.stream_chunk_timeout_secs.to_string(), |
| 2192 | editable: true, |
| 2193 | scope: ConfigScope::Session, |
| 2194 | facts: ConfigRowFacts::session_setting(), |
| 2195 | }, |
| 2196 | ConfigRow { |
| 2197 | key: "theme".to_string(), |
| 2198 | value: settings.theme.clone(), |
| 2199 | editable: true, |
| 2200 | scope: ConfigScope::Saved, |
| 2201 | // The live theme is the one the app is painting with. |
| 2202 | facts: ConfigRowFacts::saved_setting().effective(app.theme_id.name()), |
| 2203 | }, |
| 2204 | ConfigRow { |
| 2205 | key: "locale".to_string(), |
| 2206 | value: settings.locale.clone(), |
| 2207 | editable: true, |
| 2208 | scope: ConfigScope::Saved, |
| 2209 | facts: ConfigRowFacts::saved_setting().effective(app.ui_locale.tag()), |
| 2210 | }, |
| 2211 | ConfigRow { |
| 2212 | key: "background_color".to_string(), |
| 2213 | value: settings.background_color.clone().unwrap_or_else(|| { |
| 2214 | tr(app.ui_locale, MessageId::ConfigDefaultValue).to_string() |
| 2215 | }), |
| 2216 | editable: true, |
| 2217 | scope: ConfigScope::Saved, |
| 2218 | facts: ConfigRowFacts::saved_setting(), |
| 2219 | }, |
| 2220 | ConfigRow { |
| 2221 | key: "focus_texture".to_string(), |
| 2222 | value: settings.focus_texture.clone(), |
| 2223 | editable: true, |
| 2224 | scope: ConfigScope::Saved, |
| 2225 | facts: ConfigRowFacts::saved_setting(), |
| 2226 | }, |
| 2227 | ConfigRow { |
| 2228 | key: "calm_mode".to_string(), |
| 2229 | value: settings.calm_mode.to_string(), |
| 2230 | editable: true, |
| 2231 | scope: ConfigScope::Saved, |
| 2232 | facts: ConfigRowFacts::saved_setting(), |
| 2233 | }, |
| 2234 | ConfigRow { |
| 2235 | key: "low_motion".to_string(), |
| 2236 | value: settings.low_motion.to_string(), |
| 2237 | editable: true, |
| 2238 | scope: ConfigScope::Saved, |
| 2239 | // `low_motion` always wins over fancy animations; both are |
| 2240 | // Motion, and their live values come from `App`, not disk. |
| 2241 | facts: motion_facts( |
| 2242 | ConfigRowFacts::saved_setting().effective(app.low_motion.to_string()), |
| 2243 | ), |
| 2244 | }, |
| 2245 | ConfigRow { |
| 2246 | key: "fancy_animations".to_string(), |
| 2247 | value: settings.fancy_animations.to_string(), |
| 2248 | editable: true, |
| 2249 | scope: ConfigScope::Saved, |
| 2250 | facts: motion_facts( |
| 2251 | ConfigRowFacts::saved_setting().effective(app.fancy_animations.to_string()), |
| 2252 | ), |
| 2253 | }, |
| 2254 | // `launch_screen` is a retired setting: accepted on load, dropped |
| 2255 | // on save — no config row (main's retirement wins over the |
| 2256 | // branch's stale row). |
| 2257 | ConfigRow { |
| 2258 | key: "show_thinking".to_string(), |
| 2259 | value: settings.show_thinking.to_string(), |
| 2260 | editable: true, |
| 2261 | scope: ConfigScope::Saved, |
| 2262 | facts: ConfigRowFacts::saved_setting(), |
| 2263 | }, |
| 2264 | ConfigRow { |
| 2265 | key: "thinking_default_expanded".to_string(), |
| 2266 | value: settings.thinking_default_expanded.to_string(), |
| 2267 | editable: true, |
| 2268 | scope: ConfigScope::Saved, |
| 2269 | facts: ConfigRowFacts::saved_setting(), |
| 2270 | }, |
| 2271 | ConfigRow { |
| 2272 | key: "thinking_preview_lines".to_string(), |
| 2273 | value: settings.thinking_preview_lines.to_string(), |
| 2274 | editable: true, |
| 2275 | scope: ConfigScope::Saved, |
| 2276 | facts: ConfigRowFacts::saved_setting(), |
| 2277 | }, |
| 2278 | ConfigRow { |
| 2279 | key: "thinking_highlight".to_string(), |
| 2280 | value: settings.thinking_highlight.to_string(), |
| 2281 | editable: true, |
| 2282 | scope: ConfigScope::Saved, |
| 2283 | facts: ConfigRowFacts::saved_setting(), |
| 2284 | }, |
| 2285 | ConfigRow { |
| 2286 | key: "help_expand_groups".to_string(), |
| 2287 | value: settings.help_expand_groups.to_string(), |
| 2288 | editable: true, |
| 2289 | scope: ConfigScope::Saved, |
| 2290 | facts: ConfigRowFacts::saved_setting(), |
| 2291 | }, |
| 2292 | ConfigRow { |
| 2293 | key: "contextual_tips".to_string(), |
| 2294 | value: settings.contextual_tips.to_string(), |
| 2295 | editable: true, |
| 2296 | scope: ConfigScope::Saved, |
| 2297 | facts: ConfigRowFacts::saved_setting() |
| 2298 | .effective(app.behavioral_tips.enabled().to_string()), |
| 2299 | }, |
| 2300 | ConfigRow { |
| 2301 | key: "pin_last_prompt".to_string(), |
| 2302 | value: settings.pin_last_prompt.to_string(), |
| 2303 | editable: true, |
| 2304 | scope: ConfigScope::Saved, |
| 2305 | facts: ConfigRowFacts::saved_setting(), |
| 2306 | }, |
| 2307 | ConfigRow { |
| 2308 | key: "show_tool_details".to_string(), |
| 2309 | value: settings.show_tool_details.to_string(), |
| 2310 | editable: true, |
| 2311 | scope: ConfigScope::Saved, |
| 2312 | facts: ConfigRowFacts::saved_setting(), |
| 2313 | }, |
| 2314 | ConfigRow { |
| 2315 | key: "inline_diffs".to_string(), |
| 2316 | value: settings.inline_diffs.clone(), |
| 2317 | editable: true, |
| 2318 | scope: ConfigScope::Saved, |
| 2319 | facts: ConfigRowFacts::saved_setting(), |
| 2320 | }, |
| 2321 | ConfigRow { |
| 2322 | key: "status_indicator".to_string(), |
| 2323 | value: settings.status_indicator.clone(), |
| 2324 | editable: true, |
| 2325 | scope: ConfigScope::Saved, |
| 2326 | facts: ConfigRowFacts::saved_setting(), |
| 2327 | }, |
| 2328 | ConfigRow { |
| 2329 | key: "synchronized_output".to_string(), |
| 2330 | value: settings.synchronized_output.clone(), |
| 2331 | editable: true, |
| 2332 | scope: ConfigScope::Saved, |
| 2333 | facts: ConfigRowFacts::saved_setting(), |
| 2334 | }, |
| 2335 | ConfigRow { |
| 2336 | key: "cost_currency".to_string(), |
| 2337 | value: settings.cost_currency.clone(), |
| 2338 | editable: true, |
| 2339 | scope: ConfigScope::Saved, |
| 2340 | facts: ConfigRowFacts::saved_setting().effective(cost_currency_config_value(app)), |
| 2341 | }, |
| 2342 | ConfigRow { |
| 2343 | key: "transcript_spacing".to_string(), |
| 2344 | value: settings.transcript_spacing.clone(), |
| 2345 | editable: true, |
| 2346 | scope: ConfigScope::Saved, |
| 2347 | facts: ConfigRowFacts::saved_setting(), |
| 2348 | }, |
| 2349 | ConfigRow { |
| 2350 | key: "tool_collapse".to_string(), |
| 2351 | value: settings.tool_collapse_mode.clone(), |
| 2352 | editable: true, |
| 2353 | scope: ConfigScope::Saved, |
| 2354 | facts: ConfigRowFacts::saved_setting(), |
| 2355 | }, |
| 2356 | ConfigRow { |
| 2357 | key: "composer_density".to_string(), |
| 2358 | value: settings.composer_density.clone(), |
| 2359 | editable: true, |
| 2360 | scope: ConfigScope::Saved, |
| 2361 | facts: ConfigRowFacts::saved_setting(), |
| 2362 | }, |
| 2363 | ConfigRow { |
| 2364 | key: "composer_border".to_string(), |
| 2365 | value: settings.composer_border.to_string(), |
| 2366 | editable: true, |
| 2367 | scope: ConfigScope::Saved, |
| 2368 | facts: ConfigRowFacts::saved_setting(), |
| 2369 | }, |
| 2370 | ConfigRow { |
| 2371 | key: "composer_multiline_mode".to_string(), |
| 2372 | value: settings.composer_multiline_mode.to_string(), |
| 2373 | editable: true, |
| 2374 | scope: ConfigScope::Saved, |
| 2375 | facts: ConfigRowFacts::saved_setting(), |
| 2376 | }, |
| 2377 | ConfigRow { |
| 2378 | key: "composer_vim_mode".to_string(), |
| 2379 | value: settings.composer_vim_mode.clone(), |
| 2380 | editable: true, |
| 2381 | scope: ConfigScope::Saved, |
| 2382 | facts: ConfigRowFacts::saved_setting(), |
| 2383 | }, |
| 2384 | ConfigRow { |
| 2385 | key: "bracketed_paste".to_string(), |
| 2386 | value: settings.bracketed_paste.to_string(), |
| 2387 | editable: true, |
| 2388 | scope: ConfigScope::Saved, |
| 2389 | facts: ConfigRowFacts::saved_setting(), |
| 2390 | }, |
| 2391 | ConfigRow { |
| 2392 | key: "paste_burst_detection".to_string(), |
| 2393 | value: settings.paste_burst_detection.to_string(), |
| 2394 | editable: true, |
| 2395 | scope: ConfigScope::Saved, |
| 2396 | facts: ConfigRowFacts::saved_setting(), |
| 2397 | }, |
| 2398 | ConfigRow { |
| 2399 | key: "mention_menu_limit".to_string(), |
| 2400 | value: settings.mention_menu_limit.to_string(), |
| 2401 | editable: true, |
| 2402 | scope: ConfigScope::Saved, |
| 2403 | facts: ConfigRowFacts::saved_setting(), |
| 2404 | }, |
| 2405 | ConfigRow { |
| 2406 | key: "mention_menu_behavior".to_string(), |
| 2407 | value: settings.mention_menu_behavior.clone(), |
| 2408 | editable: true, |
| 2409 | scope: ConfigScope::Saved, |
| 2410 | facts: ConfigRowFacts::saved_setting(), |
| 2411 | }, |
| 2412 | ConfigRow { |
| 2413 | key: "mention_walk_depth".to_string(), |
| 2414 | value: settings.mention_walk_depth.to_string(), |
| 2415 | editable: true, |
| 2416 | scope: ConfigScope::Saved, |
| 2417 | facts: ConfigRowFacts::saved_setting(), |
| 2418 | }, |
| 2419 | ConfigRow { |
| 2420 | key: "workspace_follow_symlinks".to_string(), |
| 2421 | value: settings.workspace_follow_symlinks.to_string(), |
| 2422 | editable: true, |
| 2423 | scope: ConfigScope::Saved, |
| 2424 | // Mention menus follow it now; engine tools read it at startup. |
| 2425 | facts: ConfigRowFacts::saved_setting() |
| 2426 | .apply(SettingApplySemantics::UiNowEngineRestart), |
| 2427 | }, |
| 2428 | ConfigRow { |
| 2429 | key: "work_surface_placement".to_string(), |
| 2430 | value: settings.work_surface_placement.clone(), |
| 2431 | editable: true, |
| 2432 | scope: ConfigScope::Saved, |
| 2433 | facts: ConfigRowFacts::saved_setting(), |
| 2434 | }, |
| 2435 | ConfigRow { |
| 2436 | key: "work_surface_top_height".to_string(), |
| 2437 | value: settings.work_surface_top_height.to_string(), |
| 2438 | editable: true, |
| 2439 | scope: ConfigScope::Saved, |
| 2440 | facts: ConfigRowFacts::saved_setting(), |
| 2441 | }, |
| 2442 | ConfigRow { |
| 2443 | key: "work_surface_side_width".to_string(), |
| 2444 | value: settings.work_surface_side_width.to_string(), |
| 2445 | editable: true, |
| 2446 | scope: ConfigScope::Saved, |
| 2447 | facts: ConfigRowFacts::saved_setting(), |
| 2448 | }, |
| 2449 | ConfigRow { |
| 2450 | key: "rail_panel".to_string(), |
| 2451 | value: settings.rail_panel.clone(), |
| 2452 | editable: true, |
| 2453 | scope: ConfigScope::Saved, |
| 2454 | facts: ConfigRowFacts::saved_setting(), |
| 2455 | }, |
| 2456 | ConfigRow { |
| 2457 | key: "context_panel".to_string(), |
| 2458 | value: settings.context_panel.to_string(), |
| 2459 | editable: true, |
| 2460 | scope: ConfigScope::Saved, |
| 2461 | facts: ConfigRowFacts::saved_setting(), |
| 2462 | }, |
| 2463 | ConfigRow { |
| 2464 | key: "sessions_rail".to_string(), |
| 2465 | value: settings.sessions_rail.to_string(), |
| 2466 | editable: true, |
| 2467 | scope: ConfigScope::Saved, |
| 2468 | facts: ConfigRowFacts::saved_setting(), |
| 2469 | }, |
| 2470 | // Read at startup by `main`, not held on `App`, so the row reflects |
| 2471 | // the persisted value rather than a live field (#2934). |
| 2472 | ConfigRow { |
| 2473 | key: "session_auto_resume".to_string(), |
| 2474 | value: settings.session_auto_resume.to_string(), |
| 2475 | editable: true, |
| 2476 | scope: ConfigScope::Saved, |
| 2477 | facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession), |
| 2478 | }, |
| 2479 | ConfigRow { |
| 2480 | key: "auto_compact".to_string(), |
| 2481 | value: settings.auto_compact.to_string(), |
| 2482 | editable: true, |
| 2483 | scope: ConfigScope::Saved, |
| 2484 | facts: ConfigRowFacts::saved_setting(), |
| 2485 | }, |
| 2486 | ConfigRow { |
| 2487 | key: "auto_compact_threshold_percent".to_string(), |
| 2488 | value: format!("{:.0}", settings.auto_compact_threshold_percent), |
| 2489 | editable: true, |
| 2490 | scope: ConfigScope::Saved, |
| 2491 | facts: ConfigRowFacts::saved_setting(), |
| 2492 | }, |
| 2493 | ConfigRow { |
| 2494 | key: "max_history".to_string(), |
| 2495 | value: settings.max_input_history.to_string(), |
| 2496 | editable: true, |
| 2497 | scope: ConfigScope::Saved, |
| 2498 | facts: ConfigRowFacts::saved_setting(), |
| 2499 | }, |
| 2500 | ConfigRow { |
| 2501 | key: "mcp_open".to_string(), |
| 2502 | value: "/mcp".to_string(), |
| 2503 | editable: true, |
| 2504 | scope: ConfigScope::Session, |
| 2505 | facts: ConfigRowFacts::action("/mcp", MessageId::ConfigActionOpenMcp), |
| 2506 | }, |
| 2507 | ConfigRow { |
| 2508 | key: "mcp_reconnect".to_string(), |
| 2509 | value: "/mcp reload".to_string(), |
| 2510 | editable: true, |
| 2511 | scope: ConfigScope::Session, |
| 2512 | facts: ConfigRowFacts::action("/mcp reload", MessageId::ConfigActionMcpReconnect), |
| 2513 | }, |
| 2514 | ConfigRow { |
| 2515 | key: "mcp_diagnose".to_string(), |
| 2516 | value: "/mcp validate".to_string(), |
| 2517 | editable: true, |
| 2518 | scope: ConfigScope::Session, |
| 2519 | facts: ConfigRowFacts::action("/mcp validate", MessageId::ConfigActionMcpDiagnose), |
| 2520 | }, |
| 2521 | ConfigRow { |
| 2522 | key: "plugins_open".to_string(), |
| 2523 | value: "/plugin".to_string(), |
| 2524 | editable: true, |
| 2525 | scope: ConfigScope::Session, |
| 2526 | facts: ConfigRowFacts::action("/plugin", MessageId::ConfigActionOpenPlugins), |
| 2527 | }, |
| 2528 | ConfigRow { |
| 2529 | key: "mcp_config_path".to_string(), |
| 2530 | value: app.mcp_config_path.display().to_string(), |
| 2531 | editable: true, |
| 2532 | scope: ConfigScope::Saved, |
| 2533 | // The live path changes on save; running servers keep their |
| 2534 | // old config until `/mcp reload`. |
| 2535 | facts: ConfigRowFacts::saved_setting() |
| 2536 | .authority(SettingAuthority::WorkspaceConfiguration) |
| 2537 | .apply(SettingApplySemantics::ReloadRequired), |
| 2538 | }, |
| 2539 | ConfigRow { |
| 2540 | key: "fleet.exec.max_spawn_depth".to_string(), |
| 2541 | value: config |
| 2542 | .fleet |
| 2543 | .as_ref() |
| 2544 | .map(|fleet| fleet.exec.max_spawn_depth) |
| 2545 | .unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth) |
| 2546 | .to_string(), |
| 2547 | editable: false, |
| 2548 | scope: ConfigScope::Saved, |
| 2549 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration), |
| 2550 | }, |
| 2551 | ]; |
| 2552 | // The DeepSeek-only legacy fallback stays a persisted runtime key but |
| 2553 | // has no settings row: it is not a live choice on any provider, and |
| 2554 | // a leftover value is cleared with `/set default_model` instead of |
| 2555 | // a Legacy table section. |
| 2556 | let external_status_rows = [ApiProvider::OpenaiCodex, ApiProvider::Xai] |
| 2557 | .into_iter() |
| 2558 | .filter_map(|provider| { |
| 2559 | config |
| 2560 | .external_credential_consent_status(provider) |
| 2561 | .map(|status| { |
| 2562 | let state = if status.route_state == "active" { |
| 2563 | tr(app.ui_locale, MessageId::CtxInspActive) |
| 2564 | } else { |
| 2565 | tr(app.ui_locale, MessageId::ProviderExternalDormant) |
| 2566 | }; |
| 2567 | let scope = tr(app.ui_locale, MessageId::ProviderExternalDetailScope) |
| 2568 | .replace("{access}", status.access.as_str()) |
| 2569 | .replace("{provider}", &status.provider) |
| 2570 | .replace("{source}", status.source.as_str()) |
| 2571 | .replace("{version}", &status.consent_version.to_string()) |
| 2572 | .replace("{state}", &state); |
| 2573 | let owner_path = tr(app.ui_locale, MessageId::ProviderExternalOwnerPath) |
| 2574 | .replace("{owner}", status.owner) |
| 2575 | .replace("{path}", &codewhale_config::quote_os_path(&status.path)); |
| 2576 | let pinned_warning = status.ambient_path_changed.then(|| { |
| 2577 | tr(app.ui_locale, MessageId::ProviderExternalPinnedPathWarning) |
| 2578 | .replace("{owner}", status.owner) |
| 2579 | .replace("{path}", &codewhale_config::quote_os_path(&status.path)) |
| 2580 | }); |
| 2581 | let semantics = match status.access { |
| 2582 | codewhale_config::ExternalCredentialAccess::Disabled => { |
| 2583 | tr(app.ui_locale, MessageId::ProviderExternalDisabledDetail) |
| 2584 | } |
| 2585 | codewhale_config::ExternalCredentialAccess::ReadOnly => { |
| 2586 | tr(app.ui_locale, MessageId::ProviderExternalReadOnlySemantics) |
| 2587 | } |
| 2588 | codewhale_config::ExternalCredentialAccess::Managed => { |
| 2589 | tr(app.ui_locale, MessageId::ProviderExternalManagedDetail) |
| 2590 | } |
| 2591 | }; |
| 2592 | let semantics_revoke = |
| 2593 | tr(app.ui_locale, MessageId::ProviderExternalSemanticsRevoke) |
| 2594 | .replace("{semantics}", &semantics) |
| 2595 | .replace("{revoke}", &status.revoke_command); |
| 2596 | ConfigRow { |
| 2597 | key: format!("external_credentials.{}", provider.as_str()), |
| 2598 | value: match pinned_warning { |
| 2599 | Some(warning) => format!( |
| 2600 | "{scope} · {owner_path} · {warning} · {semantics_revoke}" |
| 2601 | ), |
| 2602 | None => format!("{scope} · {owner_path} · {semantics_revoke}"), |
| 2603 | }, |
| 2604 | editable: false, |
| 2605 | scope: ConfigScope::Saved, |
| 2606 | facts: ConfigRowFacts::diagnostic( |
| 2607 | SettingAuthority::WorkspaceConfiguration, |
| 2608 | ), |
| 2609 | } |
| 2610 | }) |
| 2611 | }); |
| 2612 | rows.splice(2..2, external_status_rows); |
| 2613 | // An explanation route, never an editable sandbox policy. The existing |
| 2614 | // status report owns the observed platform/backend enforcement facts. |
| 2615 | rows.push(ConfigRow { |
| 2616 | key: "sandbox_details".into(), |
| 2617 | value: "/status".into(), |
| 2618 | editable: true, |
| 2619 | scope: ConfigScope::Session, |
| 2620 | facts: ConfigRowFacts::action("/status", MessageId::AutomationActionInspect), |
| 2621 | }); |
| 2622 | rows.extend(experimental_config_rows(&config)); |
| 2623 | rows.extend( |
| 2624 | codewhale_config::notifications::NotificationSetting::ALL |
| 2625 | .into_iter() |
| 2626 | .map(|setting| ConfigRow { |
| 2627 | key: format!("notifications.{}", setting.key()), |
| 2628 | value: config.notifications_config().display(setting), |
| 2629 | editable: true, |
| 2630 | scope: ConfigScope::Saved, |
| 2631 | facts: ConfigRowFacts::saved_setting() |
| 2632 | .authority(SettingAuthority::WorkspaceConfiguration) |
| 2633 | .effective(app.notification_settings.display(setting)), |
| 2634 | }), |
| 2635 | ); |
| 2636 | |
| 2637 | // The schema decides what is shown and in what order. A row whose key |
| 2638 | // carries no `ui` block is declared but not browsable (it stays |
| 2639 | // settable through `/set`); a row the schema does not declare at all |
| 2640 | // cannot be placed, labelled, or edited, so it is not painted either. |
| 2641 | rows.retain(|row| row.ui().is_some()); |
| 2642 | rows.sort_by_key(|row| codewhale_config::setting_index(&row.key).unwrap_or(usize::MAX)); |
| 2643 | |
| 2644 | // A row whose store failed to load carries the error instead of a |
| 2645 | // default: its value reads unavailable, it is not editable (writing |
| 2646 | // through an unreadable store could clobber it), and its saved and |
| 2647 | // startup lanes are reported as unavailable. Keyed on the row's |
| 2648 | // *store*, not its authority: an environment/terminal override wins |
| 2649 | // the effective decision without making the store any less broken |
| 2650 | // (caught by Windows CI, where the legacy-console probe relabels the |
| 2651 | // motion rows' authority and a broken store then went unreported). |
| 2652 | let unavailable = tr(app.ui_locale, MessageId::ConfigUnavailable).into_owned(); |
| 2653 | for row in &mut rows { |
| 2654 | let error = match row.facts.store { |
| 2655 | SettingStore::UserSettings => settings_error.as_deref(), |
| 2656 | SettingStore::WorkspaceConfig => config_error.as_deref(), |
| 2657 | SettingStore::None => None, |
| 2658 | }; |
| 2659 | if let Some(error) = error |
| 2660 | && row.facts.kind == ConfigRowKind::Setting |
| 2661 | { |
| 2662 | row.facts = row.facts.clone().unavailable(error); |
| 2663 | row.editable = false; |
| 2664 | row.value = unavailable.clone(); |
| 2665 | } |
| 2666 | } |
| 2667 | |
| 2668 | let mut view = Self { |
| 2669 | rows, |
| 2670 | selected: 0, |
| 2671 | scroll: 0, |
| 2672 | editing: None, |
| 2673 | filter: String::new(), |
| 2674 | status: None, |
| 2675 | locale: app.ui_locale, |
| 2676 | last_visible_rows: Cell::new(0), |
| 2677 | last_render_scroll: Cell::new(0), |
| 2678 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 2679 | last_choice_hitboxes: RefCell::new(Vec::new()), |
| 2680 | last_editor_controls: RefCell::new(Vec::new()), |
| 2681 | last_rail_hitboxes: RefCell::new(Vec::new()), |
| 2682 | last_nav_controls: RefCell::new(Vec::new()), |
| 2683 | last_mouse_selected: None, |
| 2684 | hovered_row: None, |
| 2685 | hovered_rail: None, |
| 2686 | hovered_nav: None, |
| 2687 | hovered_editor: None, |
| 2688 | hovered_choice: None, |
| 2689 | api_provider: app.api_provider, |
| 2690 | route_base_url: app.active_route_base_url.clone(), |
| 2691 | route_model: app.model.clone(), |
| 2692 | auto_model: app.auto_model, |
| 2693 | // Settings opens on Appearance (design: first rail category). |
| 2694 | category: ConfigCategory::Appearance, |
| 2695 | snapshot: UiSnapshot::from_app(app), |
| 2696 | }; |
| 2697 | view.select_first_visible_row(); |
| 2698 | view |
| 2699 | } |
| 2700 | |
| 2701 | fn tr(&self, id: MessageId) -> Cow<'static, str> { |
| 2702 | tr(self.locale, id) |
| 2703 | } |
| 2704 | |
| 2705 | /// Keep the user's place when the host rebuilds this view after applying |
| 2706 | /// a setting to the live app. |
| 2707 | pub(crate) fn focus_key(&mut self, key: &str) { |
| 2708 | if let Some(index) = self.rows.iter().position(|row| row.key == key) { |
| 2709 | self.category = ConfigCategory::for_row(&self.rows[index]); |
| 2710 | self.selected = index; |
| 2711 | self.last_mouse_selected = None; |
| 2712 | self.clear_hover(); |
| 2713 | self.adjust_scroll(self.visible_rows_cached()); |
| 2714 | } |
| 2715 | } |
| 2716 | |
| 2717 | /// Snapshot the active search so live config updates can rebuild the |
| 2718 | /// modal without making the user's filtered result set jump away. |
| 2719 | pub(crate) fn filter_query(&self) -> &str { |
| 2720 | &self.filter |
| 2721 | } |
| 2722 | |
| 2723 | /// The key whose inline editor is open, if any. Exposes the transient |
| 2724 | /// editing state to the host-level tests in `tui::ui::tests` that drive |
| 2725 | /// the real `refresh_config_view_if_open` path (#theme-nav-exit). |
| 2726 | /// |
| 2727 | /// Test-only: production code reads `editing` directly, and a non-test lib |
| 2728 | /// build would flag this as dead code under `-D warnings`. |
| 2729 | #[cfg(test)] |
| 2730 | pub(crate) fn editing_key(&self) -> Option<&str> { |
| 2731 | self.editing.as_ref().map(|edit| edit.key.as_str()) |
| 2732 | } |
| 2733 | |
| 2734 | /// The highlighted choice index inside the open editor, if any. |
| 2735 | /// |
| 2736 | /// Test-only; see [`Self::editing_key`]. |
| 2737 | #[cfg(test)] |
| 2738 | pub(crate) fn editing_selected_choice(&self) -> Option<usize> { |
| 2739 | self.editing.as_ref().map(|edit| edit.selected_choice) |
| 2740 | } |
| 2741 | |
| 2742 | /// Rebuild after the host applied a setting (`refresh_config_view_if_open`) |
| 2743 | /// while keeping the open editor alive. |
| 2744 | /// |
| 2745 | /// The theme editor live-previews on every highlight, and each preview is a |
| 2746 | /// `ConfigUpdated` that lands here again. A bare `new_for_app` dropped |
| 2747 | /// `editing`, so the first arrow key closed the editor, the next one fell |
| 2748 | /// through to the non-editing key map (where Left/Right switch category), |
| 2749 | /// and `selected_choice` snapped back to 0. The user saw the highlight leap |
| 2750 | /// away from the row they were on — the theme never moved (#theme-nav-exit). |
| 2751 | /// |
| 2752 | /// The rows themselves must come from the refreshed snapshot: a persisted |
| 2753 | /// commit changes the value on disk and the row has to show it. Only the |
| 2754 | /// transient editing state is carried over. |
| 2755 | pub(crate) fn rebuild_preserving(app: &App, previous: &Self, focus_key: &str) -> Self { |
| 2756 | let mut view = Self::new_for_app(app); |
| 2757 | view.restore_filter(previous.filter_query().to_string()); |
| 2758 | view.focus_key(focus_key); |
| 2759 | let carried = match &previous.editing { |
| 2760 | // A row that vanished from the refreshed snapshot (filter, scope or |
| 2761 | // availability changed underneath) has no editor to belong to. |
| 2762 | Some(edit) => view |
| 2763 | .rows |
| 2764 | .iter() |
| 2765 | .position(|row| row.key == edit.key) |
| 2766 | .map(|index| (index, edit.clone())), |
| 2767 | None => None, |
| 2768 | }; |
| 2769 | match carried { |
| 2770 | Some((index, mut edit)) => { |
| 2771 | // The highlight is the user's cursor, not a disk fact: keep it |
| 2772 | // inside the refreshed choice list instead of resetting it. |
| 2773 | let choices_len = edit.choices.as_ref().map_or(0, Vec::len); |
| 2774 | if edit.selected_choice >= choices_len { |
| 2775 | edit.selected_choice = choices_len.saturating_sub(1); |
| 2776 | } |
| 2777 | view.selected = index; |
| 2778 | view.editing = Some(edit); |
| 2779 | } |
| 2780 | None => view.editing = None, |
| 2781 | } |
| 2782 | view |
| 2783 | } |
| 2784 | |
| 2785 | pub(crate) fn restore_filter(&mut self, filter: String) { |
| 2786 | self.update_filter(|current| *current = filter); |
| 2787 | } |
| 2788 | |
| 2789 | fn visible_rows_cached(&self) -> usize { |
| 2790 | let cached = self.last_visible_rows.get(); |
| 2791 | if cached == 0 { 8 } else { cached } |
| 2792 | } |
| 2793 | |
| 2794 | /// The lowercased search terms for the current filter, computed once per |
| 2795 | /// interaction instead of once per row per pass (#6213 T6). |
| 2796 | fn filter_terms(&self) -> Vec<String> { |
| 2797 | self.filter |
| 2798 | .trim() |
| 2799 | .to_lowercase() |
| 2800 | .split_whitespace() |
| 2801 | .map(str::to_string) |
| 2802 | .collect() |
| 2803 | } |
| 2804 | |
| 2805 | fn row_matches_filter(&self, row: &ConfigRow, terms: &[String]) -> bool { |
| 2806 | if terms.is_empty() { |
| 2807 | return true; |
| 2808 | } |
| 2809 | |
| 2810 | let meta = SettingsRegistry::new(self).meta(row); |
| 2811 | let section = meta.category.label(self.locale).to_lowercase(); |
| 2812 | let section_en = meta.category.label(Locale::En).to_lowercase(); |
| 2813 | let category = ConfigCategory::for_row(row); |
| 2814 | let category_label = category.label(self.locale).to_lowercase(); |
| 2815 | let category_en = category.label(Locale::En).to_lowercase(); |
| 2816 | let label = config_label_for_key_for_locale(self.locale, &row.key).to_lowercase(); |
| 2817 | let key = row.key.to_lowercase(); |
| 2818 | let raw_value = row.value.to_lowercase(); |
| 2819 | let value = self.row_display_value(row).to_lowercase(); |
| 2820 | let scope = row.scope.label(self.locale).to_lowercase(); |
| 2821 | let scope_en = row.scope.label(Locale::En).to_lowercase(); |
| 2822 | let hint = config_hint_for_key(self.locale, &row.key).to_lowercase(); |
| 2823 | |
| 2824 | let explanation_terms = if row.key == "sandbox_details" { |
| 2825 | "sandbox filesystem unenforced isolation bubblewrap bwrap doctor" |
| 2826 | } else { |
| 2827 | "" |
| 2828 | }; |
| 2829 | terms.iter().all(|term| { |
| 2830 | explanation_terms.contains(term) |
| 2831 | || section.contains(term) |
| 2832 | || section_en.contains(term) |
| 2833 | || category_label.contains(term) |
| 2834 | || category_en.contains(term) |
| 2835 | || label.contains(term) |
| 2836 | || key.contains(term) |
| 2837 | || raw_value.contains(term) |
| 2838 | || value.contains(term) |
| 2839 | || scope.contains(term) |
| 2840 | || scope_en.contains(term) |
| 2841 | || hint.contains(term) |
| 2842 | }) |
| 2843 | } |
| 2844 | |
| 2845 | fn matching_row_indices(&self) -> Vec<usize> { |
| 2846 | let filtering = !self.filter.is_empty(); |
| 2847 | let terms = self.filter_terms(); |
| 2848 | self.rows |
| 2849 | .iter() |
| 2850 | .enumerate() |
| 2851 | .filter_map(|(idx, row)| { |
| 2852 | (self.row_matches_filter(row, &terms) && (filtering || self.category.contains(row))) |
| 2853 | .then_some(idx) |
| 2854 | }) |
| 2855 | .collect() |
| 2856 | } |
| 2857 | |
| 2858 | fn visible_items(&self) -> Vec<ConfigListItem> { |
| 2859 | let mut items = Vec::new(); |
| 2860 | let mut current_section = None; |
| 2861 | let filtering = !self.filter.is_empty(); |
| 2862 | |
| 2863 | let terms = self.filter_terms(); |
| 2864 | for (idx, row) in self.rows.iter().enumerate() { |
| 2865 | if !self.row_matches_filter(row, &terms) { |
| 2866 | continue; |
| 2867 | } |
| 2868 | // The rail category filters rows unless the user is searching. |
| 2869 | if !filtering && !self.category.contains(row) { |
| 2870 | continue; |
| 2871 | } |
| 2872 | |
| 2873 | if current_section != Some(row.section()) { |
| 2874 | current_section = Some(row.section()); |
| 2875 | items.push(ConfigListItem::Section(row.section())); |
| 2876 | } |
| 2877 | items.push(ConfigListItem::Row(idx)); |
| 2878 | } |
| 2879 | |
| 2880 | items |
| 2881 | } |
| 2882 | |
| 2883 | fn select_first_visible_row(&mut self) { |
| 2884 | if let Some(idx) = self |
| 2885 | .visible_items() |
| 2886 | .into_iter() |
| 2887 | .find_map(|item| match item { |
| 2888 | ConfigListItem::Row(i) => Some(i), |
| 2889 | ConfigListItem::Section(_) => None, |
| 2890 | }) |
| 2891 | { |
| 2892 | self.selected = idx; |
| 2893 | self.scroll = 0; |
| 2894 | } |
| 2895 | self.last_mouse_selected = None; |
| 2896 | self.clear_hover(); |
| 2897 | } |
| 2898 | |
| 2899 | fn key_column_width(&self) -> usize { |
| 2900 | self.rows |
| 2901 | .iter() |
| 2902 | .map(|row| { |
| 2903 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 2904 | UnicodeWidthStr::width(label.as_str()) |
| 2905 | }) |
| 2906 | .max() |
| 2907 | .unwrap_or(CONFIG_MIN_KEY_COLUMN_WIDTH) |
| 2908 | .max(CONFIG_MIN_KEY_COLUMN_WIDTH) |
| 2909 | } |
| 2910 | |
| 2911 | fn table_column_widths(&self, content_width: usize) -> (usize, usize, usize) { |
| 2912 | // The affordance glyph is the interaction; the scope badge is |
| 2913 | // secondary and sheds first so narrow lists keep a readable value. |
| 2914 | let scope_width = if content_width >= CONFIG_SCOPE_BADGE_MIN_WIDTH { |
| 2915 | CONFIG_SCOPE_COLUMN_WIDTH |
| 2916 | } else { |
| 2917 | 0 |
| 2918 | }; |
| 2919 | let fixed_width = CONFIG_ROW_PREFIX_WIDTH |
| 2920 | + CONFIG_COLUMN_GAPS_WIDTH |
| 2921 | + CONFIG_AFFORDANCE_COLUMN_WIDTH |
| 2922 | + scope_width; |
| 2923 | let key_value_width = content_width.saturating_sub(fixed_width); |
| 2924 | let desired_key_width = self.key_column_width(); |
| 2925 | |
| 2926 | if key_value_width == 0 { |
| 2927 | return (0, 0, scope_width); |
| 2928 | } |
| 2929 | |
| 2930 | let minimum_key_width = CONFIG_MIN_KEY_COLUMN_WIDTH.min(key_value_width); |
| 2931 | let key_width = desired_key_width |
| 2932 | .min(key_value_width.saturating_sub(CONFIG_MIN_VALUE_COLUMN_WIDTH)) |
| 2933 | .max(minimum_key_width); |
| 2934 | let value_width = key_value_width |
| 2935 | .saturating_sub(key_width) |
| 2936 | .min(CONFIG_VALUE_COLUMN_WIDTH); |
| 2937 | |
| 2938 | (key_width, value_width, scope_width) |
| 2939 | } |
| 2940 | |
| 2941 | fn selected_row_index(&self) -> Option<usize> { |
| 2942 | let selected = self.selected; |
| 2943 | self.matching_row_indices() |
| 2944 | .into_iter() |
| 2945 | .any(|idx| idx == selected) |
| 2946 | .then_some(selected) |
| 2947 | } |
| 2948 | |
| 2949 | fn selected_display_position(&self, items: &[ConfigListItem]) -> Option<usize> { |
| 2950 | items |
| 2951 | .iter() |
| 2952 | .position(|item| matches!(item, ConfigListItem::Row(idx) if *idx == self.selected)) |
| 2953 | } |
| 2954 | |
| 2955 | fn sync_selection_to_filter(&mut self) { |
| 2956 | let matches = self.matching_row_indices(); |
| 2957 | if matches.is_empty() { |
| 2958 | self.selected = 0; |
| 2959 | self.scroll = 0; |
| 2960 | return; |
| 2961 | } |
| 2962 | |
| 2963 | if !matches.contains(&self.selected) { |
| 2964 | self.selected = matches[0]; |
| 2965 | } |
| 2966 | } |
| 2967 | |
| 2968 | /// Clear every hover tint: selection moves, filters, and scrolls can all |
| 2969 | /// shift painted rows out from under a stationary pointer. |
| 2970 | fn clear_hover(&mut self) { |
| 2971 | self.hovered_row = None; |
| 2972 | self.hovered_rail = None; |
| 2973 | self.hovered_nav = None; |
| 2974 | self.hovered_editor = None; |
| 2975 | self.hovered_choice = None; |
| 2976 | } |
| 2977 | |
| 2978 | /// Hover pass: tint whatever the pointer is over using the shared hover |
| 2979 | /// style. Hover never moves the keyboard selection and never activates. |
| 2980 | fn track_hover(&mut self, mouse: MouseEvent) { |
| 2981 | let position = Position::new(mouse.column, mouse.row); |
| 2982 | if self.editing.is_some() { |
| 2983 | self.hovered_choice = self |
| 2984 | .last_choice_hitboxes |
| 2985 | .borrow() |
| 2986 | .iter() |
| 2987 | .find_map(|(rect, choice)| rect.contains(position).then_some(*choice)); |
| 2988 | self.hovered_editor = self |
| 2989 | .last_editor_controls |
| 2990 | .borrow() |
| 2991 | .iter() |
| 2992 | .find_map(|(rect, control)| rect.contains(position).then_some(*control)); |
| 2993 | self.hovered_row = None; |
| 2994 | self.hovered_rail = None; |
| 2995 | self.hovered_nav = None; |
| 2996 | return; |
| 2997 | } |
| 2998 | self.hovered_row = self |
| 2999 | .last_row_hitboxes |
| 3000 | .borrow() |
| 3001 | .iter() |
| 3002 | .find_map(|(rect, row_idx)| rect.contains(position).then_some(*row_idx)); |
| 3003 | self.hovered_rail = self |
| 3004 | .last_rail_hitboxes |
| 3005 | .borrow() |
| 3006 | .iter() |
| 3007 | .find_map(|(rect, category)| rect.contains(position).then_some(*category)); |
| 3008 | self.hovered_nav = self |
| 3009 | .last_nav_controls |
| 3010 | .borrow() |
| 3011 | .iter() |
| 3012 | .find_map(|(rect, step)| rect.contains(position).then_some(*step)); |
| 3013 | self.hovered_editor = None; |
| 3014 | self.hovered_choice = None; |
| 3015 | } |
| 3016 | |
| 3017 | fn update_filter(&mut self, update: impl FnOnce(&mut String)) { |
| 3018 | update(&mut self.filter); |
| 3019 | self.status = None; |
| 3020 | self.last_mouse_selected = None; |
| 3021 | self.clear_hover(); |
| 3022 | self.sync_selection_to_filter(); |
| 3023 | self.adjust_scroll(self.visible_rows_cached()); |
| 3024 | } |
| 3025 | |
| 3026 | fn adjust_scroll(&mut self, visible_rows: usize) { |
| 3027 | self.sync_selection_to_filter(); |
| 3028 | |
| 3029 | let items = self.visible_items(); |
| 3030 | if items.is_empty() { |
| 3031 | self.scroll = 0; |
| 3032 | return; |
| 3033 | } |
| 3034 | |
| 3035 | let visible_rows = visible_rows.max(1); |
| 3036 | let max_scroll = items.len().saturating_sub(visible_rows); |
| 3037 | self.scroll = self.scroll.min(max_scroll); |
| 3038 | |
| 3039 | let Some(selected_pos) = self.selected_display_position(&items) else { |
| 3040 | self.scroll = 0; |
| 3041 | return; |
| 3042 | }; |
| 3043 | |
| 3044 | if selected_pos < self.scroll { |
| 3045 | self.scroll = selected_pos; |
| 3046 | } |
| 3047 | |
| 3048 | if selected_pos >= self.scroll + visible_rows { |
| 3049 | self.scroll = selected_pos.saturating_sub(visible_rows.saturating_sub(1)); |
| 3050 | } |
| 3051 | } |
| 3052 | |
| 3053 | fn move_selection(&mut self, delta: isize) { |
| 3054 | let matches = self.matching_row_indices(); |
| 3055 | if matches.is_empty() { |
| 3056 | return; |
| 3057 | } |
| 3058 | |
| 3059 | let current = matches |
| 3060 | .iter() |
| 3061 | .position(|idx| *idx == self.selected) |
| 3062 | .unwrap_or(0); |
| 3063 | let next = crate::tui::list_nav::wrap_index(current, matches.len(), delta); |
| 3064 | |
| 3065 | self.selected = matches[next]; |
| 3066 | self.clear_hover(); |
| 3067 | let visible_rows = self.visible_rows_cached(); |
| 3068 | self.adjust_scroll(visible_rows); |
| 3069 | } |
| 3070 | |
| 3071 | fn toggle_selected_boolean(&self) -> Option<ViewAction> { |
| 3072 | let row = self.rows.get(self.selected_row_index()?)?; |
| 3073 | if SettingsRegistry::new(self).meta(row).kind != SettingKind::Boolean { |
| 3074 | return None; |
| 3075 | } |
| 3076 | let value = if canonical_config_choice(&row.key, row.edit_value()) == "true" { |
| 3077 | "false" |
| 3078 | } else { |
| 3079 | "true" |
| 3080 | }; |
| 3081 | Some(ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 3082 | key: row.key.clone(), |
| 3083 | value: value.to_string(), |
| 3084 | persist: row.scope.persist(), |
| 3085 | })) |
| 3086 | } |
| 3087 | |
| 3088 | fn open_selected_catalog_picker(&self) -> Option<ViewAction> { |
| 3089 | let row = self.rows.get(self.selected_row_index()?)?; |
| 3090 | if !row.editable { |
| 3091 | return None; |
| 3092 | } |
| 3093 | let (command, _) = row.facts.command?; |
| 3094 | if row.key == "sandbox_details" { |
| 3095 | return Some(ViewAction::Emit(ViewEvent::ExecutePanelCommand { |
| 3096 | command: command.to_string(), |
| 3097 | pager_title: Some(config_label_for_key_for_locale(self.locale, &row.key)), |
| 3098 | })); |
| 3099 | } |
| 3100 | Some(ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 3101 | action: CommandPaletteAction::ExecuteCommand { |
| 3102 | command: command.to_string(), |
| 3103 | }, |
| 3104 | })) |
| 3105 | } |
| 3106 | |
| 3107 | fn move_choice(&mut self, delta: isize) { |
| 3108 | let Some(edit) = self.editing.as_mut() else { |
| 3109 | return; |
| 3110 | }; |
| 3111 | let Some(choices) = edit.choices.as_ref() else { |
| 3112 | return; |
| 3113 | }; |
| 3114 | let max = choices.len().saturating_sub(1); |
| 3115 | edit.selected_choice = if delta.is_negative() { |
| 3116 | edit.selected_choice.saturating_sub(delta.unsigned_abs()) |
| 3117 | } else { |
| 3118 | (edit.selected_choice + delta as usize).min(max) |
| 3119 | }; |
| 3120 | self.hovered_choice = None; |
| 3121 | } |
| 3122 | |
| 3123 | /// Live-preview the edited choice when the edited key is the theme: |
| 3124 | /// highlighting a theme row applies it session-only (`persist:false`) |
| 3125 | /// so the surface behind the editor repaints immediately, while only |
| 3126 | /// Enter/Apply persists. Other keys preview nothing. |
| 3127 | fn preview_edited_choice(&self) -> ViewAction { |
| 3128 | let Some(edit) = self.editing.as_ref() else { |
| 3129 | return ViewAction::None; |
| 3130 | }; |
| 3131 | if edit.key != "theme" { |
| 3132 | return ViewAction::None; |
| 3133 | } |
| 3134 | let Some(value) = edit |
| 3135 | .choices |
| 3136 | .as_ref() |
| 3137 | .and_then(|choices| choices.get(edit.selected_choice).cloned()) |
| 3138 | else { |
| 3139 | return ViewAction::None; |
| 3140 | }; |
| 3141 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 3142 | key: edit.key.clone(), |
| 3143 | value, |
| 3144 | persist: false, |
| 3145 | }) |
| 3146 | } |
| 3147 | |
| 3148 | /// Leave the editor without applying (Esc or the Cancel control). When |
| 3149 | /// the theme highlight moved, the live surface already previews the |
| 3150 | /// highlighted theme, so Esc reverts it to the exact value the editor |
| 3151 | /// opened with (session-only, mirroring the `/theme` picker rollback). |
| 3152 | fn cancel_edit(&mut self) -> ViewAction { |
| 3153 | let revert = self |
| 3154 | .editing |
| 3155 | .as_ref() |
| 3156 | .filter(|edit| edit.key == "theme") |
| 3157 | .and_then(|edit| { |
| 3158 | let highlighted = edit.choices.as_ref()?.get(edit.selected_choice)?; |
| 3159 | (canonical_config_choice(&edit.key, highlighted) |
| 3160 | != canonical_config_choice(&edit.key, &edit.original_value)) |
| 3161 | .then(|| ViewEvent::ConfigUpdated { |
| 3162 | key: edit.key.clone(), |
| 3163 | value: edit.original_value.clone(), |
| 3164 | persist: false, |
| 3165 | }) |
| 3166 | }); |
| 3167 | self.editing = None; |
| 3168 | self.status = Some(self.tr(MessageId::ConfigEditCancelled).to_string()); |
| 3169 | self.last_mouse_selected = None; |
| 3170 | self.clear_hover(); |
| 3171 | revert.map_or(ViewAction::None, ViewAction::Emit) |
| 3172 | } |
| 3173 | |
| 3174 | /// Hover-follow for the editor's choice rows (the global hover rule): |
| 3175 | /// the pointer highlights the hovered row, painted with the shared |
| 3176 | /// selected-row style. On the theme editor it also live-previews, |
| 3177 | /// exactly like ↑/↓. |
| 3178 | fn hover_edited_choice(&mut self, mouse: MouseEvent) -> ViewAction { |
| 3179 | let position = Position::new(mouse.column, mouse.row); |
| 3180 | let hovered = self |
| 3181 | .last_choice_hitboxes |
| 3182 | .borrow() |
| 3183 | .iter() |
| 3184 | .find_map(|(rect, choice)| rect.contains(position).then_some(*choice)); |
| 3185 | let Some(hovered) = hovered else { |
| 3186 | return ViewAction::None; |
| 3187 | }; |
| 3188 | let changed = match self.editing.as_mut() { |
| 3189 | Some(edit) if edit.selected_choice != hovered => { |
| 3190 | edit.selected_choice = hovered; |
| 3191 | true |
| 3192 | } |
| 3193 | _ => false, |
| 3194 | }; |
| 3195 | if changed { |
| 3196 | self.preview_edited_choice() |
| 3197 | } else { |
| 3198 | ViewAction::None |
| 3199 | } |
| 3200 | } |
| 3201 | |
| 3202 | /// Apply the editor's value (Enter or the Apply control): the selected |
| 3203 | /// choice, or the trimmed text buffer. |
| 3204 | fn commit_edit(&mut self) -> ViewAction { |
| 3205 | let Some(edit) = self.editing.take() else { |
| 3206 | return ViewAction::None; |
| 3207 | }; |
| 3208 | self.last_mouse_selected = None; |
| 3209 | self.clear_hover(); |
| 3210 | let value = match edit.choices.as_ref() { |
| 3211 | Some(choices) => match choices.get(edit.selected_choice).cloned() { |
| 3212 | Some(value) => value, |
| 3213 | None => return ViewAction::None, |
| 3214 | }, |
| 3215 | None => edit.buffer.iter().collect::<String>().trim().to_string(), |
| 3216 | }; |
| 3217 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 3218 | key: edit.key, |
| 3219 | value, |
| 3220 | persist: edit.scope.persist(), |
| 3221 | }) |
| 3222 | } |
| 3223 | |
| 3224 | fn handle_choice_key(&mut self, key: KeyEvent) -> ViewAction { |
| 3225 | match key.code { |
| 3226 | KeyCode::Esc => self.cancel_edit(), |
| 3227 | KeyCode::Enter => self.commit_edit(), |
| 3228 | KeyCode::Up | KeyCode::Left | KeyCode::Char('k') => { |
| 3229 | self.move_choice(-1); |
| 3230 | self.preview_edited_choice() |
| 3231 | } |
| 3232 | KeyCode::Down | KeyCode::Right | KeyCode::Char('j') => { |
| 3233 | self.move_choice(1); |
| 3234 | self.preview_edited_choice() |
| 3235 | } |
| 3236 | KeyCode::PageUp => { |
| 3237 | self.move_choice(-5); |
| 3238 | self.preview_edited_choice() |
| 3239 | } |
| 3240 | KeyCode::PageDown => { |
| 3241 | self.move_choice(5); |
| 3242 | self.preview_edited_choice() |
| 3243 | } |
| 3244 | KeyCode::Home => { |
| 3245 | if let Some(edit) = self.editing.as_mut() { |
| 3246 | edit.selected_choice = 0; |
| 3247 | } |
| 3248 | self.preview_edited_choice() |
| 3249 | } |
| 3250 | KeyCode::End => { |
| 3251 | if let Some(edit) = self.editing.as_mut() |
| 3252 | && let Some(choices) = edit.choices.as_ref() |
| 3253 | { |
| 3254 | edit.selected_choice = choices.len().saturating_sub(1); |
| 3255 | } |
| 3256 | self.preview_edited_choice() |
| 3257 | } |
| 3258 | KeyCode::Char(digit @ '1'..='9') => { |
| 3259 | if let Some(edit) = self.editing.as_mut() |
| 3260 | && let Some(choices) = edit.choices.as_ref() |
| 3261 | { |
| 3262 | let index = digit as usize - '1' as usize; |
| 3263 | if index < choices.len() { |
| 3264 | edit.selected_choice = index; |
| 3265 | } |
| 3266 | } |
| 3267 | self.preview_edited_choice() |
| 3268 | } |
| 3269 | KeyCode::Char(' ') => { |
| 3270 | self.move_choice(1); |
| 3271 | self.preview_edited_choice() |
| 3272 | } |
| 3273 | _ => ViewAction::None, |
| 3274 | } |
| 3275 | } |
| 3276 | |
| 3277 | fn handle_editing_key(&mut self, key: KeyEvent) -> ViewAction { |
| 3278 | if self |
| 3279 | .editing |
| 3280 | .as_ref() |
| 3281 | .is_some_and(|edit| edit.choices.is_some()) |
| 3282 | { |
| 3283 | return self.handle_choice_key(key); |
| 3284 | } |
| 3285 | match key.code { |
| 3286 | KeyCode::Esc => self.cancel_edit(), |
| 3287 | KeyCode::Enter => self.commit_edit(), |
| 3288 | KeyCode::Backspace => { |
| 3289 | if let Some(edit) = self.editing.as_mut() { |
| 3290 | if edit.select_all { |
| 3291 | edit.buffer.clear(); |
| 3292 | edit.cursor = 0; |
| 3293 | edit.select_all = false; |
| 3294 | } else if edit.cursor > 0 { |
| 3295 | edit.cursor = edit.cursor.saturating_sub(1); |
| 3296 | edit.buffer.remove(edit.cursor); |
| 3297 | } |
| 3298 | } |
| 3299 | ViewAction::None |
| 3300 | } |
| 3301 | KeyCode::Delete => { |
| 3302 | if let Some(edit) = self.editing.as_mut() { |
| 3303 | if edit.select_all { |
| 3304 | edit.buffer.clear(); |
| 3305 | edit.cursor = 0; |
| 3306 | edit.select_all = false; |
| 3307 | } else if edit.cursor < edit.buffer.len() { |
| 3308 | edit.buffer.remove(edit.cursor); |
| 3309 | } |
| 3310 | } |
| 3311 | ViewAction::None |
| 3312 | } |
| 3313 | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 3314 | if let Some(edit) = self.editing.as_mut() { |
| 3315 | edit.buffer.clear(); |
| 3316 | edit.cursor = 0; |
| 3317 | edit.select_all = false; |
| 3318 | } |
| 3319 | ViewAction::None |
| 3320 | } |
| 3321 | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 3322 | if let Some(edit) = self.editing.as_mut() { |
| 3323 | edit.cursor = edit.buffer.len(); |
| 3324 | edit.select_all = true; |
| 3325 | } |
| 3326 | ViewAction::None |
| 3327 | } |
| 3328 | KeyCode::Left => { |
| 3329 | if let Some(edit) = self.editing.as_mut() { |
| 3330 | if edit.select_all { |
| 3331 | edit.cursor = 0; |
| 3332 | edit.select_all = false; |
| 3333 | } else { |
| 3334 | edit.cursor = edit.cursor.saturating_sub(1); |
| 3335 | } |
| 3336 | } |
| 3337 | ViewAction::None |
| 3338 | } |
| 3339 | KeyCode::Right => { |
| 3340 | if let Some(edit) = self.editing.as_mut() { |
| 3341 | if edit.select_all { |
| 3342 | edit.cursor = edit.buffer.len(); |
| 3343 | edit.select_all = false; |
| 3344 | } else { |
| 3345 | edit.cursor = (edit.cursor + 1).min(edit.buffer.len()); |
| 3346 | } |
| 3347 | } |
| 3348 | ViewAction::None |
| 3349 | } |
| 3350 | KeyCode::Home => { |
| 3351 | if let Some(edit) = self.editing.as_mut() { |
| 3352 | edit.cursor = 0; |
| 3353 | edit.select_all = false; |
| 3354 | } |
| 3355 | ViewAction::None |
| 3356 | } |
| 3357 | KeyCode::End => { |
| 3358 | if let Some(edit) = self.editing.as_mut() { |
| 3359 | edit.cursor = edit.buffer.len(); |
| 3360 | edit.select_all = false; |
| 3361 | } |
| 3362 | ViewAction::None |
| 3363 | } |
| 3364 | KeyCode::Char(ch) |
| 3365 | if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() => |
| 3366 | { |
| 3367 | if let Some(edit) = self.editing.as_mut() { |
| 3368 | if edit.select_all { |
| 3369 | edit.buffer.clear(); |
| 3370 | edit.cursor = 0; |
| 3371 | edit.select_all = false; |
| 3372 | } |
| 3373 | edit.buffer.insert(edit.cursor, ch); |
| 3374 | edit.cursor += 1; |
| 3375 | } |
| 3376 | ViewAction::None |
| 3377 | } |
| 3378 | _ => ViewAction::None, |
| 3379 | } |
| 3380 | } |
| 3381 | |
| 3382 | fn start_edit(&mut self) { |
| 3383 | let Some(row_idx) = self.selected_row_index() else { |
| 3384 | return; |
| 3385 | }; |
| 3386 | let Some(row) = self.rows.get(row_idx) else { |
| 3387 | return; |
| 3388 | }; |
| 3389 | let key = row.key.clone(); |
| 3390 | let original_value = row.edit_value().to_string(); |
| 3391 | let initial_value = match config_default_placeholder_message(&key) { |
| 3392 | Some(message_id) |
| 3393 | if original_value == tr(self.locale, message_id) |
| 3394 | || original_value == tr(Locale::En, message_id) => |
| 3395 | { |
| 3396 | String::new() |
| 3397 | } |
| 3398 | _ => original_value.clone(), |
| 3399 | }; |
| 3400 | |
| 3401 | let meta = SettingsRegistry::new(self).meta(row); |
| 3402 | let choices = meta.choices; |
| 3403 | let selected_choice = choices |
| 3404 | .as_ref() |
| 3405 | .and_then(|choices| { |
| 3406 | let current = canonical_config_choice(&key, &initial_value); |
| 3407 | choices |
| 3408 | .iter() |
| 3409 | .position(|choice| canonical_config_choice(&key, choice) == current) |
| 3410 | }) |
| 3411 | .unwrap_or(0); |
| 3412 | let buffer: Vec<char> = initial_value.chars().collect(); |
| 3413 | self.last_mouse_selected = None; |
| 3414 | self.editing = Some(ConfigEdit { |
| 3415 | key, |
| 3416 | original_value, |
| 3417 | cursor: buffer.len(), |
| 3418 | buffer, |
| 3419 | select_all: true, |
| 3420 | scope: row.scope, |
| 3421 | choices, |
| 3422 | selected_choice, |
| 3423 | }); |
| 3424 | self.status = None; |
| 3425 | } |
| 3426 | |
| 3427 | fn clear_filter(&mut self) { |
| 3428 | if self.filter.is_empty() { |
| 3429 | return; |
| 3430 | } |
| 3431 | |
| 3432 | self.update_filter(|filter| filter.clear()); |
| 3433 | } |
| 3434 | |
| 3435 | fn row_display_value(&self, row: &ConfigRow) -> String { |
| 3436 | if row.key.starts_with("notifications.") { |
| 3437 | return config_choice_label(self.locale, &row.key, row.edit_value()); |
| 3438 | } |
| 3439 | // The effective lane is only ever an explicit `App` observation carried |
| 3440 | // on the row's typed facts; a persisted value never stands in for it. |
| 3441 | let effective = row.facts.effective.as_deref(); |
| 3442 | if row.key == "cost_currency" |
| 3443 | && row.scope == ConfigScope::Saved |
| 3444 | && let Some(effective_currency) = effective |
| 3445 | { |
| 3446 | let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value); |
| 3447 | let effective_cost_currency = |
| 3448 | crate::pricing::CostCurrency::from_setting(effective_currency); |
| 3449 | if saved_cost_currency != effective_cost_currency { |
| 3450 | return format!( |
| 3451 | "{}{}", |
| 3452 | row.value, |
| 3453 | self.tr(MessageId::ConfigRowEffective) |
| 3454 | .replace("{currency}", effective_currency) |
| 3455 | ); |
| 3456 | } |
| 3457 | } |
| 3458 | |
| 3459 | let runtime_value = effective.and_then(|value| value.parse::<bool>().ok()); |
| 3460 | if let Some(runtime_value) = runtime_value |
| 3461 | && row.value.parse::<bool>().ok() != Some(runtime_value) |
| 3462 | { |
| 3463 | let saved = config_choice_label( |
| 3464 | self.locale, |
| 3465 | &row.key, |
| 3466 | &canonical_config_choice(&row.key, &row.value), |
| 3467 | ); |
| 3468 | let effective = config_choice_label(self.locale, &row.key, &runtime_value.to_string()); |
| 3469 | return format!( |
| 3470 | "{}{}", |
| 3471 | saved, |
| 3472 | self.tr(MessageId::ConfigRowEffective) |
| 3473 | .replace("{currency}", &effective) |
| 3474 | ); |
| 3475 | } |
| 3476 | |
| 3477 | // Preserve the exact saved currency alias in the table (for example |
| 3478 | // `rmb`) while the chooser highlights its canonical `cny` option. |
| 3479 | if row.key == "cost_currency" { |
| 3480 | return row.value.clone(); |
| 3481 | } |
| 3482 | |
| 3483 | if SettingsRegistry::new(self).meta(row).choices.is_some() { |
| 3484 | if config_default_placeholder_message(&row.key).is_some_and(|message_id| { |
| 3485 | row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id) |
| 3486 | }) { |
| 3487 | return self.tr(MessageId::ConfigValueProviderDefault).into_owned(); |
| 3488 | } |
| 3489 | let canonical = canonical_config_choice(&row.key, &row.value); |
| 3490 | return config_choice_label(self.locale, &row.key, &canonical); |
| 3491 | } |
| 3492 | |
| 3493 | row.value.clone() |
| 3494 | } |
| 3495 | } |
| 3496 | |
| 3497 | fn config_base_url_row_key(provider: ApiProvider) -> &'static str { |
| 3498 | if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { |
| 3499 | "base_url" |
| 3500 | } else { |
| 3501 | "provider_url" |
| 3502 | } |
| 3503 | } |
| 3504 | |
| 3505 | fn config_base_url_row_value(app: &App) -> String { |
| 3506 | app.active_route_base_url.clone() |
| 3507 | } |
| 3508 | |
| 3509 | fn cost_currency_config_value(app: &App) -> String { |
| 3510 | match app.cost_currency { |
| 3511 | crate::pricing::CostCurrency::Usd => "usd", |
| 3512 | crate::pricing::CostCurrency::Cny => "cny", |
| 3513 | } |
| 3514 | .to_string() |
| 3515 | } |
| 3516 | |
| 3517 | fn experimental_config_rows(config: &Config) -> Vec<ConfigRow> { |
| 3518 | let features = config.features(); |
| 3519 | let configured = config.features.as_ref().map(|table| &table.entries); |
| 3520 | let mut rows = Vec::new(); |
| 3521 | |
| 3522 | for spec in FEATURES |
| 3523 | .iter() |
| 3524 | .filter(|spec| matches!(spec.stage, Stage::Experimental | Stage::Beta)) |
| 3525 | { |
| 3526 | let effective = features.enabled(spec.id); |
| 3527 | let configured_value = configured |
| 3528 | .and_then(|entries| entries.get(spec.key)) |
| 3529 | .copied(); |
| 3530 | rows.push(ConfigRow { |
| 3531 | key: format!("features.{}", spec.key), |
| 3532 | value: experimental_feature_value( |
| 3533 | effective, |
| 3534 | spec.default_enabled, |
| 3535 | configured_value.is_some(), |
| 3536 | ), |
| 3537 | editable: false, |
| 3538 | scope: ConfigScope::Saved, |
| 3539 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration), |
| 3540 | }); |
| 3541 | } |
| 3542 | |
| 3543 | rows.push(ConfigRow { |
| 3544 | key: "goal_command".to_string(), |
| 3545 | value: |
| 3546 | "/goal sets session objectives with optional token budgets; state shows in Work context" |
| 3547 | .to_string(), |
| 3548 | editable: false, |
| 3549 | scope: ConfigScope::Saved, |
| 3550 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 3551 | }); |
| 3552 | rows.push(ConfigRow { |
| 3553 | // Workflow orchestration is its own section, not a Fleet concern. |
| 3554 | key: "workflow".to_string(), |
| 3555 | value: |
| 3556 | "/workflow runs scripted fan-out/fan-in operations with run cards and cancel support" |
| 3557 | .to_string(), |
| 3558 | editable: false, |
| 3559 | scope: ConfigScope::Saved, |
| 3560 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 3561 | }); |
| 3562 | |
| 3563 | rows |
| 3564 | } |
| 3565 | |
| 3566 | fn experimental_feature_value(effective: bool, default_enabled: bool, configured: bool) -> String { |
| 3567 | let state = if effective { "enabled" } else { "disabled" }; |
| 3568 | let default_state = if default_enabled { |
| 3569 | "enabled" |
| 3570 | } else { |
| 3571 | "disabled" |
| 3572 | }; |
| 3573 | if configured { |
| 3574 | format!("{state} (configured; default {default_state})") |
| 3575 | } else { |
| 3576 | format!("{state} (default {default_state})") |
| 3577 | } |
| 3578 | } |
| 3579 | |
| 3580 | /// Localized label for a setting key. |
| 3581 | /// |
| 3582 | /// The schema names the string; the locale pack owns the text. A setting |
| 3583 | /// declared without a label message humanizes its key, and a `features.*` key |
| 3584 | /// wears the localized feature prefix. |
| 3585 | fn config_label_for_key_for_locale(locale: Locale, key: &str) -> String { |
| 3586 | let declared = codewhale_config::setting(key) |
| 3587 | .and_then(|def| def.ui.as_ref()) |
| 3588 | .map(|ui| ui.label) |
| 3589 | .unwrap_or(""); |
| 3590 | if !declared.is_empty() { |
| 3591 | return tr_key(locale, declared).to_string(); |
| 3592 | } |
| 3593 | let humanized = humanize_config_key(key.strip_prefix("features.").unwrap_or(key)); |
| 3594 | if key.starts_with("features.") { |
| 3595 | tr(locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", &humanized) |
| 3596 | } else { |
| 3597 | humanized |
| 3598 | } |
| 3599 | } |
| 3600 | |
| 3601 | #[cfg(test)] |
| 3602 | fn config_label_for_key(key: &str) -> String { |
| 3603 | config_label_for_key_for_locale(Locale::En, key) |
| 3604 | } |
| 3605 | |
| 3606 | fn humanize_config_key(key: &str) -> String { |
| 3607 | key.split(['.', '_', '-']) |
| 3608 | .filter(|part| !part.is_empty()) |
| 3609 | .map(|part| { |
| 3610 | let mut chars = part.chars(); |
| 3611 | let Some(first) = chars.next() else { |
| 3612 | return String::new(); |
| 3613 | }; |
| 3614 | let mut word = first.to_uppercase().collect::<String>(); |
| 3615 | word.push_str(chars.as_str()); |
| 3616 | word |
| 3617 | }) |
| 3618 | .collect::<Vec<_>>() |
| 3619 | .join(" ") |
| 3620 | } |
| 3621 | |
| 3622 | /// Localized description for a setting key. |
| 3623 | /// |
| 3624 | /// Theme and locale describe themselves with their shipped value lists (value |
| 3625 | /// lists, not prose, so they cannot go stale); every other sentence is the |
| 3626 | /// message the schema names. |
| 3627 | fn config_hint_for_key(locale: Locale, key: &str) -> Cow<'static, str> { |
| 3628 | match key { |
| 3629 | "theme" => { |
| 3630 | static THEME_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); |
| 3631 | return Cow::Borrowed(THEME_HINT.get_or_init(|| { |
| 3632 | codewhale_palette::SELECTABLE_THEMES |
| 3633 | .iter() |
| 3634 | .map(|id| id.name()) |
| 3635 | .collect::<Vec<_>>() |
| 3636 | .join(" | ") |
| 3637 | })); |
| 3638 | } |
| 3639 | "locale" => { |
| 3640 | static LOCALE_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); |
| 3641 | return Cow::Borrowed( |
| 3642 | LOCALE_HINT.get_or_init(|| codewhale_localization::configured_locale_values(" | ")), |
| 3643 | ); |
| 3644 | } |
| 3645 | _ => {} |
| 3646 | } |
| 3647 | let declared = codewhale_config::setting(key) |
| 3648 | .and_then(|def| def.ui.as_ref()) |
| 3649 | .map(|ui| ui.description) |
| 3650 | .unwrap_or(""); |
| 3651 | if declared.is_empty() { |
| 3652 | return Cow::Borrowed(""); |
| 3653 | } |
| 3654 | tr_key(locale, declared) |
| 3655 | } |
| 3656 | |
| 3657 | fn config_default_placeholder_message(key: &str) -> Option<MessageId> { |
| 3658 | match key { |
| 3659 | "default_model" | "background_color" => Some(MessageId::ConfigDefaultValue), |
| 3660 | "reasoning_effort" => Some(MessageId::ConfigDefaultReasoning), |
| 3661 | _ => None, |
| 3662 | } |
| 3663 | } |
| 3664 | |
| 3665 | fn config_boolean_key(key: &str) -> bool { |
| 3666 | codewhale_config::setting(key).is_some_and(|def| def.is_bool()) |
| 3667 | } |
| 3668 | |
| 3669 | fn config_integer_key(key: &str) -> bool { |
| 3670 | codewhale_config::setting(key).is_some_and(|def| def.is_int()) |
| 3671 | } |
| 3672 | |
| 3673 | /// Selectable values for a key. |
| 3674 | /// |
| 3675 | /// Two settings take their values from a live registry instead of the schema: |
| 3676 | /// the shipped palettes and the shipped locale packs. `reasoning_effort` is a |
| 3677 | /// third, and `SettingsRegistry::reasoning_effort_choices` owns it because the |
| 3678 | /// answer depends on the active route, not just the provider. Everything else |
| 3679 | /// is the declared value set. |
| 3680 | fn config_choice_values(key: &str) -> Option<Vec<String>> { |
| 3681 | match key { |
| 3682 | "theme" => { |
| 3683 | return Some( |
| 3684 | codewhale_palette::SELECTABLE_THEMES |
| 3685 | .iter() |
| 3686 | .map(|id| id.name().to_string()) |
| 3687 | .collect(), |
| 3688 | ); |
| 3689 | } |
| 3690 | "locale" => { |
| 3691 | let mut values = vec!["auto".to_string()]; |
| 3692 | values.extend( |
| 3693 | Locale::shipped() |
| 3694 | .iter() |
| 3695 | .map(|locale| locale.tag().to_string()), |
| 3696 | ); |
| 3697 | return Some(values); |
| 3698 | } |
| 3699 | "reasoning_effort" => { |
| 3700 | // Settings-canonical vocabulary; the live settings screen uses |
| 3701 | // `reasoning_effort_choices()` which narrows this by route/provider. |
| 3702 | return Some(vec![ |
| 3703 | "default".to_string(), |
| 3704 | "off".to_string(), |
| 3705 | "low".to_string(), |
| 3706 | "medium".to_string(), |
| 3707 | "high".to_string(), |
| 3708 | "xhigh".to_string(), |
| 3709 | "auto".to_string(), |
| 3710 | "ultra".to_string(), |
| 3711 | "max".to_string(), |
| 3712 | ]); |
| 3713 | } |
| 3714 | _ => {} |
| 3715 | } |
| 3716 | codewhale_config::setting(key) |
| 3717 | .and_then(|def| def.values()) |
| 3718 | .map(|values| values.into_iter().map(str::to_string).collect()) |
| 3719 | } |
| 3720 | |
| 3721 | fn canonical_config_choice(key: &str, value: &str) -> String { |
| 3722 | let normalized = value.trim().to_ascii_lowercase().replace([' ', '_'], "-"); |
| 3723 | match key { |
| 3724 | key if config_boolean_key(key) => match normalized.as_str() { |
| 3725 | "true" | "on" | "yes" | "1" | "enabled" => "true".to_string(), |
| 3726 | _ => "false".to_string(), |
| 3727 | }, |
| 3728 | "approval_mode" | "permission_posture" => match normalized.as_str() { |
| 3729 | "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(), |
| 3730 | "auto" | "auto-review" => "auto-review".to_string(), |
| 3731 | "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(), |
| 3732 | _ => normalized, |
| 3733 | }, |
| 3734 | "approval_policy" => match normalized.as_str() { |
| 3735 | "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(), |
| 3736 | "auto" | "auto-review" => "auto-review".to_string(), |
| 3737 | "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(), |
| 3738 | "never" | "deny" => "never".to_string(), |
| 3739 | _ => normalized, |
| 3740 | }, |
| 3741 | "reasoning_effort" => { |
| 3742 | if matches!(normalized.as_str(), "" | "(default)" | "config-default") { |
| 3743 | "default".to_string() |
| 3744 | } else if normalized == "max" && value.trim().eq_ignore_ascii_case("xhigh") { |
| 3745 | "xhigh".to_string() |
| 3746 | } else { |
| 3747 | normalized |
| 3748 | } |
| 3749 | } |
| 3750 | "cost_currency" => match normalized.as_str() { |
| 3751 | "rmb" | "yuan" | "cny" => "cny".to_string(), |
| 3752 | _ => "usd".to_string(), |
| 3753 | }, |
| 3754 | "default_mode" => match normalized.as_str() { |
| 3755 | "plan" => "plan".to_string(), |
| 3756 | "operate" | "operation" | "ops" => "operate".to_string(), |
| 3757 | _ => "agent".to_string(), |
| 3758 | }, |
| 3759 | "locale" => normalize_configured_locale(value) |
| 3760 | .unwrap_or(value) |
| 3761 | .to_string(), |
| 3762 | _ => normalized, |
| 3763 | } |
| 3764 | } |
| 3765 | |
| 3766 | /// Localized label for one value of a setting. |
| 3767 | /// |
| 3768 | /// The schema declares per-value labels; a boolean with none uses the shared |
| 3769 | /// on/off pair, and any other undeclared value shows itself. |
| 3770 | fn config_choice_label(locale: Locale, key: &str, value: &str) -> String { |
| 3771 | // The runtime "default" choice for reasoning_effort is the unset sentinel; |
| 3772 | // keep its localized placeholder label instead of showing the raw word. |
| 3773 | if key == "reasoning_effort" && value == "default" { |
| 3774 | return tr(locale, MessageId::ConfigDefaultReasoning).into_owned(); |
| 3775 | } |
| 3776 | let declared = codewhale_config::setting(key); |
| 3777 | let message = declared |
| 3778 | .and_then(|def| def.option(value)) |
| 3779 | .map(|option| option.label) |
| 3780 | .filter(|label| !label.is_empty()); |
| 3781 | let label = match message { |
| 3782 | Some(message) => tr_key(locale, message).into_owned(), |
| 3783 | None if declared.is_some_and(|def| def.is_bool()) => match value { |
| 3784 | "true" => tr(locale, MessageId::ConfigValueOn).into_owned(), |
| 3785 | "false" => tr(locale, MessageId::ConfigValueOff).into_owned(), |
| 3786 | other => other.to_string(), |
| 3787 | }, |
| 3788 | None => value.to_string(), |
| 3789 | }; |
| 3790 | |
| 3791 | if key == "locale" && configured_locale_is_partial_pack(value) { |
| 3792 | format!( |
| 3793 | "{label} ({})", |
| 3794 | tr(locale, MessageId::ConfigLocalePartialBadge) |
| 3795 | ) |
| 3796 | } else { |
| 3797 | label |
| 3798 | } |
| 3799 | } |
| 3800 | |
| 3801 | /// Localized one-line detail for one value of a setting. |
| 3802 | fn config_choice_detail(locale: Locale, key: &str, value: &str) -> Cow<'static, str> { |
| 3803 | if key == "locale" && configured_locale_is_partial_pack(value) { |
| 3804 | return tr(locale, MessageId::ConfigLocalePartialDetail); |
| 3805 | } |
| 3806 | let declared = codewhale_config::setting(key) |
| 3807 | .and_then(|def| def.option(value)) |
| 3808 | .map(|option| option.description) |
| 3809 | .filter(|description| !description.is_empty()); |
| 3810 | match declared { |
| 3811 | Some(message) => tr_key(locale, message), |
| 3812 | None => Cow::Borrowed(""), |
| 3813 | } |
| 3814 | } |
| 3815 | |
| 3816 | fn render_config_editor_value_line( |
| 3817 | edit: &ConfigEdit, |
| 3818 | locale: Locale, |
| 3819 | ) -> ratatui::text::Line<'static> { |
| 3820 | use ratatui::{ |
| 3821 | style::Style, |
| 3822 | text::{Line, Span}, |
| 3823 | }; |
| 3824 | |
| 3825 | let mut spans = Vec::new(); |
| 3826 | spans.push(Span::styled( |
| 3827 | tr(locale, MessageId::ConfigEditNewLabel), |
| 3828 | Style::default().fg(palette::TEXT_MUTED), |
| 3829 | )); |
| 3830 | |
| 3831 | let cursor_style = Style::default() |
| 3832 | .fg(palette::WHALE_BG) |
| 3833 | .bg(palette::WHALE_ACTION) |
| 3834 | .bold(); |
| 3835 | let selected_style = Style::default() |
| 3836 | .fg(palette::SELECTION_TEXT) |
| 3837 | .bg(palette::SELECTION_BG); |
| 3838 | |
| 3839 | if edit.select_all && !edit.buffer.is_empty() { |
| 3840 | let text = edit.buffer.iter().collect::<String>(); |
| 3841 | spans.push(Span::styled(text, selected_style)); |
| 3842 | spans.push(Span::styled(" ", cursor_style)); |
| 3843 | return Line::from(spans); |
| 3844 | } |
| 3845 | |
| 3846 | let before = edit.buffer.iter().take(edit.cursor).collect::<String>(); |
| 3847 | spans.push(Span::raw(before)); |
| 3848 | if edit.cursor < edit.buffer.len() { |
| 3849 | let ch = edit.buffer[edit.cursor]; |
| 3850 | spans.push(Span::styled(ch.to_string(), cursor_style)); |
| 3851 | let after = edit |
| 3852 | .buffer |
| 3853 | .iter() |
| 3854 | .skip(edit.cursor.saturating_add(1)) |
| 3855 | .collect::<String>(); |
| 3856 | spans.push(Span::raw(after)); |
| 3857 | } else { |
| 3858 | spans.push(Span::styled(" ", cursor_style)); |
| 3859 | } |
| 3860 | |
| 3861 | Line::from(spans) |
| 3862 | } |
| 3863 | |
| 3864 | impl ModalView for ConfigView { |
| 3865 | fn kind(&self) -> ModalKind { |
| 3866 | ModalKind::Config |
| 3867 | } |
| 3868 | |
| 3869 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 3870 | self |
| 3871 | } |
| 3872 | |
| 3873 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 3874 | // Any key is a state change: a following single click must select, |
| 3875 | // never activate, whatever the pointer touched before. |
| 3876 | self.last_mouse_selected = None; |
| 3877 | if self.editing.is_some() { |
| 3878 | return self.handle_editing_key(key); |
| 3879 | } |
| 3880 | // A status line ("Edit cancelled", …) is transient: navigation gives |
| 3881 | // the row back to its activation copy. |
| 3882 | self.status = None; |
| 3883 | |
| 3884 | match key.code { |
| 3885 | KeyCode::Esc => { |
| 3886 | if self.filter.is_empty() { |
| 3887 | ViewAction::Close |
| 3888 | } else { |
| 3889 | self.clear_filter(); |
| 3890 | ViewAction::None |
| 3891 | } |
| 3892 | } |
| 3893 | KeyCode::Tab | KeyCode::Right |
| 3894 | if !key.modifiers.contains(KeyModifiers::SHIFT) && self.filter.is_empty() => |
| 3895 | { |
| 3896 | self.category = self.category.next(); |
| 3897 | self.select_first_visible_row(); |
| 3898 | ViewAction::None |
| 3899 | } |
| 3900 | KeyCode::BackTab | KeyCode::Tab | KeyCode::Left if self.filter.is_empty() => { |
| 3901 | self.category = self.category.prev(); |
| 3902 | self.select_first_visible_row(); |
| 3903 | ViewAction::None |
| 3904 | } |
| 3905 | KeyCode::Up => { |
| 3906 | self.move_selection(-1); |
| 3907 | ViewAction::None |
| 3908 | } |
| 3909 | KeyCode::Down => { |
| 3910 | self.move_selection(1); |
| 3911 | ViewAction::None |
| 3912 | } |
| 3913 | KeyCode::PageUp => { |
| 3914 | self.move_selection(-5); |
| 3915 | ViewAction::None |
| 3916 | } |
| 3917 | KeyCode::PageDown => { |
| 3918 | self.move_selection(5); |
| 3919 | ViewAction::None |
| 3920 | } |
| 3921 | KeyCode::Backspace => { |
| 3922 | if !self.filter.is_empty() { |
| 3923 | self.update_filter(|filter| { |
| 3924 | filter.pop(); |
| 3925 | }); |
| 3926 | } |
| 3927 | ViewAction::None |
| 3928 | } |
| 3929 | // Ctrl+H is the legacy ASCII backspace many terminals emit. |
| 3930 | KeyCode::Char('h') |
| 3931 | if key.modifiers.contains(KeyModifiers::CONTROL) |
| 3932 | && !key.modifiers.contains(KeyModifiers::ALT) => |
| 3933 | { |
| 3934 | if !self.filter.is_empty() { |
| 3935 | self.update_filter(|filter| { |
| 3936 | filter.pop(); |
| 3937 | }); |
| 3938 | } |
| 3939 | ViewAction::None |
| 3940 | } |
| 3941 | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 3942 | self.clear_filter(); |
| 3943 | ViewAction::None |
| 3944 | } |
| 3945 | KeyCode::Enter => { |
| 3946 | if self |
| 3947 | .selected_row_index() |
| 3948 | .and_then(|idx| self.rows.get(idx)) |
| 3949 | .is_some_and(|row| row.editable) |
| 3950 | { |
| 3951 | if let Some(action) = self.open_selected_catalog_picker() { |
| 3952 | return action; |
| 3953 | } |
| 3954 | if let Some(action) = self.toggle_selected_boolean() { |
| 3955 | return action; |
| 3956 | } |
| 3957 | self.start_edit(); |
| 3958 | } |
| 3959 | ViewAction::None |
| 3960 | } |
| 3961 | KeyCode::Char(ch) |
| 3962 | if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() => |
| 3963 | { |
| 3964 | self.update_filter(|filter| filter.push(ch)); |
| 3965 | ViewAction::None |
| 3966 | } |
| 3967 | _ => ViewAction::None, |
| 3968 | } |
| 3969 | } |
| 3970 | |
| 3971 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 3972 | if matches!(mouse.kind, MouseEventKind::Moved) { |
| 3973 | let has_choices = self |
| 3974 | .editing |
| 3975 | .as_ref() |
| 3976 | .is_some_and(|edit| edit.choices.is_some()); |
| 3977 | if has_choices { |
| 3978 | return self.hover_edited_choice(mouse); |
| 3979 | } |
| 3980 | self.track_hover(mouse); |
| 3981 | return ViewAction::None; |
| 3982 | } |
| 3983 | if self.editing.is_some() { |
| 3984 | let has_choices = self |
| 3985 | .editing |
| 3986 | .as_ref() |
| 3987 | .is_some_and(|edit| edit.choices.is_some()); |
| 3988 | match mouse.kind { |
| 3989 | MouseEventKind::ScrollUp if has_choices => { |
| 3990 | self.move_choice(-1); |
| 3991 | return self.preview_edited_choice(); |
| 3992 | } |
| 3993 | MouseEventKind::ScrollDown if has_choices => { |
| 3994 | self.move_choice(1); |
| 3995 | return self.preview_edited_choice(); |
| 3996 | } |
| 3997 | MouseEventKind::Down(MouseButton::Left) => { |
| 3998 | let position = Position::new(mouse.column, mouse.row); |
| 3999 | let control = self |
| 4000 | .last_editor_controls |
| 4001 | .borrow() |
| 4002 | .iter() |
| 4003 | .find_map(|(rect, control)| rect.contains(position).then_some(*control)); |
| 4004 | match control { |
| 4005 | Some(EditorControl::Apply) => return self.commit_edit(), |
| 4006 | Some(EditorControl::Cancel) => return self.cancel_edit(), |
| 4007 | None => {} |
| 4008 | } |
| 4009 | let choice = self |
| 4010 | .last_choice_hitboxes |
| 4011 | .borrow() |
| 4012 | .iter() |
| 4013 | .find_map(|(rect, choice)| rect.contains(position).then_some(*choice)); |
| 4014 | let picked = match (choice, self.editing.as_mut()) { |
| 4015 | (Some(choice), Some(edit)) => { |
| 4016 | edit.selected_choice = choice; |
| 4017 | true |
| 4018 | } |
| 4019 | _ => false, |
| 4020 | }; |
| 4021 | if picked { |
| 4022 | return self.preview_edited_choice(); |
| 4023 | } |
| 4024 | } |
| 4025 | _ => {} |
| 4026 | } |
| 4027 | return ViewAction::None; |
| 4028 | } |
| 4029 | match mouse.kind { |
| 4030 | MouseEventKind::ScrollUp => { |
| 4031 | self.move_selection(-3); |
| 4032 | self.last_mouse_selected = None; |
| 4033 | self.clear_hover(); |
| 4034 | return ViewAction::None; |
| 4035 | } |
| 4036 | MouseEventKind::ScrollDown => { |
| 4037 | self.move_selection(3); |
| 4038 | self.last_mouse_selected = None; |
| 4039 | self.clear_hover(); |
| 4040 | return ViewAction::None; |
| 4041 | } |
| 4042 | _ => {} |
| 4043 | } |
| 4044 | if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { |
| 4045 | return ViewAction::None; |
| 4046 | } |
| 4047 | |
| 4048 | let position = Position::new(mouse.column, mouse.row); |
| 4049 | let clicked_category = self |
| 4050 | .last_rail_hitboxes |
| 4051 | .borrow() |
| 4052 | .iter() |
| 4053 | .find_map(|(rect, category)| rect.contains(position).then_some(*category)); |
| 4054 | let stepped = self |
| 4055 | .last_nav_controls |
| 4056 | .borrow() |
| 4057 | .iter() |
| 4058 | .find_map(|(rect, step)| rect.contains(position).then_some(*step)) |
| 4059 | .map(|step| match step { |
| 4060 | NavStep::Previous => self.category.prev(), |
| 4061 | NavStep::Next => self.category.next(), |
| 4062 | }); |
| 4063 | if let Some(category) = clicked_category.or(stepped) { |
| 4064 | // A category click (or an overflow marker) is an explicit |
| 4065 | // navigation: it leaves search so the list shows exactly that |
| 4066 | // category, never a stale filtered mix. |
| 4067 | self.clear_filter(); |
| 4068 | self.status = None; |
| 4069 | if self.category != category { |
| 4070 | self.category = category; |
| 4071 | self.select_first_visible_row(); |
| 4072 | } |
| 4073 | self.last_mouse_selected = None; |
| 4074 | return ViewAction::None; |
| 4075 | } |
| 4076 | |
| 4077 | // Only the painted cells of a list row select it; the rail, dividers, |
| 4078 | // detail pane, status row, and footer never do. |
| 4079 | let selected = self |
| 4080 | .last_row_hitboxes |
| 4081 | .borrow() |
| 4082 | .iter() |
| 4083 | .find_map(|(rect, row_idx)| rect.contains(position).then_some(*row_idx)); |
| 4084 | if let Some(row_idx) = selected { |
| 4085 | let activate = self.last_mouse_selected == Some(row_idx) && self.selected == row_idx; |
| 4086 | self.selected = row_idx; |
| 4087 | self.status = None; |
| 4088 | self.adjust_scroll(self.visible_rows_cached()); |
| 4089 | self.last_mouse_selected = Some(row_idx); |
| 4090 | if activate && self.rows.get(row_idx).is_some_and(|row| row.editable) { |
| 4091 | if let Some(action) = self.open_selected_catalog_picker() { |
| 4092 | return action; |
| 4093 | } |
| 4094 | if let Some(action) = self.toggle_selected_boolean() { |
| 4095 | return action; |
| 4096 | } |
| 4097 | self.start_edit(); |
| 4098 | } |
| 4099 | } |
| 4100 | ViewAction::None |
| 4101 | } |
| 4102 | |
| 4103 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 4104 | use ratatui::{ |
| 4105 | style::Style, |
| 4106 | text::{Line, Span}, |
| 4107 | widgets::{Paragraph, Widget}, |
| 4108 | }; |
| 4109 | |
| 4110 | let inner = |
| 4111 | render_underwater_surface(area, buf, self.tr(MessageId::ConfigModalTitle).to_string()); |
| 4112 | let (lines, footer) = if let Some(edit) = self.editing.as_ref() { |
| 4113 | *self.last_choice_hitboxes.borrow_mut() = Vec::new(); |
| 4114 | *self.last_editor_controls.borrow_mut() = Vec::new(); |
| 4115 | *self.last_rail_hitboxes.borrow_mut() = Vec::new(); |
| 4116 | *self.last_nav_controls.borrow_mut() = Vec::new(); |
| 4117 | let footer_text = if edit.choices.is_some() { |
| 4118 | if inner.width < 56 || inner.height <= 8 { |
| 4119 | self.tr(MessageId::ConfigChoiceFooterCompact).to_string() |
| 4120 | } else { |
| 4121 | self.tr(MessageId::ConfigChoiceFooter).to_string() |
| 4122 | } |
| 4123 | } else { |
| 4124 | self.tr(MessageId::ConfigEditFooter).to_string() |
| 4125 | }; |
| 4126 | let reserved_footer_lines = |
| 4127 | wrapped_footer_lines(&footer_text, inner.width, Style::default()).len(); |
| 4128 | // The clickable Apply / Cancel controls always own the last body |
| 4129 | // row above the footer. |
| 4130 | const CONTROL_ROWS: usize = 1; |
| 4131 | // Spacer rows are secondary chrome: give them up before the |
| 4132 | // editable value line falls below the wrapped footer on compact |
| 4133 | // terminals (#40x12). |
| 4134 | let body_rows = |
| 4135 | usize::from(inner.height).saturating_sub(reserved_footer_lines + CONTROL_ROWS); |
| 4136 | // The expanded header costs six rows before the options. Reserve |
| 4137 | // at least three choices plus their detail before adding spacers; |
| 4138 | // a slightly taller compact shell must not show fewer options. |
| 4139 | let spacious = body_rows >= if edit.choices.is_some() { 10 } else { 8 }; |
| 4140 | let mut lines: Vec<Line> = Vec::new(); |
| 4141 | let edit_label = config_label_for_key_for_locale(self.locale, &edit.key); |
| 4142 | let edit_title = if edit_label == edit.key { |
| 4143 | format!("{}{}", self.tr(MessageId::ConfigEditTitlePrefix), edit.key) |
| 4144 | } else { |
| 4145 | format!( |
| 4146 | "{}{} [{}]", |
| 4147 | self.tr(MessageId::ConfigEditTitlePrefix), |
| 4148 | edit_label, |
| 4149 | edit.key |
| 4150 | ) |
| 4151 | }; |
| 4152 | lines.push(Line::from(vec![Span::styled( |
| 4153 | edit_title, |
| 4154 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 4155 | )])); |
| 4156 | if spacious { |
| 4157 | lines.push(Line::from("")); |
| 4158 | } |
| 4159 | let muted = Style::default().fg(palette::TEXT_MUTED); |
| 4160 | let scope_spans = vec![ |
| 4161 | Span::styled(self.tr(MessageId::ConfigEditScopeLabel), muted), |
| 4162 | Span::raw(edit.scope.label(self.locale)), |
| 4163 | ]; |
| 4164 | let current_spans = vec![ |
| 4165 | Span::styled(self.tr(MessageId::ConfigEditCurrentLabel), muted), |
| 4166 | Span::raw(truncate_view_text(&edit.original_value, 60)), |
| 4167 | ]; |
| 4168 | if spacious { |
| 4169 | lines.push(Line::from(scope_spans)); |
| 4170 | lines.push(Line::from(current_spans)); |
| 4171 | lines.push(Line::from("")); |
| 4172 | } else { |
| 4173 | // Compact: scope and current share one row so the choices |
| 4174 | // and the controls both stay visible at 40x12. |
| 4175 | let mut merged = scope_spans; |
| 4176 | merged.push(Span::styled(" · ", muted)); |
| 4177 | merged.extend(current_spans); |
| 4178 | lines.push(Line::from(merged)); |
| 4179 | } |
| 4180 | if let Some(choices) = edit.choices.as_ref() { |
| 4181 | lines.push(Line::from(Span::styled( |
| 4182 | self.tr(MessageId::ConfigEditChooseLabel), |
| 4183 | Style::default().fg(palette::TEXT_MUTED), |
| 4184 | ))); |
| 4185 | |
| 4186 | // Large catalogs (providers and themes) remain bounded by the |
| 4187 | // terminal. Keep the active option centered and mouse-hitbox |
| 4188 | // only the slice that is actually visible. |
| 4189 | let selected_detail = choices |
| 4190 | .get(edit.selected_choice) |
| 4191 | .map(|choice| config_choice_detail(self.locale, &edit.key, choice)) |
| 4192 | .unwrap_or_default(); |
| 4193 | let available_rows = usize::from(inner.height) |
| 4194 | .saturating_sub(reserved_footer_lines + CONTROL_ROWS + lines.len()); |
| 4195 | // At the minimum supported height, the choices themselves are |
| 4196 | // the primary object. Shed the explanatory detail before any |
| 4197 | // option; larger surfaces keep one row for that detail. |
| 4198 | let detail_rows = usize::from(!selected_detail.is_empty() && available_rows > 3); |
| 4199 | let option_budget = available_rows.saturating_sub(detail_rows).max(1); |
| 4200 | let visible_options = option_budget.min(choices.len()); |
| 4201 | let max_start = choices.len().saturating_sub(visible_options); |
| 4202 | let start = edit |
| 4203 | .selected_choice |
| 4204 | .saturating_sub(visible_options / 2) |
| 4205 | .min(max_start); |
| 4206 | let end = (start + visible_options).min(choices.len()); |
| 4207 | let mut hitboxes = Vec::new(); |
| 4208 | |
| 4209 | for (choice_idx, choice) in choices.iter().enumerate().take(end).skip(start) { |
| 4210 | let selected = choice_idx == edit.selected_choice; |
| 4211 | let marker = crate::tui::glyphs::selection_marker(selected); |
| 4212 | let label = config_choice_label(self.locale, &edit.key, choice); |
| 4213 | let line_y = inner.y.saturating_add(lines.len() as u16); |
| 4214 | hitboxes.push(( |
| 4215 | Rect { |
| 4216 | x: inner.x, |
| 4217 | y: line_y, |
| 4218 | width: inner.width, |
| 4219 | height: 1, |
| 4220 | }, |
| 4221 | choice_idx, |
| 4222 | )); |
| 4223 | let mut line = Line::from(format!( |
| 4224 | " {marker} {:>2}. {}", |
| 4225 | choice_idx + 1, |
| 4226 | truncate_view_text(&label, usize::from(inner.width).saturating_sub(8)) |
| 4227 | )); |
| 4228 | line.style = if selected { |
| 4229 | menu_style::selected_row_style() |
| 4230 | } else if self.hovered_choice == Some(choice_idx) { |
| 4231 | Style::default() |
| 4232 | .fg(palette::TEXT_PRIMARY) |
| 4233 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4234 | } else { |
| 4235 | Style::default().fg(palette::TEXT_PRIMARY) |
| 4236 | }; |
| 4237 | lines.push(line); |
| 4238 | } |
| 4239 | *self.last_choice_hitboxes.borrow_mut() = hitboxes; |
| 4240 | |
| 4241 | if !selected_detail.is_empty() |
| 4242 | && lines.len() + reserved_footer_lines + CONTROL_ROWS |
| 4243 | < usize::from(inner.height) |
| 4244 | { |
| 4245 | lines.push(Line::from(Span::styled( |
| 4246 | crate::tui::ui_text::semantic_truncate( |
| 4247 | selected_detail.as_ref(), |
| 4248 | usize::from(inner.width), |
| 4249 | ), |
| 4250 | Style::default().fg(palette::TEXT_MUTED), |
| 4251 | ))); |
| 4252 | } |
| 4253 | } else { |
| 4254 | lines.push(render_config_editor_value_line(edit, self.locale)); |
| 4255 | if spacious { |
| 4256 | lines.push(Line::from("")); |
| 4257 | } |
| 4258 | let hint = config_hint_for_key(self.locale, &edit.key); |
| 4259 | if !hint.is_empty() { |
| 4260 | lines.push(Line::from(vec![ |
| 4261 | Span::styled( |
| 4262 | self.tr(MessageId::ConfigEditHintLabel), |
| 4263 | Style::default().fg(palette::TEXT_MUTED), |
| 4264 | ), |
| 4265 | Span::raw(hint), |
| 4266 | ])); |
| 4267 | } |
| 4268 | } |
| 4269 | (lines, footer_text) |
| 4270 | } else { |
| 4271 | self.render_settings_shell(inner, buf); |
| 4272 | return; |
| 4273 | }; |
| 4274 | |
| 4275 | // Footer wraps inside the body so its hints can never run off the modal |
| 4276 | // edge (#3732); the editor renders into the area above it, and the |
| 4277 | // Apply / Cancel controls own the last body row. |
| 4278 | let content = render_modal_text_footer( |
| 4279 | inner, |
| 4280 | buf, |
| 4281 | &footer, |
| 4282 | Style::default().fg(palette::TEXT_MUTED), |
| 4283 | ); |
| 4284 | let body = Rect { |
| 4285 | height: content.height.saturating_sub(1), |
| 4286 | ..content |
| 4287 | }; |
| 4288 | Paragraph::new(lines) |
| 4289 | .style(Style::default().fg(palette::TEXT_PRIMARY)) |
| 4290 | .scroll((0, 0)) |
| 4291 | .render(body, buf); |
| 4292 | if content.height > 0 { |
| 4293 | self.render_editor_controls( |
| 4294 | Rect { |
| 4295 | y: content.bottom().saturating_sub(1), |
| 4296 | height: 1, |
| 4297 | ..content |
| 4298 | }, |
| 4299 | buf, |
| 4300 | ); |
| 4301 | } |
| 4302 | } |
| 4303 | } |
| 4304 | |
| 4305 | impl ConfigView { |
| 4306 | /// Paint `[ Apply ] [ Cancel ]` and record their exact hitboxes. |
| 4307 | fn render_editor_controls(&self, row: Rect, buf: &mut Buffer) { |
| 4308 | use crate::tui::ui_text::text_display_width; |
| 4309 | |
| 4310 | let mut controls = Vec::new(); |
| 4311 | let mut x = row.x; |
| 4312 | for (control, id, style) in [ |
| 4313 | ( |
| 4314 | EditorControl::Apply, |
| 4315 | MessageId::ConfigEditorApply, |
| 4316 | // The filled Apply control answers hover with an underline: |
| 4317 | // a bg tint would erase its button fill. |
| 4318 | if self.hovered_editor == Some(EditorControl::Apply) { |
| 4319 | menu_style::selected_row_style().add_modifier(Modifier::UNDERLINED) |
| 4320 | } else { |
| 4321 | menu_style::selected_row_style() |
| 4322 | }, |
| 4323 | ), |
| 4324 | ( |
| 4325 | EditorControl::Cancel, |
| 4326 | MessageId::ConfigEditorCancel, |
| 4327 | if self.hovered_editor == Some(EditorControl::Cancel) { |
| 4328 | Style::default() |
| 4329 | .fg(palette::TEXT_PRIMARY) |
| 4330 | .add_modifier(Modifier::BOLD) |
| 4331 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4332 | } else { |
| 4333 | Style::default() |
| 4334 | .fg(palette::TEXT_PRIMARY) |
| 4335 | .add_modifier(Modifier::BOLD) |
| 4336 | }, |
| 4337 | ), |
| 4338 | ] { |
| 4339 | let label = format!("[ {} ]", self.tr(id)); |
| 4340 | let width = u16::try_from(text_display_width(&label)).unwrap_or(u16::MAX); |
| 4341 | let limit = row.right().saturating_sub(x); |
| 4342 | if limit == 0 { |
| 4343 | break; |
| 4344 | } |
| 4345 | buf.set_stringn(x, row.y, &label, usize::from(limit), style); |
| 4346 | controls.push(( |
| 4347 | Rect { |
| 4348 | x, |
| 4349 | y: row.y, |
| 4350 | width: width.min(limit), |
| 4351 | height: 1, |
| 4352 | }, |
| 4353 | control, |
| 4354 | )); |
| 4355 | x = x.saturating_add(width).saturating_add(2); |
| 4356 | } |
| 4357 | *self.last_editor_controls.borrow_mut() = controls; |
| 4358 | } |
| 4359 | } |
| 4360 | |
| 4361 | // --------------------------------------------------------------------------- |
| 4362 | // Tideline settings shell: category rail │ setting + action list │ detail at |
| 4363 | // ≥100 columns; a horizontally windowed category strip over a full-width list |
| 4364 | // below that. Every fact painted here comes from a row's typed |
| 4365 | // `ConfigRowFacts`; nothing is re-derived from the key at render time. |
| 4366 | |
| 4367 | /// Inner width at which the detail pane is painted beside the list and the |
| 4368 | /// groups column appears. Below it the panel is tabs over one full-width |
| 4369 | /// list, and the group headings inside the list carry the grouping. |
| 4370 | const CONFIG_SHELL_DETAIL_MIN_WIDTH: u16 = 100; |
| 4371 | /// Groups column width (the active tab's `ui.group` names). |
| 4372 | const CONFIG_SHELL_GROUPS_WIDTH: u16 = 18; |
| 4373 | |
| 4374 | /// Pane geometry for one render of the settings shell. |
| 4375 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 4376 | struct ConfigShellPanes { |
| 4377 | groups: Option<Rect>, |
| 4378 | list: Rect, |
| 4379 | detail: Option<Rect>, |
| 4380 | } |
| 4381 | |
| 4382 | fn config_shell_panes(body: Rect, use_rail: bool) -> ConfigShellPanes { |
| 4383 | let rail_width = if use_rail { |
| 4384 | CONFIG_SHELL_GROUPS_WIDTH |
| 4385 | } else { |
| 4386 | 0 |
| 4387 | }; |
| 4388 | let detail_width = if body.width >= CONFIG_SHELL_DETAIL_MIN_WIDTH { |
| 4389 | // A third of the body, floored so the list keeps a legible value |
| 4390 | // column at the 100-column blocker size. |
| 4391 | (body.width.saturating_mul(34) / 100).clamp(28, 44) |
| 4392 | } else { |
| 4393 | 0 |
| 4394 | }; |
| 4395 | let rail_gap = u16::from(rail_width > 0); |
| 4396 | let detail_gap = u16::from(detail_width > 0); |
| 4397 | let list_width = body |
| 4398 | .width |
| 4399 | .saturating_sub(rail_width + rail_gap + detail_width + detail_gap) |
| 4400 | .max(1); |
| 4401 | let groups = (rail_width > 0).then_some(Rect { |
| 4402 | width: rail_width, |
| 4403 | ..body |
| 4404 | }); |
| 4405 | let list = Rect { |
| 4406 | x: body.x.saturating_add(rail_width + rail_gap), |
| 4407 | width: list_width, |
| 4408 | ..body |
| 4409 | }; |
| 4410 | let detail = (detail_width > 0).then_some(Rect { |
| 4411 | x: list.right().saturating_add(detail_gap), |
| 4412 | width: detail_width, |
| 4413 | ..body |
| 4414 | }); |
| 4415 | ConfigShellPanes { |
| 4416 | groups, |
| 4417 | list, |
| 4418 | detail, |
| 4419 | } |
| 4420 | } |
| 4421 | |
| 4422 | fn setting_authority_label( |
| 4423 | locale: Locale, |
| 4424 | authority: SettingAuthority, |
| 4425 | detail: Option<&str>, |
| 4426 | ) -> Cow<'static, str> { |
| 4427 | let name = detail.unwrap_or_default(); |
| 4428 | match authority { |
| 4429 | SettingAuthority::Environment => { |
| 4430 | Cow::Owned(tr(locale, MessageId::ConfigSourceEnvironment).replace("{name}", name)) |
| 4431 | } |
| 4432 | SettingAuthority::Terminal => { |
| 4433 | Cow::Owned(tr(locale, MessageId::ConfigSourceTerminal).replace("{name}", name)) |
| 4434 | } |
| 4435 | SettingAuthority::Session => tr(locale, MessageId::ConfigSourceSession), |
| 4436 | SettingAuthority::UserSettings => tr(locale, MessageId::ConfigSourceUserSettings), |
| 4437 | SettingAuthority::WorkspaceConfiguration => tr(locale, MessageId::ConfigSourceConfig), |
| 4438 | SettingAuthority::ManagedPolicy => tr(locale, MessageId::ConfigSourceManaged), |
| 4439 | } |
| 4440 | } |
| 4441 | |
| 4442 | fn setting_apply_label(locale: Locale, apply: SettingApplySemantics) -> Cow<'static, str> { |
| 4443 | tr( |
| 4444 | locale, |
| 4445 | match apply { |
| 4446 | SettingApplySemantics::EffectiveNow => MessageId::ConfigApplyEffectiveNow, |
| 4447 | SettingApplySemantics::Immediate => MessageId::ConfigApplyOnSave, |
| 4448 | SettingApplySemantics::NextSession => MessageId::ConfigApplyNextSession, |
| 4449 | SettingApplySemantics::RestartRequired => MessageId::ConfigApplyRestart, |
| 4450 | SettingApplySemantics::ReadOnly => MessageId::ConfigApplyReadOnly, |
| 4451 | SettingApplySemantics::ReloadRequired => MessageId::ConfigApplyReload, |
| 4452 | SettingApplySemantics::UiNowEngineRestart => MessageId::ConfigApplyUiNowEngineRestart, |
| 4453 | }, |
| 4454 | ) |
| 4455 | } |
| 4456 | |
| 4457 | fn setting_kind_label(locale: Locale, kind: SettingKind) -> Cow<'static, str> { |
| 4458 | tr( |
| 4459 | locale, |
| 4460 | match kind { |
| 4461 | SettingKind::Boolean => MessageId::ConfigKindToggle, |
| 4462 | SettingKind::Choice => MessageId::ConfigKindChoice, |
| 4463 | SettingKind::Integer => MessageId::ConfigKindNumber, |
| 4464 | SettingKind::Text => MessageId::ConfigKindText, |
| 4465 | SettingKind::Action => MessageId::ConfigKindAction, |
| 4466 | SettingKind::ReadOnly => MessageId::ConfigKindReadOnly, |
| 4467 | }, |
| 4468 | ) |
| 4469 | } |
| 4470 | |
| 4471 | /// Locale-neutral affordance glyph painted beside every list row so the |
| 4472 | /// interaction (toggle / choose / edit / open / none) is visible before the |
| 4473 | /// row is selected. |
| 4474 | fn setting_affordance(kind: SettingKind, on: Option<bool>) -> &'static str { |
| 4475 | match kind { |
| 4476 | SettingKind::Boolean => { |
| 4477 | if on == Some(true) { |
| 4478 | "[x]" |
| 4479 | } else { |
| 4480 | "[ ]" |
| 4481 | } |
| 4482 | } |
| 4483 | SettingKind::Choice => "‹ ›", |
| 4484 | SettingKind::Integer | SettingKind::Text => "✎", |
| 4485 | SettingKind::Action => "›", |
| 4486 | SettingKind::ReadOnly => "⊘", |
| 4487 | } |
| 4488 | } |
| 4489 | |
| 4490 | /// Styles a category navigator paints with; `ConfigView` and the Tideline |
| 4491 | /// stage scaffold each supply their own palette. |
| 4492 | #[derive(Debug, Clone, Copy)] |
| 4493 | pub(crate) struct CategoryNavStyle { |
| 4494 | pub selected: Style, |
| 4495 | pub normal: Style, |
| 4496 | pub marker: Style, |
| 4497 | pub ascii_safe: bool, |
| 4498 | } |
| 4499 | |
| 4500 | /// Window of chips `[start, end)` that fits `width` columns while always |
| 4501 | /// containing `selected`. Chips are separated by one column and two columns |
| 4502 | /// are reserved on each side that hides chips, for the overflow markers. |
| 4503 | fn category_strip_window(widths: &[usize], selected: usize, width: usize) -> (usize, usize) { |
| 4504 | let count = widths.len(); |
| 4505 | let mut start = 0; |
| 4506 | loop { |
| 4507 | let mut used = if start > 0 { 2 } else { 0 }; |
| 4508 | let mut end = start; |
| 4509 | while end < count { |
| 4510 | let separator = usize::from(end > start); |
| 4511 | let tail = if end + 1 < count { 2 } else { 0 }; |
| 4512 | if end > start && used + separator + widths[end] + tail > width { |
| 4513 | break; |
| 4514 | } |
| 4515 | used += separator + widths[end]; |
| 4516 | end += 1; |
| 4517 | } |
| 4518 | let end = end.max((start + 1).min(count)); |
| 4519 | if selected < end || start + 1 >= count { |
| 4520 | return (start, end); |
| 4521 | } |
| 4522 | start += 1; |
| 4523 | } |
| 4524 | } |
| 4525 | |
| 4526 | /// Painted cells of a category strip: the visible chips and the ‹ / › |
| 4527 | /// overflow markers, which are themselves pointer targets so every category |
| 4528 | /// is reachable by clicking alone at any width. |
| 4529 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 4530 | pub(crate) struct CategoryStripHitboxes { |
| 4531 | pub chips: Vec<(Rect, ConfigCategory)>, |
| 4532 | pub previous: Option<Rect>, |
| 4533 | pub next: Option<Rect>, |
| 4534 | } |
| 4535 | |
| 4536 | /// Paint the horizontally windowed category strip (the narrow-width |
| 4537 | /// navigator from the design's `.settings-nav` rule) and return the painted |
| 4538 | /// rect of every visible category and overflow marker. |
| 4539 | /// |
| 4540 | /// `hovered` tints the chip under the pointer (and `hovered_nav` the overflow |
| 4541 | /// marker) with the shared hover style so every strip target answers |
| 4542 | /// visibly; hover never moves `selected`. |
| 4543 | pub(crate) fn render_settings_category_strip( |
| 4544 | area: Rect, |
| 4545 | buf: &mut Buffer, |
| 4546 | selected: ConfigCategory, |
| 4547 | locale: Locale, |
| 4548 | style: CategoryNavStyle, |
| 4549 | hovered: Option<ConfigCategory>, |
| 4550 | hovered_nav: Option<NavStep>, |
| 4551 | ) -> CategoryStripHitboxes { |
| 4552 | use crate::tui::ui_text::{text_display_width, truncate_line_to_width}; |
| 4553 | |
| 4554 | let mut hitboxes = CategoryStripHitboxes::default(); |
| 4555 | if area.width < 4 || area.height == 0 { |
| 4556 | return hitboxes; |
| 4557 | } |
| 4558 | // At phone-width terminals, show one complete category and its position. |
| 4559 | // Both arrows retain the same wraparound navigation and measured targets. |
| 4560 | if area.width < 50 { |
| 4561 | let previous = Rect::new(area.x, area.y, 2, 1); |
| 4562 | let next = Rect::new(area.right().saturating_sub(2), area.y, 2, 1); |
| 4563 | let label_area = Rect::new(area.x + 2, area.y, area.width.saturating_sub(4), 1); |
| 4564 | let label = format!( |
| 4565 | "{} {}/{}", |
| 4566 | selected.label(locale), |
| 4567 | selected.position() + 1, |
| 4568 | ConfigCategory::ALL.len() |
| 4569 | ); |
| 4570 | for (rect, glyph, step) in [ |
| 4571 | ( |
| 4572 | previous, |
| 4573 | if style.ascii_safe { "< " } else { "‹ " }, |
| 4574 | NavStep::Previous, |
| 4575 | ), |
| 4576 | ( |
| 4577 | next, |
| 4578 | if style.ascii_safe { " >" } else { " ›" }, |
| 4579 | NavStep::Next, |
| 4580 | ), |
| 4581 | ] { |
| 4582 | let marker_style = if hovered_nav == Some(step) { |
| 4583 | style.marker.patch(menu_style::hovered_row_style()) |
| 4584 | } else { |
| 4585 | style.marker |
| 4586 | }; |
| 4587 | buf.set_stringn(rect.x, rect.y, glyph, 2, marker_style); |
| 4588 | } |
| 4589 | buf.set_stringn( |
| 4590 | label_area.x, |
| 4591 | label_area.y, |
| 4592 | truncate_line_to_width(&label, usize::from(label_area.width)), |
| 4593 | usize::from(label_area.width), |
| 4594 | style.selected, |
| 4595 | ); |
| 4596 | hitboxes.chips.push((label_area, selected)); |
| 4597 | hitboxes.previous = Some(previous); |
| 4598 | hitboxes.next = Some(next); |
| 4599 | return hitboxes; |
| 4600 | } |
| 4601 | let labels: Vec<String> = ConfigCategory::ALL |
| 4602 | .iter() |
| 4603 | .map(|category| category.label(locale).into_owned()) |
| 4604 | .collect(); |
| 4605 | let widths: Vec<usize> = labels |
| 4606 | .iter() |
| 4607 | .map(|label| text_display_width(label) + 2) |
| 4608 | .collect(); |
| 4609 | let (start, end) = category_strip_window(&widths, selected.position(), usize::from(area.width)); |
| 4610 | let (prev, next) = if style.ascii_safe { |
| 4611 | ("< ", " >") |
| 4612 | } else { |
| 4613 | ("‹ ", " ›") |
| 4614 | }; |
| 4615 | let y = area.y; |
| 4616 | let right = area.right(); |
| 4617 | let mut x = area.x; |
| 4618 | if start > 0 { |
| 4619 | let prev_style = if hovered_nav == Some(NavStep::Previous) { |
| 4620 | style |
| 4621 | .marker |
| 4622 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4623 | } else { |
| 4624 | style.marker |
| 4625 | }; |
| 4626 | buf.set_stringn(x, y, prev, 2, prev_style); |
| 4627 | hitboxes.previous = Some(Rect { |
| 4628 | x, |
| 4629 | y, |
| 4630 | width: 2, |
| 4631 | height: 1, |
| 4632 | }); |
| 4633 | x = x.saturating_add(2); |
| 4634 | } |
| 4635 | let tail_reserve: u16 = if end < labels.len() { 2 } else { 0 }; |
| 4636 | for (index, label) in labels.iter().enumerate().take(end).skip(start) { |
| 4637 | let limit = right.saturating_sub(tail_reserve).saturating_sub(x); |
| 4638 | if limit == 0 { |
| 4639 | break; |
| 4640 | } |
| 4641 | let chip = format!(" {label} "); |
| 4642 | let painted = truncate_line_to_width(&chip, usize::from(limit)); |
| 4643 | let painted_width = u16::try_from(text_display_width(&painted)).unwrap_or(limit); |
| 4644 | if painted_width == 0 { |
| 4645 | break; |
| 4646 | } |
| 4647 | let category = ConfigCategory::ALL[index]; |
| 4648 | let chip_style = if category == selected { |
| 4649 | style.selected |
| 4650 | } else if hovered == Some(category) { |
| 4651 | style |
| 4652 | .normal |
| 4653 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4654 | } else { |
| 4655 | style.normal |
| 4656 | }; |
| 4657 | buf.set_stringn(x, y, &painted, usize::from(limit), chip_style); |
| 4658 | hitboxes.chips.push(( |
| 4659 | Rect { |
| 4660 | x, |
| 4661 | y, |
| 4662 | width: painted_width, |
| 4663 | height: 1, |
| 4664 | }, |
| 4665 | category, |
| 4666 | )); |
| 4667 | x = x.saturating_add(painted_width).saturating_add(1); |
| 4668 | } |
| 4669 | if end < labels.len() { |
| 4670 | let marker_x = right.saturating_sub(2); |
| 4671 | let next_style = if hovered_nav == Some(NavStep::Next) { |
| 4672 | style |
| 4673 | .marker |
| 4674 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4675 | } else { |
| 4676 | style.marker |
| 4677 | }; |
| 4678 | buf.set_stringn(marker_x, y, next, 2, next_style); |
| 4679 | hitboxes.next = Some(Rect { |
| 4680 | x: marker_x, |
| 4681 | y, |
| 4682 | width: 2, |
| 4683 | height: 1, |
| 4684 | }); |
| 4685 | } |
| 4686 | hitboxes |
| 4687 | } |
| 4688 | |
| 4689 | impl ConfigView { |
| 4690 | /// Display label of the exact persisted value, without any effective |
| 4691 | /// suffix: the `saved` lane of the detail pane. |
| 4692 | fn saved_display_value(&self, row: &ConfigRow) -> String { |
| 4693 | // Preserve the exact saved currency alias (for example `rmb`). |
| 4694 | if row.key == "cost_currency" { |
| 4695 | return row.value.clone(); |
| 4696 | } |
| 4697 | if SettingsRegistry::new(self).meta(row).choices.is_some() { |
| 4698 | if config_default_placeholder_message(&row.key).is_some_and(|message_id| { |
| 4699 | row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id) |
| 4700 | }) { |
| 4701 | return self.tr(MessageId::ConfigValueProviderDefault).into_owned(); |
| 4702 | } |
| 4703 | let canonical = canonical_config_choice(&row.key, &row.value); |
| 4704 | return config_choice_label(self.locale, &row.key, &canonical); |
| 4705 | } |
| 4706 | row.value.clone() |
| 4707 | } |
| 4708 | |
| 4709 | /// Editor kind from the existing registry (its boolean/choice/integer |
| 4710 | /// tables plus the row's typed activation command). |
| 4711 | fn editor_kind(&self, row: &ConfigRow) -> SettingKind { |
| 4712 | SettingsRegistry::new(self).meta(row).kind |
| 4713 | } |
| 4714 | |
| 4715 | /// Project a setting row onto the shared Tideline fact. Action and |
| 4716 | /// diagnostic rows are not persisted facts and project to `None`. |
| 4717 | /// |
| 4718 | /// A lane is filled only from an explicit observation: the session |
| 4719 | /// snapshot, the live session value, the persisted value (saved and |
| 4720 | /// startup), or the `App` field carried on the row as `effective`. |
| 4721 | /// Nothing is inferred across lanes. |
| 4722 | fn setting_fact(&self, row: &ConfigRow) -> Option<SettingFact<String>> { |
| 4723 | if row.facts.kind != ConfigRowKind::Setting { |
| 4724 | return None; |
| 4725 | } |
| 4726 | let mut fact = match row.facts.snapshot { |
| 4727 | Some(SnapshotLane::Provider) => self.snapshot.provider.clone(), |
| 4728 | Some(SnapshotLane::Model) => self.snapshot.model.clone(), |
| 4729 | None => match row.scope { |
| 4730 | ConfigScope::Session => SettingFact::active_session(self.saved_display_value(row)), |
| 4731 | ConfigScope::Saved => { |
| 4732 | // An unreadable store yields no saved or startup value. |
| 4733 | let saved = row |
| 4734 | .facts |
| 4735 | .store_error |
| 4736 | .is_none() |
| 4737 | .then(|| self.saved_display_value(row)); |
| 4738 | let effective = row.facts.effective.as_deref().map(|value| { |
| 4739 | config_choice_label( |
| 4740 | self.locale, |
| 4741 | &row.key, |
| 4742 | &canonical_config_choice(&row.key, value), |
| 4743 | ) |
| 4744 | }); |
| 4745 | SettingFact { |
| 4746 | current: effective.clone(), |
| 4747 | effective, |
| 4748 | startup: saved.clone(), |
| 4749 | saved, |
| 4750 | authority: row.facts.authority, |
| 4751 | apply: row.facts.apply, |
| 4752 | } |
| 4753 | } |
| 4754 | }, |
| 4755 | }; |
| 4756 | fact.authority = row.facts.authority; |
| 4757 | fact.apply = row.facts.apply; |
| 4758 | Some(fact) |
| 4759 | } |
| 4760 | |
| 4761 | /// Verb for activating the selected row (`Enter opens…`, `Space toggles`). |
| 4762 | fn setting_action_label(&self, row: &ConfigRow) -> Cow<'static, str> { |
| 4763 | if let Some((_, verb)) = row.facts.command { |
| 4764 | return self.tr(verb); |
| 4765 | } |
| 4766 | self.tr(match self.editor_kind(row) { |
| 4767 | SettingKind::Boolean => MessageId::ConfigActionToggle, |
| 4768 | SettingKind::Choice => MessageId::ConfigActionChoose, |
| 4769 | SettingKind::Integer | SettingKind::Text => MessageId::ConfigActionEdit, |
| 4770 | SettingKind::Action | SettingKind::ReadOnly => MessageId::ConfigActionReadOnly, |
| 4771 | }) |
| 4772 | } |
| 4773 | |
| 4774 | /// What activating the selected row does, spelled out: second click or |
| 4775 | /// Enter is the activation model, so the row says so. |
| 4776 | fn activation_copy(&self, row: &ConfigRow) -> String { |
| 4777 | if row.editable { |
| 4778 | format!( |
| 4779 | "{} {}", |
| 4780 | self.tr(MessageId::ConfigActivateAgain), |
| 4781 | self.setting_action_label(row) |
| 4782 | ) |
| 4783 | } else { |
| 4784 | self.tr(MessageId::ConfigActionReadOnly).into_owned() |
| 4785 | } |
| 4786 | } |
| 4787 | |
| 4788 | fn lane_or_unobserved(&self, lane: Option<&String>) -> String { |
| 4789 | lane.cloned() |
| 4790 | .unwrap_or_else(|| self.tr(MessageId::ConfigLaneUnobserved).into_owned()) |
| 4791 | } |
| 4792 | |
| 4793 | /// A persisted lane: the value, or the store's load error, or unobserved. |
| 4794 | fn store_lane(&self, lane: Option<&String>, store_error: Option<&str>) -> String { |
| 4795 | match (lane, store_error) { |
| 4796 | (Some(value), _) => value.clone(), |
| 4797 | (None, Some(error)) => self |
| 4798 | .tr(MessageId::ConfigLaneUnavailable) |
| 4799 | .replace("{error}", error), |
| 4800 | (None, None) => self.tr(MessageId::ConfigLaneUnobserved).into_owned(), |
| 4801 | } |
| 4802 | } |
| 4803 | |
| 4804 | /// The detail pane: label and key, then the typed facts for the row's |
| 4805 | /// kind, then the description and the activation copy. |
| 4806 | fn setting_detail_lines(&self, row: &ConfigRow, width: usize) -> Vec<Line<'static>> { |
| 4807 | use crate::tui::ui_text::semantic_truncate; |
| 4808 | |
| 4809 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 4810 | let kind = self.editor_kind(row); |
| 4811 | let muted = Style::default().fg(palette::TEXT_MUTED); |
| 4812 | let primary = Style::default().fg(palette::TEXT_PRIMARY); |
| 4813 | let value_width = width.saturating_sub(10); |
| 4814 | let fact_line = |name: MessageId, value: &str| { |
| 4815 | Line::from(vec![ |
| 4816 | Span::styled(format!("{:<10}", self.tr(name)), muted), |
| 4817 | Span::styled(semantic_truncate(value, value_width), primary), |
| 4818 | ]) |
| 4819 | }; |
| 4820 | let mut lines = vec![ |
| 4821 | Line::from(Span::styled( |
| 4822 | semantic_truncate(&label, width), |
| 4823 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 4824 | )), |
| 4825 | Line::from(Span::styled( |
| 4826 | semantic_truncate(&row.key, width), |
| 4827 | Style::default().fg(palette::TEXT_DIM), |
| 4828 | )), |
| 4829 | Line::from(""), |
| 4830 | ]; |
| 4831 | let source = |
| 4832 | setting_authority_label(self.locale, row.facts.authority, row.facts.authority_detail); |
| 4833 | let kind_label = setting_kind_label(self.locale, kind); |
| 4834 | match row.facts.kind { |
| 4835 | ConfigRowKind::Setting => { |
| 4836 | let fact = self |
| 4837 | .setting_fact(row) |
| 4838 | .unwrap_or_else(|| SettingFact::active_session(self.saved_display_value(row))); |
| 4839 | let store_error = row.facts.store_error.as_deref(); |
| 4840 | lines.push(fact_line( |
| 4841 | MessageId::ConfigFactCurrent, |
| 4842 | &self.lane_or_unobserved(fact.effective.as_ref().or(fact.current.as_ref())), |
| 4843 | )); |
| 4844 | lines.push(fact_line( |
| 4845 | MessageId::ConfigFactSaved, |
| 4846 | &self.store_lane(fact.saved.as_ref(), store_error), |
| 4847 | )); |
| 4848 | lines.push(fact_line( |
| 4849 | MessageId::ConfigFactStartup, |
| 4850 | &self.store_lane(fact.startup.as_ref(), store_error), |
| 4851 | )); |
| 4852 | lines.push(fact_line(MessageId::ConfigFactSource, &source)); |
| 4853 | lines.push(fact_line( |
| 4854 | MessageId::ConfigFactScope, |
| 4855 | row.scope.label(self.locale).as_ref(), |
| 4856 | )); |
| 4857 | lines.push(fact_line( |
| 4858 | MessageId::ConfigFactApply, |
| 4859 | &setting_apply_label(self.locale, fact.apply), |
| 4860 | )); |
| 4861 | lines.push(fact_line(MessageId::ConfigFactKind, &kind_label)); |
| 4862 | // No existing source reports availability; say so rather |
| 4863 | // than implying the setting is known to be usable. |
| 4864 | lines.push(fact_line( |
| 4865 | MessageId::ConfigFactAvailable, |
| 4866 | &self.tr(MessageId::ConfigLaneUnobserved), |
| 4867 | )); |
| 4868 | } |
| 4869 | ConfigRowKind::Action => { |
| 4870 | lines.push(Line::from(Span::styled( |
| 4871 | self.tr(MessageId::ConfigRowActionNote).into_owned(), |
| 4872 | muted, |
| 4873 | ))); |
| 4874 | if let Some((command, _)) = row.facts.command { |
| 4875 | lines.push(fact_line(MessageId::ConfigFactOpens, command)); |
| 4876 | } |
| 4877 | lines.push(fact_line(MessageId::ConfigFactSource, &source)); |
| 4878 | lines.push(fact_line(MessageId::ConfigFactKind, &kind_label)); |
| 4879 | } |
| 4880 | ConfigRowKind::Diagnostic => { |
| 4881 | lines.push(Line::from(Span::styled( |
| 4882 | self.tr(MessageId::ConfigRowDiagnosticNote).into_owned(), |
| 4883 | muted, |
| 4884 | ))); |
| 4885 | lines.push(fact_line(MessageId::ConfigFactObserved, &row.value)); |
| 4886 | lines.push(fact_line(MessageId::ConfigFactSource, &source)); |
| 4887 | lines.push(fact_line(MessageId::ConfigFactKind, &kind_label)); |
| 4888 | } |
| 4889 | } |
| 4890 | lines.push(Line::from("")); |
| 4891 | let hint = config_hint_for_key(self.locale, &row.key); |
| 4892 | let description = if hint.is_empty() { |
| 4893 | self.tr(MessageId::ConfigDescriptionDefault) |
| 4894 | } else { |
| 4895 | hint |
| 4896 | }; |
| 4897 | lines.push(Line::from(Span::styled(description.into_owned(), muted))); |
| 4898 | lines.push(Line::from(Span::styled( |
| 4899 | self.activation_copy(row), |
| 4900 | Style::default().fg(palette::TEXT_HINT), |
| 4901 | ))); |
| 4902 | lines |
| 4903 | } |
| 4904 | |
| 4905 | /// One-row fold of the detail for surfaces too narrow for the pane: the |
| 4906 | /// activation copy first, then the lanes that still fit. |
| 4907 | fn setting_detail_summary(&self, row: &ConfigRow) -> String { |
| 4908 | let activation = self.activation_copy(row); |
| 4909 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 4910 | match row.facts.kind { |
| 4911 | ConfigRowKind::Setting => { |
| 4912 | let fact = self |
| 4913 | .setting_fact(row) |
| 4914 | .unwrap_or_else(|| SettingFact::active_session(self.saved_display_value(row))); |
| 4915 | format!( |
| 4916 | "{activation} · {label}: {} {} · {} {} · {}", |
| 4917 | self.tr(MessageId::ConfigFactCurrent), |
| 4918 | self.lane_or_unobserved(fact.effective.as_ref().or(fact.current.as_ref())), |
| 4919 | self.tr(MessageId::ConfigFactSaved), |
| 4920 | self.store_lane(fact.saved.as_ref(), row.facts.store_error.as_deref()), |
| 4921 | setting_apply_label(self.locale, fact.apply) |
| 4922 | ) |
| 4923 | } |
| 4924 | ConfigRowKind::Action => { |
| 4925 | format!("{activation} · {}", self.tr(MessageId::ConfigRowActionNote)) |
| 4926 | } |
| 4927 | ConfigRowKind::Diagnostic => { |
| 4928 | format!( |
| 4929 | "{label}: {} · {}", |
| 4930 | row.value, |
| 4931 | self.tr(MessageId::ConfigRowDiagnosticNote) |
| 4932 | ) |
| 4933 | } |
| 4934 | } |
| 4935 | } |
| 4936 | |
| 4937 | fn render_pane_divider(area: Rect, buf: &mut Buffer, x: u16) { |
| 4938 | if x < area.x || x >= area.right() { |
| 4939 | return; |
| 4940 | } |
| 4941 | for y in area.top()..area.bottom() { |
| 4942 | buf[(x, y)] |
| 4943 | .set_symbol("│") |
| 4944 | .set_style(Style::default().fg(palette::BORDER_COLOR)); |
| 4945 | } |
| 4946 | } |
| 4947 | |
| 4948 | fn render_setting_detail(&self, area: Rect, buf: &mut Buffer) { |
| 4949 | let Some(row) = self.selected_row_index().and_then(|idx| self.rows.get(idx)) else { |
| 4950 | Paragraph::new(Line::from(Span::styled( |
| 4951 | self.tr(MessageId::ConfigNoSettings).into_owned(), |
| 4952 | Style::default().fg(palette::TEXT_MUTED), |
| 4953 | ))) |
| 4954 | .wrap(Wrap { trim: false }) |
| 4955 | .render(area, buf); |
| 4956 | return; |
| 4957 | }; |
| 4958 | Paragraph::new(self.setting_detail_lines(row, usize::from(area.width))) |
| 4959 | .wrap(Wrap { trim: false }) |
| 4960 | .render(area, buf); |
| 4961 | } |
| 4962 | |
| 4963 | /// Paint the three-pane shell into the surface body. |
| 4964 | /// The active tab's groups, in schema order, with the group holding the |
| 4965 | /// selected row lit. It is the same projection the centre list paints as |
| 4966 | /// headings — at ≥100 columns it also gets a column of its own. |
| 4967 | fn render_group_column(&self, area: Rect, buf: &mut Buffer) { |
| 4968 | let selected_group = self |
| 4969 | .selected_row_index() |
| 4970 | .and_then(|idx| self.rows.get(idx)) |
| 4971 | .map(|row| row.section()); |
| 4972 | let mut lines: Vec<Line> = Vec::new(); |
| 4973 | for group in self.visible_groups() { |
| 4974 | let selected = Some(group) == selected_group; |
| 4975 | let style = if selected { |
| 4976 | Style::default() |
| 4977 | .fg(palette::WHALE_ACTION) |
| 4978 | .add_modifier(Modifier::BOLD) |
| 4979 | } else { |
| 4980 | Style::default().fg(palette::TEXT_MUTED) |
| 4981 | }; |
| 4982 | lines.push(Line::from(vec![ |
| 4983 | Span::styled( |
| 4984 | if selected { "❯ " } else { " " }, |
| 4985 | Style::default().fg(palette::WHALE_ACTION), |
| 4986 | ), |
| 4987 | Span::styled( |
| 4988 | fit_config_column( |
| 4989 | &group.label(self.locale), |
| 4990 | usize::from(area.width).saturating_sub(2), |
| 4991 | ), |
| 4992 | style, |
| 4993 | ), |
| 4994 | ])); |
| 4995 | } |
| 4996 | Paragraph::new(lines).render(area, buf); |
| 4997 | } |
| 4998 | |
| 4999 | /// The groups of the active tab that actually have rows, in schema order. |
| 5000 | fn visible_groups(&self) -> Vec<ConfigSection> { |
| 5001 | let mut groups: Vec<ConfigSection> = Vec::new(); |
| 5002 | for item in self.visible_items() { |
| 5003 | if let ConfigListItem::Section(section) = item |
| 5004 | && !groups.contains(§ion) |
| 5005 | { |
| 5006 | groups.push(section); |
| 5007 | } |
| 5008 | } |
| 5009 | groups |
| 5010 | } |
| 5011 | |
| 5012 | /// A live preview of the footer the current values paint, drawn by the |
| 5013 | /// real footer renderer rather than a mock of it: the theme is the one |
| 5014 | /// the theme row selects, the chips are the mode and permission rows, and |
| 5015 | /// the depth line is this session's own context reading. |
| 5016 | fn render_footer_preview(&self, area: Rect, buf: &mut Buffer) { |
| 5017 | let label = self.tr(MessageId::ConfigPreviewLabel); |
| 5018 | let label_width = u16::try_from(UnicodeWidthStr::width(label.as_ref())).unwrap_or(0); |
| 5019 | if area.width <= label_width { |
| 5020 | return; |
| 5021 | } |
| 5022 | Paragraph::new(Line::from(Span::styled( |
| 5023 | label.into_owned(), |
| 5024 | Style::default().fg(palette::TEXT_HINT), |
| 5025 | ))) |
| 5026 | .render(Rect { height: 1, ..area }, buf); |
| 5027 | |
| 5028 | let value_of = |key: &str| { |
| 5029 | self.rows |
| 5030 | .iter() |
| 5031 | .find(|row| row.key == key) |
| 5032 | .map(|row| canonical_config_choice(key, &row.value)) |
| 5033 | }; |
| 5034 | let theme = value_of("theme") |
| 5035 | .and_then(|name| { |
| 5036 | palette::SELECTABLE_THEMES |
| 5037 | .iter() |
| 5038 | .find(|id| id.name() == name) |
| 5039 | .copied() |
| 5040 | }) |
| 5041 | .map_or(palette::UI_THEME, palette::ThemeId::ui_theme); |
| 5042 | let context_percent = self.snapshot.context_budget.as_ref().map_or(0, |budget| { |
| 5043 | u8::try_from(budget.percent_basis_points / 100).unwrap_or(100) |
| 5044 | }); |
| 5045 | let mode = value_of("default_mode").unwrap_or_else(|| "agent".to_string()); |
| 5046 | let mode_ink = match mode.as_str() { |
| 5047 | "plan" => palette::ChromeInk::PolicyPlan, |
| 5048 | "operate" => palette::ChromeInk::PolicyOperate, |
| 5049 | _ => palette::ChromeInk::PolicyAct, |
| 5050 | }; |
| 5051 | let permission = value_of("approval_mode") |
| 5052 | .or_else(|| value_of("permission_posture")) |
| 5053 | .unwrap_or_else(|| "ask".to_string()); |
| 5054 | let permission_ink = match permission.as_str() { |
| 5055 | "auto-review" => palette::ChromeInk::PermissionAutoReview, |
| 5056 | "full-access" => palette::ChromeInk::PermissionFullAccess, |
| 5057 | _ => palette::ChromeInk::PermissionAsk, |
| 5058 | }; |
| 5059 | let footer = crate::tui::phase_strip::TidelineFooter::new( |
| 5060 | &theme, |
| 5061 | (permission.as_str(), permission_ink), |
| 5062 | ) |
| 5063 | .mode_chip(Some((mode.as_str(), mode_ink))) |
| 5064 | .context_percent(context_percent); |
| 5065 | crate::tui::phase_strip::render_tideline_footer( |
| 5066 | Rect { |
| 5067 | x: area.x.saturating_add(label_width), |
| 5068 | width: area.width - label_width, |
| 5069 | height: 1, |
| 5070 | ..area |
| 5071 | }, |
| 5072 | buf, |
| 5073 | &footer, |
| 5074 | ); |
| 5075 | } |
| 5076 | |
| 5077 | fn render_settings_shell(&self, inner: Rect, buf: &mut Buffer) { |
| 5078 | *self.last_choice_hitboxes.borrow_mut() = Vec::new(); |
| 5079 | *self.last_editor_controls.borrow_mut() = Vec::new(); |
| 5080 | *self.last_nav_controls.borrow_mut() = Vec::new(); |
| 5081 | let items = self.visible_items(); |
| 5082 | let match_count = self.matching_row_indices().len(); |
| 5083 | |
| 5084 | let compact = inner.width < 50; |
| 5085 | // Keys and symbols stay legible in one row; the search field above |
| 5086 | // already explains typing. Shed verbose copy before editable content. |
| 5087 | let ascii_safe = crate::tui::color_compat::ascii_safe_enabled(); |
| 5088 | let compact_hints = if ascii_safe { |
| 5089 | [ |
| 5090 | ActionHint::new("Tab", "<>"), |
| 5091 | ActionHint::new("Up/Dn", ""), |
| 5092 | ActionHint::new("Enter", ""), |
| 5093 | ActionHint::new("Esc", ""), |
| 5094 | ] |
| 5095 | } else { |
| 5096 | [ |
| 5097 | ActionHint::new("Tab", "⇆"), |
| 5098 | ActionHint::new("↑↓", ""), |
| 5099 | ActionHint::new("Enter", "↵"), |
| 5100 | ActionHint::new("Esc", "×"), |
| 5101 | ] |
| 5102 | }; |
| 5103 | // Reserve the action footer by its actual wrapped height so no list |
| 5104 | // row silently falls off the bottom on compact terminals. |
| 5105 | let footer_height = |id: MessageId| -> usize { |
| 5106 | wrapped_footer_lines(&self.tr(id), inner.width, Style::default()).len() |
| 5107 | }; |
| 5108 | let footer_lines = if compact { |
| 5109 | 1 |
| 5110 | } else if !self.filter.is_empty() { |
| 5111 | footer_height(MessageId::ConfigFooterFiltered) |
| 5112 | } else { |
| 5113 | footer_height(MessageId::ConfigFooterScrollable) |
| 5114 | .max(footer_height(MessageId::ConfigFooterDefault)) |
| 5115 | } |
| 5116 | .max(1); |
| 5117 | let content_height = usize::from(inner.height).saturating_sub(footer_lines); |
| 5118 | |
| 5119 | // Header: the tab row over the search line. The tabs are always the |
| 5120 | // top row — a settings panel that hides which tab you are on is the |
| 5121 | // thing this layout exists to fix. |
| 5122 | const HEADER_LINES: usize = 2; |
| 5123 | // ≥100 columns spends 18 cells on the groups column and a detail |
| 5124 | // pane; below that the tab row and the in-list group headings carry |
| 5125 | // the same structure at 80 columns. |
| 5126 | let show_detail = inner.width >= CONFIG_SHELL_DETAIL_MIN_WIDTH; |
| 5127 | // Bottom band: the selected row's sentence, then a preview of the |
| 5128 | // footer these settings paint. The preview sheds first on short |
| 5129 | // terminals; the sentence holds while two list lines remain. |
| 5130 | let preview_lines = usize::from(content_height >= HEADER_LINES + 10); |
| 5131 | let sentence_lines = if compact { |
| 5132 | usize::from(content_height >= HEADER_LINES + 3) |
| 5133 | } else if content_height.saturating_sub(HEADER_LINES + preview_lines) >= 3 { |
| 5134 | // Without a detail pane the band also carries the lanes that pane |
| 5135 | // would have shown, on a second line so neither is truncated away. |
| 5136 | if show_detail { |
| 5137 | 1 |
| 5138 | } else if content_height >= HEADER_LINES + 9 { |
| 5139 | 3 |
| 5140 | } else { |
| 5141 | 2 |
| 5142 | } |
| 5143 | } else { |
| 5144 | usize::from(self.status.is_some()) |
| 5145 | }; |
| 5146 | let bottom_lines = sentence_lines + preview_lines; |
| 5147 | let body_height = content_height |
| 5148 | .saturating_sub(HEADER_LINES + bottom_lines) |
| 5149 | .max(1); |
| 5150 | self.last_visible_rows.set(body_height); |
| 5151 | let use_rail = show_detail && body_height >= 3; |
| 5152 | |
| 5153 | let clamp_height = |y: u16, wanted: usize| -> u16 { |
| 5154 | u16::try_from(wanted) |
| 5155 | .unwrap_or(u16::MAX) |
| 5156 | .min(inner.bottom().saturating_sub(y)) |
| 5157 | }; |
| 5158 | let header = Rect { |
| 5159 | height: clamp_height(inner.y, HEADER_LINES), |
| 5160 | ..inner |
| 5161 | }; |
| 5162 | let body = Rect { |
| 5163 | y: header.bottom(), |
| 5164 | height: clamp_height(header.bottom(), body_height), |
| 5165 | ..inner |
| 5166 | }; |
| 5167 | let bottom = Rect { |
| 5168 | y: body.bottom(), |
| 5169 | height: clamp_height(body.bottom(), bottom_lines), |
| 5170 | ..inner |
| 5171 | }; |
| 5172 | let panes = config_shell_panes(body, use_rail); |
| 5173 | |
| 5174 | // Selection-anchored scroll: the row being manipulated always renders. |
| 5175 | let list_line_budget = usize::from(body.height).max(1); |
| 5176 | // A section caption costs itself plus a blank spacer, except at the |
| 5177 | // top of the window where no spacer is painted. |
| 5178 | let item_line_cost = |item: &ConfigListItem, first: bool| match item { |
| 5179 | ConfigListItem::Section(_) if first => 1usize, |
| 5180 | ConfigListItem::Section(_) => 2usize, |
| 5181 | ConfigListItem::Row(_) => 1usize, |
| 5182 | }; |
| 5183 | let visible_end = |start: usize| { |
| 5184 | let mut used = 0usize; |
| 5185 | let mut end = start; |
| 5186 | while end < items.len() { |
| 5187 | let cost = item_line_cost(&items[end], end == start); |
| 5188 | if end > start && used.saturating_add(cost) > list_line_budget { |
| 5189 | break; |
| 5190 | } |
| 5191 | used = used.saturating_add(cost); |
| 5192 | end += 1; |
| 5193 | } |
| 5194 | end |
| 5195 | }; |
| 5196 | let mut start = self.scroll.min(items.len().saturating_sub(1)); |
| 5197 | if let Some(selected_pos) = self.selected_display_position(&items) { |
| 5198 | start = start.min(selected_pos); |
| 5199 | while selected_pos >= visible_end(start) && start < selected_pos { |
| 5200 | start += 1; |
| 5201 | } |
| 5202 | } |
| 5203 | let end = visible_end(start); |
| 5204 | let scrollable = start > 0 || end < items.len(); |
| 5205 | self.last_render_scroll.set(start); |
| 5206 | |
| 5207 | // Header. |
| 5208 | let search_value = if self.filter.is_empty() { |
| 5209 | self.tr(MessageId::ConfigSearchPlaceholder).to_string() |
| 5210 | } else { |
| 5211 | self.filter.clone() |
| 5212 | }; |
| 5213 | let search_line = Line::from(vec![ |
| 5214 | Span::styled( |
| 5215 | self.tr(MessageId::ConfigSearchLabel), |
| 5216 | Style::default().fg(palette::TEXT_MUTED), |
| 5217 | ), |
| 5218 | Span::raw(search_value), |
| 5219 | Span::styled( |
| 5220 | format!(" ({match_count}/{})", self.rows.len()), |
| 5221 | Style::default().fg(palette::TEXT_MUTED), |
| 5222 | ), |
| 5223 | ]); |
| 5224 | *self.last_rail_hitboxes.borrow_mut() = Vec::new(); |
| 5225 | if header.height > 0 { |
| 5226 | let nav_row = Rect { |
| 5227 | height: 1, |
| 5228 | ..header |
| 5229 | }; |
| 5230 | { |
| 5231 | let strip_style = CategoryNavStyle { |
| 5232 | selected: menu_style::selected_row_style(), |
| 5233 | normal: Style::default().fg(palette::TEXT_MUTED), |
| 5234 | marker: Style::default().fg(palette::TEXT_HINT), |
| 5235 | ascii_safe, |
| 5236 | }; |
| 5237 | let strip = render_settings_category_strip( |
| 5238 | nav_row, |
| 5239 | buf, |
| 5240 | self.category, |
| 5241 | self.locale, |
| 5242 | strip_style, |
| 5243 | self.hovered_rail, |
| 5244 | self.hovered_nav, |
| 5245 | ); |
| 5246 | *self.last_rail_hitboxes.borrow_mut() = strip.chips; |
| 5247 | *self.last_nav_controls.borrow_mut() = strip |
| 5248 | .previous |
| 5249 | .map(|rect| (rect, NavStep::Previous)) |
| 5250 | .into_iter() |
| 5251 | .chain(strip.next.map(|rect| (rect, NavStep::Next))) |
| 5252 | .collect(); |
| 5253 | } |
| 5254 | } |
| 5255 | if header.height > 1 { |
| 5256 | Paragraph::new(search_line).render( |
| 5257 | Rect { |
| 5258 | y: header.y.saturating_add(1), |
| 5259 | height: 1, |
| 5260 | ..header |
| 5261 | }, |
| 5262 | buf, |
| 5263 | ); |
| 5264 | } |
| 5265 | |
| 5266 | // Groups column: the active tab's `ui.group` names, the one holding |
| 5267 | // the selected row lit. |
| 5268 | if let Some(column) = panes.groups { |
| 5269 | self.render_group_column(column, buf); |
| 5270 | Self::render_pane_divider(body, buf, column.right()); |
| 5271 | } |
| 5272 | |
| 5273 | // List. |
| 5274 | let list = |
| 5275 | render_panel_scroll_rail(panes.list, buf, items.len(), start, list_line_budget, true); |
| 5276 | let (key_column_width, value_column_width, scope_column_width) = |
| 5277 | self.table_column_widths(usize::from(list.width)); |
| 5278 | let mut lines: Vec<Line> = Vec::new(); |
| 5279 | let mut row_hitboxes = Vec::new(); |
| 5280 | for item in &items[start..end] { |
| 5281 | match item { |
| 5282 | ConfigListItem::Section(section) => { |
| 5283 | if !lines.is_empty() { |
| 5284 | lines.push(Line::from("")); |
| 5285 | } |
| 5286 | lines.push(Line::from(Span::styled( |
| 5287 | format!(" {}", section.label(self.locale)), |
| 5288 | Style::default() |
| 5289 | .fg(palette::TEXT_HINT) |
| 5290 | .bold() |
| 5291 | .add_modifier(Modifier::UNDERLINED), |
| 5292 | ))); |
| 5293 | } |
| 5294 | ConfigListItem::Row(idx) => { |
| 5295 | let Some(row) = self.rows.get(*idx) else { |
| 5296 | continue; |
| 5297 | }; |
| 5298 | let line_y = list.y.saturating_add(lines.len() as u16); |
| 5299 | if line_y >= list.bottom() { |
| 5300 | break; |
| 5301 | } |
| 5302 | row_hitboxes.push(( |
| 5303 | Rect { |
| 5304 | x: list.x, |
| 5305 | y: line_y, |
| 5306 | width: list.width, |
| 5307 | height: 1, |
| 5308 | }, |
| 5309 | *idx, |
| 5310 | )); |
| 5311 | let selected = *idx == self.selected; |
| 5312 | // Hover tints but never steals the keyboard selection. |
| 5313 | let hovered = !selected && self.hovered_row == Some(*idx); |
| 5314 | let style = if selected { |
| 5315 | menu_style::selected_row_style() |
| 5316 | } else if row.editable { |
| 5317 | Style::default().fg(palette::TEXT_PRIMARY) |
| 5318 | } else { |
| 5319 | // Read-only rows look distinct from editable ones. |
| 5320 | Style::default() |
| 5321 | .fg(palette::TEXT_MUTED) |
| 5322 | .add_modifier(Modifier::DIM) |
| 5323 | }; |
| 5324 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 5325 | let (key_width, value_width) = if compact { |
| 5326 | let available = usize::from(list.width).saturating_sub( |
| 5327 | CONFIG_ROW_PREFIX_WIDTH |
| 5328 | + CONFIG_COLUMN_GAPS_WIDTH |
| 5329 | + CONFIG_AFFORDANCE_COLUMN_WIDTH, |
| 5330 | ); |
| 5331 | let key_width = UnicodeWidthStr::width(label.as_str()).min(available / 2); |
| 5332 | (key_width, available.saturating_sub(key_width)) |
| 5333 | } else { |
| 5334 | (key_column_width, value_column_width) |
| 5335 | }; |
| 5336 | let key = fit_config_column(&label, key_width); |
| 5337 | let value = fit_config_column(&self.row_display_value(row), value_width); |
| 5338 | let kind = self.editor_kind(row); |
| 5339 | let on = (kind == SettingKind::Boolean) |
| 5340 | .then(|| canonical_config_choice(&row.key, row.edit_value()) == "true"); |
| 5341 | let affordance = setting_affordance(kind, on); |
| 5342 | // Action and diagnostic rows are not persisted facts, so |
| 5343 | // they carry no scope badge. |
| 5344 | let badge = match row.facts.kind { |
| 5345 | ConfigRowKind::Setting if scope_column_width > 0 => { |
| 5346 | row.scope.label(self.locale) |
| 5347 | } |
| 5348 | _ => Cow::Borrowed(""), |
| 5349 | }; |
| 5350 | let rail = if selected { "❯" } else { " " }; |
| 5351 | let mut line = Line::from(vec![ |
| 5352 | Span::styled( |
| 5353 | rail, |
| 5354 | if selected { |
| 5355 | style |
| 5356 | } else { |
| 5357 | Style::default().fg(palette::TEXT_DIM) |
| 5358 | }, |
| 5359 | ), |
| 5360 | Span::styled(format!("{key} {value} "), style), |
| 5361 | Span::styled( |
| 5362 | format!("{affordance:<3} "), |
| 5363 | if selected { |
| 5364 | style |
| 5365 | } else if row.editable { |
| 5366 | Style::default().fg(palette::WHALE_ACTION) |
| 5367 | } else { |
| 5368 | Style::default() |
| 5369 | .fg(palette::TEXT_DIM) |
| 5370 | .add_modifier(Modifier::DIM) |
| 5371 | }, |
| 5372 | ), |
| 5373 | Span::styled( |
| 5374 | badge.into_owned(), |
| 5375 | if selected { |
| 5376 | style |
| 5377 | } else { |
| 5378 | Style::default() |
| 5379 | .fg(palette::TEXT_HINT) |
| 5380 | .add_modifier(Modifier::DIM) |
| 5381 | }, |
| 5382 | ), |
| 5383 | ]); |
| 5384 | if selected { |
| 5385 | line.style = menu_style::selected_row_bg_style(); |
| 5386 | } else if hovered { |
| 5387 | line.style = menu_style::hovered_row_style(); |
| 5388 | } |
| 5389 | lines.push(line); |
| 5390 | } |
| 5391 | } |
| 5392 | } |
| 5393 | *self.last_row_hitboxes.borrow_mut() = row_hitboxes; |
| 5394 | if items.is_empty() { |
| 5395 | let message = if self.filter.is_empty() { |
| 5396 | self.tr(MessageId::ConfigNoSettings).to_string() |
| 5397 | } else { |
| 5398 | format!( |
| 5399 | "{}\"{}\".", |
| 5400 | self.tr(MessageId::ConfigNoMatchesPrefix), |
| 5401 | self.filter |
| 5402 | ) |
| 5403 | }; |
| 5404 | lines.push(Line::from(Span::styled( |
| 5405 | message, |
| 5406 | Style::default().fg(palette::TEXT_MUTED), |
| 5407 | ))); |
| 5408 | } |
| 5409 | Paragraph::new(lines) |
| 5410 | .style(Style::default().fg(palette::TEXT_PRIMARY)) |
| 5411 | .render(list, buf); |
| 5412 | |
| 5413 | // Detail. |
| 5414 | if let Some(detail) = panes.detail { |
| 5415 | Self::render_pane_divider(body, buf, detail.x.saturating_sub(1)); |
| 5416 | self.render_setting_detail(detail, buf); |
| 5417 | } |
| 5418 | |
| 5419 | // Status row: an explicit status wins; otherwise the selected row's |
| 5420 | // activation copy, with the detail facts folded in when no pane |
| 5421 | // shows them. |
| 5422 | let sentence_height = u16::try_from(sentence_lines) |
| 5423 | .unwrap_or(0) |
| 5424 | .min(bottom.height); |
| 5425 | if sentence_height > 0 { |
| 5426 | let selected_row = self.selected_row_index().and_then(|idx| self.rows.get(idx)); |
| 5427 | // The band says what the selected setting *is*: the schema's |
| 5428 | // sentence, in one plain line. A live status or an active filter |
| 5429 | // is more urgent and takes the row while it lasts. |
| 5430 | let bottom_text = if let Some(status) = self.status.as_ref() { |
| 5431 | status.clone() |
| 5432 | } else if let Some(row) = selected_row.filter(|_| compact) { |
| 5433 | format!( |
| 5434 | "{}: {}", |
| 5435 | config_label_for_key_for_locale(self.locale, &row.key), |
| 5436 | self.row_display_value(row) |
| 5437 | ) |
| 5438 | } else if !self.filter.is_empty() { |
| 5439 | format!( |
| 5440 | "{}: {match_count}", |
| 5441 | self.tr(MessageId::ConfigFilteredSettings) |
| 5442 | ) |
| 5443 | } else if let Some(row) = selected_row { |
| 5444 | let sentence = config_hint_for_key(self.locale, &row.key); |
| 5445 | if sentence.is_empty() { |
| 5446 | self.activation_copy(row) |
| 5447 | } else { |
| 5448 | sentence.into_owned() |
| 5449 | } |
| 5450 | } else { |
| 5451 | String::new() |
| 5452 | }; |
| 5453 | let band = vec![Line::from(Span::styled( |
| 5454 | crate::tui::ui_text::semantic_truncate(&bottom_text, usize::from(inner.width)), |
| 5455 | Style::default().fg(palette::TEXT_MUTED), |
| 5456 | ))]; |
| 5457 | // With a detail pane the lanes are already on screen; without one |
| 5458 | // the band's second line carries what that pane would have shown. |
| 5459 | Paragraph::new(band).render( |
| 5460 | Rect { |
| 5461 | height: 1, |
| 5462 | ..bottom |
| 5463 | }, |
| 5464 | buf, |
| 5465 | ); |
| 5466 | if sentence_height > 1 |
| 5467 | && self.status.is_none() |
| 5468 | && self.filter.is_empty() |
| 5469 | && let Some(row) = selected_row |
| 5470 | { |
| 5471 | let folded = Rect { |
| 5472 | y: bottom.y.saturating_add(1), |
| 5473 | height: sentence_height - 1, |
| 5474 | ..bottom |
| 5475 | }; |
| 5476 | Paragraph::new(Line::from(Span::styled( |
| 5477 | crate::tui::ui_text::semantic_truncate( |
| 5478 | &self.setting_detail_summary(row), |
| 5479 | usize::from(inner.width).saturating_mul(usize::from(folded.height)), |
| 5480 | ), |
| 5481 | Style::default().fg(palette::TEXT_HINT), |
| 5482 | ))) |
| 5483 | .wrap(Wrap { trim: true }) |
| 5484 | .render(folded, buf); |
| 5485 | } |
| 5486 | } |
| 5487 | if bottom.height > sentence_height { |
| 5488 | self.render_footer_preview( |
| 5489 | Rect { |
| 5490 | y: bottom.y.saturating_add(sentence_height), |
| 5491 | height: bottom.height - sentence_height, |
| 5492 | ..bottom |
| 5493 | }, |
| 5494 | buf, |
| 5495 | ); |
| 5496 | } |
| 5497 | |
| 5498 | let footer = if !self.filter.is_empty() { |
| 5499 | self.tr(MessageId::ConfigFooterFiltered) |
| 5500 | } else if scrollable { |
| 5501 | self.tr(MessageId::ConfigFooterScrollable) |
| 5502 | } else { |
| 5503 | self.tr(MessageId::ConfigFooterDefault) |
| 5504 | }; |
| 5505 | if compact { |
| 5506 | render_modal_footer(inner, buf, &compact_hints); |
| 5507 | } else { |
| 5508 | render_modal_text_footer( |
| 5509 | inner, |
| 5510 | buf, |
| 5511 | &footer, |
| 5512 | Style::default().fg(palette::TEXT_MUTED), |
| 5513 | ); |
| 5514 | } |
| 5515 | } |
| 5516 | } |
| 5517 | |
| 5518 | pub mod help; |
| 5519 | |
| 5520 | pub use help::HelpView; |
| 5521 | |
| 5522 | pub struct SubAgentsView { |
| 5523 | agents: Vec<SubAgentResult>, |
| 5524 | scroll: usize, |
| 5525 | /// Index into the render-ordered agent list (`ordered` on `grouped`). |
| 5526 | /// Enter/click open the selected agent's transcript — the same primary |
| 5527 | /// destination every other agent surface resolves to (v0.9.7). |
| 5528 | selected: usize, |
| 5529 | /// Rendered agent blocks from the last frame: `(first_line, line_count, |
| 5530 | /// agent_id)` in render order. Interior-mutable because `render` takes |
| 5531 | /// `&self`; consumed by click resolution and selection scroll-follow. |
| 5532 | row_lines: std::cell::RefCell<Vec<(usize, usize, String)>>, |
| 5533 | /// Body area of the last render, for mapping click rows onto lines. |
| 5534 | body_area: std::cell::Cell<Rect>, |
| 5535 | /// Effective (clamped) scroll of the last render. |
| 5536 | last_render_scroll: std::cell::Cell<usize>, |
| 5537 | /// Visible body height of the last render. |
| 5538 | last_visible_lines: std::cell::Cell<usize>, |
| 5539 | /// Motion policy at open: the Whale Teams working wake animates only |
| 5540 | /// under `MotionMode::Full` (Reduced/Still hold the poster frame). |
| 5541 | motion: crate::tui::motion::mode::MotionMode, |
| 5542 | /// UI locale for the whale state words. |
| 5543 | locale: Locale, |
| 5544 | /// Wall clock anchor for the working-wake frame. |
| 5545 | opened_at: std::time::Instant, |
| 5546 | /// True when the Fleet roster is parked directly underneath on the view |
| 5547 | /// stack (#5954), i.e. this view was reached with `Tab`/`w` from the |
| 5548 | /// roster. `Esc` still pops exactly one view — the flag only decides |
| 5549 | /// whether the footer promises `back` or `close`, and lets `F` return to |
| 5550 | /// the parked roster instead of stacking a second one. Direct entry |
| 5551 | /// (`/fleet workers`, the Work dock) leaves it false, so `Esc` closes. |
| 5552 | back_to_fleet_roster: bool, |
| 5553 | } |
| 5554 | |
| 5555 | /// Build the agent rows shown by `/subagents`. |
| 5556 | /// |
| 5557 | /// The engine manager is the durable source of truth, but live UI cards can |
| 5558 | /// briefly be ahead of the manager-list refresh. Include those live rows so |
| 5559 | /// the command does not say "no agents" while the footer/sidebar already show |
| 5560 | /// active delegated work. |
| 5561 | pub(crate) fn subagent_view_agents( |
| 5562 | app: &App, |
| 5563 | manager_agents: &[SubAgentResult], |
| 5564 | ) -> Vec<SubAgentResult> { |
| 5565 | let mut agents = manager_agents.to_vec(); |
| 5566 | let manager_agent_count = agents.len(); |
| 5567 | let mut seen: std::collections::HashSet<String> = |
| 5568 | agents.iter().map(|agent| agent.agent_id.clone()).collect(); |
| 5569 | |
| 5570 | for (agent_id, progress) in &app.agent_progress { |
| 5571 | if seen.insert(agent_id.clone()) { |
| 5572 | agents.push(live_subagent_result( |
| 5573 | agent_id, |
| 5574 | FleetRole::Worker, |
| 5575 | SubAgentStatus::Running, |
| 5576 | progress, |
| 5577 | Some("live"), |
| 5578 | None, // live rows compute nickname from agent manager on render |
| 5579 | )); |
| 5580 | } |
| 5581 | } |
| 5582 | |
| 5583 | for cell in &app.history { |
| 5584 | match cell { |
| 5585 | HistoryCell::SubAgent(SubAgentCell::Delegate(card)) |
| 5586 | if seen.insert(card.agent_id.clone()) => |
| 5587 | { |
| 5588 | let agent_type = FleetRole::from_str(&card.agent_type).unwrap_or(FleetRole::Worker); |
| 5589 | agents.push(live_subagent_result( |
| 5590 | &card.agent_id, |
| 5591 | agent_type, |
| 5592 | lifecycle_to_subagent_status(card.status), |
| 5593 | card.summary.as_deref().unwrap_or(card.agent_type.as_str()), |
| 5594 | Some("transcript"), |
| 5595 | None, // transcript-derived rows get nickname from manager on render |
| 5596 | )); |
| 5597 | } |
| 5598 | HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => { |
| 5599 | for worker in &card.workers { |
| 5600 | if seen.insert(worker.agent_id.clone()) { |
| 5601 | let objective = format!( |
| 5602 | "{} worker {}", |
| 5603 | summarize_tool_output(&card.kind), |
| 5604 | summarize_tool_output(&worker.worker_id) |
| 5605 | ); |
| 5606 | agents.push(live_subagent_result( |
| 5607 | &worker.agent_id, |
| 5608 | FleetRole::Worker, |
| 5609 | lifecycle_to_subagent_status(worker.status), |
| 5610 | &objective, |
| 5611 | Some(card.kind.as_str()), |
| 5612 | None, // fanout worker rows get nickname from manager on render |
| 5613 | )); |
| 5614 | } |
| 5615 | } |
| 5616 | } |
| 5617 | _ => {} |
| 5618 | } |
| 5619 | } |
| 5620 | |
| 5621 | let mut display_names = localized_whale_display_names( |
| 5622 | agents[..manager_agent_count] |
| 5623 | .iter() |
| 5624 | .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), |
| 5625 | app.ui_locale.tag(), |
| 5626 | ); |
| 5627 | for agent in &mut agents[..manager_agent_count] { |
| 5628 | // The row headline reads `nickname`, so the dispatch name lands there |
| 5629 | // when the agent has one; the generated whale names the rest (#5287). |
| 5630 | let display_name = crate::tui::sidebar::dispatched_agent_name(agent) |
| 5631 | .map(str::to_string) |
| 5632 | .or_else(|| display_names.remove(&agent.agent_id)); |
| 5633 | agent.nickname = display_name; |
| 5634 | } |
| 5635 | for agent in &mut agents[manager_agent_count..] { |
| 5636 | // Progress and transcript rows can arrive before ListSubAgents. Keep |
| 5637 | // their stable Agent-N placeholder until the manager snapshot supplies |
| 5638 | // the locale-neutral identity needed for generated whale display. |
| 5639 | agent.nickname = app.agent_label_map.get(&agent.agent_id).cloned(); |
| 5640 | } |
| 5641 | |
| 5642 | agents |
| 5643 | } |
| 5644 | |
| 5645 | fn lifecycle_to_subagent_status(status: AgentLifecycle) -> SubAgentStatus { |
| 5646 | match status { |
| 5647 | AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running, |
| 5648 | AgentLifecycle::Completed => SubAgentStatus::Completed, |
| 5649 | AgentLifecycle::Failed => SubAgentStatus::Failed("failed in transcript".to_string()), |
| 5650 | AgentLifecycle::Cancelled => SubAgentStatus::Cancelled, |
| 5651 | AgentLifecycle::Interrupted => { |
| 5652 | SubAgentStatus::Interrupted("interrupted in transcript".to_string()) |
| 5653 | } |
| 5654 | } |
| 5655 | } |
| 5656 | |
| 5657 | fn live_subagent_result( |
| 5658 | agent_id: &str, |
| 5659 | agent_type: FleetRole, |
| 5660 | status: SubAgentStatus, |
| 5661 | objective: &str, |
| 5662 | role: Option<&str>, |
| 5663 | nickname: Option<String>, |
| 5664 | ) -> SubAgentResult { |
| 5665 | SubAgentResult { |
| 5666 | usage: None, |
| 5667 | name: agent_id.to_string(), |
| 5668 | agent_id: agent_id.to_string(), |
| 5669 | context_mode: "fresh".to_string(), |
| 5670 | fork_context: false, |
| 5671 | workspace: None, |
| 5672 | git_branch: None, |
| 5673 | agent_type, |
| 5674 | assignment: SubAgentAssignment { |
| 5675 | objective: summarize_tool_output(objective), |
| 5676 | role: role.map(str::to_string), |
| 5677 | }, |
| 5678 | model: String::new(), |
| 5679 | nickname, |
| 5680 | status, |
| 5681 | worker_status: None, |
| 5682 | runtime_permissions: None, |
| 5683 | parent_run_id: None, |
| 5684 | spawn_depth: 0, |
| 5685 | child_route: None, |
| 5686 | result: None, |
| 5687 | steps_taken: 0, |
| 5688 | checkpoint: None, |
| 5689 | needs_input: None, |
| 5690 | duration_ms: 0, |
| 5691 | started_at: None, |
| 5692 | from_prior_session: false, |
| 5693 | } |
| 5694 | } |
| 5695 | |
| 5696 | impl SubAgentsView { |
| 5697 | pub fn new(agents: Vec<SubAgentResult>) -> Self { |
| 5698 | Self { |
| 5699 | agents, |
| 5700 | scroll: 0, |
| 5701 | selected: 0, |
| 5702 | row_lines: std::cell::RefCell::new(Vec::new()), |
| 5703 | body_area: std::cell::Cell::new(Rect::default()), |
| 5704 | last_render_scroll: std::cell::Cell::new(0), |
| 5705 | last_visible_lines: std::cell::Cell::new(0), |
| 5706 | motion: crate::tui::motion::mode::MotionMode::Still, |
| 5707 | locale: Locale::En, |
| 5708 | opened_at: std::time::Instant::now(), |
| 5709 | back_to_fleet_roster: false, |
| 5710 | } |
| 5711 | } |
| 5712 | |
| 5713 | /// Open with the app's motion policy and locale so the whale rows follow |
| 5714 | /// the user's reduced-motion setting and language. |
| 5715 | pub fn for_app(app: &App, agents: Vec<SubAgentResult>) -> Self { |
| 5716 | let mut view = Self::new(agents); |
| 5717 | view.motion = app.motion_policy().mode(); |
| 5718 | view.locale = app.ui_locale; |
| 5719 | view |
| 5720 | } |
| 5721 | |
| 5722 | /// Mark this view as pushed on top of the Fleet roster (#5954), so the |
| 5723 | /// footer says `back` and `F` pops to the parked roster. |
| 5724 | #[must_use] |
| 5725 | pub fn over_fleet_roster(mut self) -> Self { |
| 5726 | self.back_to_fleet_roster = true; |
| 5727 | self |
| 5728 | } |
| 5729 | |
| 5730 | /// Footer label for `Esc`: `back` while the roster is parked underneath, |
| 5731 | /// `close` at the root. The hint has to name what the key actually does. |
| 5732 | fn esc_hint_label(&self) -> std::borrow::Cow<'static, str> { |
| 5733 | if self.back_to_fleet_roster { |
| 5734 | tr(self.locale, MessageId::SetupActionBack) |
| 5735 | } else { |
| 5736 | tr(self.locale, MessageId::SessionsActionClose) |
| 5737 | } |
| 5738 | } |
| 5739 | |
| 5740 | /// Working-wake frame for this render: 0 unless motion is Full. |
| 5741 | fn whale_frame(&self) -> usize { |
| 5742 | let now_ms = u64::try_from(self.opened_at.elapsed().as_millis()).unwrap_or(0); |
| 5743 | crate::tui::whales::working_frame(now_ms, self.motion) |
| 5744 | } |
| 5745 | |
| 5746 | /// The five status groups in render order, each sorted the way the view |
| 5747 | /// paints them. Selection, Enter, and click resolution all consume this |
| 5748 | /// so the highlighted row and the opened agent can never diverge. |
| 5749 | fn grouped(agents: &[SubAgentResult]) -> [Vec<&SubAgentResult>; 5] { |
| 5750 | let mut running = Vec::new(); |
| 5751 | let mut completed = Vec::new(); |
| 5752 | let mut interrupted = Vec::new(); |
| 5753 | let mut failed = Vec::new(); |
| 5754 | let mut cancelled = Vec::new(); |
| 5755 | |
| 5756 | for agent in agents { |
| 5757 | match agent.status { |
| 5758 | SubAgentStatus::Running => running.push(agent), |
| 5759 | SubAgentStatus::Completed => completed.push(agent), |
| 5760 | SubAgentStatus::Interrupted(_) => interrupted.push(agent), |
| 5761 | SubAgentStatus::Failed(_) => failed.push(agent), |
| 5762 | SubAgentStatus::Cancelled => cancelled.push(agent), |
| 5763 | SubAgentStatus::BudgetExhausted => failed.push(agent), |
| 5764 | } |
| 5765 | } |
| 5766 | for group in [ |
| 5767 | &mut running, |
| 5768 | &mut completed, |
| 5769 | &mut interrupted, |
| 5770 | &mut failed, |
| 5771 | &mut cancelled, |
| 5772 | ] { |
| 5773 | group.sort_by(|a, b| { |
| 5774 | agent_type_order(&a.agent_type) |
| 5775 | .cmp(&agent_type_order(&b.agent_type)) |
| 5776 | .then_with(|| a.agent_id.cmp(&b.agent_id)) |
| 5777 | }); |
| 5778 | } |
| 5779 | [running, completed, interrupted, failed, cancelled] |
| 5780 | } |
| 5781 | |
| 5782 | fn ordered_agent_ids(&self) -> Vec<String> { |
| 5783 | Self::grouped(&self.agents) |
| 5784 | .iter() |
| 5785 | .flatten() |
| 5786 | .map(|agent| agent.agent_id.clone()) |
| 5787 | .collect() |
| 5788 | } |
| 5789 | |
| 5790 | /// Keep the selected agent's block inside the visible body, using the |
| 5791 | /// last render's layout (stale by at most one frame). |
| 5792 | fn follow_selection(&mut self) { |
| 5793 | let row_lines = self.row_lines.borrow(); |
| 5794 | let Some((first, count, _)) = row_lines.get(self.selected) else { |
| 5795 | return; |
| 5796 | }; |
| 5797 | let visible = self.last_visible_lines.get().max(1); |
| 5798 | let end = first + count; |
| 5799 | if *first < self.scroll { |
| 5800 | self.scroll = *first; |
| 5801 | } else if end > self.scroll + visible { |
| 5802 | self.scroll = end.saturating_sub(visible); |
| 5803 | } |
| 5804 | } |
| 5805 | } |
| 5806 | |
| 5807 | impl ModalView for SubAgentsView { |
| 5808 | fn kind(&self) -> ModalKind { |
| 5809 | ModalKind::SubAgents |
| 5810 | } |
| 5811 | |
| 5812 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 5813 | self |
| 5814 | } |
| 5815 | |
| 5816 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 5817 | use crossterm::event::KeyCode; |
| 5818 | |
| 5819 | match key.code { |
| 5820 | KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, |
| 5821 | // Enter opens the selected agent's transcript — the same primary |
| 5822 | // destination the Work strip and sidebar resolve to (v0.9.7). On |
| 5823 | // an empty register Enter keeps its old refresh meaning. |
| 5824 | KeyCode::Enter => match self.ordered_agent_ids().get(self.selected).cloned() { |
| 5825 | Some(agent_id) => ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }), |
| 5826 | None => ViewAction::Emit(ViewEvent::SubAgentsRefresh), |
| 5827 | }, |
| 5828 | KeyCode::Char('r') | KeyCode::Char('R') => { |
| 5829 | ViewAction::Emit(ViewEvent::SubAgentsRefresh) |
| 5830 | } |
| 5831 | // Manage: stop the selected worker. Terminal workers ignore the |
| 5832 | // key; the cancel receipt names what happened either way. |
| 5833 | KeyCode::Char('x') | KeyCode::Char('X') => { |
| 5834 | match self.ordered_agent_ids().get(self.selected).cloned() { |
| 5835 | Some(agent_id) => ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }), |
| 5836 | None => ViewAction::None, |
| 5837 | } |
| 5838 | } |
| 5839 | // The roster is the same destination either way: pop back to the |
| 5840 | // parked one when there is one (#5954) — re-running `/fleet` |
| 5841 | // would stack a duplicate roster and lose its cursor. |
| 5842 | KeyCode::Char('f') | KeyCode::Char('F') if self.back_to_fleet_roster => { |
| 5843 | ViewAction::Close |
| 5844 | } |
| 5845 | KeyCode::Char('f') | KeyCode::Char('F') => { |
| 5846 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 5847 | action: CommandPaletteAction::ExecuteCommand { |
| 5848 | command: "/fleet".to_string(), |
| 5849 | }, |
| 5850 | }) |
| 5851 | } |
| 5852 | KeyCode::Up | KeyCode::Char('k') => { |
| 5853 | self.selected = self.selected.saturating_sub(1); |
| 5854 | self.follow_selection(); |
| 5855 | ViewAction::None |
| 5856 | } |
| 5857 | KeyCode::Down | KeyCode::Char('j') => { |
| 5858 | self.selected = self |
| 5859 | .selected |
| 5860 | .saturating_add(1) |
| 5861 | .min(self.agents.len().saturating_sub(1)); |
| 5862 | self.follow_selection(); |
| 5863 | ViewAction::None |
| 5864 | } |
| 5865 | _ => ViewAction::None, |
| 5866 | } |
| 5867 | } |
| 5868 | |
| 5869 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 5870 | match mouse.kind { |
| 5871 | MouseEventKind::ScrollUp => { |
| 5872 | self.scroll = self.scroll.saturating_sub(3); |
| 5873 | ViewAction::None |
| 5874 | } |
| 5875 | MouseEventKind::ScrollDown => { |
| 5876 | // Clamped to the real maximum at render time. |
| 5877 | self.scroll = self.scroll.saturating_add(3); |
| 5878 | ViewAction::None |
| 5879 | } |
| 5880 | MouseEventKind::Down(MouseButton::Left) => { |
| 5881 | let area = self.body_area.get(); |
| 5882 | if mouse.column < area.x |
| 5883 | || mouse.column >= area.x.saturating_add(area.width) |
| 5884 | || mouse.row < area.y |
| 5885 | || mouse.row >= area.y.saturating_add(area.height) |
| 5886 | { |
| 5887 | return ViewAction::None; |
| 5888 | } |
| 5889 | let line = usize::from(mouse.row - area.y) + self.last_render_scroll.get(); |
| 5890 | let hit = self |
| 5891 | .row_lines |
| 5892 | .borrow() |
| 5893 | .iter() |
| 5894 | .enumerate() |
| 5895 | .find(|(_, (first, count, _))| line >= *first && line < first + count) |
| 5896 | .map(|(index, (_, _, agent_id))| (index, agent_id.clone())); |
| 5897 | match hit { |
| 5898 | Some((index, agent_id)) => { |
| 5899 | self.selected = index; |
| 5900 | // Click opens the same door Enter does. |
| 5901 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) |
| 5902 | } |
| 5903 | None => ViewAction::None, |
| 5904 | } |
| 5905 | } |
| 5906 | _ => ViewAction::None, |
| 5907 | } |
| 5908 | } |
| 5909 | |
| 5910 | fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool { |
| 5911 | let selected_id = self.ordered_agent_ids().get(self.selected).cloned(); |
| 5912 | self.agents = agents.to_vec(); |
| 5913 | let last = self.agents.len().saturating_sub(1); |
| 5914 | self.scroll = self.scroll.min(last); |
| 5915 | self.selected = selected_id |
| 5916 | .and_then(|id| { |
| 5917 | self.ordered_agent_ids() |
| 5918 | .iter() |
| 5919 | .position(|candidate| candidate == &id) |
| 5920 | }) |
| 5921 | .unwrap_or_else(|| self.selected.min(last)); |
| 5922 | true |
| 5923 | } |
| 5924 | |
| 5925 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 5926 | Clear.render(area, buf); |
| 5927 | Block::default() |
| 5928 | .style(Style::default().bg(palette::WHALE_BG)) |
| 5929 | .render(area, buf); |
| 5930 | |
| 5931 | let mut lines: Vec<Line> = Vec::new(); |
| 5932 | let mut row_lines: Vec<(usize, usize, String)> = Vec::new(); |
| 5933 | let content_width = area.width.saturating_sub(4) as usize; |
| 5934 | |
| 5935 | if self.agents.is_empty() { |
| 5936 | lines.push(Line::from(Span::styled( |
| 5937 | tr( |
| 5938 | self.locale, |
| 5939 | MessageId::SubagentsNoCurrentSessionFleetWorkers, |
| 5940 | ), |
| 5941 | Style::default().fg(palette::TEXT_MUTED), |
| 5942 | ))); |
| 5943 | lines.push(Line::from(Span::styled( |
| 5944 | tr(self.locale, MessageId::SubagentsEmptyGuidance), |
| 5945 | Style::default().fg(palette::TEXT_DIM), |
| 5946 | ))); |
| 5947 | } else { |
| 5948 | let [running, completed, interrupted, failed, cancelled] = Self::grouped(&self.agents); |
| 5949 | let selected_id = self |
| 5950 | .ordered_agent_ids() |
| 5951 | .get(self.selected) |
| 5952 | .cloned() |
| 5953 | .unwrap_or_default(); |
| 5954 | |
| 5955 | let status_summary = [ |
| 5956 | ( |
| 5957 | MessageId::SubagentsStatusRunning, |
| 5958 | running.len(), |
| 5959 | palette::STATUS_WARNING, |
| 5960 | ), |
| 5961 | ( |
| 5962 | MessageId::SubagentsStatusCompleted, |
| 5963 | completed.len(), |
| 5964 | palette::STATUS_SUCCESS, |
| 5965 | ), |
| 5966 | ( |
| 5967 | MessageId::SubagentsStatusInterrupted, |
| 5968 | interrupted.len(), |
| 5969 | palette::STATUS_WARNING, |
| 5970 | ), |
| 5971 | ( |
| 5972 | MessageId::SubagentsStatusFailed, |
| 5973 | failed.len(), |
| 5974 | palette::WHALE_ERROR, |
| 5975 | ), |
| 5976 | ( |
| 5977 | MessageId::SubagentsStatusCancelled, |
| 5978 | cancelled.len(), |
| 5979 | palette::TEXT_MUTED, |
| 5980 | ), |
| 5981 | ]; |
| 5982 | |
| 5983 | lines.push(Line::from(Span::styled( |
| 5984 | tr( |
| 5985 | self.locale, |
| 5986 | MessageId::SubagentsCurrentSessionFleetWorkersTitle, |
| 5987 | ), |
| 5988 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 5989 | ))); |
| 5990 | lines.push(Line::from(Span::styled( |
| 5991 | tr( |
| 5992 | self.locale, |
| 5993 | MessageId::SubagentsCurrentSessionFleetWorkerRoles, |
| 5994 | ), |
| 5995 | Style::default().fg(palette::TEXT_DIM), |
| 5996 | ))); |
| 5997 | |
| 5998 | let mut summary_parts = Vec::new(); |
| 5999 | for (label_id, count, color) in status_summary { |
| 6000 | let label = tr(self.locale, label_id); |
| 6001 | let count = count.to_string(); |
| 6002 | summary_parts.push(Line::from(Span::styled( |
| 6003 | tr(self.locale, MessageId::SubagentsSummaryItem) |
| 6004 | .replace("{label}", label.as_ref()) |
| 6005 | .replace("{count}", &count), |
| 6006 | Style::default().fg(color), |
| 6007 | ))); |
| 6008 | } |
| 6009 | |
| 6010 | let mut summary = vec![Span::styled(" ", Style::default().fg(palette::TEXT_DIM))]; |
| 6011 | for (idx, part) in summary_parts.into_iter().enumerate() { |
| 6012 | if idx > 0 { |
| 6013 | summary.push(Span::raw(" · ")); |
| 6014 | } |
| 6015 | summary.extend(part); |
| 6016 | } |
| 6017 | lines.push(Line::from(summary)); |
| 6018 | lines.push(Line::from(Span::styled( |
| 6019 | "", |
| 6020 | Style::default().fg(palette::TEXT_DIM), |
| 6021 | ))); |
| 6022 | |
| 6023 | for (title_id, style, group) in [ |
| 6024 | ( |
| 6025 | MessageId::SubagentsStatusRunning, |
| 6026 | ratatui::style::Style::from(palette::STATUS_WARNING), |
| 6027 | &running, |
| 6028 | ), |
| 6029 | ( |
| 6030 | MessageId::SubagentsStatusCompleted, |
| 6031 | palette::STATUS_SUCCESS.into(), |
| 6032 | &completed, |
| 6033 | ), |
| 6034 | ( |
| 6035 | MessageId::SubagentsStatusInterrupted, |
| 6036 | palette::STATUS_WARNING.into(), |
| 6037 | &interrupted, |
| 6038 | ), |
| 6039 | ( |
| 6040 | MessageId::SubagentsStatusFailed, |
| 6041 | palette::WHALE_ERROR.into(), |
| 6042 | &failed, |
| 6043 | ), |
| 6044 | ( |
| 6045 | MessageId::SubagentsStatusCancelled, |
| 6046 | palette::TEXT_MUTED.into(), |
| 6047 | &cancelled, |
| 6048 | ), |
| 6049 | ] { |
| 6050 | let title = tr(self.locale, title_id); |
| 6051 | append_subagent_group( |
| 6052 | &mut lines, |
| 6053 | &mut row_lines, |
| 6054 | title.as_ref(), |
| 6055 | style, |
| 6056 | group, |
| 6057 | content_width, |
| 6058 | &selected_id, |
| 6059 | WhaleRowContext { |
| 6060 | locale: self.locale, |
| 6061 | frame: self.whale_frame(), |
| 6062 | }, |
| 6063 | ); |
| 6064 | } |
| 6065 | } |
| 6066 | |
| 6067 | let content = render_modal_footer( |
| 6068 | area, |
| 6069 | buf, |
| 6070 | &[ |
| 6071 | ActionHint::new("Esc", self.esc_hint_label()), |
| 6072 | ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)), |
| 6073 | ActionHint::new("Enter", tr(self.locale, MessageId::ExtensionsActionFocus)), |
| 6074 | ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)), |
| 6075 | ActionHint::new("R", tr(self.locale, MessageId::SubagentsActionRefresh)), |
| 6076 | ActionHint::new("F", tr(self.locale, MessageId::SubagentsActionRosterSetup)), |
| 6077 | ], |
| 6078 | ); |
| 6079 | let shell = ratatui::layout::Layout::default() |
| 6080 | .direction(ratatui::layout::Direction::Vertical) |
| 6081 | .constraints([ |
| 6082 | ratatui::layout::Constraint::Length(3), |
| 6083 | ratatui::layout::Constraint::Min(1), |
| 6084 | ]) |
| 6085 | .split(content); |
| 6086 | Paragraph::new(vec![ |
| 6087 | Line::from(vec![ |
| 6088 | Span::styled( |
| 6089 | format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)), |
| 6090 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 6091 | ), |
| 6092 | Span::styled( |
| 6093 | "──────────────────────── ", |
| 6094 | Style::default().fg(palette::BORDER_COLOR), |
| 6095 | ), |
| 6096 | Span::styled( |
| 6097 | format!( |
| 6098 | "{} {} ", |
| 6099 | tr(self.locale, MessageId::SubagentsHeaderRoster), |
| 6100 | tr(self.locale, MessageId::FleetRosterTabSetup), |
| 6101 | ), |
| 6102 | Style::default().fg(palette::TEXT_MUTED), |
| 6103 | ), |
| 6104 | Span::styled( |
| 6105 | tr(self.locale, MessageId::FleetRosterWorkers), |
| 6106 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 6107 | ), |
| 6108 | Span::styled( |
| 6109 | " ─────────────────", |
| 6110 | Style::default().fg(palette::BORDER_COLOR), |
| 6111 | ), |
| 6112 | ]), |
| 6113 | Line::from(""), |
| 6114 | Line::from(Span::styled( |
| 6115 | format!(" {}", tr(self.locale, MessageId::SubagentsHeaderColumns)), |
| 6116 | Style::default().fg(palette::TEXT_MUTED), |
| 6117 | )), |
| 6118 | ]) |
| 6119 | .render(shell[0], buf); |
| 6120 | |
| 6121 | let total_lines = lines.len(); |
| 6122 | let visible_lines = usize::from(shell[1].height).max(1); |
| 6123 | let max_scroll = total_lines.saturating_sub(visible_lines); |
| 6124 | let scroll = self.scroll.min(max_scroll); |
| 6125 | |
| 6126 | // Cache the layout for Enter/click resolution and scroll-follow. |
| 6127 | self.row_lines.replace(row_lines); |
| 6128 | self.body_area.set(shell[1]); |
| 6129 | self.last_render_scroll.set(scroll); |
| 6130 | self.last_visible_lines.set(visible_lines); |
| 6131 | |
| 6132 | Paragraph::new(lines) |
| 6133 | .scroll((scroll as u16, 0)) |
| 6134 | .render(shell[1], buf); |
| 6135 | } |
| 6136 | } |
| 6137 | |
| 6138 | /// Locale and working-wake frame for the whale badge on each worker row. |
| 6139 | #[derive(Debug, Clone, Copy)] |
| 6140 | struct WhaleRowContext { |
| 6141 | locale: Locale, |
| 6142 | frame: usize, |
| 6143 | } |
| 6144 | |
| 6145 | #[allow(clippy::too_many_arguments)] |
| 6146 | fn append_subagent_group( |
| 6147 | lines: &mut Vec<ratatui::text::Line<'static>>, |
| 6148 | row_lines: &mut Vec<(usize, usize, String)>, |
| 6149 | title: &str, |
| 6150 | section_style: ratatui::style::Style, |
| 6151 | agents: &[&SubAgentResult], |
| 6152 | content_width: usize, |
| 6153 | selected_id: &str, |
| 6154 | whale: WhaleRowContext, |
| 6155 | ) { |
| 6156 | use ratatui::{ |
| 6157 | style::Style, |
| 6158 | text::{Line, Span}, |
| 6159 | }; |
| 6160 | if agents.is_empty() { |
| 6161 | return; |
| 6162 | } |
| 6163 | |
| 6164 | lines.push(Line::from(Span::styled( |
| 6165 | tr(whale.locale, MessageId::SubagentsGroupHeading) |
| 6166 | .replace("{label}", title) |
| 6167 | .replace("{count}", &agents.len().to_string()), |
| 6168 | section_style.bold(), |
| 6169 | ))); |
| 6170 | |
| 6171 | for agent in agents { |
| 6172 | let block_start = lines.len(); |
| 6173 | let is_selected = agent.agent_id == selected_id; |
| 6174 | let id = truncate_view_text(&agent.agent_id, 11); |
| 6175 | let display_name = agent |
| 6176 | .nickname |
| 6177 | .as_deref() |
| 6178 | .map(|nick| format!("{nick:<12}")) |
| 6179 | .unwrap_or_else(|| format!("{id:<12}")); |
| 6180 | let kind = format_agent_type(whale.locale, &agent.agent_type); |
| 6181 | let (status, status_style, status_detail) = |
| 6182 | format_agent_status(whale.locale, &agent.status); |
| 6183 | |
| 6184 | let name_style = if is_selected { |
| 6185 | Style::default().fg(palette::WHALE_ACTION).bold() |
| 6186 | } else { |
| 6187 | Style::default().fg(palette::TEXT_PRIMARY) |
| 6188 | }; |
| 6189 | // Whale Teams: species badge from the worker's Fleet role (or its |
| 6190 | // advisory role hint), then the six-state word derived from the |
| 6191 | // child's real status — never from elapsed time. |
| 6192 | let species = agent |
| 6193 | .assignment |
| 6194 | .role |
| 6195 | .as_deref() |
| 6196 | .map(crate::tui::whales::WhaleSpecies::for_role_id) |
| 6197 | .filter(|species| *species != crate::tui::whales::WhaleSpecies::Plain) |
| 6198 | .unwrap_or_else(|| crate::tui::whales::WhaleSpecies::for_fleet_role(&agent.agent_type)); |
| 6199 | let whale_state = crate::tui::whales::WhaleState::for_subagent(agent); |
| 6200 | let mut row = vec![ |
| 6201 | // The selection cursor: Enter (or a click) opens this agent's |
| 6202 | // transcript, matching every other agent surface. |
| 6203 | Span::styled( |
| 6204 | if is_selected { "\u{25B8} " } else { " " }, |
| 6205 | Style::default().fg(palette::WHALE_ACTION), |
| 6206 | ), |
| 6207 | ]; |
| 6208 | row.extend(crate::tui::whales::badge(species, &palette::UI_THEME)); |
| 6209 | row.push(Span::raw(" ")); |
| 6210 | row.extend([ |
| 6211 | Span::styled(display_name, name_style), |
| 6212 | Span::raw(" "), |
| 6213 | Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)), |
| 6214 | Span::styled( |
| 6215 | format!("{kind:<9}"), |
| 6216 | Style::default().fg(palette::TEXT_MUTED), |
| 6217 | ), |
| 6218 | Span::raw(" "), |
| 6219 | Span::styled(format!("{status:<10}"), status_style), |
| 6220 | Span::raw(" "), |
| 6221 | Span::styled( |
| 6222 | format!("{:>4}✦", agent.steps_taken), |
| 6223 | Style::default().fg(palette::TEXT_DIM), |
| 6224 | ), |
| 6225 | Span::raw(" "), |
| 6226 | Span::styled( |
| 6227 | format!("{:>6}ms", agent.duration_ms), |
| 6228 | Style::default().fg(palette::TEXT_DIM), |
| 6229 | ), |
| 6230 | ]); |
| 6231 | lines.push(Line::from(row)); |
| 6232 | |
| 6233 | // The whale's own state word, paired with its glyph cue, so the row |
| 6234 | // says "Waiting for you" / "Blocked" in the user's language next to |
| 6235 | // the raw runtime status above. No caption text beyond that. |
| 6236 | let mut whale_line = vec![Span::raw(" ")]; |
| 6237 | whale_line.extend(crate::tui::whales::badge_with_state_frame( |
| 6238 | species, |
| 6239 | Some(whale_state), |
| 6240 | whale.frame, |
| 6241 | &palette::UI_THEME, |
| 6242 | whale.locale, |
| 6243 | )); |
| 6244 | lines.push(Line::from(whale_line)); |
| 6245 | |
| 6246 | if let Some(detail) = status_detail { |
| 6247 | let max_len = content_width.saturating_sub(10); |
| 6248 | let detail = truncate_view_text(detail, max_len); |
| 6249 | lines.push(Line::from(vec![ |
| 6250 | Span::styled( |
| 6251 | tr(whale.locale, MessageId::SubagentsLabelReason), |
| 6252 | Style::default().fg(palette::TEXT_MUTED), |
| 6253 | ), |
| 6254 | Span::styled(detail, Style::default().fg(palette::WHALE_ERROR)), |
| 6255 | ])); |
| 6256 | } |
| 6257 | |
| 6258 | if let Some(role) = agent.assignment.role.as_deref() { |
| 6259 | let max_len = content_width.saturating_sub(14); |
| 6260 | let role = truncate_view_text(role, max_len); |
| 6261 | lines.push(Line::from(vec![ |
| 6262 | Span::styled( |
| 6263 | tr(whale.locale, MessageId::SubagentsLabelRole), |
| 6264 | Style::default().fg(palette::TEXT_MUTED), |
| 6265 | ), |
| 6266 | Span::styled(role, Style::default().fg(palette::WHALE_ACTION)), |
| 6267 | ])); |
| 6268 | } |
| 6269 | |
| 6270 | if let Some(permissions) = agent.runtime_permissions.as_ref() { |
| 6271 | let network = tr( |
| 6272 | whale.locale, |
| 6273 | if permissions.network { |
| 6274 | MessageId::SubagentsValueOn |
| 6275 | } else { |
| 6276 | MessageId::SubagentsValueOff |
| 6277 | }, |
| 6278 | ); |
| 6279 | let shell = format_subagent_shell(whale.locale, &permissions.shell); |
| 6280 | let write = tr( |
| 6281 | whale.locale, |
| 6282 | if permissions.write { |
| 6283 | MessageId::SubagentsValueOn |
| 6284 | } else { |
| 6285 | MessageId::SubagentsValueOff |
| 6286 | }, |
| 6287 | ); |
| 6288 | let posture = tr(whale.locale, MessageId::SubagentsPostureDetails) |
| 6289 | .replace("{network}", network.as_ref()) |
| 6290 | .replace("{shell}", shell.as_ref()) |
| 6291 | .replace("{write}", write.as_ref()); |
| 6292 | let max_len = content_width.saturating_sub(18); |
| 6293 | let posture = truncate_view_text(&posture, max_len); |
| 6294 | lines.push(Line::from(vec![ |
| 6295 | Span::styled( |
| 6296 | tr(whale.locale, MessageId::SubagentsLabelPosture), |
| 6297 | Style::default().fg(palette::TEXT_MUTED), |
| 6298 | ), |
| 6299 | Span::styled(posture, Style::default().fg(palette::WHALE_ACTION)), |
| 6300 | ])); |
| 6301 | } |
| 6302 | |
| 6303 | if let Some(branch) = agent.git_branch.as_deref() { |
| 6304 | let workspace = agent |
| 6305 | .workspace |
| 6306 | .as_deref() |
| 6307 | .and_then(|path| path.file_name()) |
| 6308 | .and_then(|name| name.to_str()) |
| 6309 | .filter(|name| !name.is_empty()); |
| 6310 | let branch_detail = match workspace { |
| 6311 | Some(workspace) => tr(whale.locale, MessageId::SubagentsBranchWithWorkspace) |
| 6312 | .replace("{branch}", branch) |
| 6313 | .replace("{workspace}", workspace), |
| 6314 | None => tr(whale.locale, MessageId::SubagentsBranch).replace("{branch}", branch), |
| 6315 | }; |
| 6316 | let max_len = content_width.saturating_sub(14); |
| 6317 | let branch_detail = truncate_view_text(&branch_detail, max_len); |
| 6318 | lines.push(Line::from(vec![ |
| 6319 | Span::styled( |
| 6320 | tr(whale.locale, MessageId::SubagentsLabelGit), |
| 6321 | Style::default().fg(palette::TEXT_MUTED), |
| 6322 | ), |
| 6323 | Span::styled(branch_detail, Style::default().fg(palette::WHALE_ACTION)), |
| 6324 | ])); |
| 6325 | } |
| 6326 | |
| 6327 | let max_len = content_width.saturating_sub(18); |
| 6328 | let objective = truncate_view_text(&agent.assignment.objective, max_len); |
| 6329 | lines.push(Line::from(vec![ |
| 6330 | Span::styled( |
| 6331 | tr(whale.locale, MessageId::SubagentsLabelObjective), |
| 6332 | Style::default().fg(palette::TEXT_MUTED), |
| 6333 | ), |
| 6334 | Span::styled(objective, Style::default().fg(palette::TEXT_DIM)), |
| 6335 | ])); |
| 6336 | |
| 6337 | if let Some(result) = agent.result.as_ref() { |
| 6338 | let max_len = content_width.saturating_sub(16); |
| 6339 | let preview = truncate_view_text(result, max_len); |
| 6340 | lines.push(Line::from(vec![ |
| 6341 | Span::styled( |
| 6342 | tr(whale.locale, MessageId::SubagentsLabelResult), |
| 6343 | Style::default().fg(palette::TEXT_MUTED), |
| 6344 | ), |
| 6345 | Span::styled(preview, Style::default().fg(palette::TEXT_DIM)), |
| 6346 | ])); |
| 6347 | } |
| 6348 | |
| 6349 | row_lines.push(( |
| 6350 | block_start, |
| 6351 | lines.len() - block_start, |
| 6352 | agent.agent_id.clone(), |
| 6353 | )); |
| 6354 | } |
| 6355 | |
| 6356 | lines.push(Line::from("")); |
| 6357 | } |
| 6358 | |
| 6359 | fn agent_type_order(agent_type: &FleetRole) -> u8 { |
| 6360 | match agent_type { |
| 6361 | FleetRole::Worker => 0, |
| 6362 | FleetRole::Scout => 1, |
| 6363 | FleetRole::Planner => 2, |
| 6364 | FleetRole::Builder => 3, |
| 6365 | FleetRole::Verifier => 4, |
| 6366 | FleetRole::Reviewer => 5, |
| 6367 | FleetRole::Consultant => 6, |
| 6368 | FleetRole::Custom => 7, |
| 6369 | } |
| 6370 | } |
| 6371 | |
| 6372 | fn format_agent_type(locale: Locale, agent_type: &FleetRole) -> Cow<'static, str> { |
| 6373 | // `FleetRole::as_str()` is the durable runtime/schema identifier. The |
| 6374 | // register is a localized user surface, so map only the known roles here |
| 6375 | // and leave the internal identifier untouched everywhere else. |
| 6376 | let message_id = match agent_type { |
| 6377 | FleetRole::Worker => MessageId::SubagentsRoleWorker, |
| 6378 | FleetRole::Scout => MessageId::SubagentsRoleScout, |
| 6379 | FleetRole::Planner => MessageId::SubagentsRolePlanner, |
| 6380 | FleetRole::Builder => MessageId::SubagentsRoleBuilder, |
| 6381 | FleetRole::Verifier => MessageId::SubagentsRoleVerifier, |
| 6382 | FleetRole::Reviewer => MessageId::SubagentsRoleReviewer, |
| 6383 | FleetRole::Consultant => MessageId::SubagentsRoleConsultant, |
| 6384 | FleetRole::Custom => MessageId::SubagentsRoleCustom, |
| 6385 | }; |
| 6386 | tr(locale, message_id) |
| 6387 | } |
| 6388 | |
| 6389 | fn format_agent_status( |
| 6390 | locale: Locale, |
| 6391 | status: &SubAgentStatus, |
| 6392 | ) -> (Cow<'static, str>, ratatui::style::Style, Option<&str>) { |
| 6393 | use ratatui::style::Style; |
| 6394 | |
| 6395 | match status { |
| 6396 | SubAgentStatus::Running => ( |
| 6397 | tr(locale, MessageId::AutomationRunStatusRunning), |
| 6398 | Style::default().fg(palette::WHALE_ACTION), |
| 6399 | None, |
| 6400 | ), |
| 6401 | SubAgentStatus::Completed => ( |
| 6402 | tr(locale, MessageId::AutomationRunStatusCompleted), |
| 6403 | Style::default().fg(palette::STATUS_SUCCESS), |
| 6404 | None, |
| 6405 | ), |
| 6406 | SubAgentStatus::Interrupted(reason) => ( |
| 6407 | tr(locale, MessageId::SubagentsRowStatusInterrupted), |
| 6408 | Style::default().fg(palette::STATUS_WARNING), |
| 6409 | Some(reason.as_str()), |
| 6410 | ), |
| 6411 | SubAgentStatus::Cancelled => ( |
| 6412 | tr(locale, MessageId::SubagentsRowStatusCancelled), |
| 6413 | Style::default().fg(palette::TEXT_MUTED), |
| 6414 | None, |
| 6415 | ), |
| 6416 | SubAgentStatus::BudgetExhausted => ( |
| 6417 | tr(locale, MessageId::SubagentsRowStatusBudgetExhausted), |
| 6418 | Style::default().fg(palette::STATUS_WARNING), |
| 6419 | None, |
| 6420 | ), |
| 6421 | SubAgentStatus::Failed(reason) => ( |
| 6422 | tr(locale, MessageId::AutomationRunStatusFailed), |
| 6423 | Style::default().fg(palette::WHALE_ERROR), |
| 6424 | Some(reason.as_str()), |
| 6425 | ), |
| 6426 | } |
| 6427 | } |
| 6428 | |
| 6429 | fn format_subagent_shell(locale: Locale, shell: &str) -> Cow<'static, str> { |
| 6430 | match shell { |
| 6431 | "none" => tr(locale, MessageId::SubagentsShellNone), |
| 6432 | "read_only" => tr(locale, MessageId::SubagentsShellReadOnly), |
| 6433 | "full" => tr(locale, MessageId::SubagentsShellFull), |
| 6434 | // A future runtime may add a posture before the TUI knows how to |
| 6435 | // localize it. Preserve that exact runtime value instead of guessing. |
| 6436 | _ => Cow::Owned(shell.to_string()), |
| 6437 | } |
| 6438 | } |
| 6439 | |
| 6440 | fn truncate_view_text(text: &str, max_chars: usize) -> String { |
| 6441 | if max_chars == 0 { |
| 6442 | return String::new(); |
| 6443 | } |
| 6444 | match text.char_indices().nth(max_chars) { |
| 6445 | Some((idx, _)) => text[..idx].to_string(), |
| 6446 | None => text.to_string(), |
| 6447 | } |
| 6448 | } |
| 6449 | |
| 6450 | fn fit_config_column(text: &str, width: usize) -> String { |
| 6451 | let mut fitted = crate::tui::ui_text::truncate_line_to_width(text, width); |
| 6452 | let padding = width.saturating_sub(crate::tui::ui_text::text_display_width(&fitted)); |
| 6453 | fitted.push_str(&" ".repeat(padding)); |
| 6454 | fitted |
| 6455 | } |
| 6456 | |
| 6457 | #[cfg(test)] |
| 6458 | mod tests { |
| 6459 | use super::{ |
| 6460 | ActionHint, ConfigCategory, ConfigListItem, ConfigRowKind, ConfigScope, ConfigView, |
| 6461 | EmptyState, FocusTextureMode, HelpView, ListDetailLayout, ModalKind, ModalView, |
| 6462 | SettingKind, SettingStore, SettingsRegistry, ViewAction, ViewEvent, ViewStack, |
| 6463 | action_footer_lines, canonical_config_choice, centered_modal_area, config_choice_detail, |
| 6464 | config_choice_label, config_choice_values, config_label_for_key, |
| 6465 | config_label_for_key_for_locale, render_modal_footer_with_gutter, |
| 6466 | render_underwater_surface, subagent_view_agents, truncate_view_text, |
| 6467 | }; |
| 6468 | use crate::config::Config; |
| 6469 | use crate::settings::Settings; |
| 6470 | use crate::tools::subagent::{FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus}; |
| 6471 | use crate::tui::app::{App, TuiOptions}; |
| 6472 | use crate::tui::history::{HistoryCell, SubAgentCell}; |
| 6473 | use crate::tui::views::{CommandPaletteAction, SubAgentsView}; |
| 6474 | use crate::tui::widgets::agent_card::{AgentLifecycle, FanoutCard}; |
| 6475 | use codewhale_localization::{Locale, MessageId, tr, tr_key}; |
| 6476 | use codewhale_palette as palette; |
| 6477 | use crossterm::event::{ |
| 6478 | KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, |
| 6479 | }; |
| 6480 | use ratatui::{ |
| 6481 | buffer::Buffer, |
| 6482 | layout::Rect, |
| 6483 | style::{Color, Style}, |
| 6484 | }; |
| 6485 | use std::borrow::Cow; |
| 6486 | use std::fs; |
| 6487 | use std::path::PathBuf; |
| 6488 | use tempfile::TempDir; |
| 6489 | use unicode_width::UnicodeWidthStr; |
| 6490 | |
| 6491 | /// Terminal sizes the v0.8.66 modal blocker (#3732) requires every overlay |
| 6492 | /// to remain readable and fully operable at. |
| 6493 | const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)]; |
| 6494 | |
| 6495 | /// Render a modal through the `ViewStack` (so the shared opaque backdrop is |
| 6496 | /// painted exactly as in production) over a sentinel-filled buffer, then |
| 6497 | /// assert: every `required_label` is visible, no sentinel `X` survives |
| 6498 | /// anywhere (fully opaque), the center cell carries the modal ink, and no |
| 6499 | /// row overflows the frame width. |
| 6500 | fn assert_modal_usable_and_opaque<V: ModalView + 'static>( |
| 6501 | make: impl Fn() -> V, |
| 6502 | required_labels: &[&str], |
| 6503 | ) { |
| 6504 | for (w, h) in BLOCKER_SIZES { |
| 6505 | let area = Rect::new(0, 0, w, h); |
| 6506 | let mut buf = Buffer::empty(area); |
| 6507 | let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green); |
| 6508 | for y in 0..h { |
| 6509 | for x in 0..w { |
| 6510 | buf[(x, y)].set_symbol("X").set_style(sentinel_style); |
| 6511 | } |
| 6512 | } |
| 6513 | let mut stack = ViewStack::new(); |
| 6514 | stack.push(make()); |
| 6515 | stack.render(area, &mut buf); |
| 6516 | |
| 6517 | let rows: Vec<String> = (0..h) |
| 6518 | .map(|y| { |
| 6519 | (0..w) |
| 6520 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 6521 | .collect::<String>() |
| 6522 | }) |
| 6523 | .collect(); |
| 6524 | let text = rows.join("\n"); |
| 6525 | |
| 6526 | for label in required_labels { |
| 6527 | assert!(text.contains(label), "{w}x{h}: missing '{label}'"); |
| 6528 | } |
| 6529 | let unpainted = (0..h).find_map(|y| { |
| 6530 | (0..w).find_map(|x| { |
| 6531 | let cell = &buf[(x, y)]; |
| 6532 | (cell.symbol() == "X" && cell.fg == Color::Magenta && cell.bg == Color::Green) |
| 6533 | .then_some((x, y)) |
| 6534 | }) |
| 6535 | }); |
| 6536 | assert!( |
| 6537 | unpainted.is_none(), |
| 6538 | "{w}x{h}: background bleed-through at {unpainted:?}" |
| 6539 | ); |
| 6540 | assert_eq!( |
| 6541 | buf[(w / 2, h / 2)].bg, |
| 6542 | palette::WHALE_BG, |
| 6543 | "{w}x{h}: modal interior must be opaque" |
| 6544 | ); |
| 6545 | for (y, row) in rows.iter().enumerate() { |
| 6546 | assert!( |
| 6547 | UnicodeWidthStr::width(row.trim_end()) <= w as usize, |
| 6548 | "{w}x{h}: row {y} overflows width: {row:?}" |
| 6549 | ); |
| 6550 | } |
| 6551 | } |
| 6552 | } |
| 6553 | |
| 6554 | #[test] |
| 6555 | fn config_modal_is_usable_and_opaque_at_blocker_sizes() { |
| 6556 | let _lock = crate::test_support::lock_test_env(); |
| 6557 | // "Search" is the hardcoded English search-row label; asserting it (plus |
| 6558 | // the opacity/overflow checks) proves the modal renders fully and its |
| 6559 | // footer wraps inside bounds rather than clipping. |
| 6560 | assert_modal_usable_and_opaque(|| create_config_view(Locale::En), &["Search"]); |
| 6561 | } |
| 6562 | |
| 6563 | #[test] |
| 6564 | fn subagents_modal_is_usable_and_opaque_at_blocker_sizes() { |
| 6565 | assert_modal_usable_and_opaque( |
| 6566 | || SubAgentsView::new(Vec::new()), |
| 6567 | &["close", "refresh", "setup"], |
| 6568 | ); |
| 6569 | } |
| 6570 | |
| 6571 | /// #5954: the `Esc` hint has to name what the key does — `back` while the |
| 6572 | /// Fleet roster is parked underneath, `close` on direct entry. |
| 6573 | #[test] |
| 6574 | fn subagents_esc_hint_says_back_over_the_roster_and_close_at_the_root() { |
| 6575 | let area = Rect::new(0, 0, 160, 40); |
| 6576 | |
| 6577 | let direct = SubAgentsView::new(Vec::new()); |
| 6578 | let mut direct_buf = Buffer::empty(area); |
| 6579 | direct.render(area, &mut direct_buf); |
| 6580 | let direct_text = buffer_text(&direct_buf, area); |
| 6581 | assert!( |
| 6582 | direct_text.contains("Esc close"), |
| 6583 | "direct entry must still promise close: {direct_text}" |
| 6584 | ); |
| 6585 | |
| 6586 | let over_roster = SubAgentsView::new(Vec::new()).over_fleet_roster(); |
| 6587 | let mut back_buf = Buffer::empty(area); |
| 6588 | over_roster.render(area, &mut back_buf); |
| 6589 | let back_text = buffer_text(&back_buf, area); |
| 6590 | assert!( |
| 6591 | back_text.contains("Esc back"), |
| 6592 | "over the roster the hint must promise back: {back_text}" |
| 6593 | ); |
| 6594 | assert!( |
| 6595 | !back_text.contains("Esc close"), |
| 6596 | "over the roster the hint must not still promise close: {back_text}" |
| 6597 | ); |
| 6598 | } |
| 6599 | |
| 6600 | /// #5954: workers opened directly (`/fleet workers`, the Work dock) is the |
| 6601 | /// root of its own stack, so `Esc` closes the window as before. |
| 6602 | #[test] |
| 6603 | fn subagents_opened_directly_closes_on_esc() { |
| 6604 | let mut stack = ViewStack::new(); |
| 6605 | stack.push(SubAgentsView::new(Vec::new())); |
| 6606 | stack.handle_key(KeyEvent::new( |
| 6607 | crossterm::event::KeyCode::Esc, |
| 6608 | crossterm::event::KeyModifiers::NONE, |
| 6609 | )); |
| 6610 | assert!(stack.is_empty(), "direct entry must close on Esc"); |
| 6611 | } |
| 6612 | |
| 6613 | /// #5954: `F` in workers is the roster door. With a roster parked below, |
| 6614 | /// it pops back to it instead of re-running `/fleet` and stacking a |
| 6615 | /// duplicate roster. |
| 6616 | #[test] |
| 6617 | fn subagents_f_pops_back_to_the_parked_roster() { |
| 6618 | let mut over_roster = SubAgentsView::new(Vec::new()).over_fleet_roster(); |
| 6619 | assert!(matches!( |
| 6620 | over_roster.handle_key(KeyEvent::new( |
| 6621 | crossterm::event::KeyCode::Char('F'), |
| 6622 | crossterm::event::KeyModifiers::NONE, |
| 6623 | )), |
| 6624 | ViewAction::Close |
| 6625 | )); |
| 6626 | |
| 6627 | let mut direct = SubAgentsView::new(Vec::new()); |
| 6628 | assert!(matches!( |
| 6629 | direct.handle_key(KeyEvent::new( |
| 6630 | crossterm::event::KeyCode::Char('F'), |
| 6631 | crossterm::event::KeyModifiers::NONE, |
| 6632 | )), |
| 6633 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { .. }) |
| 6634 | )); |
| 6635 | } |
| 6636 | |
| 6637 | #[test] |
| 6638 | fn subagents_modal_names_current_session_pod_workers_in_each_locale() { |
| 6639 | let area = Rect::new(0, 0, 160, 40); |
| 6640 | let app = create_test_app(); |
| 6641 | |
| 6642 | let empty = SubAgentsView::for_app(&app, Vec::new()); |
| 6643 | let mut empty_buf = Buffer::empty(area); |
| 6644 | empty.render(area, &mut empty_buf); |
| 6645 | let empty_text = buffer_text(&empty_buf, area); |
| 6646 | assert!( |
| 6647 | empty_text.contains("No current-session fleet workers."), |
| 6648 | "{empty_text}" |
| 6649 | ); |
| 6650 | assert!( |
| 6651 | empty_text.contains("Set up roles with /fleet."), |
| 6652 | "{empty_text}" |
| 6653 | ); |
| 6654 | |
| 6655 | let english = SubAgentsView::for_app( |
| 6656 | &app, |
| 6657 | vec![manager_agent("agent_live", SubAgentStatus::Running)], |
| 6658 | ); |
| 6659 | let mut english_buf = Buffer::empty(area); |
| 6660 | english.render(area, &mut english_buf); |
| 6661 | let english_text = buffer_text(&english_buf, area); |
| 6662 | assert!( |
| 6663 | english_text.contains("Current-session fleet workers"), |
| 6664 | "{english_text}" |
| 6665 | ); |
| 6666 | assert!( |
| 6667 | english_text.contains("Sub-agent roles are current-session fleet worker roles."), |
| 6668 | "{english_text}" |
| 6669 | ); |
| 6670 | |
| 6671 | let mut zh_hans_app = create_test_app(); |
| 6672 | zh_hans_app.ui_locale = Locale::ZhHans; |
| 6673 | let zh_hans = SubAgentsView::for_app( |
| 6674 | &zh_hans_app, |
| 6675 | vec![manager_agent("agent_live", SubAgentStatus::Running)], |
| 6676 | ); |
| 6677 | let mut zh_hans_buf = Buffer::empty(area); |
| 6678 | zh_hans.render(area, &mut zh_hans_buf); |
| 6679 | let zh_hans_text = buffer_text(&zh_hans_buf, area); |
| 6680 | // Ratatui gives each CJK glyph a trailing buffer cell. Strip those |
| 6681 | // layout spaces before asserting the actual rendered copy. |
| 6682 | let zh_hans_compact = zh_hans_text |
| 6683 | .chars() |
| 6684 | .filter(|ch| !ch.is_whitespace()) |
| 6685 | .collect::<String>(); |
| 6686 | assert_eq!( |
| 6687 | tr( |
| 6688 | Locale::ZhHans, |
| 6689 | MessageId::SubagentsCurrentSessionFleetWorkersTitle |
| 6690 | ), |
| 6691 | "当前会话的舰队工作器" |
| 6692 | ); |
| 6693 | assert!( |
| 6694 | zh_hans_compact.contains("当前会话的舰队工作器"), |
| 6695 | "{zh_hans_text}" |
| 6696 | ); |
| 6697 | assert!( |
| 6698 | zh_hans_compact.contains("子代理角色是当前会话的舰队工作器角色。"), |
| 6699 | "{zh_hans_text}" |
| 6700 | ); |
| 6701 | assert!( |
| 6702 | !zh_hans_text.contains("Current-session fleet workers"), |
| 6703 | "{zh_hans_text}" |
| 6704 | ); |
| 6705 | } |
| 6706 | |
| 6707 | #[test] |
| 6708 | fn subagents_modal_localizes_worker_rows_and_preserves_english_status_rendering() { |
| 6709 | let area = Rect::new(0, 0, 200, 60); |
| 6710 | let mut agent = manager_agent("agent_live", SubAgentStatus::Running); |
| 6711 | agent.agent_type = FleetRole::Builder; |
| 6712 | agent.assignment.role = Some("release".to_string()); |
| 6713 | agent.assignment.objective = "verify localized row".to_string(); |
| 6714 | agent.runtime_permissions = Some(codewhale_protocol::fleet::FleetEffectivePermissions { |
| 6715 | write: true, |
| 6716 | network: true, |
| 6717 | shell: "read_only".to_string(), |
| 6718 | tool_scope: "inherit".to_string(), |
| 6719 | tools: Vec::new(), |
| 6720 | background: false, |
| 6721 | max_spawn_depth: 0, |
| 6722 | profile_id: None, |
| 6723 | profile_origin: None, |
| 6724 | source: "test".to_string(), |
| 6725 | }); |
| 6726 | agent.git_branch = Some("feature/localize".to_string()); |
| 6727 | agent.workspace = Some(PathBuf::from("/tmp/fleet-workers")); |
| 6728 | agent.result = Some("all checks passed".to_string()); |
| 6729 | let mut interrupted = manager_agent( |
| 6730 | "agent_interrupted", |
| 6731 | SubAgentStatus::Interrupted("manual review".to_string()), |
| 6732 | ); |
| 6733 | interrupted.agent_type = FleetRole::Reviewer; |
| 6734 | |
| 6735 | let app = create_test_app(); |
| 6736 | let english = SubAgentsView::for_app(&app, vec![agent.clone(), interrupted.clone()]); |
| 6737 | let mut english_buf = Buffer::empty(area); |
| 6738 | english.render(area, &mut english_buf); |
| 6739 | let english_text = buffer_text(&english_buf, area); |
| 6740 | for expected in [ |
| 6741 | "Current-session fleet workers", |
| 6742 | "Running: 1", |
| 6743 | "Completed: 0", |
| 6744 | "Interrupted: 1", |
| 6745 | "Failed: 0", |
| 6746 | "Cancelled: 0", |
| 6747 | "Running (1)", |
| 6748 | "implement", |
| 6749 | "running", |
| 6750 | "reason: manual review", |
| 6751 | "role: release", |
| 6752 | "posture: network=on · shell=read-only · write=on", |
| 6753 | "git: branch feature/localize @ fleet-workers", |
| 6754 | "objective: verify localized row", |
| 6755 | "result: all checks passed", |
| 6756 | "live worker status · role · objective · model · elapsed", |
| 6757 | "close", |
| 6758 | "select", |
| 6759 | "focus", |
| 6760 | "stop", |
| 6761 | "refresh", |
| 6762 | "roster/setup", |
| 6763 | ] { |
| 6764 | assert!( |
| 6765 | english_text.contains(expected), |
| 6766 | "missing {expected:?}: {english_text}" |
| 6767 | ); |
| 6768 | } |
| 6769 | |
| 6770 | let mut zh_hans_app = create_test_app(); |
| 6771 | zh_hans_app.ui_locale = Locale::ZhHans; |
| 6772 | let zh_hans = SubAgentsView::for_app(&zh_hans_app, vec![agent, interrupted]); |
| 6773 | let mut zh_hans_buf = Buffer::empty(area); |
| 6774 | zh_hans.render(area, &mut zh_hans_buf); |
| 6775 | let zh_hans_text = buffer_text(&zh_hans_buf, area); |
| 6776 | let zh_hans_compact = zh_hans_text |
| 6777 | .chars() |
| 6778 | .filter(|ch| !ch.is_whitespace()) |
| 6779 | .collect::<String>(); |
| 6780 | for expected in [ |
| 6781 | "当前会话的舰队工作器", |
| 6782 | "运行中:1", |
| 6783 | "已中断:1", |
| 6784 | "名册设置工作器", |
| 6785 | "实时工作器状态·角色·目标·模型·已用时间", |
| 6786 | "运行中(1)", |
| 6787 | "构建者", |
| 6788 | "原因:manualreview", |
| 6789 | "角色:release", |
| 6790 | "权限:网络=开·Shell=只读·写入=开", |
| 6791 | "Git:分支feature/localize@fleet-workers", |
| 6792 | "目标:verifylocalizedrow", |
| 6793 | "结果:allcheckspassed", |
| 6794 | "刷新", |
| 6795 | "名册/设置", |
| 6796 | ] { |
| 6797 | assert!( |
| 6798 | zh_hans_compact.contains(expected), |
| 6799 | "missing {expected:?}: {zh_hans_text}" |
| 6800 | ); |
| 6801 | } |
| 6802 | assert!( |
| 6803 | !zh_hans_text.contains("Running: 1"), |
| 6804 | "English status leaked into zh-Hans modal: {zh_hans_text}" |
| 6805 | ); |
| 6806 | } |
| 6807 | |
| 6808 | /// Focus-texture prototype (#4823): with a mode forced on, a real |
| 6809 | /// full-screen modal must render exactly as before — the texture pass |
| 6810 | /// no-ops because the focus region covers (nearly) the whole frame. |
| 6811 | /// The default `Off` case is pinned by the existing |
| 6812 | /// `*_modal_is_usable_and_opaque_at_blocker_sizes` tests above: they run |
| 6813 | /// unmodified because `ViewStack::new()` defaults to `Off`, which leaves |
| 6814 | /// the buffer byte-identical to the pre-prototype render. |
| 6815 | #[test] |
| 6816 | fn focus_texture_modes_keep_fullscreen_modal_usable_and_opaque() { |
| 6817 | let _lock = crate::test_support::lock_test_env(); |
| 6818 | let theme = codewhale_palette::ThemeId::Whale.ui_theme(); |
| 6819 | for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] { |
| 6820 | for (w, h) in BLOCKER_SIZES { |
| 6821 | let area = Rect::new(0, 0, w, h); |
| 6822 | let mut buf = Buffer::empty(area); |
| 6823 | let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green); |
| 6824 | for y in 0..h { |
| 6825 | for x in 0..w { |
| 6826 | buf[(x, y)].set_symbol("X").set_style(sentinel_style); |
| 6827 | } |
| 6828 | } |
| 6829 | let mut stack = ViewStack::new(); |
| 6830 | stack.push(create_config_view(Locale::En)); |
| 6831 | stack.set_focus_texture(mode, theme); |
| 6832 | stack.render(area, &mut buf); |
| 6833 | |
| 6834 | let rows: Vec<String> = (0..h) |
| 6835 | .map(|y| { |
| 6836 | (0..w) |
| 6837 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 6838 | .collect::<String>() |
| 6839 | }) |
| 6840 | .collect(); |
| 6841 | let text = rows.join("\n"); |
| 6842 | |
| 6843 | assert!( |
| 6844 | text.contains("Search"), |
| 6845 | "{mode:?} {w}x{h}: missing 'Search'" |
| 6846 | ); |
| 6847 | let unpainted = (0..h).find_map(|y| { |
| 6848 | (0..w).find_map(|x| { |
| 6849 | let cell = &buf[(x, y)]; |
| 6850 | (cell.symbol() == "X" |
| 6851 | && cell.fg == Color::Magenta |
| 6852 | && cell.bg == Color::Green) |
| 6853 | .then_some((x, y)) |
| 6854 | }) |
| 6855 | }); |
| 6856 | assert!( |
| 6857 | unpainted.is_none(), |
| 6858 | "{mode:?} {w}x{h}: background bleed-through at {unpainted:?}" |
| 6859 | ); |
| 6860 | assert_eq!( |
| 6861 | buf[(w / 2, h / 2)].bg, |
| 6862 | palette::WHALE_BG, |
| 6863 | "{mode:?} {w}x{h}: modal interior must be opaque" |
| 6864 | ); |
| 6865 | } |
| 6866 | } |
| 6867 | } |
| 6868 | |
| 6869 | /// The texture actually engages outside an *inline* modal's band: the |
| 6870 | /// approval prompt only occupies a bottom strip, so the sentinel field |
| 6871 | /// above it goes through the scrim/grain pass. The modal is painted |
| 6872 | /// after the texture, so its band stays fully opaque and its labels |
| 6873 | /// survive at every blocker size. |
| 6874 | #[test] |
| 6875 | fn focus_texture_modes_keep_inline_modal_usable() { |
| 6876 | let theme = codewhale_palette::ThemeId::Whale.ui_theme(); |
| 6877 | for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] { |
| 6878 | for (w, h) in BLOCKER_SIZES { |
| 6879 | let area = Rect::new(0, 0, w, h); |
| 6880 | let mut buf = Buffer::empty(area); |
| 6881 | let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green); |
| 6882 | for y in 0..h { |
| 6883 | for x in 0..w { |
| 6884 | buf[(x, y)].set_symbol("X").set_style(sentinel_style); |
| 6885 | } |
| 6886 | } |
| 6887 | let request = crate::tui::approval::ApprovalRequest::new( |
| 6888 | "test-id", |
| 6889 | "read_file", |
| 6890 | "Read a file from disk", |
| 6891 | &serde_json::json!({"path": "src/main.rs"}), |
| 6892 | "tool:read_file", |
| 6893 | ); |
| 6894 | let mut stack = ViewStack::new(); |
| 6895 | stack.push(crate::tui::approval::ApprovalView::new(request)); |
| 6896 | stack.set_focus_texture(mode, theme); |
| 6897 | let focus = stack |
| 6898 | .top_occupied_region(area) |
| 6899 | .expect("approval view on the stack"); |
| 6900 | stack.render(area, &mut buf); |
| 6901 | |
| 6902 | let rows: Vec<String> = (0..h) |
| 6903 | .map(|y| { |
| 6904 | (0..w) |
| 6905 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 6906 | .collect::<String>() |
| 6907 | }) |
| 6908 | .collect(); |
| 6909 | let text = rows.join("\n"); |
| 6910 | |
| 6911 | assert!( |
| 6912 | text.contains("Do you want to proceed?") && text.contains("read_file"), |
| 6913 | "{mode:?} {w}x{h}: approval prompt must survive the texture" |
| 6914 | ); |
| 6915 | // Zero sentinel bleed INSIDE the focused band: the backdrop |
| 6916 | // and the modal own every cell there. Outside the band the |
| 6917 | // texture intentionally leaves the sentinel glyphs in place |
| 6918 | // (Scrim only re-colors; Grain never overwrites text). |
| 6919 | let mut whale_bg_cells = 0_u32; |
| 6920 | for y in focus.top()..focus.bottom() { |
| 6921 | for x in focus.left()..focus.right() { |
| 6922 | let cell = &buf[(x, y)]; |
| 6923 | assert!( |
| 6924 | !(cell.symbol() == "X" |
| 6925 | && cell.fg == Color::Magenta |
| 6926 | && cell.bg == Color::Green), |
| 6927 | "{mode:?} {w}x{h}: sentinel bleed inside focus at ({x},{y})" |
| 6928 | ); |
| 6929 | if cell.bg == palette::WHALE_BG { |
| 6930 | whale_bg_cells += 1; |
| 6931 | } |
| 6932 | } |
| 6933 | } |
| 6934 | // The band keeps the opaque modal ink. (Not every cell: the |
| 6935 | // selected option row carries its own highlight background.) |
| 6936 | assert!( |
| 6937 | whale_bg_cells > 0, |
| 6938 | "{mode:?} {w}x{h}: modal band lost its opaque WHALE_BG surface" |
| 6939 | ); |
| 6940 | } |
| 6941 | } |
| 6942 | } |
| 6943 | |
| 6944 | #[test] |
| 6945 | fn centered_modal_area_clamps_and_centers() { |
| 6946 | // Roomy frame: preferred size honoured, centered. |
| 6947 | let area = Rect::new(0, 0, 160, 40); |
| 6948 | let rect = centered_modal_area(area, 80, 20, 40, 10); |
| 6949 | assert_eq!((rect.width, rect.height), (80, 20)); |
| 6950 | assert_eq!(rect.x, (160 - 80) / 2); |
| 6951 | assert_eq!(rect.y, (40 - 20) / 2); |
| 6952 | |
| 6953 | // Tiny frame: never exceeds the frame even below the requested minimum. |
| 6954 | let tiny = Rect::new(0, 0, 30, 8); |
| 6955 | let rect = centered_modal_area(tiny, 80, 20, 40, 10); |
| 6956 | assert!(rect.width <= tiny.width, "width must fit frame"); |
| 6957 | assert!(rect.height <= tiny.height, "height must fit frame"); |
| 6958 | assert!(rect.x + rect.width <= tiny.width); |
| 6959 | assert!(rect.y + rect.height <= tiny.height); |
| 6960 | } |
| 6961 | |
| 6962 | #[test] |
| 6963 | fn action_footer_wraps_instead_of_overflowing() { |
| 6964 | let hints = [ |
| 6965 | ActionHint::new("↑↓", "move"), |
| 6966 | ActionHint::new("a-z", "jump"), |
| 6967 | ActionHint::new("Enter", "apply"), |
| 6968 | ActionHint::new("R", "edit key"), |
| 6969 | ActionHint::new("M", "models"), |
| 6970 | ActionHint::new("Esc", "cancel"), |
| 6971 | ]; |
| 6972 | |
| 6973 | // Wide enough for a single row. |
| 6974 | let wide = action_footer_lines(&hints, 120); |
| 6975 | assert_eq!(wide.len(), 1); |
| 6976 | assert!(wide[0].width() <= 120); |
| 6977 | |
| 6978 | // Narrow forces wrapping but never truncates: every action survives and |
| 6979 | // no produced line exceeds the available width. |
| 6980 | let narrow = action_footer_lines(&hints, 28); |
| 6981 | assert!(narrow.len() >= 2, "narrow footer should wrap to >1 row"); |
| 6982 | for line in &narrow { |
| 6983 | assert!( |
| 6984 | line.width() <= 28, |
| 6985 | "wrapped footer row overflows: {} cols", |
| 6986 | line.width() |
| 6987 | ); |
| 6988 | } |
| 6989 | let joined: String = narrow |
| 6990 | .iter() |
| 6991 | .flat_map(|l| l.spans.iter()) |
| 6992 | .map(|s| s.content.as_ref()) |
| 6993 | .collect(); |
| 6994 | for label in ["move", "jump", "apply", "edit key", "models", "cancel"] { |
| 6995 | assert!(joined.contains(label), "footer dropped action: {label}"); |
| 6996 | } |
| 6997 | } |
| 6998 | |
| 6999 | #[test] |
| 7000 | fn render_modal_footer_reserves_rows_and_returns_body() { |
| 7001 | let inner = Rect::new(2, 2, 40, 10); |
| 7002 | let mut buf = Buffer::empty(Rect::new(0, 0, 44, 14)); |
| 7003 | let hints = [ |
| 7004 | ActionHint::new("Enter", "save"), |
| 7005 | ActionHint::new("Esc", "cancel"), |
| 7006 | ]; |
| 7007 | let body = render_modal_footer_with_gutter(inner, &mut buf, &hints); |
| 7008 | // Normal-height overlays reserve a single quiet gutter above the |
| 7009 | // one-row footer, so body prose never runs into the action rail. |
| 7010 | assert_eq!(body.y, inner.y); |
| 7011 | assert_eq!(body.height, inner.height - 2); |
| 7012 | assert_eq!(body.y + body.height, inner.y + inner.height - 2); |
| 7013 | let gutter_y = inner.y + inner.height - 2; |
| 7014 | assert!( |
| 7015 | (inner.x..inner.right()).all(|x| buf[(x, gutter_y)].symbol().trim().is_empty()), |
| 7016 | "modal footer gutter should stay visually quiet" |
| 7017 | ); |
| 7018 | } |
| 7019 | |
| 7020 | #[test] |
| 7021 | fn list_detail_layout_splits_wide_and_stacks_narrow() { |
| 7022 | let wide = ListDetailLayout::split(Rect::new(0, 0, 120, 24), 34); |
| 7023 | assert!(!wide.stacked); |
| 7024 | assert!(wide.list.width >= 30); |
| 7025 | assert!(wide.detail.width >= 34); |
| 7026 | assert_eq!(wide.list.height, 24); |
| 7027 | assert_eq!(wide.detail.height, 24); |
| 7028 | assert!(wide.list.right() < wide.detail.left()); |
| 7029 | |
| 7030 | let narrow = ListDetailLayout::split(Rect::new(0, 0, 80, 20), 34); |
| 7031 | assert!(narrow.stacked); |
| 7032 | assert_eq!(narrow.list.width, 80); |
| 7033 | assert_eq!(narrow.detail.width, 80); |
| 7034 | assert!(narrow.list.bottom() <= narrow.detail.top()); |
| 7035 | assert!(narrow.list.height > 0); |
| 7036 | } |
| 7037 | |
| 7038 | #[test] |
| 7039 | fn empty_state_renders_copy_and_actions() { |
| 7040 | let area = Rect::new(0, 0, 48, 8); |
| 7041 | let mut buf = Buffer::empty(area); |
| 7042 | EmptyState::new("Nothing here", "Use search or switch categories.") |
| 7043 | .primary_action("/", "filter") |
| 7044 | .secondary_action("Esc", "cancel") |
| 7045 | .render(area, &mut buf); |
| 7046 | |
| 7047 | let text = (0..area.height) |
| 7048 | .map(|y| { |
| 7049 | (0..area.width) |
| 7050 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 7051 | .collect::<String>() |
| 7052 | }) |
| 7053 | .collect::<Vec<_>>() |
| 7054 | .join("\n"); |
| 7055 | for expected in ["Nothing here", "Use search", "filter", "cancel"] { |
| 7056 | assert!( |
| 7057 | text.contains(expected), |
| 7058 | "empty state missing {expected:?}: {text:?}" |
| 7059 | ); |
| 7060 | } |
| 7061 | } |
| 7062 | |
| 7063 | struct ConfigSettingsEnvGuard { |
| 7064 | _config_path: crate::test_support::EnvVarGuard, |
| 7065 | _tmp: TempDir, |
| 7066 | _lock: crate::test_support::TestEnvLock, |
| 7067 | } |
| 7068 | |
| 7069 | impl ConfigSettingsEnvGuard { |
| 7070 | fn new(settings_toml: &str) -> Self { |
| 7071 | let lock = crate::test_support::lock_test_env(); |
| 7072 | let tmp = TempDir::new().expect("settings tempdir"); |
| 7073 | let config_path = tmp.path().join(".deepseek").join("config.toml"); |
| 7074 | let settings_path = config_path |
| 7075 | .parent() |
| 7076 | .expect("settings parent") |
| 7077 | .join("settings.toml"); |
| 7078 | std::fs::create_dir_all(config_path.parent().expect("config parent")) |
| 7079 | .expect("config dir"); |
| 7080 | std::fs::write(&settings_path, settings_toml).expect("settings file"); |
| 7081 | let config_path_guard = |
| 7082 | crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 7083 | Self { |
| 7084 | _config_path: config_path_guard, |
| 7085 | _tmp: tmp, |
| 7086 | _lock: lock, |
| 7087 | } |
| 7088 | } |
| 7089 | } |
| 7090 | |
| 7091 | fn create_test_app() -> App { |
| 7092 | static NEXT_CONFIG_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| 7093 | let config_id = NEXT_CONFIG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 7094 | let isolated_config_path = std::env::temp_dir().join(format!( |
| 7095 | "codewhale-config-view-test-{}-{config_id}.toml", |
| 7096 | std::process::id() |
| 7097 | )); |
| 7098 | let options = TuiOptions { |
| 7099 | // ConfigView consults the app's persisted config. Point generic |
| 7100 | // tests at a unique absent file so developer or concurrent test |
| 7101 | // settings cannot silently change which controls are editable. |
| 7102 | config_path: Some(isolated_config_path), |
| 7103 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 7104 | }; |
| 7105 | let mut app = App::new(options, &Config::default()); |
| 7106 | app.api_provider = crate::config::ApiProvider::Deepseek; |
| 7107 | app |
| 7108 | } |
| 7109 | |
| 7110 | fn cost_currency_row_for_settings( |
| 7111 | settings_toml: &str, |
| 7112 | ) -> (String, String, crate::pricing::CostCurrency, Locale) { |
| 7113 | let _guard = ConfigSettingsEnvGuard::new(settings_toml); |
| 7114 | let app = create_test_app(); |
| 7115 | let view = ConfigView::new_for_app(&app); |
| 7116 | let row = view |
| 7117 | .rows |
| 7118 | .iter() |
| 7119 | .find(|row| row.key == "cost_currency") |
| 7120 | .expect("cost_currency row"); |
| 7121 | |
| 7122 | ( |
| 7123 | row.value.clone(), |
| 7124 | view.row_display_value(row), |
| 7125 | app.cost_currency, |
| 7126 | app.ui_locale, |
| 7127 | ) |
| 7128 | } |
| 7129 | |
| 7130 | fn type_filter(view: &mut ConfigView, text: &str) { |
| 7131 | for ch in text.chars() { |
| 7132 | let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 7133 | assert!(matches!(action, ViewAction::None)); |
| 7134 | } |
| 7135 | } |
| 7136 | |
| 7137 | fn manager_agent(id: &str, status: SubAgentStatus) -> SubAgentResult { |
| 7138 | SubAgentResult { |
| 7139 | usage: None, |
| 7140 | name: id.to_string(), |
| 7141 | agent_id: id.to_string(), |
| 7142 | context_mode: "fresh".to_string(), |
| 7143 | fork_context: false, |
| 7144 | workspace: None, |
| 7145 | git_branch: None, |
| 7146 | agent_type: FleetRole::Scout, |
| 7147 | assignment: SubAgentAssignment { |
| 7148 | objective: "read the docs".to_string(), |
| 7149 | role: None, |
| 7150 | }, |
| 7151 | model: "deepseek-v4-flash".to_string(), |
| 7152 | nickname: None, |
| 7153 | status, |
| 7154 | worker_status: None, |
| 7155 | runtime_permissions: None, |
| 7156 | parent_run_id: None, |
| 7157 | spawn_depth: 0, |
| 7158 | child_route: None, |
| 7159 | result: None, |
| 7160 | steps_taken: 1, |
| 7161 | checkpoint: None, |
| 7162 | needs_input: None, |
| 7163 | duration_ms: 10, |
| 7164 | started_at: None, |
| 7165 | from_prior_session: false, |
| 7166 | } |
| 7167 | } |
| 7168 | |
| 7169 | #[test] |
| 7170 | fn worker_register_update_preserves_selected_agent_across_new_spawns() { |
| 7171 | let mut view = SubAgentsView::new(vec![manager_agent("b", SubAgentStatus::Running)]); |
| 7172 | view.update_subagents(&[ |
| 7173 | manager_agent("a", SubAgentStatus::Running), |
| 7174 | manager_agent("b", SubAgentStatus::Running), |
| 7175 | ]); |
| 7176 | assert_eq!(view.ordered_agent_ids()[view.selected], "b"); |
| 7177 | view.update_subagents(&[ |
| 7178 | manager_agent("a", SubAgentStatus::Running), |
| 7179 | manager_agent("b", SubAgentStatus::Completed), |
| 7180 | ]); |
| 7181 | assert_eq!(view.ordered_agent_ids()[view.selected], "b"); |
| 7182 | } |
| 7183 | |
| 7184 | #[test] |
| 7185 | fn subagent_view_agents_includes_progress_only_running_agent() { |
| 7186 | let mut app = create_test_app(); |
| 7187 | app.ensure_agent_label("agent_live"); |
| 7188 | app.agent_progress |
| 7189 | .insert("agent_live".to_string(), "reading code".to_string()); |
| 7190 | |
| 7191 | let agents = subagent_view_agents(&app, &[]); |
| 7192 | |
| 7193 | assert_eq!(agents.len(), 1); |
| 7194 | assert_eq!(agents[0].agent_id, "agent_live"); |
| 7195 | assert!(matches!(agents[0].status, SubAgentStatus::Running)); |
| 7196 | assert_eq!(agents[0].assignment.role.as_deref(), Some("live")); |
| 7197 | assert!(agents[0].assignment.objective.contains("reading code")); |
| 7198 | assert_eq!(agents[0].nickname.as_deref(), Some("Agent 1")); |
| 7199 | } |
| 7200 | |
| 7201 | #[test] |
| 7202 | fn subagent_view_replaces_progress_placeholder_after_manager_snapshot() { |
| 7203 | let mut app = create_test_app(); |
| 7204 | app.ui_locale = Locale::En; |
| 7205 | app.ensure_agent_label("agent_live"); |
| 7206 | app.agent_progress |
| 7207 | .insert("agent_live".to_string(), "reading code".to_string()); |
| 7208 | |
| 7209 | let progress_only = subagent_view_agents(&app, &[]); |
| 7210 | assert_eq!(progress_only[0].nickname.as_deref(), Some("Agent 1")); |
| 7211 | |
| 7212 | let mut manager = manager_agent("agent_live", SubAgentStatus::Running); |
| 7213 | manager.nickname = Some(crate::tools::subagent::whale_name_for_id_in_locale( |
| 7214 | "agent_live", |
| 7215 | "ja", |
| 7216 | )); |
| 7217 | let manager_backed = subagent_view_agents(&app, &[manager]); |
| 7218 | assert_eq!( |
| 7219 | manager_backed[0].nickname.as_deref(), |
| 7220 | Some(crate::tools::subagent::whale_name_for_id_in_locale("agent_live", "en").as_str()) |
| 7221 | ); |
| 7222 | } |
| 7223 | |
| 7224 | #[test] |
| 7225 | fn subagent_view_headlines_the_dispatch_name_over_the_whale() { |
| 7226 | // #5287: `/subagents` spells the identity column from `nickname`, so a |
| 7227 | // named dispatch lands there and only an unnamed one gets a whale. |
| 7228 | let mut app = create_test_app(); |
| 7229 | app.ui_locale = Locale::En; |
| 7230 | let mut named = manager_agent("agent_named_lane", SubAgentStatus::Running); |
| 7231 | named.name = "branch-triage".to_string(); |
| 7232 | let plain = manager_agent("agent_plain_lane", SubAgentStatus::Running); |
| 7233 | |
| 7234 | let agents = subagent_view_agents(&app, &[named, plain]); |
| 7235 | assert_eq!(agents[0].nickname.as_deref(), Some("branch-triage")); |
| 7236 | assert_eq!( |
| 7237 | agents[1].nickname.as_deref(), |
| 7238 | Some( |
| 7239 | crate::tools::subagent::whale_name_for_id_in_locale("agent_plain_lane", "en") |
| 7240 | .as_str() |
| 7241 | ) |
| 7242 | ); |
| 7243 | } |
| 7244 | |
| 7245 | #[test] |
| 7246 | fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() { |
| 7247 | let mut app = create_test_app(); |
| 7248 | let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]); |
| 7249 | card.upsert_worker("chunk_1", AgentLifecycle::Completed); |
| 7250 | card.upsert_worker("chunk_2", AgentLifecycle::Running); |
| 7251 | app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); |
| 7252 | app.last_fanout_card_index = Some(app.history.len().saturating_sub(1)); |
| 7253 | |
| 7254 | let agents = subagent_view_agents(&app, &[]); |
| 7255 | |
| 7256 | assert_eq!(agents.len(), 2); |
| 7257 | assert_eq!(agents[0].agent_id, "chunk_1"); |
| 7258 | assert!(matches!(agents[0].status, SubAgentStatus::Completed)); |
| 7259 | assert_eq!(agents[1].agent_id, "chunk_2"); |
| 7260 | assert!(matches!(agents[1].status, SubAgentStatus::Running)); |
| 7261 | assert_eq!(agents[1].assignment.role.as_deref(), Some("rlm")); |
| 7262 | } |
| 7263 | |
| 7264 | #[test] |
| 7265 | fn subagent_view_agents_deduplicates_manager_rows_over_live_rows() { |
| 7266 | let mut app = create_test_app(); |
| 7267 | app.agent_progress |
| 7268 | .insert("agent_cached".to_string(), "live duplicate".to_string()); |
| 7269 | let manager = vec![manager_agent("agent_cached", SubAgentStatus::Running)]; |
| 7270 | |
| 7271 | let agents = subagent_view_agents(&app, &manager); |
| 7272 | |
| 7273 | assert_eq!(agents.len(), 1); |
| 7274 | assert_eq!(agents[0].agent_type, FleetRole::Scout); |
| 7275 | assert_eq!(agents[0].assignment.objective, "read the docs"); |
| 7276 | } |
| 7277 | |
| 7278 | #[test] |
| 7279 | fn fleet_worker_status_view_can_jump_to_fleet_setup() { |
| 7280 | let mut view = SubAgentsView::new(Vec::new()); |
| 7281 | |
| 7282 | let action = view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); |
| 7283 | |
| 7284 | match action { |
| 7285 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 7286 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 7287 | }) => assert_eq!(command, "/fleet"), |
| 7288 | other => panic!("expected /fleet jump action, got {other:?}"), |
| 7289 | } |
| 7290 | } |
| 7291 | |
| 7292 | /// One agent, one destination (v0.9.7): Enter on a `/agents` row opens |
| 7293 | /// the selected agent's transcript — the same destination the Work strip |
| 7294 | /// and sidebar resolve to. Selection follows render order (running before |
| 7295 | /// completed), and an empty register keeps Enter's refresh meaning. |
| 7296 | #[test] |
| 7297 | fn subagents_enter_opens_the_selected_agents_transcript() { |
| 7298 | let mut view = SubAgentsView::new(vec![ |
| 7299 | manager_agent("agent_done", SubAgentStatus::Completed), |
| 7300 | manager_agent("agent_live", SubAgentStatus::Running), |
| 7301 | ]); |
| 7302 | |
| 7303 | // Render order groups running first, so the initial selection is the |
| 7304 | // running agent even though the completed one was pushed first. |
| 7305 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7306 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => { |
| 7307 | assert_eq!(agent_id, "agent_live"); |
| 7308 | } |
| 7309 | other => panic!("expected transcript open, got {other:?}"), |
| 7310 | } |
| 7311 | |
| 7312 | let _ = view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); |
| 7313 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7314 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => { |
| 7315 | assert_eq!(agent_id, "agent_done"); |
| 7316 | } |
| 7317 | other => panic!("expected transcript open, got {other:?}"), |
| 7318 | } |
| 7319 | |
| 7320 | let mut empty = SubAgentsView::new(Vec::new()); |
| 7321 | assert!(matches!( |
| 7322 | empty.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 7323 | ViewAction::Emit(ViewEvent::SubAgentsRefresh) |
| 7324 | )); |
| 7325 | } |
| 7326 | |
| 7327 | /// Whale Teams rows: every worker carries its species badge and a state |
| 7328 | /// word derived from the real status (running → Working, completed → |
| 7329 | /// Resting, failed → Blocked, interrupted → Waiting for you), and the |
| 7330 | /// working wake holds the poster frame outside Full motion. |
| 7331 | #[test] |
| 7332 | fn subagents_rows_carry_species_badges_and_truthful_state_words() { |
| 7333 | let mut interrupted = manager_agent("agent_wait", SubAgentStatus::Interrupted("q".into())); |
| 7334 | interrupted.agent_type = FleetRole::Builder; |
| 7335 | let mut failed = manager_agent("agent_fail", SubAgentStatus::Failed("boom".into())); |
| 7336 | failed.agent_type = FleetRole::Reviewer; |
| 7337 | let view = SubAgentsView::new(vec![ |
| 7338 | manager_agent("agent_done", SubAgentStatus::Completed), |
| 7339 | manager_agent("agent_live", SubAgentStatus::Running), |
| 7340 | interrupted, |
| 7341 | failed, |
| 7342 | ]); |
| 7343 | assert_eq!(view.whale_frame(), 0, "Still motion holds the poster frame"); |
| 7344 | let area = Rect::new(0, 0, 100, 40); |
| 7345 | let mut buf = Buffer::empty(area); |
| 7346 | view.render(area, &mut buf); |
| 7347 | let text = buffer_text(&buf, area); |
| 7348 | // Scout (manager_agent default role) → beak badge; Builder → Patch |
| 7349 | // bracket; Reviewer → Lantern lens. |
| 7350 | assert!(text.contains("◂▰ agent_live"), "{text}"); |
| 7351 | assert!(text.contains("◂▰ · Working"), "{text}"); |
| 7352 | assert!(text.contains("◂▰ Resting"), "{text}"); |
| 7353 | assert!(text.contains("▰] ◆ Waiting for you"), "{text}"); |
| 7354 | assert!(text.contains("◇▰ ▌ Blocked"), "{text}"); |
| 7355 | assert!( |
| 7356 | !text.contains("Scout · research"), |
| 7357 | "no caption labels: {text}" |
| 7358 | ); |
| 7359 | assert!( |
| 7360 | !text.contains("Lantern · review"), |
| 7361 | "no caption labels: {text}" |
| 7362 | ); |
| 7363 | } |
| 7364 | |
| 7365 | /// A click on a rendered `/agents` row opens the clicked agent's |
| 7366 | /// transcript and moves the selection cursor onto it. |
| 7367 | #[test] |
| 7368 | fn subagents_click_opens_the_clicked_agents_transcript() { |
| 7369 | let mut view = SubAgentsView::new(vec![ |
| 7370 | manager_agent("agent_done", SubAgentStatus::Completed), |
| 7371 | manager_agent("agent_live", SubAgentStatus::Running), |
| 7372 | ]); |
| 7373 | let area = Rect::new(0, 0, 100, 30); |
| 7374 | let mut buf = Buffer::empty(area); |
| 7375 | view.render(area, &mut buf); |
| 7376 | |
| 7377 | // Resolve the completed agent's on-screen row from the recorded |
| 7378 | // layout, exactly as a click does in reverse. |
| 7379 | let (first_line, _, agent_id) = view |
| 7380 | .row_lines |
| 7381 | .borrow() |
| 7382 | .iter() |
| 7383 | .find(|(_, _, id)| id == "agent_done") |
| 7384 | .cloned() |
| 7385 | .expect("completed agent block recorded"); |
| 7386 | assert_eq!(agent_id, "agent_done"); |
| 7387 | let body = view.body_area.get(); |
| 7388 | let scroll = view.last_render_scroll.get(); |
| 7389 | let click_row = body.y + u16::try_from(first_line - scroll).expect("visible row"); |
| 7390 | |
| 7391 | let action = view.handle_mouse(MouseEvent { |
| 7392 | kind: MouseEventKind::Down(MouseButton::Left), |
| 7393 | column: body.x + 2, |
| 7394 | row: click_row, |
| 7395 | modifiers: KeyModifiers::NONE, |
| 7396 | }); |
| 7397 | match action { |
| 7398 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => { |
| 7399 | assert_eq!(agent_id, "agent_done"); |
| 7400 | } |
| 7401 | other => panic!("expected transcript open, got {other:?}"), |
| 7402 | } |
| 7403 | assert_eq!(view.ordered_agent_ids()[view.selected], "agent_done"); |
| 7404 | |
| 7405 | // The selection cursor is visible after a re-render. |
| 7406 | let mut buf = Buffer::empty(area); |
| 7407 | view.render(area, &mut buf); |
| 7408 | let text = (0..area.height) |
| 7409 | .map(|y| { |
| 7410 | (0..area.width) |
| 7411 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 7412 | .collect::<String>() |
| 7413 | }) |
| 7414 | .collect::<Vec<_>>() |
| 7415 | .join("\n"); |
| 7416 | assert!( |
| 7417 | text.contains('\u{25B8}'), |
| 7418 | "selection cursor missing:\n{text}" |
| 7419 | ); |
| 7420 | } |
| 7421 | |
| 7422 | fn visible_section_labels(view: &ConfigView) -> Vec<Cow<'static, str>> { |
| 7423 | view.visible_items() |
| 7424 | .into_iter() |
| 7425 | .filter_map(|item| match item { |
| 7426 | ConfigListItem::Section(section) => Some(section.label(view.locale)), |
| 7427 | ConfigListItem::Row(_) => None, |
| 7428 | }) |
| 7429 | .collect() |
| 7430 | } |
| 7431 | |
| 7432 | fn create_config_view(locale: Locale) -> ConfigView { |
| 7433 | let mut app = create_test_app(); |
| 7434 | app.ui_locale = locale; |
| 7435 | ConfigView::new_for_app(&app) |
| 7436 | } |
| 7437 | |
| 7438 | fn visible_row_keys(view: &ConfigView) -> Vec<&str> { |
| 7439 | view.visible_items() |
| 7440 | .into_iter() |
| 7441 | .filter_map(|item| match item { |
| 7442 | ConfigListItem::Row(idx) => Some(view.rows[idx].key.as_str()), |
| 7443 | ConfigListItem::Section(_) => None, |
| 7444 | }) |
| 7445 | .collect() |
| 7446 | } |
| 7447 | |
| 7448 | #[test] |
| 7449 | fn truncate_view_text_handles_unicode() { |
| 7450 | let text = "abc😀é"; |
| 7451 | assert_eq!(truncate_view_text(text, 0), ""); |
| 7452 | assert_eq!(truncate_view_text(text, 1), "a"); |
| 7453 | assert_eq!(truncate_view_text(text, 3), "abc"); |
| 7454 | assert_eq!(truncate_view_text(text, 4), "abc😀"); |
| 7455 | assert_eq!(truncate_view_text(text, 5), "abc😀é"); |
| 7456 | } |
| 7457 | |
| 7458 | #[test] |
| 7459 | fn underwater_surface_ellipsizes_narrow_titles() { |
| 7460 | let area = Rect::new(0, 0, 24, 8); |
| 7461 | let mut buf = Buffer::empty(area); |
| 7462 | render_underwater_surface(area, &mut buf, "Help — Concepts, commands, and keybindings"); |
| 7463 | let top = (0..area.width) |
| 7464 | .map(|x| buf[(x, 0)].symbol()) |
| 7465 | .collect::<String>(); |
| 7466 | assert!( |
| 7467 | top.contains('…'), |
| 7468 | "narrow title should signal truncation: {top}" |
| 7469 | ); |
| 7470 | } |
| 7471 | |
| 7472 | #[test] |
| 7473 | fn config_view_groups_rows_by_expected_sections() { |
| 7474 | let view = create_config_view(Locale::En); |
| 7475 | assert_eq!( |
| 7476 | visible_section_labels(&view), |
| 7477 | vec!["Display"], |
| 7478 | "Settings opens on Appearance" |
| 7479 | ); |
| 7480 | } |
| 7481 | |
| 7482 | #[test] |
| 7483 | fn config_view_includes_expected_editable_rows() { |
| 7484 | let app = create_test_app(); |
| 7485 | let view = ConfigView::new_for_app(&app); |
| 7486 | let keys = view |
| 7487 | .rows |
| 7488 | .iter() |
| 7489 | .map(|row| row.key.as_str()) |
| 7490 | .collect::<Vec<_>>(); |
| 7491 | assert!(keys.contains(&"provider")); |
| 7492 | assert!(keys.contains(&"model")); |
| 7493 | assert!(keys.contains(&"reasoning_effort")); |
| 7494 | assert!(keys.contains(&"base_url")); |
| 7495 | assert!(keys.contains(&"external_credentials.openai-codex")); |
| 7496 | assert!(keys.contains(&"external_credentials.xai")); |
| 7497 | assert!(keys.contains(&"approval_mode")); |
| 7498 | assert!(keys.contains(&"permission_posture")); |
| 7499 | assert!(keys.contains(&"allow_shell")); |
| 7500 | assert!(keys.contains(&"theme")); |
| 7501 | assert!(keys.contains(&"locale")); |
| 7502 | assert!(keys.contains(&"background_color")); |
| 7503 | assert!(keys.contains(&"fancy_animations")); |
| 7504 | assert!(keys.contains(&"thinking_default_expanded")); |
| 7505 | assert!(keys.contains(&"synchronized_output")); |
| 7506 | assert!(keys.contains(&"auto_compact")); |
| 7507 | assert!(keys.contains(&"tool_collapse")); |
| 7508 | assert!(keys.contains(&"composer_border")); |
| 7509 | assert!(keys.contains(&"composer_multiline_mode")); |
| 7510 | assert!(keys.contains(&"cost_currency")); |
| 7511 | assert!(keys.contains(&"mcp_open")); |
| 7512 | assert!(keys.contains(&"mcp_reconnect")); |
| 7513 | assert!(keys.contains(&"mcp_diagnose")); |
| 7514 | assert!(keys.contains(&"plugins_open")); |
| 7515 | assert!(keys.contains(&"mcp_config_path")); |
| 7516 | assert!(keys.contains(&"fleet.exec.max_spawn_depth")); |
| 7517 | // Retired rows: the backends stay live (`default_model` routing, |
| 7518 | // the `vision_model` feature flag) or were derived receipts |
| 7519 | // (`fast_model`), but none keeps a table row. |
| 7520 | assert!(!keys.contains(&"features.vision_model")); |
| 7521 | assert!(!keys.contains(&"fast_model")); |
| 7522 | assert!(!keys.contains(&"default_model")); |
| 7523 | assert!(keys.contains(&"goal_command")); |
| 7524 | assert!(keys.contains(&"workflow")); |
| 7525 | assert!(!keys.contains(&"features.subagents")); |
| 7526 | assert!(!keys.contains(&"features.web_search")); |
| 7527 | assert!(!keys.contains(&"features.apply_patch")); |
| 7528 | assert!(!keys.contains(&"features.mcp")); |
| 7529 | assert!(!keys.contains(&"features.exec_policy")); |
| 7530 | assert!(!keys.contains(&"whaleflow")); |
| 7531 | // Diagnostic-only rows, managed permission rows, and live route |
| 7532 | // receipts are not editable; everything else outside the |
| 7533 | // read-only sections should be. |
| 7534 | const DIAGNOSTIC_ONLY: &[&str] = &[ |
| 7535 | "context_window", |
| 7536 | "effective_context_window", |
| 7537 | "external_credentials.openai-codex", |
| 7538 | "external_credentials.xai", |
| 7539 | "base_url", |
| 7540 | "provider_url", |
| 7541 | // Sub-agent depth stays a read-only config.toml receipt in its |
| 7542 | // new Model home; it is edited in the fleet config, not here. |
| 7543 | "fleet.exec.max_spawn_depth", |
| 7544 | ]; |
| 7545 | assert!( |
| 7546 | view.rows |
| 7547 | .iter() |
| 7548 | .filter(|row| { |
| 7549 | !matches!( |
| 7550 | row.section(), |
| 7551 | super::ConfigSection::Experimental |
| 7552 | | super::ConfigSection::Fleet |
| 7553 | | super::ConfigSection::Workflow |
| 7554 | | super::ConfigSection::Session |
| 7555 | | super::ConfigSection::Legacy |
| 7556 | ) && !DIAGNOSTIC_ONLY.contains(&row.key.as_str()) |
| 7557 | && !row.key.starts_with("managed_") |
| 7558 | }) |
| 7559 | .all(|row| row.editable) |
| 7560 | ); |
| 7561 | assert!( |
| 7562 | view.rows |
| 7563 | .iter() |
| 7564 | .filter(|row| { |
| 7565 | matches!( |
| 7566 | row.section(), |
| 7567 | super::ConfigSection::Experimental |
| 7568 | | super::ConfigSection::Fleet |
| 7569 | | super::ConfigSection::Workflow |
| 7570 | | super::ConfigSection::Session |
| 7571 | | super::ConfigSection::Legacy |
| 7572 | ) |
| 7573 | }) |
| 7574 | .all(|row| !row.editable || row.key.starts_with("notifications.")) |
| 7575 | ); |
| 7576 | // Route endpoint rows are provider-specific: DeepSeek routes expose |
| 7577 | // `base_url`, every other provider exposes `provider_url`. Whichever |
| 7578 | // exists must be a read-only route receipt. |
| 7579 | const ROUTE_RECEIPT_KEYS: &[&str] = &["base_url", "provider_url"]; |
| 7580 | for key in DIAGNOSTIC_ONLY |
| 7581 | .iter() |
| 7582 | .filter(|key| !ROUTE_RECEIPT_KEYS.contains(key)) |
| 7583 | { |
| 7584 | assert!( |
| 7585 | view.rows.iter().any(|row| row.key == *key && !row.editable), |
| 7586 | "{key} must remain diagnostic-only" |
| 7587 | ); |
| 7588 | } |
| 7589 | let receipt_rows: Vec<_> = view |
| 7590 | .rows |
| 7591 | .iter() |
| 7592 | .filter(|row| ROUTE_RECEIPT_KEYS.contains(&row.key.as_str())) |
| 7593 | .collect(); |
| 7594 | assert_eq!( |
| 7595 | receipt_rows.len(), |
| 7596 | 1, |
| 7597 | "exactly one endpoint receipt row must exist for the active route" |
| 7598 | ); |
| 7599 | assert!( |
| 7600 | !receipt_rows[0].editable, |
| 7601 | "endpoint receipt rows must be read-only" |
| 7602 | ); |
| 7603 | } |
| 7604 | |
| 7605 | #[test] |
| 7606 | fn config_view_surfaces_structural_external_consent_without_io() { |
| 7607 | let _env = crate::test_support::lock_test_env(); |
| 7608 | let temp = tempfile::tempdir().expect("config view fixture"); |
| 7609 | let config_path = temp.path().join("config.toml"); |
| 7610 | let auth_path = temp.path().join("codex-auth.json"); |
| 7611 | fs::write(&auth_path, "external-secret-must-not-be-read").expect("auth trap"); |
| 7612 | fs::write( |
| 7613 | &config_path, |
| 7614 | format!( |
| 7615 | r#"provider = "openai-codex" |
| 7616 | [providers.openai_codex] |
| 7617 | auth_mode = "oauth" |
| 7618 | [providers.openai_codex.external_credentials] |
| 7619 | access = "read_only" |
| 7620 | provider = "openai-codex" |
| 7621 | source = "codex_cli" |
| 7622 | path = {:?} |
| 7623 | consent_version = 1 |
| 7624 | "#, |
| 7625 | auth_path.display().to_string() |
| 7626 | ), |
| 7627 | ) |
| 7628 | .expect("config fixture"); |
| 7629 | let ambient_path = temp.path().join("new-ambient-codex-auth.json"); |
| 7630 | let _path = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &ambient_path); |
| 7631 | let mut app = create_test_app(); |
| 7632 | app.config_path = Some(config_path); |
| 7633 | crate::external_credentials::reset_side_effect_trap(); |
| 7634 | let view = ConfigView::new_for_app(&app); |
| 7635 | let row = view |
| 7636 | .rows |
| 7637 | .iter() |
| 7638 | .find(|row| row.key == "external_credentials.openai-codex") |
| 7639 | .expect("structural consent row"); |
| 7640 | assert!(row.value.contains("access=read_only"), "{}", row.value); |
| 7641 | assert!(row.value.contains("source=codex_cli"), "{}", row.value); |
| 7642 | assert!(row.value.contains("version=1"), "{}", row.value); |
| 7643 | assert!(row.value.contains("active"), "{}", row.value); |
| 7644 | assert!(row.value.contains("remains pinned"), "{}", row.value); |
| 7645 | assert!( |
| 7646 | row.value |
| 7647 | .contains(&codewhale_config::quote_os_path(&auth_path)), |
| 7648 | "{}", |
| 7649 | row.value |
| 7650 | ); |
| 7651 | assert!( |
| 7652 | !row.value.contains(&ambient_path.display().to_string()), |
| 7653 | "{}", |
| 7654 | row.value |
| 7655 | ); |
| 7656 | assert!( |
| 7657 | row.value |
| 7658 | .contains("external-revoke --provider openai-codex") |
| 7659 | ); |
| 7660 | assert_eq!( |
| 7661 | crate::external_credentials::complete_side_effect_trap_counts(), |
| 7662 | (0, 0, 0, 0, 0) |
| 7663 | ); |
| 7664 | } |
| 7665 | |
| 7666 | #[test] |
| 7667 | fn config_view_permission_row_tracks_the_controlling_saved_source() { |
| 7668 | let explicit_dir = TempDir::new().expect("explicit config tempdir"); |
| 7669 | let explicit_path = explicit_dir.path().join("config.toml"); |
| 7670 | fs::write(&explicit_path, "approval_policy = \"auto\"\n").expect("explicit config"); |
| 7671 | let mut app = create_test_app(); |
| 7672 | app.config_path = Some(explicit_path); |
| 7673 | |
| 7674 | let mut explicit = ConfigView::new_for_app(&app); |
| 7675 | let row = explicit |
| 7676 | .rows |
| 7677 | .iter() |
| 7678 | .find(|row| row.key == "approval_policy") |
| 7679 | .expect("explicit approval policy row"); |
| 7680 | assert_eq!(row.value, "auto"); |
| 7681 | assert!(row.editable); |
| 7682 | assert_eq!(row.scope, ConfigScope::Saved); |
| 7683 | assert!( |
| 7684 | explicit |
| 7685 | .rows |
| 7686 | .iter() |
| 7687 | .all(|row| row.key != "permission_posture") |
| 7688 | ); |
| 7689 | explicit.focus_key("approval_policy"); |
| 7690 | explicit.start_edit(); |
| 7691 | let choices = explicit |
| 7692 | .editing |
| 7693 | .as_ref() |
| 7694 | .and_then(|edit| edit.choices.as_ref()) |
| 7695 | .expect("approval posture choices"); |
| 7696 | assert_eq!( |
| 7697 | choices, |
| 7698 | &vec![ |
| 7699 | "use-tui-default".to_string(), |
| 7700 | "ask".to_string(), |
| 7701 | "auto-review".to_string(), |
| 7702 | "full-access".to_string(), |
| 7703 | ] |
| 7704 | ); |
| 7705 | let area = Rect::new(0, 0, 110, 30); |
| 7706 | let mut buf = Buffer::empty(area); |
| 7707 | explicit.render(area, &mut buf); |
| 7708 | let dump = buffer_text(&buf, area); |
| 7709 | assert!( |
| 7710 | dump.contains("4. Full Access"), |
| 7711 | "root permission chooser must expose the product posture:\n{dump}" |
| 7712 | ); |
| 7713 | assert!( |
| 7714 | !dump.contains("4. Never"), |
| 7715 | "root permission chooser leaked the raw fail-closed policy token:\n{dump}" |
| 7716 | ); |
| 7717 | let use_tui_default = explicit |
| 7718 | .editing |
| 7719 | .as_ref() |
| 7720 | .and_then(|edit| edit.choices.as_ref()) |
| 7721 | .and_then(|choices| { |
| 7722 | choices |
| 7723 | .iter() |
| 7724 | .position(|choice| choice == "use-tui-default") |
| 7725 | }) |
| 7726 | .expect("TUI default choice"); |
| 7727 | explicit |
| 7728 | .editing |
| 7729 | .as_mut() |
| 7730 | .expect("choice editor") |
| 7731 | .selected_choice = use_tui_default; |
| 7732 | match explicit.handle_choice_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7733 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 7734 | key, |
| 7735 | value, |
| 7736 | persist, |
| 7737 | }) => { |
| 7738 | assert_eq!(key, "approval_policy"); |
| 7739 | assert_eq!(value, "use-tui-default"); |
| 7740 | assert!(persist); |
| 7741 | } |
| 7742 | other => panic!("expected saved ConfigUpdated event, got {other:?}"), |
| 7743 | } |
| 7744 | |
| 7745 | let managed_dir = TempDir::new().expect("managed config tempdir"); |
| 7746 | let requirements_path = managed_dir.path().join("requirements.toml"); |
| 7747 | fs::write( |
| 7748 | &requirements_path, |
| 7749 | "allowed_approval_policies = [\"never\"]\n", |
| 7750 | ) |
| 7751 | .expect("requirements config"); |
| 7752 | let config_path = managed_dir.path().join("config.toml"); |
| 7753 | let requirements_value = |
| 7754 | toml::Value::String(requirements_path.to_string_lossy().into_owned()).to_string(); |
| 7755 | fs::write( |
| 7756 | &config_path, |
| 7757 | format!("approval_policy = \"never\"\nrequirements_path = {requirements_value}\n"), |
| 7758 | ) |
| 7759 | .expect("managed config"); |
| 7760 | app.config_path = Some(config_path); |
| 7761 | |
| 7762 | let managed = ConfigView::new_for_app(&app); |
| 7763 | let row = managed |
| 7764 | .rows |
| 7765 | .iter() |
| 7766 | .find(|row| row.key == "managed_approval_policy") |
| 7767 | .expect("managed approval policy row"); |
| 7768 | assert!(!row.editable); |
| 7769 | assert_eq!(row.scope, ConfigScope::Saved); |
| 7770 | assert!( |
| 7771 | managed |
| 7772 | .rows |
| 7773 | .iter() |
| 7774 | .all(|row| row.key != "permission_posture" && row.key != "approval_policy") |
| 7775 | ); |
| 7776 | } |
| 7777 | |
| 7778 | #[test] |
| 7779 | fn config_view_provider_uses_full_picker_and_preserves_custom_provider_id() { |
| 7780 | let dir = TempDir::new().expect("custom provider tempdir"); |
| 7781 | let config_path = dir.path().join("config.toml"); |
| 7782 | fs::write( |
| 7783 | &config_path, |
| 7784 | r#" |
| 7785 | provider = "acme_ai" |
| 7786 | |
| 7787 | [providers.acme_ai] |
| 7788 | kind = "openai-compatible" |
| 7789 | base_url = "https://api.example.invalid/v1" |
| 7790 | model = "acme-model" |
| 7791 | api_key_env = "ACME_API_KEY" |
| 7792 | "#, |
| 7793 | ) |
| 7794 | .expect("custom provider config"); |
| 7795 | let mut app = create_test_app(); |
| 7796 | app.config_path = Some(config_path); |
| 7797 | app.set_provider_identity(crate::config::ApiProvider::Custom, "acme_ai"); |
| 7798 | let mut view = ConfigView::new_for_app(&app); |
| 7799 | view.focus_key("provider"); |
| 7800 | |
| 7801 | let row = &view.rows[view.selected]; |
| 7802 | assert_eq!(row.value, "acme_ai"); |
| 7803 | assert_eq!( |
| 7804 | row.scope, |
| 7805 | ConfigScope::Session, |
| 7806 | "the provider row shows the live route identity, not saved config" |
| 7807 | ); |
| 7808 | assert!( |
| 7809 | config_choice_values("provider").is_none(), |
| 7810 | "provider must not be truncated to the generic enum chooser" |
| 7811 | ); |
| 7812 | |
| 7813 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7814 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 7815 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 7816 | }) => assert_eq!(command, "/provider"), |
| 7817 | other => panic!("expected full provider picker command, got {other:?}"), |
| 7818 | } |
| 7819 | assert!(view.editing.is_none()); |
| 7820 | } |
| 7821 | |
| 7822 | #[test] |
| 7823 | fn config_view_active_model_uses_picker_and_retired_rows_are_gone() { |
| 7824 | let app = create_test_app(); |
| 7825 | let mut view = ConfigView::new_for_app(&app); |
| 7826 | view.focus_key("model"); |
| 7827 | |
| 7828 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7829 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 7830 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 7831 | }) => assert_eq!(command, "/model"), |
| 7832 | other => panic!("expected full model picker, got {other:?}"), |
| 7833 | } |
| 7834 | assert!(view.editing.is_none()); |
| 7835 | |
| 7836 | // The derived fast-sibling receipt and the legacy DeepSeek fallback |
| 7837 | // have no rows: sibling choice happens in the /model picker and the |
| 7838 | // fallback stays a `/set`-only compatibility key. |
| 7839 | for key in ["fast_model", "default_model"] { |
| 7840 | assert!( |
| 7841 | view.rows.iter().all(|row| row.key != key), |
| 7842 | "{key} must have no settings row" |
| 7843 | ); |
| 7844 | } |
| 7845 | } |
| 7846 | |
| 7847 | #[test] |
| 7848 | fn config_view_zai_model_row_has_no_derived_rows() { |
| 7849 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 7850 | let mut app = create_test_app(); |
| 7851 | app.api_provider = crate::config::ApiProvider::Zai; |
| 7852 | app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); |
| 7853 | |
| 7854 | let view = ConfigView::new_for_app(&app); |
| 7855 | let active = view |
| 7856 | .rows |
| 7857 | .iter() |
| 7858 | .find(|row| row.key == "model") |
| 7859 | .expect("active model row"); |
| 7860 | |
| 7861 | assert_eq!(active.value, "Zhipu AI / Z.ai · GLM-5.2"); |
| 7862 | // Derived receipts retired: the fast sibling is named in the /model |
| 7863 | // picker, and the DeepSeek-only fallback never appears as a row. |
| 7864 | for key in ["fast_model", "default_model"] { |
| 7865 | assert!( |
| 7866 | view.rows.iter().all(|row| row.key != key), |
| 7867 | "{key} row must be gone for zai" |
| 7868 | ); |
| 7869 | } |
| 7870 | } |
| 7871 | |
| 7872 | #[test] |
| 7873 | fn config_view_live_route_never_shows_stale_saved_provider() { |
| 7874 | // The reported defect: the saved config still said `provider = |
| 7875 | // "deepseek"` while the session was actually routed to Z.ai / GLM-5.3, |
| 7876 | // and Settings presented the stale saved value as the "Active |
| 7877 | // provider". Route rows must show the live route identity; saved |
| 7878 | // config is a startup/default fact, never the active receipt. |
| 7879 | let temp_root = std::env::temp_dir().join(format!( |
| 7880 | "codewhale-stale-saved-provider-view-test-{}", |
| 7881 | std::process::id() |
| 7882 | )); |
| 7883 | fs::create_dir_all(&temp_root).unwrap(); |
| 7884 | let config_path = temp_root.join("config.toml"); |
| 7885 | fs::write( |
| 7886 | &config_path, |
| 7887 | "provider = \"deepseek\"\nbase_url = \"https://api.deepseek.com/v1\"\n", |
| 7888 | ) |
| 7889 | .unwrap(); |
| 7890 | |
| 7891 | let mut app = create_test_app(); |
| 7892 | app.config_path = Some(config_path.clone()); |
| 7893 | // Live session route, exactly as a /provider switch would leave it. |
| 7894 | app.api_provider = crate::config::ApiProvider::Zai; |
| 7895 | app.model = "GLM-5.3".to_string(); |
| 7896 | app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string(); |
| 7897 | |
| 7898 | let view = ConfigView::new_for_app(&app); |
| 7899 | |
| 7900 | let provider_row = view |
| 7901 | .rows |
| 7902 | .iter() |
| 7903 | .find(|row| row.key == "provider") |
| 7904 | .expect("provider row"); |
| 7905 | assert!( |
| 7906 | provider_row.value.contains("Z.ai"), |
| 7907 | "provider row must show the live route identity: {}", |
| 7908 | provider_row.value |
| 7909 | ); |
| 7910 | assert!( |
| 7911 | !provider_row.value.to_lowercase().contains("deepseek"), |
| 7912 | "stale saved provider must not appear as the active route: {}", |
| 7913 | provider_row.value |
| 7914 | ); |
| 7915 | assert_eq!(provider_row.scope, ConfigScope::Session); |
| 7916 | |
| 7917 | let model_row = view |
| 7918 | .rows |
| 7919 | .iter() |
| 7920 | .find(|row| row.key == "model") |
| 7921 | .expect("model row"); |
| 7922 | assert_eq!(model_row.value, "Zhipu AI / Z.ai · GLM-5.3"); |
| 7923 | |
| 7924 | let url_row = view |
| 7925 | .rows |
| 7926 | .iter() |
| 7927 | .find(|row| row.key == "provider_url") |
| 7928 | .expect("endpoint row for the live Z.ai route"); |
| 7929 | assert_eq!(url_row.value, crate::config::DEFAULT_ZAI_BASE_URL); |
| 7930 | assert!(!view.rows.iter().any(|row| row.key == "base_url")); |
| 7931 | } |
| 7932 | |
| 7933 | #[test] |
| 7934 | fn config_view_shows_no_deepseek_fallback_row_on_any_provider() { |
| 7935 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 7936 | let mut app = create_test_app(); |
| 7937 | for provider in [ |
| 7938 | crate::config::ApiProvider::Zai, |
| 7939 | crate::config::ApiProvider::Xai, |
| 7940 | crate::config::ApiProvider::Openrouter, |
| 7941 | crate::config::ApiProvider::Ollama, |
| 7942 | crate::config::ApiProvider::Deepseek, |
| 7943 | ] { |
| 7944 | app.api_provider = provider; |
| 7945 | let view = ConfigView::new_for_app(&app); |
| 7946 | assert!( |
| 7947 | view.rows.iter().all(|row| row.key != "default_model"), |
| 7948 | "default_model must have no row for {:?}", |
| 7949 | provider |
| 7950 | ); |
| 7951 | } |
| 7952 | } |
| 7953 | |
| 7954 | #[test] |
| 7955 | fn config_view_saved_deepseek_fallback_is_a_read_only_migration_input() { |
| 7956 | // Old fallback values still parse, but new model choices belong to |
| 7957 | // the canonical config selection writer. |
| 7958 | let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n"); |
| 7959 | let mut app = create_test_app(); |
| 7960 | app.api_provider = crate::config::ApiProvider::Zai; |
| 7961 | |
| 7962 | let view = ConfigView::new_for_app(&app); |
| 7963 | assert!( |
| 7964 | view.rows.iter().all(|row| row.key != "default_model"), |
| 7965 | "saved legacy fallback must not surface a row" |
| 7966 | ); |
| 7967 | let mut settings = Settings::default(); |
| 7968 | let error = settings |
| 7969 | .set("default_model", "deepseek-v4-pro") |
| 7970 | .expect_err("legacy model settings must not become another writer"); |
| 7971 | assert!(error.to_string().contains("config.toml")); |
| 7972 | assert!(settings.default_model.is_none()); |
| 7973 | } |
| 7974 | |
| 7975 | /// Retired rows leave no section behind: sub-agent depth moved into the |
| 7976 | /// Model group, the legacy fallback and the vision flag lost their rows, |
| 7977 | /// and `/goal` + Workflow keep their own sections. Persisted keys are |
| 7978 | /// unchanged. |
| 7979 | #[test] |
| 7980 | fn config_view_settings_rows_land_in_truthful_sections() { |
| 7981 | let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n"); |
| 7982 | let mut app = create_test_app(); |
| 7983 | app.api_provider = crate::config::ApiProvider::Zai; |
| 7984 | let view = ConfigView::new_for_app(&app); |
| 7985 | |
| 7986 | let section_of = |key: &str| { |
| 7987 | view.rows |
| 7988 | .iter() |
| 7989 | .find(|row| row.key == key) |
| 7990 | .unwrap_or_else(|| panic!("{key} row")) |
| 7991 | .section() |
| 7992 | }; |
| 7993 | assert_eq!( |
| 7994 | section_of("fleet.exec.max_spawn_depth"), |
| 7995 | super::ConfigSection::Model |
| 7996 | ); |
| 7997 | assert_eq!(section_of("goal_command"), super::ConfigSection::Session); |
| 7998 | assert_eq!(section_of("workflow"), super::ConfigSection::Workflow); |
| 7999 | |
| 8000 | // The retired rows are gone on every provider, even with a saved |
| 8001 | // fallback value still on disk. |
| 8002 | for key in ["default_model", "fast_model", "features.vision_model"] { |
| 8003 | assert!( |
| 8004 | view.rows.iter().all(|row| row.key != key), |
| 8005 | "{key} must have no row" |
| 8006 | ); |
| 8007 | } |
| 8008 | |
| 8009 | // …and their sections retire with them: no Legacy, Experimental, or |
| 8010 | // Fleet headings may survive with zero rows behind them. |
| 8011 | let retired_sections = [ |
| 8012 | super::ConfigSection::Legacy, |
| 8013 | super::ConfigSection::Experimental, |
| 8014 | super::ConfigSection::Fleet, |
| 8015 | ]; |
| 8016 | for row in &view.rows { |
| 8017 | assert!( |
| 8018 | !retired_sections.contains(&row.section()), |
| 8019 | "{} still files under a retired section", |
| 8020 | row.key |
| 8021 | ); |
| 8022 | } |
| 8023 | |
| 8024 | // Relabelling is presentation only: the persisted key and value |
| 8025 | // round-trip unchanged, so existing config files keep loading |
| 8026 | // identically. |
| 8027 | let depth = view |
| 8028 | .rows |
| 8029 | .iter() |
| 8030 | .find(|row| row.key == "fleet.exec.max_spawn_depth") |
| 8031 | .expect("sub-agent depth row"); |
| 8032 | assert_eq!(depth.scope, ConfigScope::Saved); |
| 8033 | assert!(!depth.editable); |
| 8034 | assert_eq!(config_label_for_key(&depth.key), "sub-agent depth"); |
| 8035 | |
| 8036 | // Workflow keeps its own name and its `/workflow` wording. |
| 8037 | let workflow = view |
| 8038 | .rows |
| 8039 | .iter() |
| 8040 | .find(|row| row.section() == super::ConfigSection::Workflow) |
| 8041 | .expect("workflow row"); |
| 8042 | assert_eq!(workflow.key, "workflow"); |
| 8043 | assert!(workflow.value.starts_with("/workflow "), "{workflow:?}"); |
| 8044 | assert_eq!(config_label_for_key("workflow"), "Workflow"); |
| 8045 | } |
| 8046 | |
| 8047 | #[test] |
| 8048 | fn config_view_experimental_features_leave_no_rows() { |
| 8049 | // The vision row retired: even a configured beta flag surfaces no |
| 8050 | // table row. The flag itself stays live in the feature backend, |
| 8051 | // diagnosed where vision runs instead of in Advanced. |
| 8052 | let temp_root = std::env::temp_dir().join(format!( |
| 8053 | "codewhale-experimental-config-view-test-{}", |
| 8054 | std::process::id() |
| 8055 | )); |
| 8056 | fs::create_dir_all(&temp_root).unwrap(); |
| 8057 | let config_path = temp_root.join("config.toml"); |
| 8058 | fs::write( |
| 8059 | &config_path, |
| 8060 | r#" |
| 8061 | [features] |
| 8062 | web_search = false |
| 8063 | vision_model = true |
| 8064 | "#, |
| 8065 | ) |
| 8066 | .unwrap(); |
| 8067 | |
| 8068 | let mut app = create_test_app(); |
| 8069 | app.config_path = Some(config_path); |
| 8070 | let view = ConfigView::new_for_app(&app); |
| 8071 | |
| 8072 | for key in [ |
| 8073 | "features.web_search", |
| 8074 | "features.vision_model", |
| 8075 | "features.subagents", |
| 8076 | ] { |
| 8077 | assert!( |
| 8078 | view.rows.iter().all(|row| row.key != key), |
| 8079 | "{key} must have no settings row" |
| 8080 | ); |
| 8081 | } |
| 8082 | } |
| 8083 | |
| 8084 | #[test] |
| 8085 | fn config_view_shows_fleet_max_spawn_depth_from_config() { |
| 8086 | let temp_root = std::env::temp_dir().join(format!( |
| 8087 | "codewhale-fleet-config-view-test-{}", |
| 8088 | std::process::id() |
| 8089 | )); |
| 8090 | fs::create_dir_all(&temp_root).unwrap(); |
| 8091 | let config_path = temp_root.join("config.toml"); |
| 8092 | fs::write( |
| 8093 | &config_path, |
| 8094 | r#" |
| 8095 | [fleet.exec] |
| 8096 | max_spawn_depth = 2 |
| 8097 | "#, |
| 8098 | ) |
| 8099 | .unwrap(); |
| 8100 | |
| 8101 | let mut app = create_test_app(); |
| 8102 | app.config_path = Some(config_path); |
| 8103 | let view = ConfigView::new_for_app(&app); |
| 8104 | |
| 8105 | let row = view |
| 8106 | .rows |
| 8107 | .iter() |
| 8108 | .find(|row| row.key == "fleet.exec.max_spawn_depth") |
| 8109 | .expect("fleet spawn depth row"); |
| 8110 | assert_eq!(row.value, "2"); |
| 8111 | assert!(!row.editable); |
| 8112 | } |
| 8113 | |
| 8114 | #[test] |
| 8115 | fn config_view_retired_experimental_section_stays_gone() { |
| 8116 | let mut view = create_config_view(Locale::En); |
| 8117 | |
| 8118 | // The Experimental group retired with the vision row: the flag stays |
| 8119 | // live in the backend, but no section or row answers to it anymore. |
| 8120 | view.update_filter(|filter| filter.push_str("experimental")); |
| 8121 | assert!(visible_section_labels(&view).is_empty()); |
| 8122 | assert!(visible_row_keys(&view).is_empty()); |
| 8123 | |
| 8124 | view.clear_filter(); |
| 8125 | type_filter(&mut view, "feature vision"); |
| 8126 | assert!(visible_section_labels(&view).is_empty()); |
| 8127 | assert!(visible_row_keys(&view).is_empty()); |
| 8128 | |
| 8129 | view.clear_filter(); |
| 8130 | type_filter(&mut view, "goal"); |
| 8131 | assert_eq!(visible_section_labels(&view), vec!["Session"]); |
| 8132 | assert_eq!(visible_row_keys(&view), vec!["goal_command"]); |
| 8133 | |
| 8134 | // The `workflow` row keeps its key and its name; #4751 only moved it |
| 8135 | // out of Fleet into its own Workflow section. |
| 8136 | view.clear_filter(); |
| 8137 | type_filter(&mut view, "workflow"); |
| 8138 | assert_eq!(visible_section_labels(&view), vec!["Workflow"]); |
| 8139 | let workflow_keys = visible_row_keys(&view); |
| 8140 | assert_eq!(workflow_keys.first(), Some(&"workflow")); |
| 8141 | assert_eq!( |
| 8142 | workflow_keys.len(), |
| 8143 | 1 + codewhale_config::notifications::NotificationSetting::ALL.len() |
| 8144 | ); |
| 8145 | assert!(workflow_keys[1..].iter().all(|key| { |
| 8146 | codewhale_config::notifications::NotificationSetting::parse(key).is_some() |
| 8147 | })); |
| 8148 | |
| 8149 | view.clear_filter(); |
| 8150 | type_filter(&mut view, "whaleflow"); |
| 8151 | assert!(visible_row_keys(&view).is_empty()); |
| 8152 | } |
| 8153 | |
| 8154 | #[test] |
| 8155 | fn config_view_base_url_reflects_active_route_receipt() { |
| 8156 | let mut app = create_test_app(); |
| 8157 | app.active_route_base_url = "https://ui-config-view.local/v1".to_string(); |
| 8158 | let view = ConfigView::new_for_app(&app); |
| 8159 | |
| 8160 | let row = view |
| 8161 | .rows |
| 8162 | .iter() |
| 8163 | .find(|row| row.key == "base_url") |
| 8164 | .expect("base_url row missing"); |
| 8165 | assert_eq!( |
| 8166 | config_label_for_key(&row.key), |
| 8167 | "Provider API URL (DeepSeek route)" |
| 8168 | ); |
| 8169 | // The endpoint row is a read-only receipt for the live route; it must |
| 8170 | // not re-read config files, which may describe a different saved |
| 8171 | // route than the one the session is actually using. |
| 8172 | assert_eq!(row.value, "https://ui-config-view.local/v1"); |
| 8173 | assert!(!row.editable); |
| 8174 | assert_eq!(row.scope, ConfigScope::Session); |
| 8175 | } |
| 8176 | |
| 8177 | #[test] |
| 8178 | fn config_view_uses_provider_url_for_non_deepseek_provider() { |
| 8179 | let temp_root = std::env::temp_dir().join(format!( |
| 8180 | "codewhale-provider-url-view-test-{}", |
| 8181 | std::process::id() |
| 8182 | )); |
| 8183 | fs::create_dir_all(&temp_root).unwrap(); |
| 8184 | let config_path = temp_root.join("config.toml"); |
| 8185 | fs::write( |
| 8186 | &config_path, |
| 8187 | r#" |
| 8188 | provider = "xiaomi-mimo" |
| 8189 | |
| 8190 | [providers.xiaomi_mimo] |
| 8191 | api_key = "tp-test-token-plan-key" |
| 8192 | base_url = "https://api.xiaomimimo.com/v1" |
| 8193 | "#, |
| 8194 | ) |
| 8195 | .unwrap(); |
| 8196 | |
| 8197 | let mut app = create_test_app(); |
| 8198 | app.api_provider = crate::config::ApiProvider::XiaomiMimo; |
| 8199 | app.active_route_base_url = crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(); |
| 8200 | app.ui_locale = Locale::Es419; |
| 8201 | app.config_path = Some(config_path.clone()); |
| 8202 | let mut view = ConfigView::new_for_app(&app); |
| 8203 | |
| 8204 | let row = view |
| 8205 | .rows |
| 8206 | .iter() |
| 8207 | .find(|row| row.key == "provider_url") |
| 8208 | .expect("provider_url row missing"); |
| 8209 | // The endpoint row reflects the live route identity (the default when |
| 8210 | // nothing overrides it), not a config-file re-read, and is a receipt. |
| 8211 | assert_eq!(row.value, crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL); |
| 8212 | assert!(!row.editable); |
| 8213 | assert!(!view.rows.iter().any(|row| row.key == "base_url")); |
| 8214 | |
| 8215 | view.focus_key("provider_url"); |
| 8216 | let hint = view |
| 8217 | .setting_detail_lines(&view.rows[view.selected], 200) |
| 8218 | .iter() |
| 8219 | .map(ToString::to_string) |
| 8220 | .collect::<Vec<_>>() |
| 8221 | .join("\n"); |
| 8222 | let es_hint = tr(Locale::Es419, MessageId::ConfigHintProviderUrl); |
| 8223 | assert!(hint.contains(es_hint.as_ref()), "{hint}"); |
| 8224 | assert!(hint.contains("pago por uso"), "{hint}"); |
| 8225 | assert!( |
| 8226 | !hint.contains(tr(Locale::En, MessageId::ConfigHintProviderUrl).as_ref()), |
| 8227 | "the Spanish settings view must not leak the English guidance: {hint}" |
| 8228 | ); |
| 8229 | } |
| 8230 | |
| 8231 | #[test] |
| 8232 | fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() { |
| 8233 | let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n"); |
| 8234 | let app = create_test_app(); |
| 8235 | assert_eq!(app.ui_locale, Locale::ZhHans); |
| 8236 | assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny); |
| 8237 | |
| 8238 | let view = ConfigView::new_for_app(&app); |
| 8239 | let row = view |
| 8240 | .rows |
| 8241 | .iter() |
| 8242 | .find(|row| row.key == "cost_currency") |
| 8243 | .expect("cost_currency row"); |
| 8244 | |
| 8245 | assert_eq!(row.value, "usd"); |
| 8246 | assert_eq!(view.row_display_value(row), "usd (实际 cny)"); |
| 8247 | assert_eq!(Settings::load().expect("settings").cost_currency, "usd"); |
| 8248 | } |
| 8249 | |
| 8250 | #[test] |
| 8251 | fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() { |
| 8252 | for alias in ["rmb", "yuan", "¥"] { |
| 8253 | let (saved_value, display_value, effective_currency, locale) = |
| 8254 | cost_currency_row_for_settings(&format!( |
| 8255 | "locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n" |
| 8256 | )); |
| 8257 | |
| 8258 | assert_eq!(locale, Locale::ZhHans); |
| 8259 | assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); |
| 8260 | assert_eq!(saved_value, alias); |
| 8261 | assert_eq!(display_value, alias); |
| 8262 | } |
| 8263 | } |
| 8264 | |
| 8265 | #[test] |
| 8266 | fn config_view_cost_currency_matching_cny_setting_is_silent() { |
| 8267 | let (saved_value, display_value, effective_currency, locale) = |
| 8268 | cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n"); |
| 8269 | |
| 8270 | assert_eq!(locale, Locale::ZhHans); |
| 8271 | assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); |
| 8272 | assert_eq!(saved_value, "cny"); |
| 8273 | assert_eq!(display_value, "cny"); |
| 8274 | } |
| 8275 | |
| 8276 | #[test] |
| 8277 | fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() { |
| 8278 | let (saved_value, display_value, effective_currency, locale) = |
| 8279 | cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n"); |
| 8280 | |
| 8281 | assert_eq!(locale, Locale::En); |
| 8282 | assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); |
| 8283 | assert_eq!(saved_value, "cny"); |
| 8284 | assert_eq!(display_value, "cny"); |
| 8285 | } |
| 8286 | |
| 8287 | /// The panel's contract, cell-exact: tabs across the top, the groups |
| 8288 | /// column beside the list at 120 columns and folded into the headings at |
| 8289 | /// 80, the selected setting's sentence in the band, and the live footer |
| 8290 | /// preview under it. A visual change that cannot show as a golden diff |
| 8291 | /// did not happen. |
| 8292 | #[test] |
| 8293 | fn config_panel_golden_at_eighty_and_one_twenty() { |
| 8294 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 8295 | let app = create_test_app(); |
| 8296 | let view = ConfigView::new_for_app(&app); |
| 8297 | for (width, height) in [(40u16, 12u16), (80u16, 24u16), (120u16, 32u16)] { |
| 8298 | let rendered = crate::tui::golden_harness::render_golden_text(width, height, |buf| { |
| 8299 | view.render(Rect::new(0, 0, width, height), buf); |
| 8300 | }); |
| 8301 | crate::tui::golden_harness::assert_matches_golden( |
| 8302 | &format!("config_panel_{width}x{height}"), |
| 8303 | &rendered, |
| 8304 | ); |
| 8305 | } |
| 8306 | } |
| 8307 | |
| 8308 | /// Slice C: cell-exact goldens for Edit Theme with the underwater |
| 8309 | /// default open — title, scope/current lanes, the 14 theme rows, and |
| 8310 | /// the Apply/Cancel controls. Empty settings mean the editor opens on |
| 8311 | /// the default theme, so these goldens pin the default end to end. |
| 8312 | /// Re-bless with `CODEWHALE_BLESS_GOLDENS=1`. |
| 8313 | #[test] |
| 8314 | fn edit_theme_matches_goldens_at_blocker_sizes() { |
| 8315 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 8316 | let app = create_test_app(); |
| 8317 | let mut view = ConfigView::new_for_app(&app); |
| 8318 | view.focus_key("theme"); |
| 8319 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 8320 | assert!( |
| 8321 | view.editing |
| 8322 | .as_ref() |
| 8323 | .is_some_and(|edit| edit.key == "theme"), |
| 8324 | "Enter must open the theme editor" |
| 8325 | ); |
| 8326 | for (width, height) in [(80u16, 24u16), (120u16, 32u16)] { |
| 8327 | let rendered = crate::tui::golden_harness::render_golden_text(width, height, |buf| { |
| 8328 | view.render(Rect::new(0, 0, width, height), buf); |
| 8329 | }); |
| 8330 | crate::tui::golden_harness::assert_matches_golden( |
| 8331 | &format!("edit_theme_{width}x{height}"), |
| 8332 | &trim_golden_rows(&rendered), |
| 8333 | ); |
| 8334 | } |
| 8335 | } |
| 8336 | |
| 8337 | /// Goldens are stored without cell padding: every row is right-trimmed |
| 8338 | /// and trailing empty rows are dropped, so `git diff --check` stays |
| 8339 | /// clean. |
| 8340 | fn trim_golden_rows(text: &str) -> String { |
| 8341 | let mut rows: Vec<&str> = text.lines().map(str::trim_end).collect(); |
| 8342 | while rows.last().is_some_and(|row| row.is_empty()) { |
| 8343 | rows.pop(); |
| 8344 | } |
| 8345 | let mut out = rows.join("\n"); |
| 8346 | out.push('\n'); |
| 8347 | out |
| 8348 | } |
| 8349 | |
| 8350 | #[test] |
| 8351 | fn notification_rows_keep_saved_and_live_values_distinct_after_reopening() { |
| 8352 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 8353 | let temp = tempfile::tempdir().unwrap(); |
| 8354 | let path = temp.path().join("config.toml"); |
| 8355 | std::fs::write(&path, "[notifications]\nquiet = false\nsound = \"off\"\n").unwrap(); |
| 8356 | let mut app = create_test_app(); |
| 8357 | app.config_path = Some(path.clone()); |
| 8358 | let mut config = Config::load(Some(path), None).unwrap(); |
| 8359 | crate::tui::ui::apply_notification_update( |
| 8360 | &mut app, |
| 8361 | &mut config, |
| 8362 | crate::config::NotificationConfigUpdate::Quiet(true), |
| 8363 | ) |
| 8364 | .unwrap(); |
| 8365 | crate::tui::ui::apply_notification_update( |
| 8366 | &mut app, |
| 8367 | &mut config, |
| 8368 | crate::config::NotificationConfigUpdate::Sound(Some( |
| 8369 | crate::config::CompletionSound::Whale, |
| 8370 | )), |
| 8371 | ) |
| 8372 | .unwrap(); |
| 8373 | for _ in 0..2 { |
| 8374 | let mut view = ConfigView::new_for_app(&app); |
| 8375 | let row = view |
| 8376 | .rows |
| 8377 | .iter() |
| 8378 | .find(|row| row.key == "notifications.quiet") |
| 8379 | .unwrap(); |
| 8380 | assert_eq!(row.value, "false"); |
| 8381 | assert_eq!(row.edit_value(), "true"); |
| 8382 | let fact = view.setting_fact(row).unwrap(); |
| 8383 | assert_ne!(fact.saved, fact.current); |
| 8384 | view.focus_key("notifications.quiet"); |
| 8385 | assert!( |
| 8386 | matches!(view.toggle_selected_boolean(), Some(ViewAction::Emit(ViewEvent::ConfigUpdated { value, .. })) if value == "false") |
| 8387 | ); |
| 8388 | view.focus_key("notifications.sound"); |
| 8389 | view.start_edit(); |
| 8390 | let edit = view.editing.as_ref().unwrap(); |
| 8391 | assert_eq!( |
| 8392 | edit.choices.as_ref().unwrap()[edit.selected_choice], |
| 8393 | "whale" |
| 8394 | ); |
| 8395 | } |
| 8396 | app.refresh_notification_settings(&Config::default()); |
| 8397 | } |
| 8398 | |
| 8399 | /// The settings screen is a projection of the schema: its rail tabs, the |
| 8400 | /// group headings inside them, and the row order are the schema's |
| 8401 | /// declaration order, not a second table's. This is the one table test |
| 8402 | /// that keeps the projection honest. |
| 8403 | #[test] |
| 8404 | fn settings_tree_equals_the_schema_projection() { |
| 8405 | let mut view = create_config_view(Locale::En); |
| 8406 | let built: std::collections::HashSet<&str> = |
| 8407 | view.rows.iter().map(|row| row.key.as_str()).collect(); |
| 8408 | let schema_keys: std::collections::HashSet<&str> = |
| 8409 | codewhale_config::schema_rows().map(|def| def.key).collect(); |
| 8410 | |
| 8411 | // Every schema row must appear in the view; `ui: None` settings stay |
| 8412 | // declared but off-screen, and anything the schema says is visible must |
| 8413 | // have a row here. A few rows are mutually exclusive: exactly one member |
| 8414 | // of each set is shown, chosen by which store controls the fact. |
| 8415 | let conditional_pairs: &[&[&str]] = &[ |
| 8416 | &[ |
| 8417 | "permission_posture", |
| 8418 | "approval_policy", |
| 8419 | "managed_approval_policy", |
| 8420 | ], |
| 8421 | &["allow_shell", "managed_allow_shell"], |
| 8422 | &["base_url", "provider_url"], |
| 8423 | ]; |
| 8424 | for def in codewhale_config::schema_rows() { |
| 8425 | let present_in_pair = conditional_pairs |
| 8426 | .iter() |
| 8427 | .any(|pair| pair.contains(&def.key) && pair.iter().any(|key| built.contains(key))); |
| 8428 | // Experimental feature rows exist only when the flag is configured |
| 8429 | // or non-default (`experimental_feature_rows`). |
| 8430 | let configured_only = def.key.starts_with("features."); |
| 8431 | assert!( |
| 8432 | def.ui.is_none() || built.contains(def.key) || present_in_pair || configured_only, |
| 8433 | "{} is declared in schema_rows but has no ConfigRow", |
| 8434 | def.key |
| 8435 | ); |
| 8436 | } |
| 8437 | |
| 8438 | // No row should exist that is not declared in the schema. |
| 8439 | for key in &built { |
| 8440 | assert!( |
| 8441 | schema_keys.contains(key), |
| 8442 | "{key} has a ConfigRow but is not declared in schema_rows" |
| 8443 | ); |
| 8444 | } |
| 8445 | |
| 8446 | assert_eq!( |
| 8447 | codewhale_config::schema_tabs(), |
| 8448 | ConfigCategory::ALL |
| 8449 | .iter() |
| 8450 | .map(|category| category.id()) |
| 8451 | .collect::<Vec<_>>(), |
| 8452 | "rail order and schema tab order have drifted apart" |
| 8453 | ); |
| 8454 | |
| 8455 | let mut expected: Vec<String> = Vec::new(); |
| 8456 | for tab in codewhale_config::schema_tabs() { |
| 8457 | let mut group: Option<&str> = None; |
| 8458 | for def in codewhale_config::schema_rows() { |
| 8459 | let ui = def.ui.as_ref().expect("schema_rows filters on ui"); |
| 8460 | if ui.tab != tab || !built.contains(def.key) { |
| 8461 | continue; |
| 8462 | } |
| 8463 | if group != Some(ui.group) { |
| 8464 | group = Some(ui.group); |
| 8465 | expected.push(format!("{tab}/{}", ui.group)); |
| 8466 | } |
| 8467 | expected.push(format!("{tab}/{}/{}", ui.group, def.key)); |
| 8468 | } |
| 8469 | } |
| 8470 | |
| 8471 | let mut actual: Vec<String> = Vec::new(); |
| 8472 | for category in ConfigCategory::ALL { |
| 8473 | view.category = category; |
| 8474 | for item in view.visible_items() { |
| 8475 | match item { |
| 8476 | ConfigListItem::Section(section) => { |
| 8477 | actual.push(format!("{}/{}", category.id(), section.id())); |
| 8478 | } |
| 8479 | ConfigListItem::Row(idx) => { |
| 8480 | let row = &view.rows[idx]; |
| 8481 | actual.push(format!( |
| 8482 | "{}/{}/{}", |
| 8483 | category.id(), |
| 8484 | row.section().id(), |
| 8485 | row.key |
| 8486 | )); |
| 8487 | } |
| 8488 | } |
| 8489 | } |
| 8490 | } |
| 8491 | |
| 8492 | assert_eq!(actual, expected); |
| 8493 | } |
| 8494 | |
| 8495 | /// Every row the screen shows must land in a store. `settings.toml` rows |
| 8496 | /// round-trip through `Settings`; the rest are actions, receipts, or |
| 8497 | /// `config.toml` keys, and that list is spelled out so a new row cannot |
| 8498 | /// quietly become one that discards the user's edit. |
| 8499 | #[test] |
| 8500 | fn every_settings_row_reaches_a_store() { |
| 8501 | let _guard = crate::test_support::lock_test_env(); |
| 8502 | // Not `settings.toml`: opens another surface, reports a fact, or is |
| 8503 | // persisted to config.toml by `set_config_value`. |
| 8504 | const NOT_SETTINGS_TOML: &[&str] = &[ |
| 8505 | "provider", |
| 8506 | "model", |
| 8507 | "fleet.exec.max_spawn_depth", |
| 8508 | "goal_command", |
| 8509 | "workflow", |
| 8510 | "mcp_open", |
| 8511 | "mcp_reconnect", |
| 8512 | "mcp_diagnose", |
| 8513 | "plugins_open", |
| 8514 | "mcp_config_path", |
| 8515 | "approval_mode", |
| 8516 | "permission_posture", |
| 8517 | "approval_policy", |
| 8518 | "managed_approval_policy", |
| 8519 | "allow_shell", |
| 8520 | "managed_allow_shell", |
| 8521 | "telemetry", |
| 8522 | "context_window", |
| 8523 | "effective_context_window", |
| 8524 | "fast_model", |
| 8525 | "features.vision_model", |
| 8526 | "features.subagents", |
| 8527 | "features.web_search", |
| 8528 | "features.apply_patch", |
| 8529 | "features.mcp", |
| 8530 | "features.exec_policy", |
| 8531 | "base_url", |
| 8532 | "provider_url", |
| 8533 | "effective_context_window", |
| 8534 | "external_credentials.openai-codex", |
| 8535 | "external_credentials.xai", |
| 8536 | ]; |
| 8537 | |
| 8538 | for def in codewhale_config::schema_rows() { |
| 8539 | if let Some(setting) = |
| 8540 | codewhale_config::notifications::NotificationSetting::parse(def.key) |
| 8541 | { |
| 8542 | let samples = match setting { |
| 8543 | codewhale_config::notifications::NotificationSetting::SoundFile => { |
| 8544 | vec!["call with spaces.wav".to_string()] |
| 8545 | } |
| 8546 | codewhale_config::notifications::NotificationSetting::EventSoundEvents => { |
| 8547 | vec![r#"["input-needed", "model-notify"]"#.to_string()] |
| 8548 | } |
| 8549 | _ => def |
| 8550 | .values() |
| 8551 | .map(|values| values.into_iter().map(str::to_string).collect()) |
| 8552 | .unwrap_or_else(|| vec!["37".to_string()]), |
| 8553 | }; |
| 8554 | let temp = tempfile::tempdir().unwrap(); |
| 8555 | let path = temp.path().join("config.toml"); |
| 8556 | for sample in samples { |
| 8557 | let edit = |
| 8558 | crate::config::NotificationConfigUpdate::parse(setting, &sample).unwrap(); |
| 8559 | edit.persist(&path).unwrap(); |
| 8560 | let loaded = Config::load(Some(path.clone()), None) |
| 8561 | .unwrap() |
| 8562 | .notifications_config(); |
| 8563 | assert_eq!( |
| 8564 | loaded.display(setting), |
| 8565 | edit.display(), |
| 8566 | "{} must reach the TUI config store", |
| 8567 | def.key |
| 8568 | ); |
| 8569 | } |
| 8570 | continue; |
| 8571 | } |
| 8572 | // At least one value per row that is not the default, so a row |
| 8573 | // whose store silently drops writes cannot pass by looking like |
| 8574 | // an untouched `Settings`: bools and enums try every value, an |
| 8575 | // int tries the default plus one, free text tries a sentinel. |
| 8576 | // `config_choice_values` also covers the two registry-backed |
| 8577 | // strings (theme, locale), whose value set is the shipped list. |
| 8578 | let samples: Vec<String> = match config_choice_values(def.key) { |
| 8579 | Some(values) => values, |
| 8580 | // Validated free text: a value the store's own parser accepts. |
| 8581 | None if def.key == "background_color" => vec!["#1a1b26".to_string()], |
| 8582 | None if def.key == "default_model" => vec!["deepseek-v4-pro".to_string()], |
| 8583 | None if def.is_int() => { |
| 8584 | let default: i64 = def.default.parse().unwrap_or(0); |
| 8585 | vec![(default + 1).to_string()] |
| 8586 | } |
| 8587 | None if def.is_float() => { |
| 8588 | let default: f64 = def.default.parse().unwrap_or(50.0); |
| 8589 | vec![(default + 0.5).to_string()] |
| 8590 | } |
| 8591 | None => vec!["roundtrip-probe".to_string()], |
| 8592 | }; |
| 8593 | assert!( |
| 8594 | !samples.is_empty(), |
| 8595 | "{} yields no sample to round-trip", |
| 8596 | def.key |
| 8597 | ); |
| 8598 | let mut settings = Settings::default(); |
| 8599 | let accepted = samples |
| 8600 | .first() |
| 8601 | .is_some_and(|sample| settings.set(def.key, sample).is_ok()); |
| 8602 | if !accepted { |
| 8603 | assert!( |
| 8604 | NOT_SETTINGS_TOML.contains(&def.key), |
| 8605 | "{} shows a row that settings.toml will not take", |
| 8606 | def.key |
| 8607 | ); |
| 8608 | continue; |
| 8609 | } |
| 8610 | for sample in &samples { |
| 8611 | let mut settings = Settings::default(); |
| 8612 | settings |
| 8613 | .set(def.key, sample) |
| 8614 | .unwrap_or_else(|error| panic!("{} rejects {sample}: {error}", def.key)); |
| 8615 | let written = toml::to_string(&settings) |
| 8616 | .unwrap_or_else(|error| panic!("{} will not serialize: {error}", def.key)); |
| 8617 | let reloaded: Settings = toml::from_str(&written) |
| 8618 | .unwrap_or_else(|error| panic!("{} will not reload: {error}", def.key)); |
| 8619 | assert_eq!( |
| 8620 | toml::to_string(&reloaded).expect("reloaded settings serialize"), |
| 8621 | written, |
| 8622 | "{} does not survive a settings.toml round trip at {sample}", |
| 8623 | def.key |
| 8624 | ); |
| 8625 | } |
| 8626 | } |
| 8627 | } |
| 8628 | |
| 8629 | /// `/set` and the settings screen read one declaration. A key `/set` |
| 8630 | /// accepts but the schema does not declare would be settable and |
| 8631 | /// unplaceable — no kind, no label, no home. |
| 8632 | #[test] |
| 8633 | fn every_available_setting_is_declared_in_the_schema() { |
| 8634 | for (key, _) in Settings::available_settings() { |
| 8635 | assert!( |
| 8636 | codewhale_config::setting(key).is_some(), |
| 8637 | "`/set {key}` is accepted but undeclared in SETTINGS_SCHEMA" |
| 8638 | ); |
| 8639 | } |
| 8640 | } |
| 8641 | |
| 8642 | /// Every field `Settings` persists to settings.toml is declared in |
| 8643 | /// SETTINGS_SCHEMA — a row for editable values, a hidden def for picker |
| 8644 | /// memory and one-way flags. A persisted field without a declaration has |
| 8645 | /// no kind, no provenance layer, and no resolver home. |
| 8646 | #[test] |
| 8647 | fn every_persisted_settings_field_is_declared_in_the_schema() { |
| 8648 | use crate::settings::PinnedModel; |
| 8649 | |
| 8650 | // Options serialize as absent when None; force them present so the |
| 8651 | // table below names every key settings.toml can hold. |
| 8652 | let settings = Settings { |
| 8653 | background_color: Some("#1a1b26".to_string()), |
| 8654 | default_provider: Some("deepseek".to_string()), |
| 8655 | default_model: Some("deepseek-v4-pro".to_string()), |
| 8656 | reasoning_effort: Some("medium".to_string()), |
| 8657 | permission_posture: Some("ask".to_string()), |
| 8658 | sandbox_mode: Some("read-only".to_string()), |
| 8659 | provider_models: Some(std::collections::HashMap::from([( |
| 8660 | "deepseek".to_string(), |
| 8661 | "deepseek-v4-pro".to_string(), |
| 8662 | )])), |
| 8663 | enabled_models: Some(std::collections::HashMap::from([( |
| 8664 | "deepseek".to_string(), |
| 8665 | vec!["deepseek-v4-pro".to_string()], |
| 8666 | )])), |
| 8667 | pinned_models: vec![PinnedModel { |
| 8668 | provider: "deepseek".to_string(), |
| 8669 | model: "deepseek-v4-pro".to_string(), |
| 8670 | label: None, |
| 8671 | }], |
| 8672 | behavioral_tip_impressions: std::collections::BTreeMap::from([( |
| 8673 | "probe".to_string(), |
| 8674 | 1u8, |
| 8675 | )]), |
| 8676 | footer_hint_uses: std::collections::BTreeMap::from([("probe".to_string(), 1u8)]), |
| 8677 | ..Settings::default() |
| 8678 | }; |
| 8679 | let table = toml::Value::try_from(&settings) |
| 8680 | .expect("settings serialize") |
| 8681 | .as_table() |
| 8682 | .expect("settings are a table") |
| 8683 | .clone(); |
| 8684 | // The probe is only trustworthy if it actually names the keys whose |
| 8685 | // only declaration is hidden; a future `skip_serializing` would |
| 8686 | // silently drop a key from this table instead of failing below. |
| 8687 | for key in [ |
| 8688 | "tool_collapse_mode", |
| 8689 | "max_input_history", |
| 8690 | "default_provider", |
| 8691 | "sandbox_mode", |
| 8692 | "provider_models", |
| 8693 | "enabled_models", |
| 8694 | "pinned_models", |
| 8695 | "feature_intro_shown", |
| 8696 | "yolo_deprecation_shown", |
| 8697 | "work_surface_bottom_migrated", |
| 8698 | "behavioral_tip_impressions", |
| 8699 | "footer_hint_uses", |
| 8700 | ] { |
| 8701 | assert!( |
| 8702 | table.contains_key(key), |
| 8703 | "probe settings undercovers settings.toml: `{key}` did not serialize" |
| 8704 | ); |
| 8705 | } |
| 8706 | for key in table.keys() { |
| 8707 | assert!( |
| 8708 | codewhale_config::setting(key).is_some(), |
| 8709 | "settings.toml persists `{key}` with no SETTINGS_SCHEMA declaration" |
| 8710 | ); |
| 8711 | } |
| 8712 | } |
| 8713 | |
| 8714 | /// Every message key declared by the schema must resolve to a localized |
| 8715 | /// string in every shipped locale. `tr_key` returns the key itself when a |
| 8716 | /// pack is missing the entry, so this fails fast on a stale binding. |
| 8717 | #[test] |
| 8718 | fn settings_schema_message_keys_are_localized() { |
| 8719 | let mut keys: Vec<&'static str> = Vec::new(); |
| 8720 | for def in codewhale_config::SETTINGS_SCHEMA { |
| 8721 | if let Some(ui) = def.ui { |
| 8722 | if !ui.label.is_empty() { |
| 8723 | keys.push(ui.label); |
| 8724 | } |
| 8725 | if !ui.description.is_empty() { |
| 8726 | keys.push(ui.description); |
| 8727 | } |
| 8728 | } |
| 8729 | let options = match def.kind { |
| 8730 | codewhale_config::SettingKind::Bool(options) => options, |
| 8731 | codewhale_config::SettingKind::Enum(options) => options, |
| 8732 | codewhale_config::SettingKind::Int |
| 8733 | | codewhale_config::SettingKind::String |
| 8734 | | codewhale_config::SettingKind::Float => &[], |
| 8735 | }; |
| 8736 | for option in options { |
| 8737 | if !option.label.is_empty() { |
| 8738 | keys.push(option.label); |
| 8739 | } |
| 8740 | if !option.description.is_empty() { |
| 8741 | keys.push(option.description); |
| 8742 | } |
| 8743 | } |
| 8744 | } |
| 8745 | |
| 8746 | for locale in Locale::shipped() { |
| 8747 | for key in &keys { |
| 8748 | let resolved = tr_key(*locale, key); |
| 8749 | assert_ne!( |
| 8750 | resolved.as_ref(), |
| 8751 | *key, |
| 8752 | "{key} is not localized for {locale:?}" |
| 8753 | ); |
| 8754 | } |
| 8755 | } |
| 8756 | } |
| 8757 | |
| 8758 | #[test] |
| 8759 | fn config_view_exposes_configured_and_effective_context_window() { |
| 8760 | let temp = tempfile::tempdir().expect("config fixture"); |
| 8761 | let config_path = temp.path().join("config.toml"); |
| 8762 | std::fs::write( |
| 8763 | &config_path, |
| 8764 | r#" |
| 8765 | provider = "moonshot" |
| 8766 | [providers.moonshot] |
| 8767 | model = "kimi-k3" |
| 8768 | context_window = 262144 |
| 8769 | "#, |
| 8770 | ) |
| 8771 | .expect("config"); |
| 8772 | let mut app = create_test_app(); |
| 8773 | app.config_path = Some(config_path); |
| 8774 | app.api_provider = crate::config::ApiProvider::Moonshot; |
| 8775 | app.model = "kimi-k3".to_string(); |
| 8776 | app.active_route_limits = Some(codewhale_config::route::RouteLimits { |
| 8777 | context_tokens: Some(262_144), |
| 8778 | ..Default::default() |
| 8779 | }); |
| 8780 | app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured; |
| 8781 | |
| 8782 | let view = ConfigView::new_for_app(&app); |
| 8783 | let configured = view |
| 8784 | .rows |
| 8785 | .iter() |
| 8786 | .find(|row| row.key == "context_window") |
| 8787 | .expect("configured context row"); |
| 8788 | let effective = view |
| 8789 | .rows |
| 8790 | .iter() |
| 8791 | .find(|row| row.key == "effective_context_window") |
| 8792 | .expect("effective context row"); |
| 8793 | |
| 8794 | assert_eq!(configured.value, "262144"); |
| 8795 | assert_eq!(effective.value, "262144 tokens · configured"); |
| 8796 | } |
| 8797 | |
| 8798 | #[test] |
| 8799 | fn config_view_displays_saved_codex_reasoning_effort_label() { |
| 8800 | let _guard = ConfigSettingsEnvGuard::new("reasoning_effort = \"max\"\n"); |
| 8801 | let mut app = create_test_app(); |
| 8802 | app.api_provider = crate::config::ApiProvider::OpenaiCodex; |
| 8803 | |
| 8804 | let view = ConfigView::new_for_app(&app); |
| 8805 | let row = view |
| 8806 | .rows |
| 8807 | .iter() |
| 8808 | .find(|row| row.key == "reasoning_effort") |
| 8809 | .expect("reasoning_effort row"); |
| 8810 | |
| 8811 | assert_eq!(row.value, "max"); |
| 8812 | } |
| 8813 | |
| 8814 | #[test] |
| 8815 | fn config_view_editing_localized_default_placeholders_starts_blank() { |
| 8816 | let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\n"); |
| 8817 | let app = create_test_app(); |
| 8818 | let mut view = ConfigView::new_for_app(&app); |
| 8819 | |
| 8820 | for (key, message_id) in [ |
| 8821 | ("reasoning_effort", MessageId::ConfigDefaultReasoning), |
| 8822 | ("background_color", MessageId::ConfigDefaultValue), |
| 8823 | ] { |
| 8824 | view.focus_key(key); |
| 8825 | view.start_edit(); |
| 8826 | |
| 8827 | let edit = view.editing.as_ref().expect("editing should start"); |
| 8828 | assert_eq!(edit.original_value, tr(Locale::ZhHans, message_id)); |
| 8829 | assert!( |
| 8830 | edit.buffer.is_empty(), |
| 8831 | "localized default placeholder should not become edit text for {key}" |
| 8832 | ); |
| 8833 | |
| 8834 | view.editing = None; |
| 8835 | } |
| 8836 | } |
| 8837 | |
| 8838 | #[test] |
| 8839 | fn config_view_filter_matches_group_and_rows() { |
| 8840 | let mut view = create_config_view(Locale::En); |
| 8841 | |
| 8842 | type_filter(&mut view, "workbar"); |
| 8843 | |
| 8844 | assert_eq!(view.filter, "workbar"); |
| 8845 | assert_eq!(visible_section_labels(&view), vec!["Workbar"]); |
| 8846 | assert_eq!( |
| 8847 | visible_row_keys(&view), |
| 8848 | vec![ |
| 8849 | "work_surface_placement", |
| 8850 | "work_surface_top_height", |
| 8851 | "work_surface_side_width", |
| 8852 | "rail_panel", |
| 8853 | ] |
| 8854 | ); |
| 8855 | assert_eq!(view.rows[view.selected].key, "work_surface_placement"); |
| 8856 | } |
| 8857 | |
| 8858 | #[test] |
| 8859 | fn localized_config_view_filter_matches_english_section_and_scope_labels() { |
| 8860 | let mut view = create_config_view(Locale::PtBr); |
| 8861 | |
| 8862 | type_filter(&mut view, "workbar saved"); |
| 8863 | |
| 8864 | assert_eq!(view.filter, "workbar saved"); |
| 8865 | assert_eq!(visible_section_labels(&view), vec!["Barra lateral"]); |
| 8866 | assert_eq!( |
| 8867 | visible_row_keys(&view), |
| 8868 | vec![ |
| 8869 | "work_surface_placement", |
| 8870 | "work_surface_top_height", |
| 8871 | "work_surface_side_width", |
| 8872 | "rail_panel", |
| 8873 | ] |
| 8874 | ); |
| 8875 | } |
| 8876 | |
| 8877 | #[test] |
| 8878 | fn config_view_filter_accepts_unicode_case() { |
| 8879 | let app = create_test_app(); |
| 8880 | let mut view = ConfigView::new_for_app(&app); |
| 8881 | |
| 8882 | type_filter(&mut view, "thinking"); |
| 8883 | assert_eq!( |
| 8884 | visible_row_keys(&view), |
| 8885 | vec![ |
| 8886 | // `reasoning_effort` joined this filter when the thinking |
| 8887 | // ladder gave it a config row; the schema files it under |
| 8888 | // Models, so it now sorts after the appearance rows. |
| 8889 | "show_thinking", |
| 8890 | "thinking_default_expanded", |
| 8891 | "thinking_preview_lines", |
| 8892 | "thinking_highlight", |
| 8893 | "reasoning_effort" |
| 8894 | ] |
| 8895 | ); |
| 8896 | |
| 8897 | view.clear_filter(); |
| 8898 | view.rows[0].value = "CAFÉ".to_string(); |
| 8899 | type_filter(&mut view, "café"); |
| 8900 | assert_eq!(visible_row_keys(&view), vec!["theme"]); |
| 8901 | } |
| 8902 | |
| 8903 | fn assert_config_search_owns_text(query: &str) { |
| 8904 | let mut view = create_config_view(Locale::En); |
| 8905 | // Start on an actionable boolean so a stolen Space would emit a |
| 8906 | // persisted update, and a stolen e would open an editor. |
| 8907 | view.focus_key("low_motion"); |
| 8908 | let values = view |
| 8909 | .rows |
| 8910 | .iter() |
| 8911 | .map(|row| row.value.clone()) |
| 8912 | .collect::<Vec<_>>(); |
| 8913 | let mut stack = ViewStack::new(); |
| 8914 | stack.push(view); |
| 8915 | for ch in query.chars() { |
| 8916 | assert!( |
| 8917 | stack |
| 8918 | .handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)) |
| 8919 | .is_empty(), |
| 8920 | "{query:?}" |
| 8921 | ); |
| 8922 | assert_eq!(stack.top_kind(), Some(ModalKind::Config), "{query:?}"); |
| 8923 | } |
| 8924 | let mut modal = stack.pop().unwrap(); |
| 8925 | let view = modal.as_any_mut().downcast_mut::<ConfigView>().unwrap(); |
| 8926 | assert_eq!(view.filter, query); |
| 8927 | assert!( |
| 8928 | view.editing.is_none(), |
| 8929 | "search text must not enter a settings editor" |
| 8930 | ); |
| 8931 | assert_eq!( |
| 8932 | view.rows |
| 8933 | .iter() |
| 8934 | .map(|row| row.value.clone()) |
| 8935 | .collect::<Vec<_>>(), |
| 8936 | values |
| 8937 | ); |
| 8938 | assert!(matches!( |
| 8939 | view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), |
| 8940 | ViewAction::None |
| 8941 | )); |
| 8942 | assert!(view.filter.is_empty()); |
| 8943 | assert!(matches!( |
| 8944 | view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), |
| 8945 | ViewAction::Close |
| 8946 | )); |
| 8947 | } |
| 8948 | |
| 8949 | #[test] |
| 8950 | fn config_search_owns_initial_q() { |
| 8951 | assert_config_search_owns_text("quiet"); |
| 8952 | assert_config_search_owns_text("Queue"); |
| 8953 | } |
| 8954 | |
| 8955 | #[test] |
| 8956 | fn config_search_owns_initial_e() { |
| 8957 | assert_config_search_owns_text("effort"); |
| 8958 | assert_config_search_owns_text("Effort"); |
| 8959 | } |
| 8960 | |
| 8961 | #[test] |
| 8962 | fn config_search_owns_initial_j() { |
| 8963 | assert_config_search_owns_text("json"); |
| 8964 | } |
| 8965 | |
| 8966 | #[test] |
| 8967 | fn config_search_owns_initial_k() { |
| 8968 | assert_config_search_owns_text("key"); |
| 8969 | } |
| 8970 | |
| 8971 | #[test] |
| 8972 | fn config_search_owns_initial_space() { |
| 8973 | assert_config_search_owns_text(" 队列é"); |
| 8974 | } |
| 8975 | |
| 8976 | #[test] |
| 8977 | fn config_view_filter_matches_friendly_labels_and_hints() { |
| 8978 | let mut view = create_config_view(Locale::En); |
| 8979 | |
| 8980 | type_filter(&mut view, "shell access"); |
| 8981 | assert_eq!(visible_row_keys(&view), vec!["allow_shell"]); |
| 8982 | |
| 8983 | view.clear_filter(); |
| 8984 | type_filter(&mut view, "reasoning level"); |
| 8985 | assert_eq!(visible_row_keys(&view), vec!["reasoning_effort"]); |
| 8986 | |
| 8987 | view.clear_filter(); |
| 8988 | type_filter(&mut view, "fan-out/fan-in"); |
| 8989 | assert_eq!(visible_row_keys(&view), vec!["workflow"]); |
| 8990 | } |
| 8991 | |
| 8992 | /// #5134 filed an issue to ask how to raise the context window, because |
| 8993 | /// the rows that answer it are keyed `context_window` and only findable by |
| 8994 | /// someone who already knows that name. The filter has to answer the words |
| 8995 | /// a user actually types. |
| 8996 | #[test] |
| 8997 | fn config_view_filter_finds_context_window_by_user_vocabulary() { |
| 8998 | let mut view = create_config_view(Locale::En); |
| 8999 | |
| 9000 | for phrase in ["context length", "context size", "max context length"] { |
| 9001 | view.clear_filter(); |
| 9002 | type_filter(&mut view, phrase); |
| 9003 | let keys = visible_row_keys(&view); |
| 9004 | assert!( |
| 9005 | keys.contains(&"context_window"), |
| 9006 | "`{phrase}` must surface the context_window row: {keys:?}" |
| 9007 | ); |
| 9008 | assert!( |
| 9009 | keys.contains(&"effective_context_window"), |
| 9010 | "`{phrase}` must surface the resolved window row: {keys:?}" |
| 9011 | ); |
| 9012 | } |
| 9013 | |
| 9014 | // The adjacent knob the same user reaches for next. |
| 9015 | view.clear_filter(); |
| 9016 | type_filter(&mut view, "compaction threshold"); |
| 9017 | let keys = visible_row_keys(&view); |
| 9018 | assert!( |
| 9019 | keys.contains(&"auto_compact_threshold_percent"), |
| 9020 | "`compaction threshold` must surface the auto-compaction trigger: {keys:?}" |
| 9021 | ); |
| 9022 | } |
| 9023 | |
| 9024 | #[test] |
| 9025 | fn config_view_renders_friendly_setting_labels() { |
| 9026 | let mut view = create_config_view(Locale::En); |
| 9027 | assert_ne!( |
| 9028 | config_label_for_key("show_thinking"), |
| 9029 | config_label_for_key("thinking_highlight"), |
| 9030 | "reasoning visibility and background controls need distinct labels" |
| 9031 | ); |
| 9032 | view.category = ConfigCategory::ModelsProviders; |
| 9033 | view.select_first_visible_row(); |
| 9034 | let area = Rect::new(0, 0, 100, 40); |
| 9035 | let mut buf = Buffer::empty(area); |
| 9036 | |
| 9037 | view.render(area, &mut buf); |
| 9038 | |
| 9039 | let dump = buffer_text(&buf, area); |
| 9040 | assert!( |
| 9041 | dump.contains("Active provider"), |
| 9042 | "missing provider label:\n{dump}" |
| 9043 | ); |
| 9044 | assert!( |
| 9045 | dump.contains("Models & providers"), |
| 9046 | "missing settings rail:\n{dump}" |
| 9047 | ); |
| 9048 | |
| 9049 | view.category = ConfigCategory::Trust; |
| 9050 | view.select_first_visible_row(); |
| 9051 | let mut permission_buf = Buffer::empty(area); |
| 9052 | view.render(area, &mut permission_buf); |
| 9053 | let permission_dump = buffer_text(&permission_buf, area); |
| 9054 | assert!( |
| 9055 | permission_dump.contains("Shell access"), |
| 9056 | "missing shell label:\n{permission_dump}" |
| 9057 | ); |
| 9058 | } |
| 9059 | |
| 9060 | #[test] |
| 9061 | fn localized_config_view_renders_at_narrow_width() { |
| 9062 | let mut app = create_test_app(); |
| 9063 | app.ui_locale = Locale::PtBr; |
| 9064 | let mut view = ConfigView::new_for_app(&app); |
| 9065 | view.category = ConfigCategory::ModelsProviders; |
| 9066 | view.select_first_visible_row(); |
| 9067 | let area = Rect::new(0, 0, 60, 18); |
| 9068 | let mut buf = Buffer::empty(area); |
| 9069 | |
| 9070 | view.render(area, &mut buf); |
| 9071 | |
| 9072 | let dump = buffer_text(&buf, area); |
| 9073 | assert!(dump.contains("Provedor"), "missing localized rows:\n{dump}"); |
| 9074 | assert!( |
| 9075 | !dump.contains("MISSING"), |
| 9076 | "missing-key marker leaked:\n{dump}" |
| 9077 | ); |
| 9078 | } |
| 9079 | |
| 9080 | #[test] |
| 9081 | fn config_view_selected_row_uses_muted_selection_highlight() { |
| 9082 | let mut view = create_config_view(Locale::En); |
| 9083 | view.selected = view |
| 9084 | .rows |
| 9085 | .iter() |
| 9086 | .position(|row| row.key == "theme") |
| 9087 | .expect("theme row"); |
| 9088 | view.category = ConfigCategory::Appearance; |
| 9089 | view.adjust_scroll(8); |
| 9090 | let area = Rect::new(0, 0, 100, 24); |
| 9091 | let mut buf = Buffer::empty(area); |
| 9092 | |
| 9093 | view.render(area, &mut buf); |
| 9094 | |
| 9095 | let y = view |
| 9096 | .last_row_hitboxes |
| 9097 | .borrow() |
| 9098 | .iter() |
| 9099 | .find_map(|(rect, idx)| (*idx == view.selected).then_some(rect.y)) |
| 9100 | .expect("selected config row should have a hitbox"); |
| 9101 | let highlighted_cells = (area.x..area.x.saturating_add(area.width)) |
| 9102 | .filter(|&x| { |
| 9103 | let cell = &buf[(x, y)]; |
| 9104 | !cell.symbol().trim().is_empty() |
| 9105 | && cell.bg == palette::SELECTION_BG |
| 9106 | && cell.fg == palette::SELECTION_TEXT |
| 9107 | }) |
| 9108 | .count(); |
| 9109 | |
| 9110 | assert!( |
| 9111 | highlighted_cells >= 4, |
| 9112 | "selected config row should render readable selection text" |
| 9113 | ); |
| 9114 | assert!( |
| 9115 | !(area.x..area.x.saturating_add(area.width)) |
| 9116 | .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION), |
| 9117 | "selected config row should not use the bright accent background" |
| 9118 | ); |
| 9119 | } |
| 9120 | |
| 9121 | #[test] |
| 9122 | fn config_view_keeps_scope_column_aligned_for_long_keys() { |
| 9123 | let mut view = create_config_view(Locale::ZhHans); |
| 9124 | type_filter(&mut view, "composer"); |
| 9125 | let area = Rect::new(0, 0, 100, 24); |
| 9126 | let mut buf = Buffer::empty(area); |
| 9127 | |
| 9128 | view.render(area, &mut buf); |
| 9129 | |
| 9130 | let dump = buffer_text(&buf, area); |
| 9131 | assert!( |
| 9132 | dump.contains("粘 贴 检 测"), |
| 9133 | "localized config labels should stay readable:\n{dump}" |
| 9134 | ); |
| 9135 | let scope_columns = (area.y..area.y.saturating_add(area.height)) |
| 9136 | .filter_map(|y| { |
| 9137 | // One dumped char per cell (wide glyphs dump as glyph + |
| 9138 | // continuation cell), so a char count is the cell column. |
| 9139 | // Every list row paints an affordance in the same column, so |
| 9140 | // the first affordance glyph is the shared alignment anchor. |
| 9141 | let line = buffer_row_text(&buf, area, y); |
| 9142 | if !line.contains("已 保 存") { |
| 9143 | return None; |
| 9144 | } |
| 9145 | line.find(['‹', '[', '✎']) |
| 9146 | .map(|byte| line[..byte].chars().count()) |
| 9147 | }) |
| 9148 | .collect::<Vec<_>>(); |
| 9149 | assert!( |
| 9150 | scope_columns.len() >= 2, |
| 9151 | "expected composer config rows with scopes:\n{dump}" |
| 9152 | ); |
| 9153 | assert!( |
| 9154 | scope_columns |
| 9155 | .iter() |
| 9156 | .all(|column| *column == scope_columns[0]), |
| 9157 | "scope column should stay aligned even for long keys ({scope_columns:?}):\n{dump}" |
| 9158 | ); |
| 9159 | } |
| 9160 | |
| 9161 | #[test] |
| 9162 | fn config_view_filter_no_match_does_not_edit_hidden_row() { |
| 9163 | let app = create_test_app(); |
| 9164 | let mut view = ConfigView::new_for_app(&app); |
| 9165 | |
| 9166 | type_filter(&mut view, "zzzz"); |
| 9167 | assert!(visible_row_keys(&view).is_empty()); |
| 9168 | |
| 9169 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9170 | assert!(matches!(action, ViewAction::None)); |
| 9171 | assert!(view.editing.is_none()); |
| 9172 | |
| 9173 | let clear = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 9174 | assert!(matches!(clear, ViewAction::None)); |
| 9175 | assert!(view.filter.is_empty()); |
| 9176 | assert!(!visible_row_keys(&view).is_empty()); |
| 9177 | } |
| 9178 | |
| 9179 | #[test] |
| 9180 | fn config_view_can_edit_filtered_row() { |
| 9181 | let app = create_test_app(); |
| 9182 | let mut view = ConfigView::new_for_app(&app); |
| 9183 | |
| 9184 | type_filter(&mut view, "mcp_config"); |
| 9185 | assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]); |
| 9186 | |
| 9187 | let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9188 | assert!(matches!(start, ViewAction::None)); |
| 9189 | assert!(view.editing.is_some()); |
| 9190 | |
| 9191 | let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); |
| 9192 | assert!(matches!(clear, ViewAction::None)); |
| 9193 | type_filter(&mut view, "servers.json"); |
| 9194 | |
| 9195 | let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9196 | match submit { |
| 9197 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9198 | key, |
| 9199 | value, |
| 9200 | persist, |
| 9201 | }) => { |
| 9202 | assert_eq!(key, "mcp_config_path"); |
| 9203 | assert_eq!(value, "servers.json"); |
| 9204 | assert!(persist); |
| 9205 | } |
| 9206 | other => panic!("expected config update emit, got {other:?}"), |
| 9207 | } |
| 9208 | } |
| 9209 | |
| 9210 | #[test] |
| 9211 | fn config_view_enter_and_ctrl_u_emit_config_updated() { |
| 9212 | let app = create_test_app(); |
| 9213 | let mut view = ConfigView::new_for_app(&app); |
| 9214 | view.focus_key("background_color"); |
| 9215 | |
| 9216 | let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9217 | assert!(matches!(start, ViewAction::None)); |
| 9218 | assert!(view.editing.is_some()); |
| 9219 | |
| 9220 | let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); |
| 9221 | assert!(matches!(clear, ViewAction::None)); |
| 9222 | let cleared = view |
| 9223 | .editing |
| 9224 | .as_ref() |
| 9225 | .expect("editing should remain active after Ctrl+U"); |
| 9226 | assert!(cleared.buffer.is_empty()); |
| 9227 | |
| 9228 | for ch in "55".chars() { |
| 9229 | let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 9230 | assert!(matches!(action, ViewAction::None)); |
| 9231 | } |
| 9232 | |
| 9233 | let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9234 | match submit { |
| 9235 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9236 | key, |
| 9237 | value, |
| 9238 | persist, |
| 9239 | }) => { |
| 9240 | assert_eq!(key, "background_color"); |
| 9241 | assert_eq!(value, "55"); |
| 9242 | assert!(persist); |
| 9243 | } |
| 9244 | other => panic!("expected config update emit, got {other:?}"), |
| 9245 | } |
| 9246 | assert!(view.editing.is_none()); |
| 9247 | } |
| 9248 | |
| 9249 | #[test] |
| 9250 | fn config_view_boolean_rows_toggle_without_text_editing() { |
| 9251 | let app = create_test_app(); |
| 9252 | let mut view = ConfigView::new_for_app(&app); |
| 9253 | view.focus_key("low_motion"); |
| 9254 | let expected = |
| 9255 | if canonical_config_choice("low_motion", &view.rows[view.selected].value) == "true" { |
| 9256 | "false" |
| 9257 | } else { |
| 9258 | "true" |
| 9259 | }; |
| 9260 | |
| 9261 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9262 | |
| 9263 | match action { |
| 9264 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9265 | key, |
| 9266 | value, |
| 9267 | persist, |
| 9268 | }) => { |
| 9269 | assert_eq!(key, "low_motion"); |
| 9270 | assert_eq!(value, expected); |
| 9271 | assert!(persist); |
| 9272 | } |
| 9273 | other => panic!("expected direct boolean update, got {other:?}"), |
| 9274 | } |
| 9275 | assert!(view.editing.is_none()); |
| 9276 | } |
| 9277 | |
| 9278 | #[test] |
| 9279 | fn config_view_enum_rows_use_a_bounded_choice_list() { |
| 9280 | let app = create_test_app(); |
| 9281 | let mut view = ConfigView::new_for_app(&app); |
| 9282 | view.focus_key("default_mode"); |
| 9283 | |
| 9284 | let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9285 | assert!(matches!(start, ViewAction::None)); |
| 9286 | let edit = view.editing.as_ref().expect("choice editor"); |
| 9287 | assert_eq!( |
| 9288 | edit.choices.as_deref(), |
| 9289 | Some( |
| 9290 | &[ |
| 9291 | "agent".to_string(), |
| 9292 | "plan".to_string(), |
| 9293 | "operate".to_string(), |
| 9294 | ][..] |
| 9295 | ) |
| 9296 | ); |
| 9297 | assert!( |
| 9298 | edit.choices |
| 9299 | .as_ref() |
| 9300 | .expect("startup choices") |
| 9301 | .iter() |
| 9302 | .all(|choice| choice != "yolo") |
| 9303 | ); |
| 9304 | |
| 9305 | let _ = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); |
| 9306 | let apply = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9307 | match apply { |
| 9308 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9309 | key, |
| 9310 | value, |
| 9311 | persist, |
| 9312 | }) => { |
| 9313 | assert_eq!(key, "default_mode"); |
| 9314 | assert_eq!(value, "operate"); |
| 9315 | assert!(persist); |
| 9316 | } |
| 9317 | other => panic!("expected startup choice update, got {other:?}"), |
| 9318 | } |
| 9319 | |
| 9320 | assert_eq!( |
| 9321 | canonical_config_choice("default_mode", "Operate"), |
| 9322 | "operate" |
| 9323 | ); |
| 9324 | assert_eq!( |
| 9325 | config_choice_label(Locale::En, "default_mode", "operate"), |
| 9326 | "Operate" |
| 9327 | ); |
| 9328 | assert!(!config_choice_detail(Locale::En, "default_mode", "operate").is_empty()); |
| 9329 | } |
| 9330 | |
| 9331 | #[test] |
| 9332 | fn locale_choices_cover_shipped_registry_and_mark_partial_packs() { |
| 9333 | let choices = config_choice_values("locale").expect("locale choices"); |
| 9334 | let expected = std::iter::once("auto".to_string()) |
| 9335 | .chain( |
| 9336 | Locale::shipped() |
| 9337 | .iter() |
| 9338 | .map(|locale| locale.tag().to_string()), |
| 9339 | ) |
| 9340 | .collect::<Vec<_>>(); |
| 9341 | assert_eq!( |
| 9342 | choices, expected, |
| 9343 | "native locale choices must match Locale::shipped()" |
| 9344 | ); |
| 9345 | |
| 9346 | let partial_badge = tr(Locale::En, MessageId::ConfigLocalePartialBadge); |
| 9347 | let partial_detail = tr(Locale::En, MessageId::ConfigLocalePartialDetail); |
| 9348 | for locale in Locale::shipped() { |
| 9349 | let canonical = canonical_config_choice("locale", locale.tag()); |
| 9350 | assert_eq!(canonical, locale.tag()); |
| 9351 | |
| 9352 | let label = config_choice_label(Locale::En, "locale", &canonical); |
| 9353 | assert_eq!( |
| 9354 | label.contains(partial_badge.as_ref()), |
| 9355 | locale.is_partial_pack(), |
| 9356 | "{} partial-pack badge drifted", |
| 9357 | locale.tag() |
| 9358 | ); |
| 9359 | |
| 9360 | let detail = config_choice_detail(Locale::En, "locale", &canonical); |
| 9361 | assert_eq!( |
| 9362 | !detail.is_empty(), |
| 9363 | locale.is_partial_pack(), |
| 9364 | "{} partial-pack detail drifted", |
| 9365 | locale.tag() |
| 9366 | ); |
| 9367 | if locale.is_partial_pack() { |
| 9368 | assert_eq!(detail, partial_detail); |
| 9369 | } |
| 9370 | } |
| 9371 | } |
| 9372 | |
| 9373 | #[test] |
| 9374 | fn locale_choice_editor_submits_newly_admitted_locales() { |
| 9375 | for tag in ["ko", "vi", "zh-Hant"] { |
| 9376 | let mut view = create_config_view(Locale::En); |
| 9377 | view.focus_key("locale"); |
| 9378 | view.start_edit(); |
| 9379 | let edit = view.editing.as_mut().expect("locale choice editor"); |
| 9380 | edit.selected_choice = edit |
| 9381 | .choices |
| 9382 | .as_ref() |
| 9383 | .and_then(|choices| choices.iter().position(|choice| choice == tag)) |
| 9384 | .unwrap_or_else(|| panic!("locale choices must include {tag}")); |
| 9385 | |
| 9386 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 9387 | ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => { |
| 9388 | assert_eq!(key, "locale"); |
| 9389 | assert_eq!(value, tag); |
| 9390 | } |
| 9391 | other => panic!("selecting locale {tag} must submit ConfigUpdated, got {other:?}"), |
| 9392 | } |
| 9393 | } |
| 9394 | } |
| 9395 | |
| 9396 | #[test] |
| 9397 | fn complete_locale_shows_no_partial_badge_at_minimum_terminal_layout() { |
| 9398 | // zh-Hant reached full en.json parity in #5143 and no shipped pack is |
| 9399 | // partial anymore, so the picker must not render the partial badge. |
| 9400 | let mut view = create_config_view(Locale::En); |
| 9401 | view.focus_key("locale"); |
| 9402 | view.start_edit(); |
| 9403 | let edit = view.editing.as_mut().expect("locale choice editor"); |
| 9404 | edit.selected_choice = edit |
| 9405 | .choices |
| 9406 | .as_ref() |
| 9407 | .and_then(|choices| choices.iter().position(|choice| choice == "zh-Hant")) |
| 9408 | .expect("zh-Hant choice"); |
| 9409 | |
| 9410 | let area = Rect::new(0, 0, 40, 12); |
| 9411 | let mut buf = Buffer::empty(area); |
| 9412 | view.render(area, &mut buf); |
| 9413 | let dump = buffer_text(&buf, area); |
| 9414 | assert!( |
| 9415 | dump.contains("zh-Hant"), |
| 9416 | "zh-Hant choice must render at minimum layout: {dump:?}" |
| 9417 | ); |
| 9418 | assert!( |
| 9419 | !dump.contains("zh-Hant (partial)"), |
| 9420 | "zh-Hant is a complete pack and must not show the partial badge: {dump:?}" |
| 9421 | ); |
| 9422 | } |
| 9423 | |
| 9424 | #[test] |
| 9425 | fn settings_registry_types_every_config_row() { |
| 9426 | let app = create_test_app(); |
| 9427 | let view = ConfigView::new_for_app(&app); |
| 9428 | let registry = SettingsRegistry::new(&view); |
| 9429 | |
| 9430 | let kind_for = |key: &str| { |
| 9431 | let row = view |
| 9432 | .rows |
| 9433 | .iter() |
| 9434 | .find(|row| row.key == key) |
| 9435 | .unwrap_or_else(|| panic!("missing config row {key}")); |
| 9436 | registry.meta(row).kind |
| 9437 | }; |
| 9438 | |
| 9439 | assert_eq!(kind_for("provider"), SettingKind::Action); |
| 9440 | assert_eq!(kind_for("model"), SettingKind::Action); |
| 9441 | assert_eq!(kind_for("low_motion"), SettingKind::Boolean); |
| 9442 | assert_eq!(kind_for("default_mode"), SettingKind::Choice); |
| 9443 | assert_eq!(kind_for("thinking_preview_lines"), SettingKind::Integer); |
| 9444 | assert_eq!(kind_for("mcp_open"), SettingKind::Action); |
| 9445 | assert_eq!(kind_for("mcp_reconnect"), SettingKind::Action); |
| 9446 | assert_eq!(kind_for("mcp_diagnose"), SettingKind::Action); |
| 9447 | assert_eq!(kind_for("plugins_open"), SettingKind::Action); |
| 9448 | assert_eq!(kind_for("mcp_config_path"), SettingKind::Text); |
| 9449 | assert_eq!( |
| 9450 | kind_for("fleet.exec.max_spawn_depth"), |
| 9451 | SettingKind::ReadOnly |
| 9452 | ); |
| 9453 | |
| 9454 | for row in &view.rows { |
| 9455 | let meta = registry.meta(row); |
| 9456 | assert_eq!(meta.category, row.section()); |
| 9457 | assert_eq!( |
| 9458 | meta.kind == SettingKind::Choice || meta.kind == SettingKind::Boolean, |
| 9459 | meta.choices.is_some(), |
| 9460 | "choice metadata drifted for {}", |
| 9461 | row.key |
| 9462 | ); |
| 9463 | } |
| 9464 | } |
| 9465 | |
| 9466 | #[test] |
| 9467 | fn config_labels_are_consumed_from_complete_locale_packs() { |
| 9468 | for locale in Locale::shipped_complete() { |
| 9469 | assert_eq!( |
| 9470 | config_label_for_key_for_locale(*locale, "provider"), |
| 9471 | tr(*locale, MessageId::ConfigLabelProvider) |
| 9472 | ); |
| 9473 | assert_eq!( |
| 9474 | config_label_for_key_for_locale(*locale, "features.mcp"), |
| 9475 | tr(*locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", "Mcp") |
| 9476 | ); |
| 9477 | } |
| 9478 | assert_ne!( |
| 9479 | config_label_for_key_for_locale(Locale::Ja, "provider"), |
| 9480 | config_label_for_key_for_locale(Locale::En, "provider") |
| 9481 | ); |
| 9482 | } |
| 9483 | |
| 9484 | #[test] |
| 9485 | fn model_row_hint_names_the_model_picker() { |
| 9486 | let app = create_test_app(); |
| 9487 | let mut view = ConfigView::new_for_app(&app); |
| 9488 | view.focus_key("model"); |
| 9489 | |
| 9490 | let hint = view.activation_copy(&view.rows[view.selected]); |
| 9491 | assert!(hint.contains("Enter opens model picker"), "{hint}"); |
| 9492 | assert!(!hint.contains("Enter opens provider picker"), "{hint}"); |
| 9493 | assert!( |
| 9494 | hint.starts_with(&en(MessageId::ConfigActivateAgain)), |
| 9495 | "{hint}" |
| 9496 | ); |
| 9497 | } |
| 9498 | |
| 9499 | #[test] |
| 9500 | fn config_view_mouse_wheel_moves_rows_and_choice_selection() { |
| 9501 | let app = create_test_app(); |
| 9502 | let mut view = ConfigView::new_for_app(&app); |
| 9503 | let first_row = view.selected; |
| 9504 | |
| 9505 | let _ = view.handle_mouse(MouseEvent { |
| 9506 | kind: MouseEventKind::ScrollDown, |
| 9507 | column: 0, |
| 9508 | row: 0, |
| 9509 | modifiers: KeyModifiers::NONE, |
| 9510 | }); |
| 9511 | assert!( |
| 9512 | view.selected > first_row, |
| 9513 | "wheel should move the settings list" |
| 9514 | ); |
| 9515 | |
| 9516 | view.focus_key("default_mode"); |
| 9517 | view.start_edit(); |
| 9518 | view.editing |
| 9519 | .as_mut() |
| 9520 | .expect("choice editor") |
| 9521 | .selected_choice = 0; |
| 9522 | let _ = view.handle_mouse(MouseEvent { |
| 9523 | kind: MouseEventKind::ScrollDown, |
| 9524 | column: 0, |
| 9525 | row: 0, |
| 9526 | modifiers: KeyModifiers::NONE, |
| 9527 | }); |
| 9528 | assert_eq!( |
| 9529 | view.editing |
| 9530 | .as_ref() |
| 9531 | .expect("choice editor") |
| 9532 | .selected_choice, |
| 9533 | 1 |
| 9534 | ); |
| 9535 | } |
| 9536 | |
| 9537 | #[test] |
| 9538 | fn config_view_mouse_click_selects_row() { |
| 9539 | let app = create_test_app(); |
| 9540 | let mut view = ConfigView::new_for_app(&app); |
| 9541 | view.category = ConfigCategory::ModelsProviders; |
| 9542 | view.select_first_visible_row(); |
| 9543 | let area = Rect::new(0, 0, 100, 30); |
| 9544 | let mut buf = Buffer::empty(area); |
| 9545 | view.render(area, &mut buf); |
| 9546 | |
| 9547 | let hitboxes = view.last_row_hitboxes.borrow().clone(); |
| 9548 | let (_, row_idx) = hitboxes |
| 9549 | .iter() |
| 9550 | .find(|(_, idx)| view.rows.get(*idx).is_some_and(|row| row.key == "model")) |
| 9551 | .copied() |
| 9552 | .expect("model row should have a hitbox"); |
| 9553 | let y = hitboxes |
| 9554 | .iter() |
| 9555 | .find_map(|(rect, idx)| (*idx == row_idx).then_some(rect.y)) |
| 9556 | .expect("selected row should have a y coordinate"); |
| 9557 | |
| 9558 | let action = view.handle_mouse(MouseEvent { |
| 9559 | kind: MouseEventKind::Down(MouseButton::Left), |
| 9560 | column: 20, |
| 9561 | row: y, |
| 9562 | modifiers: KeyModifiers::NONE, |
| 9563 | }); |
| 9564 | |
| 9565 | assert!(matches!(action, ViewAction::None)); |
| 9566 | assert_eq!(view.selected, row_idx); |
| 9567 | |
| 9568 | let second = view.handle_mouse(MouseEvent { |
| 9569 | kind: MouseEventKind::Down(MouseButton::Left), |
| 9570 | column: 20, |
| 9571 | row: y, |
| 9572 | modifiers: KeyModifiers::NONE, |
| 9573 | }); |
| 9574 | match second { |
| 9575 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 9576 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 9577 | }) => assert_eq!(command, "/model"), |
| 9578 | other => panic!("second click should open the model picker, got {other:?}"), |
| 9579 | } |
| 9580 | assert!(view.editing.is_none()); |
| 9581 | } |
| 9582 | |
| 9583 | #[test] |
| 9584 | fn config_view_hover_tints_without_moving_selection() { |
| 9585 | let app = create_test_app(); |
| 9586 | let mut view = ConfigView::new_for_app(&app); |
| 9587 | let area = Rect::new(0, 0, 120, 32); |
| 9588 | let mut buf = Buffer::empty(area); |
| 9589 | view.render(area, &mut buf); |
| 9590 | let selected_before = view.selected; |
| 9591 | |
| 9592 | // Hover a non-selected row: the tint lands, the selection holds. |
| 9593 | let (rect, row_idx) = view |
| 9594 | .last_row_hitboxes |
| 9595 | .borrow() |
| 9596 | .iter() |
| 9597 | .copied() |
| 9598 | .find(|(_, idx)| *idx != selected_before) |
| 9599 | .expect("a non-selected row"); |
| 9600 | let action = view.handle_mouse(MouseEvent { |
| 9601 | kind: MouseEventKind::Moved, |
| 9602 | column: rect.x.saturating_add(1), |
| 9603 | row: rect.y, |
| 9604 | modifiers: KeyModifiers::NONE, |
| 9605 | }); |
| 9606 | assert!(matches!(action, ViewAction::None)); |
| 9607 | assert_eq!(view.hovered_row, Some(row_idx)); |
| 9608 | assert_eq!(view.selected, selected_before); |
| 9609 | |
| 9610 | // Repaint: the hovered row wears the shared hover band. |
| 9611 | let mut buf = Buffer::empty(area); |
| 9612 | view.render(area, &mut buf); |
| 9613 | assert_eq!( |
| 9614 | buf[(rect.x, rect.y)].bg, |
| 9615 | palette::SURFACE_ELEVATED, |
| 9616 | "hovered row must show the shared hover band" |
| 9617 | ); |
| 9618 | |
| 9619 | // Hover a strip chip: the rail tint lands, the tab holds. |
| 9620 | let (chip, _) = view |
| 9621 | .last_rail_hitboxes |
| 9622 | .borrow() |
| 9623 | .iter() |
| 9624 | .copied() |
| 9625 | .find(|(_, category)| *category == ConfigCategory::Advanced) |
| 9626 | .expect("Advanced chip"); |
| 9627 | let action = view.handle_mouse(MouseEvent { |
| 9628 | kind: MouseEventKind::Moved, |
| 9629 | column: chip.x.saturating_add(1), |
| 9630 | row: chip.y, |
| 9631 | modifiers: KeyModifiers::NONE, |
| 9632 | }); |
| 9633 | assert!(matches!(action, ViewAction::None)); |
| 9634 | assert_eq!(view.hovered_rail, Some(ConfigCategory::Advanced)); |
| 9635 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 9636 | |
| 9637 | // The search line is no target: hovering it clears every tint. |
| 9638 | let action = view.handle_mouse(MouseEvent { |
| 9639 | kind: MouseEventKind::Moved, |
| 9640 | column: 5, |
| 9641 | row: 1, |
| 9642 | modifiers: KeyModifiers::NONE, |
| 9643 | }); |
| 9644 | assert!(matches!(action, ViewAction::None)); |
| 9645 | assert_eq!(view.hovered_row, None); |
| 9646 | assert_eq!(view.hovered_rail, None); |
| 9647 | } |
| 9648 | |
| 9649 | #[test] |
| 9650 | fn config_view_rail_categories_are_clickable() { |
| 9651 | let app = create_test_app(); |
| 9652 | let mut view = ConfigView::new_for_app(&app); |
| 9653 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 9654 | // 120 columns: the vertical rail; 100 columns (96 inner): the strip. |
| 9655 | for width in [120u16, 100] { |
| 9656 | view.category = ConfigCategory::Appearance; |
| 9657 | view.select_first_visible_row(); |
| 9658 | let area = Rect::new(0, 0, width, 30); |
| 9659 | let mut buf = Buffer::empty(area); |
| 9660 | view.render(area, &mut buf); |
| 9661 | |
| 9662 | let hitboxes = view.last_rail_hitboxes.borrow().clone(); |
| 9663 | assert_eq!(hitboxes.len(), ConfigCategory::ALL.len(), "{width}"); |
| 9664 | let (rect, category) = hitboxes |
| 9665 | .iter() |
| 9666 | .copied() |
| 9667 | .find(|(_, category)| *category == ConfigCategory::Advanced) |
| 9668 | .expect("Advanced should have a hitbox"); |
| 9669 | |
| 9670 | let action = view.handle_mouse(MouseEvent { |
| 9671 | kind: MouseEventKind::Down(MouseButton::Left), |
| 9672 | column: rect.x.saturating_add(1), |
| 9673 | row: rect.y, |
| 9674 | modifiers: KeyModifiers::NONE, |
| 9675 | }); |
| 9676 | assert!(matches!(action, ViewAction::None)); |
| 9677 | assert_eq!(category, ConfigCategory::Advanced); |
| 9678 | assert_eq!(view.category, ConfigCategory::Advanced, "{width}"); |
| 9679 | assert!( |
| 9680 | view.rows |
| 9681 | .get(view.selected) |
| 9682 | .is_some_and(|row| ConfigCategory::for_row(row) == ConfigCategory::Advanced) |
| 9683 | ); |
| 9684 | } |
| 9685 | } |
| 9686 | |
| 9687 | #[test] |
| 9688 | fn config_categories_cover_every_row_with_the_approved_seven() { |
| 9689 | let app = create_test_app(); |
| 9690 | let view = ConfigView::new_for_app(&app); |
| 9691 | let labels: Vec<Cow<'static, str>> = ConfigCategory::ALL |
| 9692 | .iter() |
| 9693 | .map(|category| category.label(Locale::En)) |
| 9694 | .collect(); |
| 9695 | assert_eq!( |
| 9696 | labels, |
| 9697 | [ |
| 9698 | "Appearance", |
| 9699 | "Models & providers", |
| 9700 | "Work", |
| 9701 | "Tools & MCP", |
| 9702 | "Trust", |
| 9703 | "Motion", |
| 9704 | "Advanced", |
| 9705 | ] |
| 9706 | ); |
| 9707 | let category_of = |key: &str| { |
| 9708 | let row = view |
| 9709 | .rows |
| 9710 | .iter() |
| 9711 | .find(|row| row.key == key) |
| 9712 | .unwrap_or_else(|| panic!("row {key}")); |
| 9713 | ConfigCategory::for_row(row) |
| 9714 | }; |
| 9715 | assert_eq!(category_of("theme"), ConfigCategory::Appearance); |
| 9716 | assert_eq!(category_of("provider"), ConfigCategory::ModelsProviders); |
| 9717 | assert_eq!(category_of("model"), ConfigCategory::ModelsProviders); |
| 9718 | assert_eq!( |
| 9719 | category_of("reasoning_effort"), |
| 9720 | ConfigCategory::ModelsProviders |
| 9721 | ); |
| 9722 | // Raw endpoint, credential receipt, and context diagnostic rows live |
| 9723 | // under Advanced so default categories read as product language. |
| 9724 | for key in ["base_url", "context_window", "effective_context_window"] { |
| 9725 | assert_eq!(category_of(key), ConfigCategory::Advanced, "{key}"); |
| 9726 | } |
| 9727 | assert!( |
| 9728 | view.rows |
| 9729 | .iter() |
| 9730 | .filter(|row| ConfigCategory::for_row(row) == ConfigCategory::ModelsProviders) |
| 9731 | .all(|row| !row.key.starts_with("external_credentials.")), |
| 9732 | "credential receipts must not surface in Models & providers" |
| 9733 | ); |
| 9734 | // Sub-agent depth moved out of the one-row Fleet tab into Models. |
| 9735 | assert_eq!( |
| 9736 | category_of("fleet.exec.max_spawn_depth"), |
| 9737 | ConfigCategory::ModelsProviders |
| 9738 | ); |
| 9739 | assert_eq!(category_of("composer_density"), ConfigCategory::Work); |
| 9740 | assert_eq!(category_of("work_surface_placement"), ConfigCategory::Work); |
| 9741 | assert_eq!(category_of("auto_compact"), ConfigCategory::Work); |
| 9742 | assert_eq!(category_of("mcp_open"), ConfigCategory::ToolsMcp); |
| 9743 | assert_eq!(category_of("approval_mode"), ConfigCategory::Trust); |
| 9744 | assert_eq!(category_of("allow_shell"), ConfigCategory::Trust); |
| 9745 | assert_eq!(category_of("telemetry"), ConfigCategory::Trust); |
| 9746 | assert_eq!(category_of("low_motion"), ConfigCategory::Motion); |
| 9747 | assert_eq!(category_of("fancy_animations"), ConfigCategory::Motion); |
| 9748 | for category in ConfigCategory::ALL { |
| 9749 | assert!( |
| 9750 | view.rows.iter().any(|row| category.contains(row)), |
| 9751 | "{} lists no rows", |
| 9752 | category.label(Locale::En) |
| 9753 | ); |
| 9754 | } |
| 9755 | for category in ConfigCategory::ALL { |
| 9756 | assert_eq!(category.next().prev(), category); |
| 9757 | } |
| 9758 | } |
| 9759 | |
| 9760 | /// English copy for an id, for asserting on rendered chrome. |
| 9761 | fn en(id: MessageId) -> String { |
| 9762 | tr(Locale::En, id).into_owned() |
| 9763 | } |
| 9764 | |
| 9765 | fn render_dump(view: &ConfigView, width: u16, height: u16) -> String { |
| 9766 | let area = Rect::new(0, 0, width, height); |
| 9767 | let mut buf = Buffer::empty(area); |
| 9768 | view.render(area, &mut buf); |
| 9769 | buffer_text(&buf, area) |
| 9770 | } |
| 9771 | |
| 9772 | #[test] |
| 9773 | fn config_shell_uses_strip_and_list_at_80x24_and_rail_list_detail_when_wide() { |
| 9774 | let app = create_test_app(); |
| 9775 | let view = ConfigView::new_for_app(&app); |
| 9776 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 9777 | |
| 9778 | let dump = render_dump(&view, 80, 24); |
| 9779 | assert!( |
| 9780 | dump.contains("Appearance"), |
| 9781 | "strip shows the active category:\n{dump}" |
| 9782 | ); |
| 9783 | assert!(dump.contains("Theme"), "list missing theme row:\n{dump}"); |
| 9784 | assert!( |
| 9785 | !dump.contains("▸ Appearance"), |
| 9786 | "no vertical rail at 80 columns:\n{dump}" |
| 9787 | ); |
| 9788 | assert!( |
| 9789 | !dump.contains(&format!("{:<10}", en(MessageId::ConfigFactSource))), |
| 9790 | "detail pane must shed at 80 columns:\n{dump}" |
| 9791 | ); |
| 9792 | assert!( |
| 9793 | dump.contains(&en(MessageId::ConfigActivateAgain)), |
| 9794 | "selected row spells out its activation:\n{dump}" |
| 9795 | ); |
| 9796 | assert!( |
| 9797 | dump.contains(&en(MessageId::ConfigFactCurrent)) |
| 9798 | && dump.contains(&en(MessageId::ConfigFactSaved)), |
| 9799 | "narrow status row folds the lanes:\n{dump}" |
| 9800 | ); |
| 9801 | { |
| 9802 | let rows = view.last_row_hitboxes.borrow(); |
| 9803 | assert!(!rows.is_empty(), "list rows must stay clickable at 80x24"); |
| 9804 | assert!( |
| 9805 | rows.iter() |
| 9806 | .all(|(rect, _)| rect.width > 20 && rect.height == 1), |
| 9807 | "row hitboxes are exact rects: {rows:?}" |
| 9808 | ); |
| 9809 | let strip = view.last_rail_hitboxes.borrow(); |
| 9810 | assert!(!strip.is_empty() && strip.len() <= 8, "{strip:?}"); |
| 9811 | assert!( |
| 9812 | strip.iter().all(|(rect, _)| rect.y < rows[0].0.y), |
| 9813 | "strip sits above the list: {strip:?}" |
| 9814 | ); |
| 9815 | } |
| 9816 | |
| 9817 | let dump = render_dump(&view, 120, 32); |
| 9818 | assert!( |
| 9819 | dump.contains("❯ Display"), |
| 9820 | "groups column lists the active tab's groups:\n{dump}" |
| 9821 | ); |
| 9822 | for label in [ |
| 9823 | &en(MessageId::ConfigFactCurrent), |
| 9824 | &en(MessageId::ConfigFactSaved), |
| 9825 | &en(MessageId::ConfigFactStartup), |
| 9826 | &en(MessageId::ConfigFactSource), |
| 9827 | &en(MessageId::ConfigFactScope), |
| 9828 | &en(MessageId::ConfigFactApply), |
| 9829 | &en(MessageId::ConfigFactKind), |
| 9830 | &en(MessageId::ConfigFactAvailable), |
| 9831 | ] { |
| 9832 | assert!( |
| 9833 | dump.contains(&format!("{label:<10}")), |
| 9834 | "detail pane missing {label:?}:\n{dump}" |
| 9835 | ); |
| 9836 | } |
| 9837 | assert!( |
| 9838 | dump.contains(&en(MessageId::ConfigSourceUserSettings)), |
| 9839 | "source names the store:\n{dump}" |
| 9840 | ); |
| 9841 | assert!( |
| 9842 | dump.contains(&en(MessageId::ConfigApplyOnSave)), |
| 9843 | "apply semantics:\n{dump}" |
| 9844 | ); |
| 9845 | assert!( |
| 9846 | dump.contains(&en(MessageId::ConfigKindChoice)), |
| 9847 | "editor kind painted for the theme row:\n{dump}" |
| 9848 | ); |
| 9849 | assert_eq!(view.last_rail_hitboxes.borrow().len(), 7); |
| 9850 | |
| 9851 | for (w, h) in [(0u16, 0u16), (20, 4), (44, 12), (60, 18), (300, 60)] { |
| 9852 | let _ = render_dump(&view, w, h); |
| 9853 | } |
| 9854 | } |
| 9855 | |
| 9856 | #[test] |
| 9857 | fn config_shell_blocker_sizes_keep_active_category_list_and_affordances() { |
| 9858 | let app = create_test_app(); |
| 9859 | let mut view = ConfigView::new_for_app(&app); |
| 9860 | for (w, h) in [(40u16, 12u16), (80, 24), (100, 30), (120, 32)] { |
| 9861 | view.category = ConfigCategory::Appearance; |
| 9862 | view.select_first_visible_row(); |
| 9863 | let area = Rect::new(0, 0, w, h); |
| 9864 | let mut buf = Buffer::empty(area); |
| 9865 | view.render(area, &mut buf); |
| 9866 | let dump = buffer_text(&buf, area); |
| 9867 | assert!(dump.contains("Appearance"), "{w}x{h}:\n{dump}"); |
| 9868 | assert!(dump.contains("Search:"), "{w}x{h} keeps search:\n{dump}"); |
| 9869 | let rows = view.last_row_hitboxes.borrow().clone(); |
| 9870 | assert!(!rows.is_empty(), "{w}x{h} lists rows:\n{dump}"); |
| 9871 | // Every listed row paints its affordance glyph inside its own |
| 9872 | // hitbox cells (toggle / choose / edit / open / read-only). |
| 9873 | for (rect, idx) in rows { |
| 9874 | let row = &view.rows[idx]; |
| 9875 | let kind = view.editor_kind(row); |
| 9876 | let line: String = (rect.x..rect.right()) |
| 9877 | .map(|x| buf[(x, rect.y)].symbol().to_string()) |
| 9878 | .collect(); |
| 9879 | let glyph = super::setting_affordance(kind, Some(true)); |
| 9880 | let glyph_off = super::setting_affordance(kind, Some(false)); |
| 9881 | assert!( |
| 9882 | line.contains(glyph) || line.contains(glyph_off), |
| 9883 | "{w}x{h} row {} lacks its {kind:?} affordance: {line:?}", |
| 9884 | row.key |
| 9885 | ); |
| 9886 | } |
| 9887 | } |
| 9888 | } |
| 9889 | |
| 9890 | #[test] |
| 9891 | fn config_shell_short_heights_keep_advanced_reachable_by_keys_and_pointer() { |
| 9892 | let app = create_test_app(); |
| 9893 | let mut view = ConfigView::new_for_app(&app); |
| 9894 | for (w, h) in [(40u16, 12u16), (44, 12), (60, 16)] { |
| 9895 | view.category = ConfigCategory::Appearance; |
| 9896 | view.select_first_visible_row(); |
| 9897 | for _ in 0..6 { |
| 9898 | let _ = view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); |
| 9899 | } |
| 9900 | assert_eq!(view.category, ConfigCategory::Advanced, "{w}x{h}"); |
| 9901 | let area = Rect::new(0, 0, w, h); |
| 9902 | let mut buf = Buffer::empty(area); |
| 9903 | view.render(area, &mut buf); |
| 9904 | let dump = buffer_text(&buf, area); |
| 9905 | assert!( |
| 9906 | dump.contains("Advanced"), |
| 9907 | "{w}x{h} active category:\n{dump}" |
| 9908 | ); |
| 9909 | let strip = view.last_rail_hitboxes.borrow().clone(); |
| 9910 | let (advanced, _) = strip |
| 9911 | .iter() |
| 9912 | .copied() |
| 9913 | .find(|(_, category)| *category == ConfigCategory::Advanced) |
| 9914 | .unwrap_or_else(|| panic!("{w}x{h} Advanced hitbox: {dump}")); |
| 9915 | let cells: String = (advanced.x..advanced.right()) |
| 9916 | .map(|x| buf[(x, advanced.y)].symbol().to_string()) |
| 9917 | .collect(); |
| 9918 | assert!( |
| 9919 | cells.contains("Advanced"), |
| 9920 | "{w}x{h} hitbox cells: {cells:?}" |
| 9921 | ); |
| 9922 | // Pointer parity: the neighbour chip or compact Previous control |
| 9923 | // moves the category exactly as ← does. |
| 9924 | let _ = view.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); |
| 9925 | let by_key = view.category; |
| 9926 | let _ = view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); |
| 9927 | let mut buf = Buffer::empty(area); |
| 9928 | view.render(area, &mut buf); |
| 9929 | let strip = view.last_rail_hitboxes.borrow().clone(); |
| 9930 | let motion = strip |
| 9931 | .iter() |
| 9932 | .find(|(_, category)| *category == by_key) |
| 9933 | .map(|(rect, _)| *rect) |
| 9934 | .or_else(|| { |
| 9935 | view.last_nav_controls |
| 9936 | .borrow() |
| 9937 | .iter() |
| 9938 | .find(|(_, step)| *step == super::NavStep::Previous) |
| 9939 | .map(|(rect, _)| *rect) |
| 9940 | }) |
| 9941 | .unwrap_or_else(|| panic!("{w}x{h} {by_key:?} navigation target")); |
| 9942 | let action = view.handle_mouse(MouseEvent { |
| 9943 | kind: MouseEventKind::Down(MouseButton::Left), |
| 9944 | column: motion.x, |
| 9945 | row: motion.y, |
| 9946 | modifiers: KeyModifiers::NONE, |
| 9947 | }); |
| 9948 | assert!(matches!(action, ViewAction::None)); |
| 9949 | assert_eq!(view.category, by_key, "{w}x{h} pointer parity"); |
| 9950 | } |
| 9951 | } |
| 9952 | |
| 9953 | #[test] |
| 9954 | fn config_detail_never_synthesizes_current_from_saved() { |
| 9955 | let app = create_test_app(); |
| 9956 | let mut view = ConfigView::new_for_app(&app); |
| 9957 | let low_motion = view |
| 9958 | .rows |
| 9959 | .iter() |
| 9960 | .position(|row| row.key == "low_motion") |
| 9961 | .expect("low_motion row"); |
| 9962 | view.rows[low_motion].value = "false".to_string(); |
| 9963 | view.rows[low_motion].facts.effective = Some("true".to_string()); |
| 9964 | // Pin the authority: the host motion-override probe (a legacy console |
| 9965 | // host, NO_ANIMATIONS, an SSH session, …) would otherwise relabel this |
| 9966 | // row and make the test host-dependent. The subject here is the |
| 9967 | // saved/effective synthesis contract, not override detection. |
| 9968 | view.rows[low_motion].facts.authority = super::SettingAuthority::UserSettings; |
| 9969 | view.rows[low_motion].facts.authority_detail = None; |
| 9970 | view.category = ConfigCategory::Motion; |
| 9971 | view.selected = low_motion; |
| 9972 | |
| 9973 | let fact = view |
| 9974 | .setting_fact(&view.rows[low_motion]) |
| 9975 | .expect("setting fact"); |
| 9976 | assert_eq!(fact.effective.as_deref(), Some("On")); |
| 9977 | assert_eq!(fact.saved.as_deref(), Some("Off")); |
| 9978 | assert_eq!(fact.startup.as_deref(), Some("Off")); |
| 9979 | assert_eq!(fact.authority, super::SettingAuthority::UserSettings); |
| 9980 | assert_eq!(fact.apply, super::SettingApplySemantics::Immediate); |
| 9981 | |
| 9982 | let dump = render_dump(&view, 120, 32); |
| 9983 | let lane = |name: &str| { |
| 9984 | dump.lines() |
| 9985 | .find(|line| line.contains(&format!("{name:<10}"))) |
| 9986 | .unwrap_or_else(|| panic!("{name} lane:\n{dump}")) |
| 9987 | .to_string() |
| 9988 | }; |
| 9989 | assert!(lane("current").contains("On"), "{}", lane("current")); |
| 9990 | assert!(lane("saved").contains("Off"), "{}", lane("saved")); |
| 9991 | |
| 9992 | // A saved row with no App observation reports current as unobserved |
| 9993 | // instead of echoing the persisted value. |
| 9994 | let calm = view |
| 9995 | .rows |
| 9996 | .iter() |
| 9997 | .find(|row| row.key == "calm_mode") |
| 9998 | .expect("calm_mode row"); |
| 9999 | let fact = view.setting_fact(calm).expect("setting fact"); |
| 10000 | assert!(fact.effective.is_none() && fact.current.is_none()); |
| 10001 | assert_eq!( |
| 10002 | fact.saved.as_deref(), |
| 10003 | Some(config_choice_label(Locale::En, "calm_mode", &calm.value).as_str()) |
| 10004 | ); |
| 10005 | assert!( |
| 10006 | view.setting_detail_summary(calm) |
| 10007 | .contains(&en(MessageId::ConfigLaneUnobserved)) |
| 10008 | ); |
| 10009 | |
| 10010 | // Theme and locale report the value the app is actually running |
| 10011 | // with, from App, not the persisted string. |
| 10012 | let theme = view |
| 10013 | .rows |
| 10014 | .iter() |
| 10015 | .find(|row| row.key == "theme") |
| 10016 | .expect("theme row"); |
| 10017 | assert_eq!( |
| 10018 | theme.facts.effective.as_deref(), |
| 10019 | Some(app.theme_id.name()), |
| 10020 | "theme current lane comes from App" |
| 10021 | ); |
| 10022 | let locale = view |
| 10023 | .rows |
| 10024 | .iter() |
| 10025 | .find(|row| row.key == "locale") |
| 10026 | .expect("locale row"); |
| 10027 | assert_eq!(locale.facts.effective.as_deref(), Some(app.ui_locale.tag())); |
| 10028 | |
| 10029 | // Session-owned facts never claim a saved default they did not read. |
| 10030 | let provider = view |
| 10031 | .rows |
| 10032 | .iter() |
| 10033 | .find(|row| row.key == "provider") |
| 10034 | .expect("provider row"); |
| 10035 | let fact = view.setting_fact(provider).expect("setting fact"); |
| 10036 | assert_eq!(fact.authority, super::SettingAuthority::Session); |
| 10037 | assert_eq!(fact.effective, view.snapshot.provider.effective); |
| 10038 | assert!(fact.saved.is_none() && fact.startup.is_none()); |
| 10039 | } |
| 10040 | |
| 10041 | #[test] |
| 10042 | fn config_rows_carry_truthful_apply_semantics_and_kinds() { |
| 10043 | use super::{ConfigRowKind, SettingApplySemantics, SettingAuthority}; |
| 10044 | let app = create_test_app(); |
| 10045 | let view = ConfigView::new_for_app(&app); |
| 10046 | let row = |key: &str| { |
| 10047 | view.rows |
| 10048 | .iter() |
| 10049 | .find(|row| row.key == key) |
| 10050 | .unwrap_or_else(|| panic!("row {key}")) |
| 10051 | }; |
| 10052 | assert_eq!( |
| 10053 | row("default_mode").facts.apply, |
| 10054 | SettingApplySemantics::NextSession |
| 10055 | ); |
| 10056 | assert_eq!( |
| 10057 | row("mcp_config_path").facts.apply, |
| 10058 | SettingApplySemantics::ReloadRequired |
| 10059 | ); |
| 10060 | assert_eq!(row("theme").facts.apply, SettingApplySemantics::Immediate); |
| 10061 | for key in ["mcp_open", "mcp_reconnect", "mcp_diagnose", "plugins_open"] { |
| 10062 | let action = row(key); |
| 10063 | assert_eq!(action.facts.kind, ConfigRowKind::Action, "{key}"); |
| 10064 | assert!(view.setting_fact(action).is_none(), "{key} is not a fact"); |
| 10065 | assert!( |
| 10066 | view.setting_detail_summary(action) |
| 10067 | .contains(&en(MessageId::ConfigRowActionNote)), |
| 10068 | "{key}" |
| 10069 | ); |
| 10070 | } |
| 10071 | for key in ["effective_context_window", "base_url"] { |
| 10072 | let receipt = row(key); |
| 10073 | assert_eq!(receipt.facts.kind, ConfigRowKind::Diagnostic, "{key}"); |
| 10074 | assert!(view.setting_fact(receipt).is_none(), "{key}"); |
| 10075 | } |
| 10076 | assert_eq!( |
| 10077 | row("permission_posture").facts.authority, |
| 10078 | SettingAuthority::UserSettings |
| 10079 | ); |
| 10080 | assert_eq!( |
| 10081 | row("allow_shell").facts.authority, |
| 10082 | SettingAuthority::WorkspaceConfiguration |
| 10083 | ); |
| 10084 | assert_eq!( |
| 10085 | super::ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy).apply, |
| 10086 | SettingApplySemantics::ReadOnly |
| 10087 | ); |
| 10088 | assert_eq!( |
| 10089 | row("telemetry").facts.authority, |
| 10090 | SettingAuthority::WorkspaceConfiguration |
| 10091 | ); |
| 10092 | assert_eq!( |
| 10093 | super::setting_apply_label(Locale::En, SettingApplySemantics::ReloadRequired), |
| 10094 | en(MessageId::ConfigApplyReload) |
| 10095 | ); |
| 10096 | assert_eq!( |
| 10097 | super::setting_apply_label(Locale::En, SettingApplySemantics::UiNowEngineRestart), |
| 10098 | en(MessageId::ConfigApplyUiNowEngineRestart) |
| 10099 | ); |
| 10100 | assert_eq!( |
| 10101 | super::setting_apply_label(Locale::ZhHans, SettingApplySemantics::ReloadRequired), |
| 10102 | tr(Locale::ZhHans, MessageId::ConfigApplyReload) |
| 10103 | ); |
| 10104 | } |
| 10105 | |
| 10106 | #[test] |
| 10107 | fn config_list_clicks_outside_row_rects_never_select_or_activate() { |
| 10108 | let app = create_test_app(); |
| 10109 | let mut view = ConfigView::new_for_app(&app); |
| 10110 | let area = Rect::new(0, 0, 120, 32); |
| 10111 | let mut buf = Buffer::empty(area); |
| 10112 | view.render(area, &mut buf); |
| 10113 | let before = view.selected; |
| 10114 | let rows = view.last_row_hitboxes.borrow().clone(); |
| 10115 | let (first, _) = rows[0]; |
| 10116 | let list_bottom = rows.iter().map(|(rect, _)| rect.bottom()).max().unwrap(); |
| 10117 | let tabs = view.last_rail_hitboxes.borrow().clone(); |
| 10118 | let tabs_bottom = tabs.iter().map(|(rect, _)| rect.bottom()).max().unwrap(); |
| 10119 | assert!(tabs_bottom <= first.y, "tabs must not overlap the list"); |
| 10120 | |
| 10121 | let click = |view: &mut ConfigView, column: u16, row: u16| { |
| 10122 | view.handle_mouse(MouseEvent { |
| 10123 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10124 | column, |
| 10125 | row, |
| 10126 | modifiers: KeyModifiers::NONE, |
| 10127 | }) |
| 10128 | }; |
| 10129 | // Rail area on a row that is not a category, the divider between |
| 10130 | // rail and list, the detail pane, the status row, and the footer. |
| 10131 | let probes = [ |
| 10132 | (first.x.saturating_sub(4), list_bottom.saturating_sub(1)), |
| 10133 | (first.x.saturating_sub(1), first.y), |
| 10134 | (first.right().saturating_add(3), first.y), |
| 10135 | (first.x.saturating_add(2), area.bottom().saturating_sub(4)), |
| 10136 | (first.x.saturating_add(2), area.bottom().saturating_sub(2)), |
| 10137 | ]; |
| 10138 | for (column, row) in probes { |
| 10139 | // Twice: a second click is the activation gesture on a row. |
| 10140 | let _ = click(&mut view, column, row); |
| 10141 | let action = click(&mut view, column, row); |
| 10142 | assert!(matches!(action, ViewAction::None), "({column},{row})"); |
| 10143 | assert_eq!(view.selected, before, "({column},{row}) must not select"); |
| 10144 | assert!(view.editing.is_none(), "({column},{row}) must not activate"); |
| 10145 | } |
| 10146 | // Inside the rect: selects on the first click. |
| 10147 | let (rect, idx) = rows[rows.len() - 1]; |
| 10148 | let _ = click(&mut view, rect.x, rect.y); |
| 10149 | assert_eq!(view.selected, idx); |
| 10150 | } |
| 10151 | |
| 10152 | #[test] |
| 10153 | fn config_search_indexes_categories_and_category_click_clears_filter() { |
| 10154 | let app = create_test_app(); |
| 10155 | let mut view = ConfigView::new_for_app(&app); |
| 10156 | type_filter(&mut view, "fleet"); |
| 10157 | assert!( |
| 10158 | visible_row_keys(&view).contains(&"fleet.exec.max_spawn_depth"), |
| 10159 | "{:?}", |
| 10160 | visible_row_keys(&view) |
| 10161 | ); |
| 10162 | view.clear_filter(); |
| 10163 | type_filter(&mut view, "trust"); |
| 10164 | let keys = visible_row_keys(&view); |
| 10165 | assert!(keys.contains(&"approval_mode"), "{keys:?}"); |
| 10166 | assert!(keys.contains(&"telemetry"), "{keys:?}"); |
| 10167 | |
| 10168 | let area = Rect::new(0, 0, 80, 24); |
| 10169 | let mut buf = Buffer::empty(area); |
| 10170 | view.render(area, &mut buf); |
| 10171 | // The strip windows from the active (Appearance) chip at 80 columns; |
| 10172 | // Trust is inside that window. |
| 10173 | let (rect, category) = view |
| 10174 | .last_rail_hitboxes |
| 10175 | .borrow() |
| 10176 | .iter() |
| 10177 | .copied() |
| 10178 | .find(|(_, category)| *category == ConfigCategory::Trust) |
| 10179 | .expect("Trust chip visible while filtering"); |
| 10180 | let action = view.handle_mouse(MouseEvent { |
| 10181 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10182 | column: rect.x, |
| 10183 | row: rect.y, |
| 10184 | modifiers: KeyModifiers::NONE, |
| 10185 | }); |
| 10186 | assert!(matches!(action, ViewAction::None)); |
| 10187 | assert_eq!(category, ConfigCategory::Trust); |
| 10188 | assert!(view.filter.is_empty(), "category click clears the filter"); |
| 10189 | assert_eq!(view.category, ConfigCategory::Trust); |
| 10190 | let keys = visible_row_keys(&view); |
| 10191 | assert!(keys.contains(&"approval_mode"), "{keys:?}"); |
| 10192 | assert!(keys.contains(&"telemetry"), "{keys:?}"); |
| 10193 | assert!( |
| 10194 | keys.iter().all(|key| ConfigCategory::Trust |
| 10195 | .contains(view.rows.iter().find(|row| row.key == *key).unwrap())), |
| 10196 | "only Trust rows remain after the click: {keys:?}" |
| 10197 | ); |
| 10198 | } |
| 10199 | |
| 10200 | /// Interaction evidence at the blocker sizes: the real `ConfigView` |
| 10201 | /// driven by keys and pointer at 40x12, 80x24, 100x30, and 120x32. The |
| 10202 | /// rendered buffers are printed (`--nocapture`) as harness evidence — the |
| 10203 | /// real renderer into a ratatui `Buffer`, not a terminal capture. |
| 10204 | #[test] |
| 10205 | #[allow( |
| 10206 | clippy::print_stdout, |
| 10207 | reason = "prints the rendered buffers as interaction evidence under --nocapture" |
| 10208 | )] |
| 10209 | fn config_shell_interaction_evidence_at_blocker_sizes() { |
| 10210 | let app = create_test_app(); |
| 10211 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10212 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10213 | }; |
| 10214 | let click = |view: &mut ConfigView, column: u16, row: u16| { |
| 10215 | view.handle_mouse(MouseEvent { |
| 10216 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10217 | column, |
| 10218 | row, |
| 10219 | modifiers: KeyModifiers::NONE, |
| 10220 | }) |
| 10221 | }; |
| 10222 | for (w, h) in [(40u16, 12u16), (80, 24), (100, 30), (120, 32)] { |
| 10223 | let mut view = ConfigView::new_for_app(&app); |
| 10224 | let area = Rect::new(0, 0, w, h); |
| 10225 | let snapshot = |view: &ConfigView, step: &str| { |
| 10226 | let mut buf = Buffer::empty(area); |
| 10227 | view.render(area, &mut buf); |
| 10228 | let dump = buffer_text(&buf, area); |
| 10229 | println!("== {w}x{h} · {step} ==\n{dump}"); |
| 10230 | dump |
| 10231 | }; |
| 10232 | |
| 10233 | // Opens on Appearance with the first Appearance row selected. |
| 10234 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 10235 | assert_eq!(view.rows[view.selected].key, "theme"); |
| 10236 | let dump = snapshot(&view, "open"); |
| 10237 | assert!(dump.contains("Appearance"), "{w}x{h}:\n{dump}"); |
| 10238 | assert!(dump.contains("Search:"), "{w}x{h}:\n{dump}"); |
| 10239 | |
| 10240 | // → lands on Models & providers (the one-row Fleet tab is gone; |
| 10241 | // sub-agent depth moved into the Model group). Focus it and check |
| 10242 | // the read-only config.toml posture. |
| 10243 | assert!(matches!(key(&mut view, KeyCode::Right), ViewAction::None)); |
| 10244 | assert_eq!(view.category, ConfigCategory::ModelsProviders); |
| 10245 | view.focus_key("fleet.exec.max_spawn_depth"); |
| 10246 | assert_eq!(view.rows[view.selected].key, "fleet.exec.max_spawn_depth"); |
| 10247 | let dump = snapshot(&view, "after → (Models & providers)"); |
| 10248 | assert!(dump.contains("Models & providers"), "{w}x{h}:\n{dump}"); |
| 10249 | assert!( |
| 10250 | dump.contains(super::setting_affordance(SettingKind::ReadOnly, None)), |
| 10251 | "{w}x{h} read-only affordance:\n{dump}" |
| 10252 | ); |
| 10253 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 10254 | assert!(view.editing.is_none(), "{w}x{h} read-only rows never edit"); |
| 10255 | |
| 10256 | // Tab ×4 → Motion; ↓ → fancy_animations; Enter toggles it and |
| 10257 | // emits the persisted update without opening an editor. |
| 10258 | for _ in 0..4 { |
| 10259 | assert!(matches!(key(&mut view, KeyCode::Tab), ViewAction::None)); |
| 10260 | } |
| 10261 | assert_eq!(view.category, ConfigCategory::Motion); |
| 10262 | assert_eq!(view.rows[view.selected].key, "low_motion"); |
| 10263 | assert!(matches!(key(&mut view, KeyCode::Down), ViewAction::None)); |
| 10264 | assert_eq!(view.rows[view.selected].key, "fancy_animations"); |
| 10265 | let dump = snapshot(&view, "Motion · ↓ to fancy_animations"); |
| 10266 | assert!( |
| 10267 | if w < 50 { |
| 10268 | dump.contains("Enter") |
| 10269 | } else { |
| 10270 | dump.contains(&en(MessageId::ConfigActivateAgain)) |
| 10271 | }, |
| 10272 | "{w}x{h} activation copy:\n{dump}" |
| 10273 | ); |
| 10274 | match key(&mut view, KeyCode::Enter) { |
| 10275 | ViewAction::Emit(ViewEvent::ConfigUpdated { key, persist, .. }) => { |
| 10276 | assert_eq!(key, "fancy_animations"); |
| 10277 | assert!(persist); |
| 10278 | } |
| 10279 | other => panic!("{w}x{h} Enter should toggle, got {other:?}"), |
| 10280 | } |
| 10281 | assert!(view.editing.is_none()); |
| 10282 | |
| 10283 | // Pointer parity: click a visible non-active chip or the compact |
| 10284 | // Previous control, then select and activate the first row. |
| 10285 | let mut buf = Buffer::empty(area); |
| 10286 | view.render(area, &mut buf); |
| 10287 | let (chip, target) = view |
| 10288 | .last_rail_hitboxes |
| 10289 | .borrow() |
| 10290 | .iter() |
| 10291 | .copied() |
| 10292 | .find(|(_, category)| *category != ConfigCategory::Motion) |
| 10293 | .or_else(|| { |
| 10294 | view.last_nav_controls |
| 10295 | .borrow() |
| 10296 | .iter() |
| 10297 | .find(|(_, step)| *step == super::NavStep::Previous) |
| 10298 | .map(|(rect, _)| (*rect, ConfigCategory::Trust)) |
| 10299 | }) |
| 10300 | .expect("another category is reachable through a painted target"); |
| 10301 | assert!(matches!(click(&mut view, chip.x, chip.y), ViewAction::None)); |
| 10302 | assert_eq!(view.category, target, "{w}x{h} chip click"); |
| 10303 | let mut buf = Buffer::empty(area); |
| 10304 | view.render(area, &mut buf); |
| 10305 | let (row_rect, row_idx) = view.last_row_hitboxes.borrow()[0]; |
| 10306 | assert!(matches!( |
| 10307 | click(&mut view, row_rect.x + 1, row_rect.y), |
| 10308 | ViewAction::None |
| 10309 | )); |
| 10310 | assert_eq!(view.selected, row_idx, "{w}x{h} first click selects"); |
| 10311 | let dump = snapshot( |
| 10312 | &view, |
| 10313 | &format!("pointer · {} chip, row selected", target.label(Locale::En)), |
| 10314 | ); |
| 10315 | assert!(dump.contains("❯"), "{w}x{h} selected row marker:\n{dump}"); |
| 10316 | let second = click(&mut view, row_rect.x + 1, row_rect.y); |
| 10317 | let row = &view.rows[row_idx]; |
| 10318 | match (row.editable, row.facts.command) { |
| 10319 | (false, _) => { |
| 10320 | assert!(matches!(second, ViewAction::None)); |
| 10321 | assert!(view.editing.is_none()); |
| 10322 | } |
| 10323 | (true, Some((command, _))) => match second { |
| 10324 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 10325 | action: CommandPaletteAction::ExecuteCommand { command: emitted }, |
| 10326 | }) => assert_eq!(emitted, command), |
| 10327 | ViewAction::Emit(ViewEvent::ExecutePanelCommand { |
| 10328 | command: emitted, |
| 10329 | pager_title: Some(_), |
| 10330 | }) => assert_eq!(emitted, command), |
| 10331 | other => panic!("{w}x{h} second click should open {command}: {other:?}"), |
| 10332 | }, |
| 10333 | (true, None) => assert!( |
| 10334 | view.editing.is_some() || matches!(second, ViewAction::Emit(_)), |
| 10335 | "{w}x{h} second click must edit or emit: {second:?}" |
| 10336 | ), |
| 10337 | } |
| 10338 | if view.editing.is_some() { |
| 10339 | let _ = snapshot(&view, "editor after second click"); |
| 10340 | assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::None)); |
| 10341 | assert!(view.editing.is_none()); |
| 10342 | } |
| 10343 | |
| 10344 | // Search: typing filters across categories; Esc clears; Esc again |
| 10345 | // closes the view. |
| 10346 | for ch in "telemetry".chars() { |
| 10347 | assert!(matches!( |
| 10348 | key(&mut view, KeyCode::Char(ch)), |
| 10349 | ViewAction::None |
| 10350 | )); |
| 10351 | } |
| 10352 | assert_eq!(visible_row_keys(&view), vec!["telemetry"]); |
| 10353 | let dump = snapshot(&view, "search \"telemetry\""); |
| 10354 | assert!(dump.contains("telemetry"), "{w}x{h}:\n{dump}"); |
| 10355 | assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::None)); |
| 10356 | assert!(view.filter.is_empty()); |
| 10357 | assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::Close)); |
| 10358 | } |
| 10359 | } |
| 10360 | |
| 10361 | /// P1.1: a store that fails to load never becomes a default labelled |
| 10362 | /// saved/startup. The row is unavailable, read-only, and its lanes carry |
| 10363 | /// the load error; App-observed lanes (low_motion) still render. |
| 10364 | #[test] |
| 10365 | fn config_rows_report_unreadable_stores_instead_of_defaults() { |
| 10366 | let _lock = crate::test_support::lock_test_env(); |
| 10367 | let tmp = TempDir::new().expect("tempdir"); |
| 10368 | let config_path = tmp.path().join(".deepseek").join("config.toml"); |
| 10369 | std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); |
| 10370 | std::fs::write( |
| 10371 | config_path.parent().unwrap().join("settings.toml"), |
| 10372 | "theme = [broken\n", |
| 10373 | ) |
| 10374 | .unwrap(); |
| 10375 | std::fs::write(&config_path, "approval_policy = [broken\n").unwrap(); |
| 10376 | let _guard = crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 10377 | let mut app = create_test_app(); |
| 10378 | app.config_path = Some(config_path.clone()); |
| 10379 | let mut view = ConfigView::new_for_app(&app); |
| 10380 | |
| 10381 | let theme = view.rows.iter().find(|row| row.key == "theme").unwrap(); |
| 10382 | assert!( |
| 10383 | !theme.editable, |
| 10384 | "an unreadable store is never written through" |
| 10385 | ); |
| 10386 | assert_eq!(theme.value, en(MessageId::ConfigUnavailable)); |
| 10387 | let error = theme |
| 10388 | .facts |
| 10389 | .store_error |
| 10390 | .clone() |
| 10391 | .expect("settings load error is preserved"); |
| 10392 | assert!(!error.is_empty()); |
| 10393 | let fact = view.setting_fact(theme).expect("setting fact"); |
| 10394 | assert!(fact.saved.is_none() && fact.startup.is_none(), "{fact:?}"); |
| 10395 | assert_eq!( |
| 10396 | fact.effective.as_deref(), |
| 10397 | Some(app.theme_id.name()), |
| 10398 | "the live theme is still known from App" |
| 10399 | ); |
| 10400 | let summary = view.setting_detail_summary(theme); |
| 10401 | assert!( |
| 10402 | summary.contains(&en(MessageId::ConfigLaneUnavailable).replace("{error}", &error)), |
| 10403 | "{summary}" |
| 10404 | ); |
| 10405 | |
| 10406 | let telemetry = view.rows.iter().find(|row| row.key == "telemetry").unwrap(); |
| 10407 | assert!(!telemetry.editable); |
| 10408 | assert!( |
| 10409 | telemetry.facts.store_error.is_some(), |
| 10410 | "config.toml error is preserved" |
| 10411 | ); |
| 10412 | let low_motion = view |
| 10413 | .rows |
| 10414 | .iter() |
| 10415 | .find(|row| row.key == "low_motion") |
| 10416 | .unwrap(); |
| 10417 | let fact = view.setting_fact(low_motion).expect("setting fact"); |
| 10418 | assert!( |
| 10419 | fact.saved.is_none(), |
| 10420 | "no persisted lane from a broken store" |
| 10421 | ); |
| 10422 | assert!( |
| 10423 | fact.effective.is_some(), |
| 10424 | "App still supplies the live value" |
| 10425 | ); |
| 10426 | |
| 10427 | // Rendered: the detail pane names the failure, never a default. |
| 10428 | view.focus_key("theme"); |
| 10429 | let lines = view.setting_detail_lines(&view.rows[view.selected], 400); |
| 10430 | let expected = en(MessageId::ConfigLaneUnavailable).replace("{error}", &error); |
| 10431 | // The lane is one line (the error is folded) and may be ellipsized |
| 10432 | // past the pane width, so its head is the stable part. |
| 10433 | assert!( |
| 10434 | !error.contains('\n'), |
| 10435 | "store error is folded onto one line: {error:?}" |
| 10436 | ); |
| 10437 | let head: String = expected.chars().take(60).collect(); |
| 10438 | assert!( |
| 10439 | lines.iter().any(|line| line.to_string().contains(&head)), |
| 10440 | "saved lane carries the load error: {expected}" |
| 10441 | ); |
| 10442 | let dump = render_dump(&view, 120, 32); |
| 10443 | let head: String = en(MessageId::ConfigUnavailable).chars().take(7).collect(); |
| 10444 | assert!( |
| 10445 | dump.contains(&head), |
| 10446 | "unavailable value painted in the list:\n{dump}" |
| 10447 | ); |
| 10448 | let unavailable_prefix = en(MessageId::ConfigLaneUnavailable) |
| 10449 | .split("{error}") |
| 10450 | .next() |
| 10451 | .unwrap() |
| 10452 | .to_string(); |
| 10453 | assert!( |
| 10454 | dump.contains(unavailable_prefix.trim_end()), |
| 10455 | "unavailable lane painted:\n{dump}" |
| 10456 | ); |
| 10457 | // Session rows are untouched by store failures. |
| 10458 | let provider = view.rows.iter().find(|row| row.key == "provider").unwrap(); |
| 10459 | assert!(provider.editable && provider.facts.store_error.is_none()); |
| 10460 | } |
| 10461 | |
| 10462 | /// An environment/terminal override wins the effective decision but does |
| 10463 | /// not repair a broken store: the overridden motion row must still report |
| 10464 | /// the settings.toml load failure instead of synthesizing a saved lane. |
| 10465 | /// (Windows CI caught this through the legacy-console probe; store truth |
| 10466 | /// is keyed on the row's store, not its current authority.) |
| 10467 | #[test] |
| 10468 | fn overridden_motion_row_still_reports_a_broken_settings_store() { |
| 10469 | let _lock = crate::test_support::lock_test_env(); |
| 10470 | let tmp = TempDir::new().expect("tempdir"); |
| 10471 | let config_dir = tmp.path().join(".deepseek"); |
| 10472 | std::fs::create_dir_all(&config_dir).unwrap(); |
| 10473 | std::fs::write(config_dir.join("settings.toml"), "theme = [broken\n").unwrap(); |
| 10474 | let config_path = config_dir.join("config.toml"); |
| 10475 | std::fs::write(&config_path, "approval_policy = [broken\n").unwrap(); |
| 10476 | let _guard = crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 10477 | let _override = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1"); |
| 10478 | let app = create_test_app(); |
| 10479 | let view = ConfigView::new_for_app(&app); |
| 10480 | |
| 10481 | let row = view |
| 10482 | .rows |
| 10483 | .iter() |
| 10484 | .find(|row| row.key == "low_motion") |
| 10485 | .expect("low_motion row"); |
| 10486 | assert_eq!(row.facts.authority, super::SettingAuthority::Environment); |
| 10487 | assert!( |
| 10488 | row.facts.store_error.is_some(), |
| 10489 | "the override wins the effective decision; the broken store is still reported" |
| 10490 | ); |
| 10491 | let fact = view.setting_fact(row).expect("setting fact"); |
| 10492 | assert!(fact.saved.is_none() && fact.startup.is_none(), "{fact:?}"); |
| 10493 | assert!( |
| 10494 | fact.effective.is_some(), |
| 10495 | "App still supplies the live value" |
| 10496 | ); |
| 10497 | } |
| 10498 | |
| 10499 | /// P1.2: when a runtime overlay forces low motion, the row's source is |
| 10500 | /// that override, not `settings.toml`. |
| 10501 | #[test] |
| 10502 | fn motion_rows_name_the_winning_environment_override() { |
| 10503 | let _lock = crate::test_support::lock_test_env(); |
| 10504 | let _no_animations = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1"); |
| 10505 | let app = create_test_app(); |
| 10506 | let mut view = ConfigView::new_for_app(&app); |
| 10507 | for key in ["low_motion", "fancy_animations"] { |
| 10508 | let row = view.rows.iter().find(|row| row.key == key).unwrap(); |
| 10509 | assert_eq!( |
| 10510 | row.facts.authority, |
| 10511 | super::SettingAuthority::Environment, |
| 10512 | "{key}" |
| 10513 | ); |
| 10514 | assert_eq!(row.facts.authority_detail, Some("NO_ANIMATIONS"), "{key}"); |
| 10515 | } |
| 10516 | let theme = view.rows.iter().find(|row| row.key == "theme").unwrap(); |
| 10517 | assert_eq!(theme.facts.authority, super::SettingAuthority::UserSettings); |
| 10518 | view.focus_key("low_motion"); |
| 10519 | let expected = en(MessageId::ConfigSourceEnvironment).replace("{name}", "NO_ANIMATIONS"); |
| 10520 | // The detail pane is 39 columns wide at 120x32 and ellipsizes long |
| 10521 | // values, so the unbounded detail lines carry the full label and the |
| 10522 | // rendered pane carries its visible head. |
| 10523 | let lines = view.setting_detail_lines(&view.rows[view.selected], 200); |
| 10524 | assert!( |
| 10525 | lines |
| 10526 | .iter() |
| 10527 | .any(|line| line.to_string().contains(&expected)), |
| 10528 | "detail names the override: {expected}" |
| 10529 | ); |
| 10530 | let dump = render_dump(&view, 120, 32); |
| 10531 | let head: String = expected.chars().take(20).collect(); |
| 10532 | assert!(dump.contains(&head), "source names the override:\n{dump}"); |
| 10533 | } |
| 10534 | |
| 10535 | /// Slice C: Edit Theme live preview — highlighting a theme row emits a |
| 10536 | /// session-only `ConfigUpdated` (the surface repaints immediately) while |
| 10537 | /// only Enter/Apply persists; Esc reverts to the opening value. |
| 10538 | #[test] |
| 10539 | fn edit_theme_highlight_previews_without_persisting_and_esc_reverts() { |
| 10540 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 10541 | let app = create_test_app(); |
| 10542 | let mut view = ConfigView::new_for_app(&app); |
| 10543 | view.focus_key("theme"); |
| 10544 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10545 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10546 | }; |
| 10547 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 10548 | assert!( |
| 10549 | view.editing |
| 10550 | .as_ref() |
| 10551 | .is_some_and(|edit| edit.key == "theme"), |
| 10552 | "Enter must open the theme editor" |
| 10553 | ); |
| 10554 | |
| 10555 | // ↓ highlights shoreline: preview (persist:false), editor stays open. |
| 10556 | match key(&mut view, KeyCode::Down) { |
| 10557 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 10558 | key, |
| 10559 | value, |
| 10560 | persist, |
| 10561 | }) => { |
| 10562 | assert_eq!(key, "theme"); |
| 10563 | assert_eq!(value, "shoreline"); |
| 10564 | assert!(!persist, "highlighting must not persist"); |
| 10565 | } |
| 10566 | other => panic!("highlight must preview, got {other:?}"), |
| 10567 | } |
| 10568 | assert!(view.editing.is_some(), "preview keeps the editor open"); |
| 10569 | |
| 10570 | // Esc reverts the live surface to the opening value, session-only. |
| 10571 | match key(&mut view, KeyCode::Esc) { |
| 10572 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 10573 | key, |
| 10574 | value, |
| 10575 | persist, |
| 10576 | }) => { |
| 10577 | assert_eq!(key, "theme"); |
| 10578 | assert_eq!(value, "terminal"); |
| 10579 | assert!(!persist, "revert must not persist"); |
| 10580 | } |
| 10581 | other => panic!("esc must revert the preview, got {other:?}"), |
| 10582 | } |
| 10583 | assert!(view.editing.is_none()); |
| 10584 | } |
| 10585 | |
| 10586 | /// Slice C: Enter/Apply in Edit Theme persists the highlighted theme. |
| 10587 | #[test] |
| 10588 | fn edit_theme_enter_persists_the_highlighted_theme() { |
| 10589 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 10590 | let app = create_test_app(); |
| 10591 | let mut view = ConfigView::new_for_app(&app); |
| 10592 | view.focus_key("theme"); |
| 10593 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10594 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10595 | }; |
| 10596 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 10597 | let _ = key(&mut view, KeyCode::Down); |
| 10598 | match key(&mut view, KeyCode::Enter) { |
| 10599 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 10600 | key, |
| 10601 | value, |
| 10602 | persist, |
| 10603 | }) => { |
| 10604 | assert_eq!(key, "theme"); |
| 10605 | assert_eq!(value, "shoreline"); |
| 10606 | assert!(persist, "Apply must persist"); |
| 10607 | } |
| 10608 | other => panic!("enter must persist the highlight, got {other:?}"), |
| 10609 | } |
| 10610 | assert!(view.editing.is_none()); |
| 10611 | } |
| 10612 | |
| 10613 | /// Slice C: Esc without moving the highlight previews nothing and |
| 10614 | /// reverts nothing. |
| 10615 | #[test] |
| 10616 | fn edit_theme_esc_without_preview_is_silent() { |
| 10617 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 10618 | let app = create_test_app(); |
| 10619 | let mut view = ConfigView::new_for_app(&app); |
| 10620 | view.focus_key("theme"); |
| 10621 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10622 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10623 | }; |
| 10624 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 10625 | assert!( |
| 10626 | matches!(key(&mut view, KeyCode::Esc), ViewAction::None), |
| 10627 | "no preview happened, so there is nothing to revert" |
| 10628 | ); |
| 10629 | } |
| 10630 | |
| 10631 | /// Slice C: live preview is theme-only — other choice editors keep |
| 10632 | /// their silent highlight behavior. |
| 10633 | #[test] |
| 10634 | fn edit_choice_highlight_previews_only_the_theme_key() { |
| 10635 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 10636 | let app = create_test_app(); |
| 10637 | let mut view = ConfigView::new_for_app(&app); |
| 10638 | view.focus_key("default_mode"); |
| 10639 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10640 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10641 | }; |
| 10642 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 10643 | assert!( |
| 10644 | view.editing |
| 10645 | .as_ref() |
| 10646 | .is_some_and(|edit| edit.key == "default_mode"), |
| 10647 | "Enter must open the default_mode editor" |
| 10648 | ); |
| 10649 | assert!( |
| 10650 | matches!(key(&mut view, KeyCode::Down), ViewAction::None), |
| 10651 | "non-theme highlight must stay silent" |
| 10652 | ); |
| 10653 | assert!( |
| 10654 | matches!(key(&mut view, KeyCode::Esc), ViewAction::None), |
| 10655 | "no preview means no revert" |
| 10656 | ); |
| 10657 | } |
| 10658 | |
| 10659 | /// Slice C (global hover rule): hovering an Edit Theme choice row |
| 10660 | /// highlights it and live-previews; hovering the same row again is |
| 10661 | /// silent. |
| 10662 | #[test] |
| 10663 | fn edit_theme_hover_highlights_and_previews() { |
| 10664 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 10665 | let app = create_test_app(); |
| 10666 | let mut view = ConfigView::new_for_app(&app); |
| 10667 | view.focus_key("theme"); |
| 10668 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 10669 | let area = Rect::new(0, 0, 120, 32); |
| 10670 | let mut buf = Buffer::empty(area); |
| 10671 | view.render(area, &mut buf); |
| 10672 | let hover = |view: &mut ConfigView, column: u16, row: u16| { |
| 10673 | view.handle_mouse(MouseEvent { |
| 10674 | kind: MouseEventKind::Moved, |
| 10675 | column, |
| 10676 | row, |
| 10677 | modifiers: KeyModifiers::NONE, |
| 10678 | }) |
| 10679 | }; |
| 10680 | // Choice index 2 is shoreline (system, terminal, shoreline, …). |
| 10681 | let (rect, _) = view |
| 10682 | .last_choice_hitboxes |
| 10683 | .borrow() |
| 10684 | .iter() |
| 10685 | .copied() |
| 10686 | .find(|(_, idx)| *idx == 2) |
| 10687 | .expect("rendered shoreline hitbox"); |
| 10688 | match hover(&mut view, rect.x, rect.y) { |
| 10689 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 10690 | key, |
| 10691 | value, |
| 10692 | persist, |
| 10693 | }) => { |
| 10694 | assert_eq!(key, "theme"); |
| 10695 | assert_eq!(value, "shoreline"); |
| 10696 | assert!(!persist, "hover preview must not persist"); |
| 10697 | } |
| 10698 | other => panic!("hover must preview, got {other:?}"), |
| 10699 | } |
| 10700 | assert!( |
| 10701 | matches!(hover(&mut view, rect.x, rect.y), ViewAction::None), |
| 10702 | "hovering the highlighted row is silent" |
| 10703 | ); |
| 10704 | assert!( |
| 10705 | matches!(hover(&mut view, 0, 0), ViewAction::None), |
| 10706 | "hovering outside every row is silent" |
| 10707 | ); |
| 10708 | } |
| 10709 | |
| 10710 | /// P1.3: at 40 columns every category is reachable with the pointer alone |
| 10711 | /// — visible chips are clicked directly, hidden ones through the › and ‹ |
| 10712 | /// overflow markers, which are themselves hitboxes. |
| 10713 | #[test] |
| 10714 | fn config_strip_categories_are_reachable_by_pointer_alone_at_40_columns() { |
| 10715 | let app = create_test_app(); |
| 10716 | let mut view = ConfigView::new_for_app(&app); |
| 10717 | let area = Rect::new(0, 0, 40, 12); |
| 10718 | let click = |view: &mut ConfigView, rect: Rect| { |
| 10719 | view.handle_mouse(MouseEvent { |
| 10720 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10721 | column: rect.x, |
| 10722 | row: rect.y, |
| 10723 | modifiers: KeyModifiers::NONE, |
| 10724 | }) |
| 10725 | }; |
| 10726 | let mut reached = vec![view.category]; |
| 10727 | for target in ConfigCategory::ALL { |
| 10728 | // Walk to `target` using only painted hitboxes. |
| 10729 | for _ in 0..16 { |
| 10730 | if view.category == target { |
| 10731 | break; |
| 10732 | } |
| 10733 | let mut buf = Buffer::empty(area); |
| 10734 | view.render(area, &mut buf); |
| 10735 | let chip = view |
| 10736 | .last_rail_hitboxes |
| 10737 | .borrow() |
| 10738 | .iter() |
| 10739 | .find(|(_, category)| *category == target) |
| 10740 | .map(|(rect, _)| *rect); |
| 10741 | let step = |step: super::NavStep| { |
| 10742 | view.last_nav_controls |
| 10743 | .borrow() |
| 10744 | .iter() |
| 10745 | .find(|(_, s)| *s == step) |
| 10746 | .map(|(rect, _)| *rect) |
| 10747 | }; |
| 10748 | let rect = chip |
| 10749 | .or_else(|| step(super::NavStep::Next)) |
| 10750 | .or_else(|| step(super::NavStep::Previous)) |
| 10751 | .expect("a chip or an overflow marker is always clickable"); |
| 10752 | // The marker cells are painted, not empty. |
| 10753 | let cells: String = (rect.x..rect.right()) |
| 10754 | .map(|x| buf[(x, rect.y)].symbol().to_string()) |
| 10755 | .collect(); |
| 10756 | assert!(!cells.trim().is_empty(), "{target:?}: {cells:?}"); |
| 10757 | assert!(matches!(click(&mut view, rect), ViewAction::None)); |
| 10758 | } |
| 10759 | assert_eq!(view.category, target, "pointer-only path to {target:?}"); |
| 10760 | reached.push(target); |
| 10761 | } |
| 10762 | for category in ConfigCategory::ALL { |
| 10763 | assert!(reached.contains(&category)); |
| 10764 | } |
| 10765 | // And back to the front by pointer only: the Appearance chip once it |
| 10766 | // scrolls into view, ‹ until then. |
| 10767 | for _ in 0..16 { |
| 10768 | if view.category == ConfigCategory::Appearance { |
| 10769 | break; |
| 10770 | } |
| 10771 | let mut buf = Buffer::empty(area); |
| 10772 | view.render(area, &mut buf); |
| 10773 | let chip = view |
| 10774 | .last_rail_hitboxes |
| 10775 | .borrow() |
| 10776 | .iter() |
| 10777 | .find(|(_, category)| *category == ConfigCategory::Appearance) |
| 10778 | .map(|(rect, _)| *rect); |
| 10779 | let rect = chip |
| 10780 | .or_else(|| { |
| 10781 | view.last_nav_controls |
| 10782 | .borrow() |
| 10783 | .iter() |
| 10784 | .find(|(_, s)| *s == super::NavStep::Previous) |
| 10785 | .map(|(rect, _)| *rect) |
| 10786 | }) |
| 10787 | .expect("the Appearance chip or ‹ is painted"); |
| 10788 | let _ = click(&mut view, rect); |
| 10789 | } |
| 10790 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 10791 | } |
| 10792 | |
| 10793 | /// P1.4: choice and text editors expose clickable Apply / Cancel controls |
| 10794 | /// and exact choice hitboxes at 40x12 and 80x24. |
| 10795 | #[test] |
| 10796 | fn config_editors_apply_and_cancel_by_pointer_with_exact_hitboxes() { |
| 10797 | let app = create_test_app(); |
| 10798 | let click = |view: &mut ConfigView, column: u16, row: u16| { |
| 10799 | view.handle_mouse(MouseEvent { |
| 10800 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10801 | column, |
| 10802 | row, |
| 10803 | modifiers: KeyModifiers::NONE, |
| 10804 | }) |
| 10805 | }; |
| 10806 | for (w, h) in [(40u16, 12u16), (80, 24)] { |
| 10807 | let area = Rect::new(0, 0, w, h); |
| 10808 | // Choice editor. |
| 10809 | let mut view = ConfigView::new_for_app(&app); |
| 10810 | view.focus_key("default_mode"); |
| 10811 | assert!(matches!( |
| 10812 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 10813 | ViewAction::None |
| 10814 | )); |
| 10815 | assert!(view.editing.is_some()); |
| 10816 | let mut buf = Buffer::empty(area); |
| 10817 | view.render(area, &mut buf); |
| 10818 | let dump = buffer_text(&buf, area); |
| 10819 | let controls = view.last_editor_controls.borrow().clone(); |
| 10820 | let apply = controls |
| 10821 | .iter() |
| 10822 | .find(|(_, c)| *c == super::EditorControl::Apply) |
| 10823 | .map(|(r, _)| *r) |
| 10824 | .unwrap_or_else(|| panic!("{w}x{h} Apply control:\n{dump}")); |
| 10825 | let cancel = controls |
| 10826 | .iter() |
| 10827 | .find(|(_, c)| *c == super::EditorControl::Cancel) |
| 10828 | .map(|(r, _)| *r) |
| 10829 | .unwrap_or_else(|| panic!("{w}x{h} Cancel control:\n{dump}")); |
| 10830 | for (rect, id) in [ |
| 10831 | (apply, MessageId::ConfigEditorApply), |
| 10832 | (cancel, MessageId::ConfigEditorCancel), |
| 10833 | ] { |
| 10834 | let cells: String = (rect.x..rect.right()) |
| 10835 | .map(|x| buf[(x, rect.y)].symbol().to_string()) |
| 10836 | .collect(); |
| 10837 | assert!(cells.contains(&en(id)), "{w}x{h} {cells:?}"); |
| 10838 | assert!(rect.bottom() <= area.bottom()); |
| 10839 | } |
| 10840 | assert!( |
| 10841 | dump.contains(&en(MessageId::ConfigEditChooseLabel)), |
| 10842 | "{w}x{h}:\n{dump}" |
| 10843 | ); |
| 10844 | let choices = view.last_choice_hitboxes.borrow().clone(); |
| 10845 | let (operate_rect, operate_idx) = choices |
| 10846 | .iter() |
| 10847 | .copied() |
| 10848 | .find(|(_, idx)| *idx == 2) |
| 10849 | .unwrap_or_else(|| panic!("{w}x{h} third choice painted:\n{dump}")); |
| 10850 | // Clicking a choice selects it; clicking beside the controls does |
| 10851 | // nothing; clicking Apply emits exactly that choice. |
| 10852 | assert!(matches!( |
| 10853 | click(&mut view, operate_rect.x + 2, operate_rect.y), |
| 10854 | ViewAction::None |
| 10855 | )); |
| 10856 | assert_eq!(view.editing.as_ref().unwrap().selected_choice, operate_idx); |
| 10857 | let beside = apply.right().saturating_add(1); |
| 10858 | if beside < cancel.x { |
| 10859 | assert!(matches!( |
| 10860 | click(&mut view, beside, apply.y), |
| 10861 | ViewAction::None |
| 10862 | )); |
| 10863 | assert!(view.editing.is_some(), "{w}x{h} gap click is inert"); |
| 10864 | } |
| 10865 | match click(&mut view, apply.x + 1, apply.y) { |
| 10866 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 10867 | key, |
| 10868 | value, |
| 10869 | persist, |
| 10870 | }) => { |
| 10871 | assert_eq!(key, "default_mode"); |
| 10872 | assert_eq!(value, "operate"); |
| 10873 | assert!(persist); |
| 10874 | } |
| 10875 | other => panic!("{w}x{h} Apply click should emit: {other:?}"), |
| 10876 | } |
| 10877 | assert!(view.editing.is_none()); |
| 10878 | |
| 10879 | // Cancel by pointer. |
| 10880 | view.focus_key("default_mode"); |
| 10881 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 10882 | let mut buf = Buffer::empty(area); |
| 10883 | view.render(area, &mut buf); |
| 10884 | let cancel = view |
| 10885 | .last_editor_controls |
| 10886 | .borrow() |
| 10887 | .iter() |
| 10888 | .find(|(_, c)| *c == super::EditorControl::Cancel) |
| 10889 | .map(|(r, _)| *r) |
| 10890 | .unwrap(); |
| 10891 | assert!(matches!( |
| 10892 | click(&mut view, cancel.x, cancel.y), |
| 10893 | ViewAction::None |
| 10894 | )); |
| 10895 | assert!(view.editing.is_none(), "{w}x{h} Cancel leaves the editor"); |
| 10896 | assert_eq!( |
| 10897 | view.status.as_deref(), |
| 10898 | Some(en(MessageId::ConfigEditCancelled).as_str()) |
| 10899 | ); |
| 10900 | |
| 10901 | // Text editor: type, then Apply by pointer. |
| 10902 | view.focus_key("thinking_preview_lines"); |
| 10903 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 10904 | for ch in "77".chars() { |
| 10905 | let _ = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 10906 | } |
| 10907 | let mut buf = Buffer::empty(area); |
| 10908 | view.render(area, &mut buf); |
| 10909 | let dump = buffer_text(&buf, area); |
| 10910 | let apply = view |
| 10911 | .last_editor_controls |
| 10912 | .borrow() |
| 10913 | .iter() |
| 10914 | .find(|(_, c)| *c == super::EditorControl::Apply) |
| 10915 | .map(|(r, _)| *r) |
| 10916 | .unwrap_or_else(|| panic!("{w}x{h} text editor Apply:\n{dump}")); |
| 10917 | assert!( |
| 10918 | dump.contains(&en(MessageId::ConfigEditNewLabel).trim_end().to_string()), |
| 10919 | "{w}x{h} value line stays visible above the controls:\n{dump}" |
| 10920 | ); |
| 10921 | match click(&mut view, apply.x, apply.y) { |
| 10922 | ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => { |
| 10923 | assert_eq!(key, "thinking_preview_lines"); |
| 10924 | assert_eq!(value, "77"); |
| 10925 | } |
| 10926 | other => panic!("{w}x{h} text Apply should emit: {other:?}"), |
| 10927 | } |
| 10928 | } |
| 10929 | } |
| 10930 | |
| 10931 | /// P1.5: second-click activation is disarmed by every keyboard step, so no |
| 10932 | /// single click after navigation can mutate anything. |
| 10933 | #[test] |
| 10934 | fn config_second_click_arming_resets_on_every_keyboard_step() { |
| 10935 | let app = create_test_app(); |
| 10936 | let mut view = ConfigView::new_for_app(&app); |
| 10937 | view.category = ConfigCategory::Motion; |
| 10938 | view.select_first_visible_row(); |
| 10939 | let area = Rect::new(0, 0, 80, 24); |
| 10940 | let click_row = |view: &mut ConfigView, key: &str| -> ViewAction { |
| 10941 | let mut buf = Buffer::empty(area); |
| 10942 | view.render(area, &mut buf); |
| 10943 | let rect = view |
| 10944 | .last_row_hitboxes |
| 10945 | .borrow() |
| 10946 | .iter() |
| 10947 | .find(|(_, idx)| view.rows[*idx].key == key) |
| 10948 | .map(|(rect, _)| *rect) |
| 10949 | .unwrap_or_else(|| panic!("{key} row painted")); |
| 10950 | view.handle_mouse(MouseEvent { |
| 10951 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10952 | column: rect.x + 1, |
| 10953 | row: rect.y, |
| 10954 | modifiers: KeyModifiers::NONE, |
| 10955 | }) |
| 10956 | }; |
| 10957 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10958 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10959 | }; |
| 10960 | let sequences: Vec<Vec<KeyCode>> = vec![ |
| 10961 | vec![KeyCode::Down, KeyCode::Up], |
| 10962 | vec![KeyCode::Right, KeyCode::Left], |
| 10963 | vec![KeyCode::Tab, KeyCode::BackTab], |
| 10964 | vec![KeyCode::Char('x'), KeyCode::Backspace], |
| 10965 | vec![KeyCode::PageDown, KeyCode::PageUp], |
| 10966 | ]; |
| 10967 | for sequence in sequences { |
| 10968 | // The previous iteration's checking click left the row armed; a |
| 10969 | // keyboard step (like any real navigation) disarms it first. |
| 10970 | let _ = key(&mut view, KeyCode::Up); |
| 10971 | assert!(matches!( |
| 10972 | click_row(&mut view, "low_motion"), |
| 10973 | ViewAction::None |
| 10974 | )); |
| 10975 | for code in &sequence { |
| 10976 | let _ = key(&mut view, *code); |
| 10977 | } |
| 10978 | assert_eq!(view.category, ConfigCategory::Motion, "{sequence:?}"); |
| 10979 | let action = click_row(&mut view, "low_motion"); |
| 10980 | assert!( |
| 10981 | matches!(action, ViewAction::None), |
| 10982 | "{sequence:?} then one click must not mutate: {action:?}" |
| 10983 | ); |
| 10984 | assert!(view.editing.is_none(), "{sequence:?}"); |
| 10985 | assert_eq!(view.rows[view.selected].key, "low_motion"); |
| 10986 | } |
| 10987 | // Control: two consecutive clicks with nothing in between activate |
| 10988 | // (the previous check click is disarmed by a keyboard step first). |
| 10989 | let _ = key(&mut view, KeyCode::Up); |
| 10990 | assert!(matches!( |
| 10991 | click_row(&mut view, "low_motion"), |
| 10992 | ViewAction::None |
| 10993 | )); |
| 10994 | assert!(matches!( |
| 10995 | click_row(&mut view, "low_motion"), |
| 10996 | ViewAction::Emit(ViewEvent::ConfigUpdated { .. }) |
| 10997 | )); |
| 10998 | // A rebuilt focus (the host re-renders after applying) is disarmed |
| 10999 | // even though the emitting click left the row armed. |
| 11000 | view.focus_key("low_motion"); |
| 11001 | assert!(matches!( |
| 11002 | click_row(&mut view, "low_motion"), |
| 11003 | ViewAction::None |
| 11004 | )); |
| 11005 | } |
| 11006 | |
| 11007 | /// P1.6: the reachable editor surface renders from the packs — search |
| 11008 | /// label, choose label, choice labels and details, footer, controls, and |
| 11009 | /// hints — with no English fallbacks in zh-Hans. |
| 11010 | #[test] |
| 11011 | fn config_editor_surface_is_localized() { |
| 11012 | let mut app = create_test_app(); |
| 11013 | app.ui_locale = Locale::ZhHans; |
| 11014 | let mut view = ConfigView::new_for_app(&app); |
| 11015 | let zh = |id: MessageId| tr(Locale::ZhHans, id).into_owned(); |
| 11016 | let spaced = |text: &str| -> String { |
| 11017 | text.chars() |
| 11018 | .map(|ch| { |
| 11019 | if UnicodeWidthStr::width(ch.to_string().as_str()) > 1 { |
| 11020 | format!("{ch} ") |
| 11021 | } else { |
| 11022 | ch.to_string() |
| 11023 | } |
| 11024 | }) |
| 11025 | .collect() |
| 11026 | }; |
| 11027 | let dump = render_dump(&view, 80, 24); |
| 11028 | assert!( |
| 11029 | dump.contains(spaced(&zh(MessageId::ConfigSearchLabel)).trim()), |
| 11030 | "search label:\n{dump}" |
| 11031 | ); |
| 11032 | view.focus_key("default_mode"); |
| 11033 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11034 | let dump = render_dump(&view, 80, 24); |
| 11035 | for id in [ |
| 11036 | MessageId::ConfigEditChooseLabel, |
| 11037 | MessageId::ConfigChoiceModeAct, |
| 11038 | MessageId::ConfigChoiceModePlan, |
| 11039 | MessageId::ConfigChoiceModeOperate, |
| 11040 | MessageId::ConfigChoiceDetailModeAgent, |
| 11041 | MessageId::ConfigEditorApply, |
| 11042 | MessageId::ConfigEditorCancel, |
| 11043 | ] { |
| 11044 | let text = zh(id); |
| 11045 | assert!( |
| 11046 | dump.contains(spaced(&text).trim_end()), |
| 11047 | "localized {id:?} = {text}:\n{dump}" |
| 11048 | ); |
| 11049 | } |
| 11050 | assert!( |
| 11051 | dump.contains(spaced(&zh(MessageId::ConfigChoiceFooter)).trim()) |
| 11052 | || dump.contains(spaced(&zh(MessageId::ConfigChoiceFooterCompact)).trim()), |
| 11053 | "localized choice footer:\n{dump}" |
| 11054 | ); |
| 11055 | for english in ["Choose:", "Full Access", "Apply", "Cancel", "Search:"] { |
| 11056 | assert!(!dump.contains(english), "English leaked: {english}\n{dump}"); |
| 11057 | } |
| 11058 | let _ = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 11059 | view.focus_key("low_motion"); |
| 11060 | let dump = render_dump(&view, 120, 32); |
| 11061 | let hint = zh(MessageId::ConfigHintLowMotion); |
| 11062 | assert!( |
| 11063 | dump.contains(spaced(&hint).trim_end()), |
| 11064 | "localized hint in the detail pane:\n{dump}" |
| 11065 | ); |
| 11066 | } |
| 11067 | |
| 11068 | #[test] |
| 11069 | fn config_shell_renders_localized_chrome_without_missing_markers() { |
| 11070 | let mut app = create_test_app(); |
| 11071 | app.ui_locale = Locale::ZhHans; |
| 11072 | let view = ConfigView::new_for_app(&app); |
| 11073 | for (w, h) in [(80u16, 24u16), (120, 32)] { |
| 11074 | let dump = render_dump(&view, w, h); |
| 11075 | assert!(!dump.contains("MISSING"), "{w}x{h}:\n{dump}"); |
| 11076 | let section = tr(Locale::ZhHans, MessageId::ConfigSectionDisplay); |
| 11077 | let spaced: String = section.chars().map(|ch| format!("{ch} ")).collect(); |
| 11078 | assert!( |
| 11079 | dump.contains(spaced.trim_end()), |
| 11080 | "{w}x{h} localized section label {section}:\n{dump}" |
| 11081 | ); |
| 11082 | // The new shell chrome renders from the packs too: the active |
| 11083 | // category chip/rail row and, when wide, the detail fact labels. |
| 11084 | let category = tr(Locale::ZhHans, MessageId::ConfigCategoryAppearance); |
| 11085 | let spaced: String = category.chars().map(|ch| format!("{ch} ")).collect(); |
| 11086 | assert!( |
| 11087 | dump.contains(spaced.trim_end()), |
| 11088 | "{w}x{h} localized category {category}:\n{dump}" |
| 11089 | ); |
| 11090 | if w >= 100 { |
| 11091 | for id in [MessageId::ConfigFactCurrent, MessageId::ConfigFactApply] { |
| 11092 | let label = tr(Locale::ZhHans, id); |
| 11093 | let spaced: String = label.chars().map(|ch| format!("{ch} ")).collect(); |
| 11094 | assert!( |
| 11095 | dump.contains(spaced.trim_end()), |
| 11096 | "{w}x{h} localized fact label {label}:\n{dump}" |
| 11097 | ); |
| 11098 | } |
| 11099 | let unobserved = tr(Locale::ZhHans, MessageId::ConfigLaneUnobserved); |
| 11100 | let spaced: String = unobserved.chars().map(|ch| format!("{ch} ")).collect(); |
| 11101 | assert!( |
| 11102 | dump.contains(spaced.trim_end()), |
| 11103 | "{w}x{h} localized unobserved lane:\n{dump}" |
| 11104 | ); |
| 11105 | } |
| 11106 | let scope = tr(Locale::ZhHans, MessageId::ConfigScopeSaved); |
| 11107 | let spaced: String = scope.chars().map(|ch| format!("{ch} ")).collect(); |
| 11108 | assert!( |
| 11109 | dump.contains(spaced.trim_end()) || dump.contains(scope.as_ref()), |
| 11110 | "{w}x{h} localized scope badge {scope}:\n{dump}" |
| 11111 | ); |
| 11112 | } |
| 11113 | } |
| 11114 | |
| 11115 | #[test] |
| 11116 | fn config_view_mcp_action_rows_run_existing_commands() { |
| 11117 | let app = create_test_app(); |
| 11118 | let mut view = ConfigView::new_for_app(&app); |
| 11119 | for (key, command) in [ |
| 11120 | ("mcp_open", "/mcp"), |
| 11121 | ("mcp_reconnect", "/mcp reload"), |
| 11122 | ("mcp_diagnose", "/mcp validate"), |
| 11123 | ("plugins_open", "/plugin"), |
| 11124 | ] { |
| 11125 | view.focus_key(key); |
| 11126 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11127 | match action { |
| 11128 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 11129 | action: CommandPaletteAction::ExecuteCommand { command: emitted }, |
| 11130 | }) => { |
| 11131 | assert_eq!(emitted, command, "{key}"); |
| 11132 | assert!(!emitted.contains("/mcp auth"), "{key}"); |
| 11133 | } |
| 11134 | other => panic!("{key} should run {command}, got {other:?}"), |
| 11135 | } |
| 11136 | } |
| 11137 | } |
| 11138 | |
| 11139 | #[test] |
| 11140 | fn config_view_bottom_hint_semantically_truncates_at_narrow_width() { |
| 11141 | // The dense bottom status line must truncate on a word boundary with an |
| 11142 | // ellipsis instead of leaving a mid-word fragment clipped by the |
| 11143 | // terminal (#3987). |
| 11144 | let mut app = create_test_app(); |
| 11145 | app.ui_locale = Locale::En; |
| 11146 | let mut view = ConfigView::new_for_app(&app); |
| 11147 | view.status = Some( |
| 11148 | "CFGSTATUS persisted the configuration override to disk successfully \ |
| 11149 | without clipping the trailing MARKEREND status text" |
| 11150 | .to_string(), |
| 11151 | ); |
| 11152 | |
| 11153 | let area = Rect::new(0, 0, 100, 40); |
| 11154 | let mut buf = Buffer::empty(area); |
| 11155 | view.render(area, &mut buf); |
| 11156 | |
| 11157 | let rows: Vec<String> = (0..area.height) |
| 11158 | .map(|y| { |
| 11159 | (0..area.width) |
| 11160 | .map(|x| buf[(x, y)].symbol()) |
| 11161 | .collect::<String>() |
| 11162 | }) |
| 11163 | .collect(); |
| 11164 | |
| 11165 | // No rendered row may overflow the available columns. |
| 11166 | for (idx, row) in rows.iter().enumerate() { |
| 11167 | assert!( |
| 11168 | crate::tui::ui_text::text_display_width(row) <= usize::from(area.width), |
| 11169 | "line {idx} overflows: {row:?}" |
| 11170 | ); |
| 11171 | } |
| 11172 | |
| 11173 | let status_line = rows |
| 11174 | .iter() |
| 11175 | .find(|row| row.contains("CFGSTATUS")) |
| 11176 | .expect("bottom status hint should be rendered"); |
| 11177 | assert!( |
| 11178 | status_line.contains('…'), |
| 11179 | "status should be truncated with an ellipsis: {status_line:?}" |
| 11180 | ); |
| 11181 | assert!( |
| 11182 | !status_line.contains("MARKEREND"), |
| 11183 | "truncated status must drop trailing text: {status_line:?}" |
| 11184 | ); |
| 11185 | } |
| 11186 | |
| 11187 | #[test] |
| 11188 | fn config_view_typing_replaces_on_first_char() { |
| 11189 | let app = create_test_app(); |
| 11190 | let mut view = ConfigView::new_for_app(&app); |
| 11191 | view.focus_key("background_color"); |
| 11192 | |
| 11193 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11194 | let edit = view.editing.as_ref().expect("editing should be active"); |
| 11195 | assert!(edit.select_all, "editor should start with select-all"); |
| 11196 | |
| 11197 | let _ = view.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); |
| 11198 | let edit = view.editing.as_ref().expect("editing should remain active"); |
| 11199 | assert_eq!(edit.buffer.iter().collect::<String>(), "x"); |
| 11200 | } |
| 11201 | |
| 11202 | #[test] |
| 11203 | fn config_view_escape_cancels_editing() { |
| 11204 | let mut app = create_test_app(); |
| 11205 | app.ui_locale = Locale::En; |
| 11206 | let mut view = ConfigView::new_for_app(&app); |
| 11207 | view.focus_key("thinking_preview_lines"); |
| 11208 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11209 | assert!(view.editing.is_some()); |
| 11210 | |
| 11211 | let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 11212 | assert!(matches!(cancel, ViewAction::None)); |
| 11213 | assert!(view.editing.is_none()); |
| 11214 | assert_eq!( |
| 11215 | view.status.as_deref(), |
| 11216 | Some(&*tr(Locale::En, MessageId::ConfigEditCancelled)) |
| 11217 | ); |
| 11218 | } |
| 11219 | |
| 11220 | /// A modal that doesn't override `handle_paste` must report |
| 11221 | /// "not consumed" so the host can fall through to the composer. |
| 11222 | /// Regression: views/mod.rs previously inverted the boolean, swallowing |
| 11223 | /// every Cmd-V while any modal was on top. |
| 11224 | #[test] |
| 11225 | fn default_modal_does_not_consume_paste() { |
| 11226 | let mut stack = ViewStack::new(); |
| 11227 | stack.push(HelpView::new_for_locale(codewhale_localization::Locale::En)); |
| 11228 | assert!(!stack.handle_paste("hello")); |
| 11229 | assert_eq!(stack.top_kind(), Some(ModalKind::Help)); |
| 11230 | } |
| 11231 | |
| 11232 | struct BareModal; |
| 11233 | |
| 11234 | impl ModalView for BareModal { |
| 11235 | fn kind(&self) -> ModalKind { |
| 11236 | ModalKind::ContextMenu |
| 11237 | } |
| 11238 | |
| 11239 | fn handle_key(&mut self, _key: KeyEvent) -> ViewAction { |
| 11240 | ViewAction::None |
| 11241 | } |
| 11242 | |
| 11243 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 11244 | let x = area.x + area.width / 2; |
| 11245 | let y = area.y + area.height / 2; |
| 11246 | buf[(x, y)] |
| 11247 | .set_symbol("M") |
| 11248 | .set_style(Style::default().fg(Color::White).bg(Color::Red)); |
| 11249 | } |
| 11250 | |
| 11251 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 11252 | self |
| 11253 | } |
| 11254 | } |
| 11255 | |
| 11256 | #[test] |
| 11257 | fn view_stack_paints_opaque_backdrop_before_modal() { |
| 11258 | let area = Rect::new(0, 0, 24, 8); |
| 11259 | let modal_x = area.x + area.width / 2; |
| 11260 | let modal_y = area.y + area.height / 2; |
| 11261 | let mut buf = Buffer::empty(area); |
| 11262 | for y in area.top()..area.bottom() { |
| 11263 | for x in area.left()..area.right() { |
| 11264 | buf[(x, y)] |
| 11265 | .set_symbol("X") |
| 11266 | .set_style(Style::default().fg(Color::Red).bg(Color::Blue)); |
| 11267 | } |
| 11268 | } |
| 11269 | |
| 11270 | let mut stack = ViewStack::new(); |
| 11271 | stack.push(BareModal); |
| 11272 | stack.render(area, &mut buf); |
| 11273 | |
| 11274 | assert_eq!(buf[(modal_x, modal_y)].symbol(), "M"); |
| 11275 | for y in area.top()..area.bottom() { |
| 11276 | for x in area.left()..area.right() { |
| 11277 | if x == modal_x && y == modal_y { |
| 11278 | continue; |
| 11279 | } |
| 11280 | let cell = &buf[(x, y)]; |
| 11281 | assert_eq!( |
| 11282 | cell.symbol(), |
| 11283 | " ", |
| 11284 | "stale glyph at ({x},{y}) must be cleared" |
| 11285 | ); |
| 11286 | assert_eq!( |
| 11287 | cell.bg, |
| 11288 | palette::WHALE_BG, |
| 11289 | "backdrop at ({x},{y}) must be opaque" |
| 11290 | ); |
| 11291 | } |
| 11292 | } |
| 11293 | } |
| 11294 | |
| 11295 | #[test] |
| 11296 | fn view_stack_masks_links_behind_opaque_modals() { |
| 11297 | let area = Rect::new(0, 0, 24, 8); |
| 11298 | crate::tui::osc8::set_frame_links(vec![crate::tui::osc8::LinkRegion { |
| 11299 | row: 3, |
| 11300 | col_start: 2, |
| 11301 | col_end: 18, |
| 11302 | target: "https://example.invalid/under-modal".to_string(), |
| 11303 | }]); |
| 11304 | let mut stack = ViewStack::new(); |
| 11305 | stack.push(BareModal); |
| 11306 | stack.render(area, &mut Buffer::empty(area)); |
| 11307 | assert!(crate::tui::osc8::take_frame_links().is_empty()); |
| 11308 | } |
| 11309 | |
| 11310 | fn buffer_text(buf: &Buffer, area: Rect) -> String { |
| 11311 | let mut out = String::new(); |
| 11312 | for y in area.top()..area.bottom() { |
| 11313 | for x in area.left()..area.right() { |
| 11314 | out.push_str(buf[(x, y)].symbol()); |
| 11315 | } |
| 11316 | out.push('\n'); |
| 11317 | } |
| 11318 | out |
| 11319 | } |
| 11320 | |
| 11321 | fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String { |
| 11322 | (area.left()..area.right()) |
| 11323 | .map(|x| buf[(x, y)].symbol()) |
| 11324 | .collect() |
| 11325 | } |
| 11326 | |
| 11327 | /// 40x12 regression: the compact tier must surrender secondary chrome |
| 11328 | /// (in-body title, column captions, separator) before it surrenders the |
| 11329 | /// settings rows, and the wrapped footer height must come out of the |
| 11330 | /// table budget instead of silently clipping rows. |
| 11331 | #[test] |
| 11332 | fn config_compact_theme_category_and_footer_remain_legible() { |
| 11333 | let _guard = ConfigSettingsEnvGuard::new("theme = \"shoreline\"\n"); |
| 11334 | let mut view = create_config_view(Locale::En); |
| 11335 | view.focus_key("theme"); |
| 11336 | let area = Rect::new(0, 0, 40, 12); |
| 11337 | let mut buf = Buffer::empty(area); |
| 11338 | view.render(area, &mut buf); |
| 11339 | let dump = buffer_text(&buf, area); |
| 11340 | let theme_rect = view |
| 11341 | .last_row_hitboxes |
| 11342 | .borrow() |
| 11343 | .iter() |
| 11344 | .find(|(_, idx)| view.rows[*idx].key == "theme") |
| 11345 | .unwrap() |
| 11346 | .0; |
| 11347 | assert!( |
| 11348 | buffer_row_text(&buf, area, theme_rect.y).contains("shoreline"), |
| 11349 | "{dump}" |
| 11350 | ); |
| 11351 | assert!(dump.contains("Appearance 1/7"), "{dump}"); |
| 11352 | let footer = dump |
| 11353 | .lines() |
| 11354 | .find(|line| line.contains("Enter") && line.contains("Esc")) |
| 11355 | .expect("all compact action hints share one line"); |
| 11356 | assert!(footer.contains("Tab")); |
| 11357 | for category in ConfigCategory::ALL { |
| 11358 | view.category = category; |
| 11359 | view.select_first_visible_row(); |
| 11360 | view.render(area, &mut buf); |
| 11361 | let dump = buffer_text(&buf, area); |
| 11362 | assert!( |
| 11363 | dump.contains(category.label(Locale::En).as_ref()), |
| 11364 | "{category:?}: {dump}" |
| 11365 | ); |
| 11366 | assert_eq!(view.last_nav_controls.borrow().len(), 2); |
| 11367 | } |
| 11368 | } |
| 11369 | |
| 11370 | #[test] |
| 11371 | fn config_sandbox_search_opens_observed_status_without_editing_policy() { |
| 11372 | let mut view = create_config_view(Locale::En); |
| 11373 | for query in [ |
| 11374 | "sandbox", |
| 11375 | "filesystem", |
| 11376 | "unenforced", |
| 11377 | "bubblewrap", |
| 11378 | "doctor", |
| 11379 | ] { |
| 11380 | view.restore_filter(query.to_string()); |
| 11381 | let matches = view.matching_row_indices(); |
| 11382 | let index = *matches |
| 11383 | .iter() |
| 11384 | .find(|&&idx| view.rows[idx].key == "sandbox_details") |
| 11385 | .expect("sandbox explanation discoverable"); |
| 11386 | view.selected = index; |
| 11387 | let row = &view.rows[index]; |
| 11388 | assert_eq!(row.facts.kind, ConfigRowKind::Action); |
| 11389 | assert_eq!(row.facts.store, SettingStore::None); |
| 11390 | assert!( |
| 11391 | matches!(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 11392 | ViewAction::Emit(ViewEvent::ExecutePanelCommand { command, pager_title: Some(_) }) if command == "/status") |
| 11393 | ); |
| 11394 | assert!(view.editing.is_none()); |
| 11395 | } |
| 11396 | assert!(!view.rows.iter().any(|row| row.key == "sandbox_mode")); |
| 11397 | } |
| 11398 | |
| 11399 | #[test] |
| 11400 | fn config_view_compact_heights_always_show_a_selectable_setting() { |
| 11401 | let mut view = create_config_view(Locale::En); |
| 11402 | for (width, height, label) in [(40u16, 12u16, "40x12"), (60, 16, "60x16")] { |
| 11403 | let area = Rect::new(0, 0, width, height); |
| 11404 | let mut buf = Buffer::empty(area); |
| 11405 | |
| 11406 | view.render(area, &mut buf); |
| 11407 | |
| 11408 | let dump = buffer_text(&buf, area); |
| 11409 | let (selected_y, selected_idx) = { |
| 11410 | let hitboxes = view.last_row_hitboxes.borrow(); |
| 11411 | assert!( |
| 11412 | !hitboxes.is_empty(), |
| 11413 | "{label} should register selectable setting hitboxes:\n{dump}" |
| 11414 | ); |
| 11415 | hitboxes |
| 11416 | .iter() |
| 11417 | .find(|(_, idx)| *idx == view.selected) |
| 11418 | .copied() |
| 11419 | .unwrap_or_else(|| { |
| 11420 | panic!("{label} selected setting should be rendered:\n{dump}") |
| 11421 | }) |
| 11422 | }; |
| 11423 | let row = buffer_row_text(&buf, area, selected_y.y); |
| 11424 | let row_label = config_label_for_key(&view.rows[selected_idx].key); |
| 11425 | let prefix: String = row_label.chars().take(8).collect(); |
| 11426 | assert!( |
| 11427 | row.contains(&prefix), |
| 11428 | "{label} hitbox row should contain the selected setting ({row_label:?}); got {row:?}" |
| 11429 | ); |
| 11430 | assert!( |
| 11431 | dump.contains("Search:"), |
| 11432 | "{label} should keep the search affordance:\n{dump}" |
| 11433 | ); |
| 11434 | } |
| 11435 | |
| 11436 | // The selection anchor must hold while navigating across sections at |
| 11437 | // the smallest supported size. |
| 11438 | let area = Rect::new(0, 0, 40, 12); |
| 11439 | for step in 0..12 { |
| 11440 | view.move_selection(1); |
| 11441 | let mut buf = Buffer::empty(area); |
| 11442 | view.render(area, &mut buf); |
| 11443 | let rendered = view |
| 11444 | .last_row_hitboxes |
| 11445 | .borrow() |
| 11446 | .iter() |
| 11447 | .any(|(_, idx)| *idx == view.selected); |
| 11448 | assert!( |
| 11449 | rendered, |
| 11450 | "selected setting fell out of the 40x12 window after {} moves", |
| 11451 | step + 1 |
| 11452 | ); |
| 11453 | } |
| 11454 | } |
| 11455 | |
| 11456 | /// 40x12 regression: the edit surface must keep the editable value line |
| 11457 | /// (and its hint) above the wrapped footer. |
| 11458 | #[test] |
| 11459 | fn config_view_compact_edit_surface_keeps_value_line_visible() { |
| 11460 | let mut view = create_config_view(Locale::En); |
| 11461 | view.focus_key("approval_mode"); |
| 11462 | view.start_edit(); |
| 11463 | assert!(view.editing.is_some(), "approval_mode should be editable"); |
| 11464 | assert_eq!( |
| 11465 | view.editing |
| 11466 | .as_ref() |
| 11467 | .and_then(|edit| edit.choices.as_ref()) |
| 11468 | .expect("session permission choices"), |
| 11469 | &vec![ |
| 11470 | "ask".to_string(), |
| 11471 | "auto-review".to_string(), |
| 11472 | "full-access".to_string(), |
| 11473 | ] |
| 11474 | ); |
| 11475 | let area = Rect::new(0, 0, 40, 12); |
| 11476 | let mut buf = Buffer::empty(area); |
| 11477 | |
| 11478 | view.render(area, &mut buf); |
| 11479 | |
| 11480 | let dump = buffer_text(&buf, area); |
| 11481 | assert!( |
| 11482 | dump.contains("Choose:") && dump.contains("Full Access"), |
| 11483 | "the choice list must stay visible at 40x12:\n{dump}" |
| 11484 | ); |
| 11485 | } |
| 11486 | } |
| 11487 |