| 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::{ApprovalPolicyControl, Config, ProviderKind}; |
| 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(crate) mod router_setup; |
| 42 | pub mod skills_manager; |
| 43 | pub mod status_picker; |
| 44 | pub mod workflows_manager; |
| 45 | |
| 46 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 47 | pub enum ModalKind { |
| 48 | PetHabitat, |
| 49 | Approval, |
| 50 | Elevation, |
| 51 | UserInput, |
| 52 | CommandPalette, |
| 53 | Help, |
| 54 | SubAgents, |
| 55 | Pager, |
| 56 | LiveTranscript, |
| 57 | SessionPicker, |
| 58 | Config, |
| 59 | ModelPicker, |
| 60 | ProviderPicker, |
| 61 | ModePicker, |
| 62 | FleetRoster, |
| 63 | FleetSetup, |
| 64 | FleetList, |
| 65 | FleetDetail, |
| 66 | HotbarSetup, |
| 67 | SetupWizard, |
| 68 | FilePicker, |
| 69 | StatusPicker, |
| 70 | FeedbackPicker, |
| 71 | ThemePicker, |
| 72 | ContextMenu, |
| 73 | ContextInspector, |
| 74 | SkillsManager, |
| 75 | /// Unified, read-only extensions inventory. Mutations delegate to the |
| 76 | /// existing Hooks / Plugins / Skills / MCP command controllers. |
| 77 | Extensions, |
| 78 | /// Native git worktree manager (list / create / switch / compare). |
| 79 | WorktreeManager, |
| 80 | /// Live workflow **run** dashboard (`/workflows`): active and retained |
| 81 | /// runs from the journal, with host-side cancel. |
| 82 | WorkflowsManager, |
| 83 | /// The scheduled-automation room (`/automation`): every automation the |
| 84 | /// person owns, with pause / resume / run / cancel / delete. |
| 85 | Automations, |
| 86 | /// "Resume this session?" over the launch card. Resuming replaces the |
| 87 | /// whole session context, so it asks first. |
| 88 | LaunchResumeConfirm, |
| 89 | /// Router setup (`/router`, `/model router`): presets for `[auto.router]`. |
| 90 | RouterSetup, |
| 91 | } |
| 92 | |
| 93 | /// Clear and paint a modal popup with an opaque surface. |
| 94 | /// |
| 95 | /// Older modals often called `Clear` only, which left reset-background blank |
| 96 | /// cells that could read as translucent on terminals with a non-default app |
| 97 | /// background. This helper makes the popup area explicit and keeps the small |
| 98 | /// shadow from inheriting stale transcript glyphs. |
| 99 | pub(crate) fn render_modal_surface(area: Rect, popup_area: Rect, buf: &mut Buffer) { |
| 100 | let shadow_x = popup_area.x.saturating_add(1); |
| 101 | let shadow_y = popup_area.y.saturating_add(1); |
| 102 | let shadow_right = area.x.saturating_add(area.width); |
| 103 | let shadow_bottom = area.y.saturating_add(area.height); |
| 104 | let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x)); |
| 105 | let shadow_height = popup_area |
| 106 | .height |
| 107 | .min(shadow_bottom.saturating_sub(shadow_y)); |
| 108 | |
| 109 | if shadow_width > 0 && shadow_height > 0 { |
| 110 | Block::default() |
| 111 | .style(Style::default().bg(palette::SURFACE_ELEVATED)) |
| 112 | .render( |
| 113 | Rect { |
| 114 | x: shadow_x, |
| 115 | y: shadow_y, |
| 116 | width: shadow_width, |
| 117 | height: shadow_height, |
| 118 | }, |
| 119 | buf, |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | Clear.render(popup_area, buf); |
| 124 | Block::default() |
| 125 | .style(Style::default().bg(palette::WHALE_BG)) |
| 126 | .render(popup_area, buf); |
| 127 | } |
| 128 | |
| 129 | /// Paint a full-screen underwater instrument surface and return its body. |
| 130 | /// |
| 131 | /// Secondary rooms use one title hairline and one bottom action rail instead |
| 132 | /// of a centered generic card. A one-cell outer margin is retained when the |
| 133 | /// terminal can afford it; compact panes use every cell. |
| 134 | pub(crate) fn render_underwater_surface( |
| 135 | area: Rect, |
| 136 | buf: &mut Buffer, |
| 137 | title: impl Into<String>, |
| 138 | ) -> Rect { |
| 139 | let margin_x = u16::from(area.width >= 44); |
| 140 | let margin_y = u16::from(area.height >= 24); |
| 141 | let surface = Rect { |
| 142 | x: area.x.saturating_add(margin_x), |
| 143 | y: area.y.saturating_add(margin_y), |
| 144 | width: area.width.saturating_sub(margin_x.saturating_mul(2)), |
| 145 | height: area.height.saturating_sub(margin_y.saturating_mul(2)), |
| 146 | }; |
| 147 | Clear.render(area, buf); |
| 148 | Block::default() |
| 149 | .style(Style::default().bg(palette::WHALE_BG)) |
| 150 | .render(area, buf); |
| 151 | // Ratatui clips long block titles at the border edge without signalling |
| 152 | // that anything is missing. Reserve the corner cells and semantic-ellipsis |
| 153 | // the title so compact terminals still read as intentional instruments. |
| 154 | let title_width = usize::from(surface.width.saturating_sub(4)); |
| 155 | let title = crate::tui::ui_text::semantic_truncate(&title.into(), title_width); |
| 156 | let block = Block::default() |
| 157 | .title(Line::from(Span::styled( |
| 158 | format!(" {title} "), |
| 159 | Style::default() |
| 160 | .fg(palette::WHALE_ACTION) |
| 161 | .add_modifier(Modifier::BOLD), |
| 162 | ))) |
| 163 | .borders(Borders::TOP | Borders::BOTTOM) |
| 164 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 165 | .style(Style::default().bg(palette::WHALE_BG)) |
| 166 | .padding(Padding::new(1, 1, u16::from(area.height >= 24), 0)); |
| 167 | let inner = block.inner(surface); |
| 168 | block.render(surface, buf); |
| 169 | inner |
| 170 | } |
| 171 | |
| 172 | /// Render wrapped detail text scrolled by *rendered* rows and return the |
| 173 | /// largest useful scroll. Clamping by logical lines before wrapping left the |
| 174 | /// wrapped tail unreachable and made each step jump a whole wrapped line. |
| 175 | pub(crate) fn render_wrapped_detail( |
| 176 | lines: Vec<Line<'static>>, |
| 177 | area: Rect, |
| 178 | buf: &mut Buffer, |
| 179 | requested_scroll: usize, |
| 180 | ) -> usize { |
| 181 | let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); |
| 182 | let max_scroll = paragraph |
| 183 | .line_count(area.width.max(1)) |
| 184 | .saturating_sub(usize::from(area.height).max(1)); |
| 185 | let scroll = u16::try_from(requested_scroll.min(max_scroll)).unwrap_or(u16::MAX); |
| 186 | paragraph.scroll((scroll, 0)).render(area, buf); |
| 187 | max_scroll |
| 188 | } |
| 189 | |
| 190 | /// Paint a scrollbar on the exact right edge of the panel it controls and |
| 191 | /// return the content rect with that rail reserved. Nothing is drawn when all |
| 192 | /// rows fit, so narrow surfaces do not spend a column on a fictional control. |
| 193 | pub(crate) fn render_panel_scroll_rail( |
| 194 | area: Rect, |
| 195 | buf: &mut Buffer, |
| 196 | total_rows: usize, |
| 197 | offset: usize, |
| 198 | visible_rows: usize, |
| 199 | focused: bool, |
| 200 | ) -> Rect { |
| 201 | if area.width < 2 || area.height == 0 || total_rows <= visible_rows.max(1) { |
| 202 | return area; |
| 203 | } |
| 204 | let rail_x = area.right().saturating_sub(1); |
| 205 | let rail_height = usize::from(area.height); |
| 206 | let visible = visible_rows.max(1).min(total_rows); |
| 207 | let thumb_height = ((rail_height * visible).div_ceil(total_rows)).clamp(1, rail_height); |
| 208 | let max_offset = total_rows.saturating_sub(visible); |
| 209 | let travel = rail_height.saturating_sub(thumb_height); |
| 210 | let thumb_top = travel |
| 211 | .saturating_mul(offset.min(max_offset)) |
| 212 | .checked_div(max_offset) |
| 213 | .unwrap_or(0); |
| 214 | let thumb_color = if focused { |
| 215 | palette::TEXT_MUTED |
| 216 | } else { |
| 217 | palette::TEXT_DIM |
| 218 | }; |
| 219 | for local_y in 0..area.height { |
| 220 | let y = area.y.saturating_add(local_y); |
| 221 | let local = usize::from(local_y); |
| 222 | let is_thumb = local >= thumb_top && local < thumb_top + thumb_height; |
| 223 | buf[(rail_x, y)] |
| 224 | .set_symbol(if is_thumb { "█" } else { "│" }) |
| 225 | .set_style(Style::default().fg(if is_thumb { |
| 226 | thumb_color |
| 227 | } else { |
| 228 | palette::BORDER_COLOR |
| 229 | })); |
| 230 | } |
| 231 | Rect { |
| 232 | width: area.width.saturating_sub(1), |
| 233 | ..area |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | fn render_modal_backdrop(area: Rect, buf: &mut Buffer) { |
| 238 | for y in area.top()..area.bottom() { |
| 239 | for x in area.left()..area.right() { |
| 240 | buf[(x, y)] |
| 241 | .set_symbol(" ") |
| 242 | .set_style(Style::default().bg(palette::WHALE_BG)); |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | /// Compute a centered, responsive popup rect for a modal. |
| 248 | /// |
| 249 | /// The size starts from `preferred_*`, but is clamped so it never exceeds the |
| 250 | /// frame (leaving a small breathing-room margin when there is space) and never |
| 251 | /// drops below `min_*` unless the frame itself is smaller. Centering the result |
| 252 | /// inside `area` replaces the repeated, error-prone |
| 253 | /// `N.min(area.width.saturating_sub(..))` arithmetic scattered across modals so |
| 254 | /// every overlay sizes itself the same way at 80x24, 100x30, 120x32, 160x40, |
| 255 | /// and beyond. See #3732. |
| 256 | pub(crate) fn centered_modal_area( |
| 257 | area: Rect, |
| 258 | preferred_width: u16, |
| 259 | preferred_height: u16, |
| 260 | min_width: u16, |
| 261 | min_height: u16, |
| 262 | ) -> Rect { |
| 263 | // Keep a 2-cell margin on each axis when the frame can spare it so the |
| 264 | // backdrop stays visible around the card; otherwise fill the frame. |
| 265 | let avail_width = area.width.saturating_sub(2).max(1); |
| 266 | let avail_height = area.height.saturating_sub(2).max(1); |
| 267 | let width = preferred_width.clamp(min_width.min(avail_width), avail_width); |
| 268 | let height = preferred_height.clamp(min_height.min(avail_height), avail_height); |
| 269 | Rect { |
| 270 | x: area.x + area.width.saturating_sub(width) / 2, |
| 271 | y: area.y + area.height.saturating_sub(height) / 2, |
| 272 | width, |
| 273 | height, |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | /// A single key/label hint shown in a modal's action footer. |
| 278 | /// |
| 279 | /// Footers built from `ActionHint`s are laid out by [`action_footer_lines`], |
| 280 | /// which wraps to additional rows instead of letting an action run off the |
| 281 | /// right edge of the modal — the core overflow bug behind #3732. Use this for |
| 282 | /// action/navigation hints; truncate only identifiers/paths/hashes elsewhere. |
| 283 | pub(crate) struct ActionHint { |
| 284 | key: Cow<'static, str>, |
| 285 | label: Cow<'static, str>, |
| 286 | } |
| 287 | |
| 288 | impl ActionHint { |
| 289 | pub(crate) fn new( |
| 290 | key: impl Into<Cow<'static, str>>, |
| 291 | label: impl Into<Cow<'static, str>>, |
| 292 | ) -> Self { |
| 293 | Self { |
| 294 | key: key.into(), |
| 295 | label: label.into(), |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | /// Display columns this hint occupies: ` key ` (key padded by a space on |
| 300 | /// each side) followed by the label. |
| 301 | fn width(&self) -> usize { |
| 302 | UnicodeWidthStr::width(self.key.as_ref()) + 2 + UnicodeWidthStr::width(self.label.as_ref()) |
| 303 | } |
| 304 | |
| 305 | fn spans(&self) -> [Span<'static>; 2] { |
| 306 | [ |
| 307 | Span::styled( |
| 308 | format!(" {} ", self.key), |
| 309 | Style::default() |
| 310 | .fg(palette::WHALE_ACTION) |
| 311 | .add_modifier(Modifier::BOLD), |
| 312 | ), |
| 313 | Span::styled( |
| 314 | self.label.clone().into_owned(), |
| 315 | Style::default().fg(palette::TEXT_MUTED), |
| 316 | ), |
| 317 | ] |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | /// Lay out action hints into one or more lines that each fit within `width`. |
| 322 | /// |
| 323 | /// Hints are packed greedily; when the next hint would overflow the current row |
| 324 | /// the layout starts a new row rather than truncating. No action is ever |
| 325 | /// dropped or clipped (a single hint wider than `width` is emitted alone, which |
| 326 | /// only happens at degenerate widths below the modal minimums). This is the |
| 327 | /// shared replacement for the single-line `title_bottom` footers that silently |
| 328 | /// pushed actions off-screen. |
| 329 | pub(crate) fn action_footer_lines(hints: &[ActionHint], width: u16) -> Vec<Line<'static>> { |
| 330 | let width = usize::from(width); |
| 331 | if hints.is_empty() || width == 0 { |
| 332 | return Vec::new(); |
| 333 | } |
| 334 | const GAP: usize = 1; |
| 335 | let mut lines: Vec<Line<'static>> = Vec::new(); |
| 336 | let mut current: Vec<Span<'static>> = Vec::new(); |
| 337 | let mut current_width = 0usize; |
| 338 | for hint in hints { |
| 339 | let hint_width = hint.width(); |
| 340 | let needed = if current.is_empty() { |
| 341 | hint_width |
| 342 | } else { |
| 343 | current_width + GAP + hint_width |
| 344 | }; |
| 345 | if !current.is_empty() && needed > width { |
| 346 | lines.push(Line::from(std::mem::take(&mut current))); |
| 347 | current_width = 0; |
| 348 | } |
| 349 | if !current.is_empty() { |
| 350 | current.push(Span::raw(" ".repeat(GAP))); |
| 351 | current_width += GAP; |
| 352 | } |
| 353 | current.extend(hint.spans()); |
| 354 | current_width += hint_width; |
| 355 | } |
| 356 | if !current.is_empty() { |
| 357 | lines.push(Line::from(current)); |
| 358 | } |
| 359 | lines |
| 360 | } |
| 361 | |
| 362 | /// Reserve `lines` worth of rows at the bottom of `inner`, paint them, and |
| 363 | /// return the content area that remains above. Shared by the action-hint and |
| 364 | /// free-text modal footers. |
| 365 | fn place_footer_lines( |
| 366 | inner: Rect, |
| 367 | buf: &mut Buffer, |
| 368 | lines: Vec<Line<'static>>, |
| 369 | quiet_gutter: bool, |
| 370 | ) -> Rect { |
| 371 | if lines.is_empty() || inner.height == 0 { |
| 372 | return inner; |
| 373 | } |
| 374 | let footer_height = u16::try_from(lines.len()) |
| 375 | .unwrap_or(u16::MAX) |
| 376 | .min(inner.height); |
| 377 | // Opted-in overlays keep one quiet row between scrollable body copy and |
| 378 | // the action rail. Degenerate heights keep every row for content. |
| 379 | let gutter_height = u16::from(quiet_gutter && inner.height >= footer_height.saturating_add(4)); |
| 380 | let footer_area = Rect { |
| 381 | x: inner.x, |
| 382 | y: inner.y + inner.height - footer_height, |
| 383 | width: inner.width, |
| 384 | height: footer_height, |
| 385 | }; |
| 386 | Paragraph::new(lines).render(footer_area, buf); |
| 387 | Rect { |
| 388 | x: inner.x, |
| 389 | y: inner.y, |
| 390 | width: inner.width, |
| 391 | height: inner |
| 392 | .height |
| 393 | .saturating_sub(footer_height.saturating_add(gutter_height)), |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | /// Render a wrapping action footer anchored to the bottom of `inner` and |
| 398 | /// return the content area that remains above it. |
| 399 | /// |
| 400 | /// Modals call this after painting their block so the footer reserves exactly |
| 401 | /// as many rows as it needs (bounded by the available height) and the body |
| 402 | /// fills the rest. Centralizing it keeps every modal's action row visible and |
| 403 | /// reachable at narrow widths. |
| 404 | pub(crate) fn render_modal_footer(inner: Rect, buf: &mut Buffer, hints: &[ActionHint]) -> Rect { |
| 405 | let lines = action_footer_lines(hints, inner.width); |
| 406 | place_footer_lines(inner, buf, lines, false) |
| 407 | } |
| 408 | |
| 409 | /// Render a modal action footer with one quiet body-to-footer row when the |
| 410 | /// caller's responsive layout has explicitly budgeted for it. |
| 411 | pub(crate) fn render_modal_footer_with_gutter( |
| 412 | inner: Rect, |
| 413 | buf: &mut Buffer, |
| 414 | hints: &[ActionHint], |
| 415 | ) -> Rect { |
| 416 | let lines = action_footer_lines(hints, inner.width); |
| 417 | place_footer_lines(inner, buf, lines, true) |
| 418 | } |
| 419 | |
| 420 | /// Word-wrap a free-form footer string into styled lines that each fit `width`. |
| 421 | /// |
| 422 | /// For footers that are pre-composed prose/sentences (e.g. localized config |
| 423 | /// hints) rather than discrete key/label hints. Wrapping on whitespace keeps |
| 424 | /// every word visible instead of clipping the tail at the modal edge. |
| 425 | pub(crate) fn wrapped_footer_lines(text: &str, width: u16, style: Style) -> Vec<Line<'static>> { |
| 426 | let width = usize::from(width); |
| 427 | if text.trim().is_empty() || width == 0 { |
| 428 | return Vec::new(); |
| 429 | } |
| 430 | let mut lines: Vec<Line<'static>> = Vec::new(); |
| 431 | let mut current = String::new(); |
| 432 | let mut current_width = 0usize; |
| 433 | for word in text.split_whitespace() { |
| 434 | let word_width = UnicodeWidthStr::width(word); |
| 435 | let needed = if current.is_empty() { |
| 436 | word_width |
| 437 | } else { |
| 438 | current_width + 1 + word_width |
| 439 | }; |
| 440 | if !current.is_empty() && needed > width { |
| 441 | lines.push(Line::from(Span::styled( |
| 442 | std::mem::take(&mut current), |
| 443 | style, |
| 444 | ))); |
| 445 | current_width = 0; |
| 446 | } |
| 447 | if !current.is_empty() { |
| 448 | current.push(' '); |
| 449 | current_width += 1; |
| 450 | } |
| 451 | current.push_str(word); |
| 452 | current_width += word_width; |
| 453 | } |
| 454 | if !current.is_empty() { |
| 455 | lines.push(Line::from(Span::styled(current, style))); |
| 456 | } |
| 457 | lines |
| 458 | } |
| 459 | |
| 460 | /// Render a wrapping free-text footer anchored to the bottom of `inner` and |
| 461 | /// return the content area above it. The prose counterpart to |
| 462 | /// [`render_modal_footer`]. |
| 463 | pub(crate) fn render_modal_text_footer( |
| 464 | inner: Rect, |
| 465 | buf: &mut Buffer, |
| 466 | text: &str, |
| 467 | style: Style, |
| 468 | ) -> Rect { |
| 469 | let lines = wrapped_footer_lines(text, inner.width, style); |
| 470 | // Free-text status footers are already separated semantically from their |
| 471 | // table body and can carry the last visible receipt themselves. Do not |
| 472 | // spend another row here; action-rail layouts can opt into that gutter. |
| 473 | place_footer_lines(inner, buf, lines, false) |
| 474 | } |
| 475 | |
| 476 | /// Shared list/detail geometry for modal managers and pickers. |
| 477 | /// |
| 478 | /// Wide modals get a stable left list and a right detail pane. Narrow modals |
| 479 | /// stack the list over the detail so neither side becomes unreadably thin. |
| 480 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 481 | pub(crate) struct ListDetailLayout { |
| 482 | pub(crate) list: Rect, |
| 483 | pub(crate) detail: Rect, |
| 484 | pub(crate) stacked: bool, |
| 485 | } |
| 486 | |
| 487 | impl ListDetailLayout { |
| 488 | #[must_use] |
| 489 | pub(crate) fn split(area: Rect, min_detail_width: u16) -> Self { |
| 490 | if area.width == 0 || area.height == 0 { |
| 491 | return Self { |
| 492 | list: area, |
| 493 | detail: area, |
| 494 | stacked: true, |
| 495 | }; |
| 496 | } |
| 497 | |
| 498 | let gap = 1; |
| 499 | let min_list_width = 30.min(area.width); |
| 500 | let can_split = area.width >= 96 |
| 501 | && area |
| 502 | .width |
| 503 | .saturating_sub(gap) |
| 504 | .saturating_sub(min_list_width) |
| 505 | >= min_detail_width; |
| 506 | if can_split { |
| 507 | let max_list_width = area.width.saturating_sub(gap + min_detail_width); |
| 508 | let preferred = area.width.saturating_mul(42) / 100; |
| 509 | let list_width = preferred.clamp(min_list_width, max_list_width.min(52)); |
| 510 | let detail_width = area.width.saturating_sub(list_width + gap); |
| 511 | return Self { |
| 512 | list: Rect { |
| 513 | x: area.x, |
| 514 | y: area.y, |
| 515 | width: list_width, |
| 516 | height: area.height, |
| 517 | }, |
| 518 | detail: Rect { |
| 519 | x: area.x + list_width + gap, |
| 520 | y: area.y, |
| 521 | width: detail_width, |
| 522 | height: area.height, |
| 523 | }, |
| 524 | stacked: false, |
| 525 | }; |
| 526 | } |
| 527 | |
| 528 | let gap = if area.height >= 8 { 1 } else { 0 }; |
| 529 | let min_detail_height = 4.min(area.height); |
| 530 | let max_list_height = area.height.saturating_sub(gap + min_detail_height); |
| 531 | let preferred = area.height.saturating_mul(3) / 5; |
| 532 | let list_height = preferred.clamp(1, max_list_height.max(1)); |
| 533 | let detail_height = area.height.saturating_sub(list_height + gap); |
| 534 | Self { |
| 535 | list: Rect { |
| 536 | x: area.x, |
| 537 | y: area.y, |
| 538 | width: area.width, |
| 539 | height: list_height, |
| 540 | }, |
| 541 | detail: Rect { |
| 542 | x: area.x, |
| 543 | y: area.y + list_height + gap, |
| 544 | width: area.width, |
| 545 | height: detail_height, |
| 546 | }, |
| 547 | stacked: true, |
| 548 | } |
| 549 | } |
| 550 | } |
| 551 | |
| 552 | /// Plain empty-state copy for modal list/detail bodies. |
| 553 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 554 | pub(crate) struct EmptyState { |
| 555 | title: Cow<'static, str>, |
| 556 | body: Cow<'static, str>, |
| 557 | primary_action: Option<(Cow<'static, str>, Cow<'static, str>)>, |
| 558 | secondary_action: Option<(Cow<'static, str>, Cow<'static, str>)>, |
| 559 | } |
| 560 | |
| 561 | impl EmptyState { |
| 562 | pub(crate) fn new( |
| 563 | title: impl Into<Cow<'static, str>>, |
| 564 | body: impl Into<Cow<'static, str>>, |
| 565 | ) -> Self { |
| 566 | Self { |
| 567 | title: title.into(), |
| 568 | body: body.into(), |
| 569 | primary_action: None, |
| 570 | secondary_action: None, |
| 571 | } |
| 572 | } |
| 573 | |
| 574 | #[must_use] |
| 575 | pub(crate) fn primary_action( |
| 576 | mut self, |
| 577 | key: impl Into<Cow<'static, str>>, |
| 578 | label: impl Into<Cow<'static, str>>, |
| 579 | ) -> Self { |
| 580 | self.primary_action = Some((key.into(), label.into())); |
| 581 | self |
| 582 | } |
| 583 | |
| 584 | #[must_use] |
| 585 | pub(crate) fn secondary_action( |
| 586 | mut self, |
| 587 | key: impl Into<Cow<'static, str>>, |
| 588 | label: impl Into<Cow<'static, str>>, |
| 589 | ) -> Self { |
| 590 | self.secondary_action = Some((key.into(), label.into())); |
| 591 | self |
| 592 | } |
| 593 | |
| 594 | pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) { |
| 595 | let mut lines = vec![ |
| 596 | Line::from(Span::styled( |
| 597 | self.title.clone().into_owned(), |
| 598 | Style::default() |
| 599 | .fg(palette::TEXT_PRIMARY) |
| 600 | .add_modifier(Modifier::BOLD), |
| 601 | )), |
| 602 | Line::from(""), |
| 603 | Line::from(Span::styled( |
| 604 | self.body.clone().into_owned(), |
| 605 | Style::default().fg(palette::TEXT_MUTED), |
| 606 | )), |
| 607 | ]; |
| 608 | if self.primary_action.is_some() || self.secondary_action.is_some() { |
| 609 | lines.push(Line::from("")); |
| 610 | } |
| 611 | for (key, label) in [self.primary_action.as_ref(), self.secondary_action.as_ref()] |
| 612 | .into_iter() |
| 613 | .flatten() |
| 614 | { |
| 615 | let hint = ActionHint::new(key.clone(), label.clone()); |
| 616 | lines.push(Line::from(hint.spans().to_vec())); |
| 617 | } |
| 618 | Paragraph::new(lines) |
| 619 | .style(Style::default().fg(palette::TEXT_PRIMARY)) |
| 620 | .wrap(Wrap { trim: true }) |
| 621 | .render(area, buf); |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | #[derive(Debug, Clone)] |
| 626 | pub enum CommandPaletteAction { |
| 627 | ExecuteCommand { command: String }, |
| 628 | InsertText { text: String }, |
| 629 | OpenTextPager { title: String, content: String }, |
| 630 | } |
| 631 | |
| 632 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 633 | pub enum ContextMenuAction { |
| 634 | CopySelection, |
| 635 | OpenSelection, |
| 636 | ClearSelection, |
| 637 | CopyCell { |
| 638 | cell_index: usize, |
| 639 | }, |
| 640 | OpenDetails { |
| 641 | cell_index: usize, |
| 642 | }, |
| 643 | Paste, |
| 644 | OpenCommandPalette, |
| 645 | OpenContextInspector, |
| 646 | OpenHelp, |
| 647 | /// Open a workspace file at a line in the user's editor. The path was |
| 648 | /// resolved inside the workspace when the menu was built; the entry is |
| 649 | /// only offered when it resolved. |
| 650 | OpenFileAtLine { |
| 651 | path: std::path::PathBuf, |
| 652 | line: u32, |
| 653 | }, |
| 654 | /// Hide a transcript cell. Adds the cell's index to `collapsed_cells`. |
| 655 | HideCell { |
| 656 | cell_index: usize, |
| 657 | }, |
| 658 | /// Show a previously hidden cell (when right-clicking near it). |
| 659 | ShowCell { |
| 660 | cell_index: usize, |
| 661 | }, |
| 662 | /// Show all currently hidden cells. |
| 663 | ShowAllHidden, |
| 664 | /// Run a work-surface row action — the same typed action a left click or |
| 665 | /// Enter on that row runs, never a free-form command string. |
| 666 | Row(crate::tui::app::SidebarRowAction), |
| 667 | /// Copy a pre-resolved text payload (e.g. a sidebar row's full text) |
| 668 | /// to the clipboard. |
| 669 | CopyText { |
| 670 | text: String, |
| 671 | }, |
| 672 | /// Act on a row of the open Extensions panel, found again by its id. |
| 673 | Extension { |
| 674 | item_id: String, |
| 675 | verb: ExtensionMenuVerb, |
| 676 | }, |
| 677 | /// Pin/unpin the host terminal window (normal window ↔ always-on-top |
| 678 | /// mini window). Windows only; no-op elsewhere. |
| 679 | ToggleWindowPin, |
| 680 | } |
| 681 | |
| 682 | /// What an Extensions row menu entry does to its row. |
| 683 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 684 | pub enum ExtensionMenuVerb { |
| 685 | /// The row's own action — what Enter runs. |
| 686 | Activate, |
| 687 | /// Show the row's state, description and detail in a pager. |
| 688 | Details, |
| 689 | /// The row's reversible on/off switch. |
| 690 | Toggle, |
| 691 | /// Remove the row. The menu already asked for confirmation. |
| 692 | Remove, |
| 693 | } |
| 694 | |
| 695 | #[derive(Debug, Clone)] |
| 696 | pub enum ViewEvent { |
| 697 | CommandPaletteSelected { |
| 698 | action: CommandPaletteAction, |
| 699 | }, |
| 700 | OpenTextPager { |
| 701 | title: String, |
| 702 | content: String, |
| 703 | }, |
| 704 | ApprovalDecision { |
| 705 | tool_id: String, |
| 706 | tool_name: String, |
| 707 | decision: ReviewDecision, |
| 708 | timed_out: bool, |
| 709 | /// Exact-argument fingerprint, used to scope *denials* (#1617). |
| 710 | approval_key: String, |
| 711 | /// Lossy / arity-aware fingerprint, used to scope *approvals*. |
| 712 | approval_grouping_key: String, |
| 713 | /// Permission rules to append when the decision approves. |
| 714 | persistent_rules: Vec<codewhale_config::ToolAskRule>, |
| 715 | }, |
| 716 | ElevationDecision { |
| 717 | tool_id: String, |
| 718 | tool_name: String, |
| 719 | option: ElevationOption, |
| 720 | }, |
| 721 | UserInputSubmitted { |
| 722 | tool_id: String, |
| 723 | response: UserInputResponse, |
| 724 | }, |
| 725 | UserInputCancelled { |
| 726 | tool_id: String, |
| 727 | }, |
| 728 | ConfigUpdated { |
| 729 | key: String, |
| 730 | value: String, |
| 731 | persist: bool, |
| 732 | }, |
| 733 | /// The canonical `/theme` picker's selection. Preview, rollback, and |
| 734 | /// persist travel in one event so the theme never changes by half. |
| 735 | ThemeSelectionUpdated { |
| 736 | theme: String, |
| 737 | persist: bool, |
| 738 | }, |
| 739 | SubAgentsRefresh, |
| 740 | SidebarAgentCancel { |
| 741 | agent_id: String, |
| 742 | }, |
| 743 | /// An agent row activation (Work strip, sidebar dossier, `/agents`) or |
| 744 | /// Alt+V from Agent Details, Enter/click on any agent row, and Enter in the |
| 745 | /// `/agents` register all request the agent's transcript — since v0.9.7's |
| 746 | /// "one agent, one destination" inversion that is the in-place focus. |
| 747 | OpenAgentTranscript { |
| 748 | agent_id: String, |
| 749 | }, |
| 750 | /// Agent Details was popped with Esc/q/Left. The Work surface uses this |
| 751 | /// to release only its detail-open owner while retaining selection. |
| 752 | AgentDetailsClosed { |
| 753 | agent_id: String, |
| 754 | }, |
| 755 | /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a |
| 756 | /// candidate. The handler should insert `@<path>` at the composer's cursor |
| 757 | /// position. |
| 758 | FilePickerSelected { |
| 759 | path: String, |
| 760 | }, |
| 761 | SessionSelected { |
| 762 | session_id: String, |
| 763 | }, |
| 764 | SessionRenamed { |
| 765 | metadata: Box<crate::session_manager::SessionMetadata>, |
| 766 | }, |
| 767 | /// A session's archive flag was flipped (#2934 / #4397). |
| 768 | /// |
| 769 | /// Distinct from `SessionRenamed` so the receipt can say what actually |
| 770 | /// happened; reusing rename would report "Renamed session …" for an |
| 771 | /// archive, which is exactly the kind of small lie that erodes trust in |
| 772 | /// every other receipt. |
| 773 | SessionArchived { |
| 774 | metadata: Box<crate::session_manager::SessionMetadata>, |
| 775 | }, |
| 776 | SessionDeleted { |
| 777 | session_id: String, |
| 778 | title: String, |
| 779 | }, |
| 780 | /// Emitted by the `/model` picker on Enter or Shift+D. Carries both the |
| 781 | /// chosen model id and reasoning effort tier so the UI handler can update |
| 782 | /// App state and forward `Op::SetModel` to the running engine. |
| 783 | /// `save_as_startup_default` is true only for the explicit Shift+D action; |
| 784 | /// ordinary Enter remains a session-local route change. `previous_*` |
| 785 | /// fields let the handler skip work when nothing changed and craft a clear |
| 786 | /// status message. |
| 787 | ModelPickerApplied { |
| 788 | model: String, |
| 789 | identity: Option<crate::config::ProviderIdentity>, |
| 790 | effort: crate::reasoning_preference::ReasoningEffort, |
| 791 | previous_model: String, |
| 792 | previous_effort: crate::reasoning_preference::ReasoningEffort, |
| 793 | save_as_startup_default: bool, |
| 794 | }, |
| 795 | /// Emitted by the `/model` picker on Esc so the next open can restore |
| 796 | /// the browsing context — view mode and highlighted row (#4109 / #4115). |
| 797 | ModelPickerDismissed { |
| 798 | /// True when the dismissed view browses beyond configured providers |
| 799 | /// (Catalog / Recent / Coding / Cheap / Long context). |
| 800 | catalog_view: bool, |
| 801 | /// Named view key (`configured`, `catalog`, `recent`, `coding`, |
| 802 | /// `cheap`, `long_context`) for reopen restore (#4115). |
| 803 | view: String, |
| 804 | selected_row_id: Option<String>, |
| 805 | }, |
| 806 | /// Enter on a locked (unauthenticated) model: explain why selection is |
| 807 | /// blocked and open the provider auth/setup path when possible. |
| 808 | /// Re-resolve readiness + rebuild catalog rows for the open model picker. |
| 809 | ModelPickerRefresh, |
| 810 | ModelPickerTogglePin { |
| 811 | provider: crate::config::ProviderKind, |
| 812 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 813 | provider_id: Option<String>, |
| 814 | model: String, |
| 815 | }, |
| 816 | ModelPickerMovePin { |
| 817 | provider: crate::config::ProviderKind, |
| 818 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 819 | provider_id: Option<String>, |
| 820 | model: String, |
| 821 | delta: isize, |
| 822 | }, |
| 823 | /// `⇧F` in the picker: add the row's exact route to the team (the |
| 824 | /// selected saved team), or remove it when it is already there (design §10 F1). |
| 825 | ModelPickerToggleFleet { |
| 826 | provider: crate::config::ProviderKind, |
| 827 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 828 | provider_id: Option<String>, |
| 829 | model: String, |
| 830 | }, |
| 831 | /// Enter on a Fleet editor row: open the standard `/model` picker for |
| 832 | /// that row (the editor stays underneath) instead of the editor's own |
| 833 | /// inline route list. |
| 834 | FleetProfileRoutePickRequested { |
| 835 | editor_id: uuid::Uuid, |
| 836 | }, |
| 837 | FleetProfileRoutePicked { |
| 838 | editor_id: uuid::Uuid, |
| 839 | provider: crate::config::ProviderKind, |
| 840 | provider_id: Option<String>, |
| 841 | model: String, |
| 842 | reasoning: Option<crate::reasoning_preference::ReasoningEffort>, |
| 843 | }, |
| 844 | FleetProfileRouteCommitRequested { |
| 845 | editor_id: uuid::Uuid, |
| 846 | }, |
| 847 | FleetAssignmentPickerDismissed { |
| 848 | editor_id: uuid::Uuid, |
| 849 | }, |
| 850 | FleetRosterOpenCoordinatorRequested, |
| 851 | FleetDetailRoutePickRequested { |
| 852 | target: crate::tui::views::fleet_detail::FleetRouteTarget, |
| 853 | editor_id: uuid::Uuid, |
| 854 | }, |
| 855 | /// The `/model` picker, opened for a Fleet editor row, resolved a route. |
| 856 | /// Carries the row's absolute route — never a diff against the session — |
| 857 | /// and the host applies and saves it on the editor still on the stack. |
| 858 | /// The picker's `auto` row means "inherit the session route". |
| 859 | FleetRoutePicked { |
| 860 | target: crate::tui::views::fleet_detail::FleetRouteTarget, |
| 861 | editor_id: uuid::Uuid, |
| 862 | provider: crate::config::ProviderKind, |
| 863 | /// Exact named route for `Custom`; built-in providers leave this unset. |
| 864 | provider_id: Option<String>, |
| 865 | model: String, |
| 866 | reasoning: Option<crate::reasoning_preference::ReasoningEffort>, |
| 867 | }, |
| 868 | ModelPickerNeedsAuth { |
| 869 | identity: crate::config::ProviderIdentity, |
| 870 | model: String, |
| 871 | reason: String, |
| 872 | }, |
| 873 | /// Transient status toast from a modal (e.g. locked-model explanation). |
| 874 | StatusMessage { |
| 875 | message: String, |
| 876 | }, |
| 877 | /// The Tideline topbar's route segment requested the normal `/provider` |
| 878 | /// surface. It carries no catalog, readiness, or selected-route payload: |
| 879 | /// those facts remain owned by the provider picker and its apply path. |
| 880 | TopbarRoutePickerRequested, |
| 881 | /// The info line's model field requested the normal `/model` surface. |
| 882 | /// Same rule as the route segment: an entry point carrying no catalog. |
| 883 | TopbarModelPickerRequested, |
| 884 | /// Emitted by the `/provider` picker on Esc so the next open can restore |
| 885 | /// the browsing context — view mode and highlighted row. |
| 886 | ProviderPickerDismissed { |
| 887 | catalog_view: bool, |
| 888 | selected_provider_id: Option<String>, |
| 889 | }, |
| 890 | /// Emitted by the `/provider` picker when the user selects a provider |
| 891 | /// that already has credentials — the handler should perform the same |
| 892 | /// switch as `AppAction::SwitchProvider`. |
| 893 | ProviderPickerApplied { |
| 894 | identity: crate::config::ProviderIdentity, |
| 895 | }, |
| 896 | /// Emitted by the `/provider` picker after the user types an API key |
| 897 | /// inline for a provider that lacked one. The handler validates the key |
| 898 | /// live; on success it reopens the guided flow at the model-pick stage |
| 899 | /// without persisting yet (#3875). |
| 900 | ProviderPickerApiKeySubmitted { |
| 901 | identity: crate::config::ProviderIdentity, |
| 902 | api_key: String, |
| 903 | /// Endpoint chosen in the wizard's billing-route stage, applied to the |
| 904 | /// verification config only — nothing is written until confirm (#4526). |
| 905 | base_url: Option<String>, |
| 906 | }, |
| 907 | /// Emitted by the `/provider` guided setup confirm stage after the user |
| 908 | /// accepted provider + model. The handler persists the key (and model) |
| 909 | /// via the comment-preserving config path, then performs the switch. |
| 910 | ProviderPickerSetupConfirmed { |
| 911 | identity: crate::config::ProviderIdentity, |
| 912 | api_key: String, |
| 913 | model: String, |
| 914 | context_window: Option<u32>, |
| 915 | /// Endpoint the key was verified against, persisted to the provider's |
| 916 | /// own `base_url` before the key is saved (#4526). |
| 917 | base_url: Option<String>, |
| 918 | }, |
| 919 | /// Emitted by the `/provider` picker after the custom provider form is |
| 920 | /// completed. The handler persists a named OpenAI-compatible provider |
| 921 | /// table and switches to it without storing raw secrets. |
| 922 | ProviderPickerCustomProviderSubmitted { |
| 923 | provider_id: String, |
| 924 | base_url: String, |
| 925 | model: Option<String>, |
| 926 | api_key_env: Option<String>, |
| 927 | }, |
| 928 | /// Emitted by provider/setup UI when xAI device-code OAuth is requested. |
| 929 | ProviderPickerXaiOAuthRequested, |
| 930 | /// Emitted by provider/setup UI when native ChatGPT PKCE sign-in is requested. |
| 931 | ProviderPickerChatgptOAuthRequested, |
| 932 | /// Emitted only after the picker showed owner, exact path, and the full |
| 933 | /// read-only side-effect contract and the user explicitly confirmed it. |
| 934 | ProviderPickerExternalConsentConfirmed { |
| 935 | provider: crate::config::ProviderKind, |
| 936 | consent_provider: codewhale_config::ProviderKind, |
| 937 | source: codewhale_config::ExternalCredentialSource, |
| 938 | path: std::path::PathBuf, |
| 939 | }, |
| 940 | /// One-step revocation from a provider row that currently has consent. |
| 941 | ProviderPickerExternalConsentRevoked { |
| 942 | provider: crate::config::ProviderKind, |
| 943 | }, |
| 944 | /// Emitted by the `/provider` picker (the `M` action) to jump straight to |
| 945 | /// the `/model` picker pre-filtered to the highlighted provider (#3083). |
| 946 | ProviderPickerOpenModels { |
| 947 | identity: crate::config::ProviderIdentity, |
| 948 | }, |
| 949 | /// Emitted by `/provider` `T`: probe `/models` and refresh readiness |
| 950 | /// without treating a 2xx as model-ready (#5350). |
| 951 | ProviderPickerTestConnection { |
| 952 | identity: crate::config::ProviderIdentity, |
| 953 | /// Restore Catalog vs Configured after the probe. Must not force |
| 954 | /// the all-providers catalog if the user was on configured-only. |
| 955 | catalog_view: bool, |
| 956 | }, |
| 957 | /// Emitted by the `/mode` picker when the user chooses a mode. |
| 958 | ModeSelected { |
| 959 | mode: codewhale_config::AppMode, |
| 960 | }, |
| 961 | /// Emitted by the `/statusline` picker every time the user toggles an |
| 962 | /// item (live preview) and once more on Enter (final). The handler |
| 963 | /// updates `app.status_items` immediately and persists on `final_save` |
| 964 | /// so the footer animates without a write per keystroke. |
| 965 | StatusItemsUpdated { |
| 966 | items: Vec<crate::config::StatusItem>, |
| 967 | final_save: bool, |
| 968 | }, |
| 969 | /// Emitted by the `/hotbar` setup wizard when the user saves the draft |
| 970 | /// bindings. The host updates live config state; disk persistence is |
| 971 | /// handled by the follow-up persistence slice. |
| 972 | HotbarSetupSaved { |
| 973 | bindings: Vec<codewhale_config::HotbarBindingToml>, |
| 974 | }, |
| 975 | /// Emitted by the constitution-first setup shell when a staged setup-state |
| 976 | /// record should be committed atomically to `$CODEWHALE_HOME/setup_state.json`. |
| 977 | SetupStateCommitRequested { |
| 978 | state: codewhale_config::SetupState, |
| 979 | message: String, |
| 980 | }, |
| 981 | /// Emitted by the constitution-first setup shell when accepting a guided |
| 982 | /// structured user-global constitution. The host commits the constitution |
| 983 | /// and matching setup-state record together. |
| 984 | SetupConstitutionCommitRequested { |
| 985 | constitution: codewhale_config::UserConstitution, |
| 986 | state: codewhale_config::SetupState, |
| 987 | message: String, |
| 988 | }, |
| 989 | /// Emitted by the setup Constitution card (`A`, provider route ready) to |
| 990 | /// ask the user's first configured model to draft the constitution from |
| 991 | /// the guided answers plus an optional bounded own-words note. The host |
| 992 | /// performs the one-shot call, pushes the sanitized/bounded draft back into the wizard, and opens the |
| 993 | /// ratification preview; on any failure it reports why and leaves the |
| 994 | /// deterministic guided draft standing. Nothing is persisted by this |
| 995 | /// event — saving still goes through the ratify keypress and |
| 996 | /// [`SetupConstitutionCommitRequested`](Self::SetupConstitutionCommitRequested). |
| 997 | SetupConstitutionModelDraftRequested { |
| 998 | draft: crate::tui::setup::GuidedConstitutionDraft, |
| 999 | freeform_note: Option<String>, |
| 1000 | locale: codewhale_localization::Locale, |
| 1001 | }, |
| 1002 | /// Emitted by the fleet setup Review step (`m`) to ask the configured |
| 1003 | /// model to draft the agent profile the wizard describes. The host |
| 1004 | /// performs the one-shot call, pushes the sanitized/bounded draft back |
| 1005 | /// into the wizard, and opens the rendered-TOML preview; on failure it |
| 1006 | /// reports why and the manual authoring flow stands. Nothing is |
| 1007 | /// persisted by this event. |
| 1008 | FleetProfileModelDraftRequested { |
| 1009 | role: String, |
| 1010 | /// Target model for the worker: a concrete model id, or "inherit". |
| 1011 | model: String, |
| 1012 | /// Canonical provider id for a concrete cross-provider route pick, or |
| 1013 | /// `None` for `inherit` (#4093). Carried so the model-drafted profile |
| 1014 | /// keeps the picked provider instead of collapsing to an ambiguous, |
| 1015 | /// provider-scoped profile — the exact bug #4093 fixes. |
| 1016 | provider: Option<String>, |
| 1017 | /// Canonical reasoning tier selected by the wizard, or `None` for |
| 1018 | /// inherit (#4137). Carried with the async draft for the same reason |
| 1019 | /// as `provider`: the ratified profile must preserve the operator's |
| 1020 | /// explicit choice, not whatever the model echoed. |
| 1021 | reasoning_effort: Option<String>, |
| 1022 | locale: codewhale_localization::Locale, |
| 1023 | }, |
| 1024 | /// Emitted by the `/fleet` roster view (`s` / Enter) to edit a member. |
| 1025 | /// The host routes a selected v2 Fleet to its exact editor and uses the |
| 1026 | /// legacy profile wizard only when no named Fleet is selected. |
| 1027 | FleetRosterOpenSetupRequested { |
| 1028 | /// Exact Fleet member id; roles are not unique and therefore cannot |
| 1029 | /// identify which row the operator selected. |
| 1030 | member_id: String, |
| 1031 | }, |
| 1032 | /// Open the live workers tab from the unified Fleet surface. |
| 1033 | FleetRosterOpenWorkersRequested, |
| 1034 | |
| 1035 | /// The roster asks the host to open the secondary named-Fleet switcher |
| 1036 | /// (`/fleet fleets`; `/fleet fleets` remains compatible). Editing stays on |
| 1037 | /// setup; this is pick/select only. |
| 1038 | FleetRosterOpenFleetsRequested, |
| 1039 | |
| 1040 | /// The Fleet list view asks the host to open a saved Fleet's detail view. |
| 1041 | FleetListOpenDetailRequested { |
| 1042 | name: String, |
| 1043 | scope: crate::fleet::store::FleetScope, |
| 1044 | }, |
| 1045 | /// A Fleet store mutation happened (select/save/delete/rename/copy). |
| 1046 | /// The message is the exact receipt; the host refreshes roster state. |
| 1047 | FleetStoreChanged { |
| 1048 | message: String, |
| 1049 | }, |
| 1050 | /// Emitted by the fleet setup Review step after the user previewed a |
| 1051 | /// model-drafted profile and pressed the explicit ratify key. The host |
| 1052 | /// renders TOML deterministically from the validated draft and persists it |
| 1053 | /// atomically in the explicitly selected project or personal scope. |
| 1054 | FleetProfileDraftCommitRequested { |
| 1055 | draft: Box<crate::fleet::profile::FleetProfileDraft>, |
| 1056 | scope: crate::fleet::profile::FleetProfileScope, |
| 1057 | }, |
| 1058 | /// Emitted by the Fleet setup Model step when the user selects a route that |
| 1059 | /// has structurally valid external-consent credentials but is not the |
| 1060 | /// active session provider. The host performs a route-scoped validation |
| 1061 | /// (minting the read capability only for this exact provider/source/path) |
| 1062 | /// and records the result in the session health snapshot so the same row |
| 1063 | /// becomes selectable on the next render. The parent session provider and |
| 1064 | /// model are never changed. |
| 1065 | FleetSetupExternalConsentActivationRequested { |
| 1066 | provider_id: String, |
| 1067 | model: String, |
| 1068 | }, |
| 1069 | /// Emitted by the setup Runtime Posture card after the user has previewed |
| 1070 | /// and confirmed an explicit preset/config diff. |
| 1071 | SetupRuntimePresetApplyRequested { |
| 1072 | preset: crate::tui::setup::SetupRuntimePreset, |
| 1073 | state: codewhale_config::SetupState, |
| 1074 | message: String, |
| 1075 | }, |
| 1076 | /// Emitted by the setup Provider/Model readiness card to hand off to the |
| 1077 | /// existing provider manager instead of duplicating provider auth UI. |
| 1078 | SetupOpenProviderRequested, |
| 1079 | /// Emitted by the setup Provider/Model readiness card to hand off to the |
| 1080 | /// existing provider-qualified model route picker. |
| 1081 | SetupOpenModelRequested, |
| 1082 | /// Emitted by the setup Operate/Fleet readiness card to hand off to the |
| 1083 | /// existing Fleet setup wizard without writing Fleet config itself. |
| 1084 | SetupOpenFleetRequested, |
| 1085 | /// Emitted by the setup Hotbar card to hand off to the existing Hotbar |
| 1086 | /// setup wizard without rewriting bindings itself. |
| 1087 | SetupOpenHotbarRequested, |
| 1088 | /// Emitted by the setup Runtime Posture card to hand off to the existing |
| 1089 | /// work-mode picker. |
| 1090 | SetupOpenModeRequested, |
| 1091 | /// Emitted by the setup Runtime Posture card to hand off to the existing |
| 1092 | /// config view for approval/sandbox/network details. |
| 1093 | SetupOpenConfigRequested, |
| 1094 | /// Emitted by the progressive setup guide to start the same account-owned |
| 1095 | /// web remote-control flow as `/rc`. Setup never duplicates enrollment. |
| 1096 | SetupOpenRemoteControlRequested, |
| 1097 | /// Emitted by the `/hotbar` setup wizard when the user chooses "Disable |
| 1098 | /// Hotbar". The host persists `hotbar = []` and hides the panel. |
| 1099 | HotbarDisableRequested, |
| 1100 | /// Emitted by the live-transcript overlay while in backtrack preview |
| 1101 | /// mode (#133) when the user steps the highlighted user message with |
| 1102 | /// Left or Right. The handler advances `app.backtrack`, refreshes the |
| 1103 | /// overlay's `selected_idx`, and pins scroll near the new highlight. |
| 1104 | BacktrackStep { |
| 1105 | direction: crate::tui::backtrack::Direction, |
| 1106 | }, |
| 1107 | /// Emitted by the live-transcript overlay when the user presses Enter |
| 1108 | /// in backtrack preview mode (#133). The handler calls |
| 1109 | /// `app.backtrack.confirm()`, trims `app.history`/`api_messages` to |
| 1110 | /// the selected user message, populates the composer with the |
| 1111 | /// dropped user text, and closes the overlay. |
| 1112 | BacktrackConfirm, |
| 1113 | /// Emitted by the live-transcript overlay when the user presses Esc |
| 1114 | /// in backtrack preview mode (#133). The handler resets |
| 1115 | /// `app.backtrack` and closes the overlay without trimming. |
| 1116 | BacktrackCancel, |
| 1117 | ContextMenuSelected { |
| 1118 | action: ContextMenuAction, |
| 1119 | }, |
| 1120 | /// A modal view asks for a context menu over itself (the Extensions |
| 1121 | /// panel's row menu). The host pushes it on top of the view. |
| 1122 | OpenContextMenu { |
| 1123 | title: String, |
| 1124 | entries: Vec<crate::tui::context_menu::ContextMenuEntry>, |
| 1125 | column: u16, |
| 1126 | row: u16, |
| 1127 | }, |
| 1128 | /// Emitted by the pager (`c` / `y`) to copy its body to the system |
| 1129 | /// clipboard. The host handler writes via `app.clipboard` and surfaces a |
| 1130 | /// status message — modal views cannot reach `app` directly. `label` is |
| 1131 | /// the noun shown in the success / failure status (e.g. "Pager content"). |
| 1132 | CopyToClipboard { |
| 1133 | text: String, |
| 1134 | label: String, |
| 1135 | }, |
| 1136 | /// Emitted by the skills manager when the user confirms an install / |
| 1137 | /// import / update / remove / trust action. The host runs the mutation |
| 1138 | /// controller and rebuilds the open manager view. |
| 1139 | SkillMutationRequested { |
| 1140 | request: crate::skills::mutation::SkillMutationRequest, |
| 1141 | }, |
| 1142 | /// Toggle owned-only vs compatible audit scan inside the skills manager. |
| 1143 | SkillsManagerToggleCompatible, |
| 1144 | /// The launch card's resume confirmation was accepted. The host resumes |
| 1145 | /// the named session through the same path the card's own Enter uses. |
| 1146 | LaunchResumeConfirmed { |
| 1147 | session_id: String, |
| 1148 | }, |
| 1149 | /// A slash command an Extensions row activated in place: the panel stays |
| 1150 | /// open, the host runs the command through the normal command path, then |
| 1151 | /// hands the panel a fresh snapshot so every row re-reads live state. |
| 1152 | /// When `pager_title` is set, the command's text output renders in a |
| 1153 | /// pager stacked on the panel rather than landing in the transcript. |
| 1154 | ExecutePanelCommand { |
| 1155 | command: String, |
| 1156 | pager_title: Option<String>, |
| 1157 | }, |
| 1158 | /// The open Extensions panel's bounded poll: the host rebuilds the read |
| 1159 | /// model only when the MCP snapshot generation or the initializing flag |
| 1160 | /// moved past what the panel's snapshot last saw. |
| 1161 | RefreshExtensions { |
| 1162 | mcp_generation: u64, |
| 1163 | mcp_initializing: bool, |
| 1164 | }, |
| 1165 | } |
| 1166 | |
| 1167 | #[derive(Debug, Clone)] |
| 1168 | pub enum ViewAction { |
| 1169 | None, |
| 1170 | /// The view's own state changed with no event to report (a background |
| 1171 | /// load landed): the host must repaint, nothing else. |
| 1172 | Redraw, |
| 1173 | Close, |
| 1174 | Emit(ViewEvent), |
| 1175 | EmitAndClose(ViewEvent), |
| 1176 | } |
| 1177 | |
| 1178 | pub trait ModalView: std::any::Any { |
| 1179 | fn kind(&self) -> ModalKind; |
| 1180 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction; |
| 1181 | /// Returns `true` if the modal consumed the paste; `false` to let the |
| 1182 | /// host route the text elsewhere (e.g. drop it because a modal is open, |
| 1183 | /// or insert it into the composer when no modal wants it). The default |
| 1184 | /// is `false` so modals that don't care about paste don't silently |
| 1185 | /// swallow Cmd-V. |
| 1186 | fn handle_paste(&mut self, _text: &str) -> bool { |
| 1187 | false |
| 1188 | } |
| 1189 | |
| 1190 | fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction { |
| 1191 | ViewAction::None |
| 1192 | } |
| 1193 | fn render(&self, area: Rect, buf: &mut Buffer); |
| 1194 | /// The region this modal actually paints within the full frame `area`. |
| 1195 | /// |
| 1196 | /// Defaults to the whole frame, which is the legacy full-screen overlay |
| 1197 | /// behaviour every picker/menu still relies on. Inline modals (the |
| 1198 | /// approval prompt) override this to return a bottom-anchored band so the |
| 1199 | /// backdrop only dims their strip and the transcript above stays visible. |
| 1200 | /// The returned rect MUST match the region the modal renders into, or the |
| 1201 | /// dim and the painted content will disagree. |
| 1202 | fn occupied_region(&self, area: Rect) -> Rect { |
| 1203 | area |
| 1204 | } |
| 1205 | fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool { |
| 1206 | false |
| 1207 | } |
| 1208 | fn tick(&mut self) -> ViewAction { |
| 1209 | ViewAction::None |
| 1210 | } |
| 1211 | /// Erased downcast hook for views that need a typed reference back from |
| 1212 | /// the boxed trait object (e.g. the live transcript overlay needs `&mut` |
| 1213 | /// access from outside the trait so it can refresh its snapshot of the |
| 1214 | /// app's transcript state right before render). |
| 1215 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any; |
| 1216 | |
| 1217 | /// The approval tool id this view decides, when this view is an approval |
| 1218 | /// card. Enables identity-aware dismissal: a remote decision must close |
| 1219 | /// its own card, not whichever approval happens to be on top. |
| 1220 | fn approval_request_id(&self) -> Option<&str> { |
| 1221 | None |
| 1222 | } |
| 1223 | } |
| 1224 | |
| 1225 | #[derive(Default)] |
| 1226 | pub struct ViewStack { |
| 1227 | views: Vec<Box<dyn ModalView>>, |
| 1228 | /// Focus-context texture prototype mode (#4823). `Off` by default, which |
| 1229 | /// keeps the render output byte-identical to the pre-prototype path. |
| 1230 | focus_texture: FocusTextureMode, |
| 1231 | /// Theme snapshot for the texture pass, set alongside the mode each |
| 1232 | /// frame. `None` (e.g. tests that never opt in) disables the texture. |
| 1233 | focus_texture_theme: Option<codewhale_palette::UiTheme>, |
| 1234 | /// When the view now on top became the top view — pushed, or revealed |
| 1235 | /// by closing or removing the views above it. A key observed before |
| 1236 | /// this instant was typed at something else and must never answer an |
| 1237 | /// approval or elevation card that only then became visible (approvals M2). |
| 1238 | top_since: Option<std::time::Instant>, |
| 1239 | } |
| 1240 | |
| 1241 | /// What one [`ViewStack::tick`] produced: events to handle, and whether the |
| 1242 | /// frame must be repainted. |
| 1243 | #[derive(Debug, Default)] |
| 1244 | pub struct ViewTick { |
| 1245 | pub events: Vec<ViewEvent>, |
| 1246 | pub redraw: bool, |
| 1247 | } |
| 1248 | |
| 1249 | impl ViewStack { |
| 1250 | pub fn new() -> Self { |
| 1251 | Self { |
| 1252 | views: Vec::new(), |
| 1253 | focus_texture: FocusTextureMode::Off, |
| 1254 | focus_texture_theme: None, |
| 1255 | top_since: None, |
| 1256 | } |
| 1257 | } |
| 1258 | |
| 1259 | /// Identity of the top view, for noticing when a different view becomes |
| 1260 | /// the top one. |
| 1261 | fn top_identity(&self) -> Option<*const ()> { |
| 1262 | self.views |
| 1263 | .last() |
| 1264 | .map(|view| std::ptr::from_ref::<dyn ModalView>(view.as_ref()).cast::<()>()) |
| 1265 | } |
| 1266 | |
| 1267 | /// Restamp `top_since` when the top view changed since `before`. |
| 1268 | fn note_top_change(&mut self, before: Option<*const ()>) { |
| 1269 | if self.top_identity() != before { |
| 1270 | self.top_since = Some(std::time::Instant::now()); |
| 1271 | } |
| 1272 | } |
| 1273 | |
| 1274 | /// Set the focus-context texture mode and theme for subsequent renders |
| 1275 | /// (#4823 prototype). Called once per frame from the UI render path with |
| 1276 | /// the parsed setting; a plain enum/theme copy, no allocation. |
| 1277 | pub fn set_focus_texture(&mut self, mode: FocusTextureMode, theme: codewhale_palette::UiTheme) { |
| 1278 | self.focus_texture = mode; |
| 1279 | self.focus_texture_theme = Some(theme); |
| 1280 | } |
| 1281 | |
| 1282 | pub fn is_empty(&self) -> bool { |
| 1283 | self.views.is_empty() |
| 1284 | } |
| 1285 | |
| 1286 | pub fn top_kind(&self) -> Option<ModalKind> { |
| 1287 | self.views.last().map(|view| view.kind()) |
| 1288 | } |
| 1289 | |
| 1290 | /// Remove the approval card deciding exactly `gate` at any depth, not |
| 1291 | /// only the top: a decision made elsewhere (web, phone) must retire its |
| 1292 | /// card even when another view sits above it. Identity-aware: it never |
| 1293 | /// closes an unrelated approval card. |
| 1294 | pub fn remove_approval_for_gate(&mut self, gate: &str) -> bool { |
| 1295 | let before = self.views.len(); |
| 1296 | let top = self.top_identity(); |
| 1297 | self.views |
| 1298 | .retain(|view| !crate::remote_control::view_is_approval_for_gate(view.as_ref(), gate)); |
| 1299 | self.note_top_change(top); |
| 1300 | self.views.len() != before |
| 1301 | } |
| 1302 | |
| 1303 | /// Remove the approval card for tool/approval id `id` at any depth. |
| 1304 | pub fn remove_approval_by_id(&mut self, id: &str) -> bool { |
| 1305 | let before = self.views.len(); |
| 1306 | let top = self.top_identity(); |
| 1307 | self.views |
| 1308 | .retain(|view| view.approval_request_id() != Some(id)); |
| 1309 | self.note_top_change(top); |
| 1310 | self.views.len() != before |
| 1311 | } |
| 1312 | |
| 1313 | /// Whether an approval card for `id` is anywhere in the stack. |
| 1314 | pub fn contains_approval_id(&self, id: &str) -> bool { |
| 1315 | self.views |
| 1316 | .iter() |
| 1317 | .any(|view| view.approval_request_id() == Some(id)) |
| 1318 | } |
| 1319 | |
| 1320 | /// The approval id of the top view, when it is an approval card. |
| 1321 | pub fn top_approval_id(&self) -> Option<&str> { |
| 1322 | self.views |
| 1323 | .last() |
| 1324 | .and_then(|view| view.approval_request_id()) |
| 1325 | } |
| 1326 | |
| 1327 | /// Whether a key observed at `observed_at` predates the moment the |
| 1328 | /// approval or elevation card on top became visible — raised, or revealed |
| 1329 | /// by closing the card above it — i.e. it was typed ahead and must not |
| 1330 | /// answer that card. Two quick answers cannot confirm the card beneath. |
| 1331 | pub fn key_predates_top_approval(&self, observed_at: std::time::Instant) -> bool { |
| 1332 | matches!( |
| 1333 | self.top_kind(), |
| 1334 | Some(ModalKind::Approval | ModalKind::Elevation) |
| 1335 | ) && self |
| 1336 | .top_since |
| 1337 | .is_some_and(|top_since| observed_at < top_since) |
| 1338 | } |
| 1339 | |
| 1340 | pub fn contains_kind(&self, kind: ModalKind) -> bool { |
| 1341 | self.views.iter().any(|view| view.kind() == kind) |
| 1342 | } |
| 1343 | |
| 1344 | /// Close the named view and any child modal opened above it. This keeps a |
| 1345 | /// shell-global toggle from stacking a duplicate parent behind its picker. |
| 1346 | pub fn pop_through_kind(&mut self, kind: ModalKind) -> bool { |
| 1347 | while let Some(view) = self.pop() { |
| 1348 | if view.kind() == kind { |
| 1349 | return true; |
| 1350 | } |
| 1351 | } |
| 1352 | false |
| 1353 | } |
| 1354 | |
| 1355 | pub fn top_occupied_region(&self, area: Rect) -> Option<Rect> { |
| 1356 | self.views.last().map(|view| view.occupied_region(area)) |
| 1357 | } |
| 1358 | |
| 1359 | pub fn push<V: ModalView + 'static>(&mut self, view: V) { |
| 1360 | let kind = view.kind(); |
| 1361 | let top = self.top_identity(); |
| 1362 | self.views.push(Box::new(view)); |
| 1363 | self.note_top_change(top); |
| 1364 | tracing::debug!(target: "codewhale_tui::view_stack", action = "push", kind = ?kind, depth = self.views.len(), "view pushed"); |
| 1365 | } |
| 1366 | |
| 1367 | /// Push an already-boxed view back onto the stack. Used by call sites |
| 1368 | /// that pop a view, mutate it externally, and need to restore it without |
| 1369 | /// the generic `push` re-boxing dance. |
| 1370 | pub fn push_boxed(&mut self, view: Box<dyn ModalView>) { |
| 1371 | let kind = view.kind(); |
| 1372 | let top = self.top_identity(); |
| 1373 | self.views.push(view); |
| 1374 | self.note_top_change(top); |
| 1375 | tracing::debug!(target: "codewhale_tui::view_stack", action = "push_boxed", kind = ?kind, depth = self.views.len(), "view pushed"); |
| 1376 | } |
| 1377 | |
| 1378 | pub fn pop(&mut self) -> Option<Box<dyn ModalView>> { |
| 1379 | let top = self.top_identity(); |
| 1380 | let popped = self.views.pop(); |
| 1381 | self.note_top_change(top); |
| 1382 | if let Some(view) = popped.as_ref() { |
| 1383 | tracing::debug!(target: "codewhale_tui::view_stack", action = "pop", kind = ?view.kind(), depth = self.views.len(), "view popped"); |
| 1384 | } |
| 1385 | popped |
| 1386 | } |
| 1387 | |
| 1388 | pub fn render(&self, area: Rect, buf: &mut Buffer) { |
| 1389 | // Focus-context texture prototype (#4823): runs over the already |
| 1390 | // rendered background BEFORE any backdrop or view paint, so the |
| 1391 | // focused modal is painted afterwards at full strength and the |
| 1392 | // texture can never overwrite it. `Off` (the default) leaves the |
| 1393 | // buffer untouched, keeping output byte-identical to the |
| 1394 | // pre-prototype path. |
| 1395 | if self.focus_texture != FocusTextureMode::Off |
| 1396 | && let (Some(focus), Some(theme)) = |
| 1397 | (self.top_occupied_region(area), self.focus_texture_theme) |
| 1398 | { |
| 1399 | crate::tui::focus_texture::apply_focus_texture( |
| 1400 | area, |
| 1401 | buf, |
| 1402 | focus, |
| 1403 | &theme, |
| 1404 | self.focus_texture, |
| 1405 | crate::tui::color_compat::ascii_safe_enabled(), |
| 1406 | ); |
| 1407 | } |
| 1408 | // Dim each view's own occupied region rather than the whole frame, so |
| 1409 | // an inline modal (the approval prompt) leaves the transcript above it |
| 1410 | // visible instead of blacking out the screen. Full-screen modals keep |
| 1411 | // the default `occupied_region` of the entire frame, so their backdrop |
| 1412 | // is unchanged. |
| 1413 | for view in &self.views { |
| 1414 | let region = view.occupied_region(area); |
| 1415 | crate::tui::osc8::overlay_frame_links(region, Vec::new()); |
| 1416 | render_modal_backdrop(region, buf); |
| 1417 | view.render(area, buf); |
| 1418 | } |
| 1419 | } |
| 1420 | |
| 1421 | pub fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool { |
| 1422 | let mut updated = false; |
| 1423 | for view in &mut self.views { |
| 1424 | updated |= view.update_subagents(agents); |
| 1425 | } |
| 1426 | updated |
| 1427 | } |
| 1428 | |
| 1429 | pub fn handle_key(&mut self, key: KeyEvent) -> Vec<ViewEvent> { |
| 1430 | let action = self |
| 1431 | .views |
| 1432 | .last_mut() |
| 1433 | .map(|view| view.handle_key(key)) |
| 1434 | .unwrap_or(ViewAction::None); |
| 1435 | self.apply_action(action) |
| 1436 | } |
| 1437 | |
| 1438 | pub fn handle_paste(&mut self, text: &str) -> bool { |
| 1439 | self.views |
| 1440 | .last_mut() |
| 1441 | .map(|view| view.handle_paste(text)) |
| 1442 | .unwrap_or(false) |
| 1443 | } |
| 1444 | |
| 1445 | pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec<ViewEvent> { |
| 1446 | let action = self |
| 1447 | .views |
| 1448 | .last_mut() |
| 1449 | .map(|view| view.handle_mouse(mouse)) |
| 1450 | .unwrap_or(ViewAction::None); |
| 1451 | self.apply_action(action) |
| 1452 | } |
| 1453 | |
| 1454 | /// Advance the top view's timers. The host repaints when `redraw` is |
| 1455 | /// set — a view whose state changed on its own (a background preview |
| 1456 | /// landing) returns [`ViewAction::Redraw`], and any emitted event also |
| 1457 | /// implies a repaint. Without this, tick-driven changes stay invisible |
| 1458 | /// until the next key press. |
| 1459 | pub fn tick(&mut self) -> ViewTick { |
| 1460 | let action = self |
| 1461 | .views |
| 1462 | .last_mut() |
| 1463 | .map(|view| view.tick()) |
| 1464 | .unwrap_or(ViewAction::None); |
| 1465 | let view_redraw = matches!(action, ViewAction::Redraw); |
| 1466 | let events = self.apply_action(action); |
| 1467 | ViewTick { |
| 1468 | redraw: view_redraw || !events.is_empty(), |
| 1469 | events, |
| 1470 | } |
| 1471 | } |
| 1472 | |
| 1473 | fn apply_action(&mut self, action: ViewAction) -> Vec<ViewEvent> { |
| 1474 | let mut events = Vec::new(); |
| 1475 | let top = self.top_identity(); |
| 1476 | match action { |
| 1477 | // Key and mouse paths already repaint after dispatch; `tick` |
| 1478 | // reads `Redraw` before calling here. |
| 1479 | ViewAction::None | ViewAction::Redraw => {} |
| 1480 | ViewAction::Close => { |
| 1481 | if let Some(view) = self.views.pop() { |
| 1482 | tracing::debug!(target: "codewhale_tui::view_stack", action = "close", kind = ?view.kind(), depth = self.views.len(), "view closed via action"); |
| 1483 | } |
| 1484 | } |
| 1485 | ViewAction::Emit(event) => { |
| 1486 | events.push(event); |
| 1487 | } |
| 1488 | ViewAction::EmitAndClose(event) => { |
| 1489 | events.push(event); |
| 1490 | if let Some(view) = self.views.pop() { |
| 1491 | tracing::debug!(target: "codewhale_tui::view_stack", action = "emit_and_close", kind = ?view.kind(), depth = self.views.len(), "view closed via action"); |
| 1492 | } |
| 1493 | } |
| 1494 | } |
| 1495 | self.note_top_change(top); |
| 1496 | events |
| 1497 | } |
| 1498 | |
| 1499 | /// Whether the Extensions panel is the top view. |
| 1500 | pub fn extensions_is_top(&self) -> bool { |
| 1501 | self.views |
| 1502 | .last() |
| 1503 | .is_some_and(|view| view.kind() == ModalKind::Extensions) |
| 1504 | } |
| 1505 | |
| 1506 | /// Run an Extensions row-menu entry against the panel, when it is on |
| 1507 | /// top. `None` when the panel is gone or no longer lists the row. |
| 1508 | pub fn extensions_menu_action( |
| 1509 | &mut self, |
| 1510 | item_id: &str, |
| 1511 | verb: ExtensionMenuVerb, |
| 1512 | ) -> Option<Vec<ViewEvent>> { |
| 1513 | let action = self |
| 1514 | .views |
| 1515 | .last_mut()? |
| 1516 | .as_any_mut() |
| 1517 | .downcast_mut::<extensions::ExtensionsView>()? |
| 1518 | .run_menu_verb(item_id, verb)?; |
| 1519 | Some(self.apply_action(action)) |
| 1520 | } |
| 1521 | |
| 1522 | /// Whether a provider picker anywhere in the stack has been used — a key |
| 1523 | /// pressed or a click landed in it. |
| 1524 | pub fn provider_picker_interacted(&mut self) -> bool { |
| 1525 | self.views.iter_mut().any(|view| { |
| 1526 | view.as_any_mut() |
| 1527 | .downcast_mut::<crate::tui::provider_picker::ProviderPickerView>() |
| 1528 | .is_some_and(|picker| picker.interacted()) |
| 1529 | }) |
| 1530 | } |
| 1531 | |
| 1532 | /// Hand a freshly-built read model to the open Extensions panel, when it |
| 1533 | /// is on top. A pager or another modal stacked above it means the user is |
| 1534 | /// looking at something else — the rebuild is skipped and the next poll |
| 1535 | /// retries. |
| 1536 | pub fn refresh_extensions(&mut self, snapshot: extensions::ExtensionsSnapshot) { |
| 1537 | if let Some(view) = self.views.last_mut() |
| 1538 | && let Some(panel) = view |
| 1539 | .as_any_mut() |
| 1540 | .downcast_mut::<extensions::ExtensionsView>() |
| 1541 | { |
| 1542 | panel.refresh_snapshot(snapshot); |
| 1543 | } |
| 1544 | } |
| 1545 | } |
| 1546 | |
| 1547 | impl fmt::Debug for ViewStack { |
| 1548 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1549 | f.debug_struct("ViewStack") |
| 1550 | .field("len", &self.views.len()) |
| 1551 | .field("top", &self.top_kind()) |
| 1552 | .finish() |
| 1553 | } |
| 1554 | } |
| 1555 | |
| 1556 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1557 | enum ConfigScope { |
| 1558 | Session, |
| 1559 | Saved, |
| 1560 | } |
| 1561 | |
| 1562 | impl ConfigScope { |
| 1563 | fn label(self, locale: Locale) -> Cow<'static, str> { |
| 1564 | tr( |
| 1565 | locale, |
| 1566 | match self { |
| 1567 | ConfigScope::Session => MessageId::ConfigScopeSession, |
| 1568 | ConfigScope::Saved => MessageId::ConfigScopeSaved, |
| 1569 | }, |
| 1570 | ) |
| 1571 | } |
| 1572 | |
| 1573 | fn persist(self) -> bool { |
| 1574 | matches!(self, ConfigScope::Saved) |
| 1575 | } |
| 1576 | } |
| 1577 | |
| 1578 | #[derive(Debug, Clone)] |
| 1579 | struct ConfigRow { |
| 1580 | key: String, |
| 1581 | value: String, |
| 1582 | editable: bool, |
| 1583 | scope: ConfigScope, |
| 1584 | /// Typed facts decided when the row is built from `App`, `Settings`, and |
| 1585 | /// `Config`; the shell never re-derives them from the key at render time. |
| 1586 | facts: ConfigRowFacts, |
| 1587 | } |
| 1588 | |
| 1589 | impl ConfigRow { |
| 1590 | fn edit_value(&self) -> &str { |
| 1591 | if self.key.starts_with("notifications.") { |
| 1592 | self.facts.effective.as_deref().unwrap_or(&self.value) |
| 1593 | } else { |
| 1594 | &self.value |
| 1595 | } |
| 1596 | } |
| 1597 | |
| 1598 | /// The schema declaration behind this row. `None` means the key is not |
| 1599 | /// declared, and the row is dropped before the view is built. |
| 1600 | fn schema(&self) -> Option<&'static codewhale_config::SettingDef> { |
| 1601 | codewhale_config::setting(&self.key) |
| 1602 | } |
| 1603 | |
| 1604 | /// The row's `ui` block. `None` means "declared, but not shown". |
| 1605 | fn ui(&self) -> Option<&'static codewhale_config::SettingUi> { |
| 1606 | self.schema().and_then(|def| def.ui.as_ref()) |
| 1607 | } |
| 1608 | |
| 1609 | /// Section heading, from the schema's group id. |
| 1610 | fn section(&self) -> ConfigSection { |
| 1611 | self.ui() |
| 1612 | .and_then(|ui| ConfigSection::from_id(ui.group)) |
| 1613 | .unwrap_or(ConfigSection::Experimental) |
| 1614 | } |
| 1615 | } |
| 1616 | |
| 1617 | /// What a row *is*: a persisted or session setting fact, an action that opens |
| 1618 | /// another surface, or a read-only receipt observed from the running app. |
| 1619 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1620 | enum ConfigRowKind { |
| 1621 | Setting, |
| 1622 | Action, |
| 1623 | Diagnostic, |
| 1624 | } |
| 1625 | |
| 1626 | /// Which [`UiSnapshot`] fact a row projects, when it projects one. |
| 1627 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1628 | enum SnapshotLane { |
| 1629 | Provider, |
| 1630 | Model, |
| 1631 | } |
| 1632 | |
| 1633 | /// Which durable store a saved row persists to — independent of which |
| 1634 | /// authority currently wins the *effective* decision. An environment or |
| 1635 | /// terminal override (NO_ANIMATIONS, a legacy console host, …) relabels the |
| 1636 | /// row's authority without repairing or breaking its store, so store-error |
| 1637 | /// marking must key on this field, never on `authority`. |
| 1638 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1639 | enum SettingStore { |
| 1640 | /// `settings.toml` (user settings). |
| 1641 | UserSettings, |
| 1642 | /// `config.toml` (workspace configuration). |
| 1643 | WorkspaceConfig, |
| 1644 | /// Session-owned, diagnostic, or otherwise not persisted from this surface. |
| 1645 | None, |
| 1646 | } |
| 1647 | |
| 1648 | impl SettingStore { |
| 1649 | /// The store an authority implies when a row is *built* under it. Only |
| 1650 | /// meaningful at construction: an override authority applied later must |
| 1651 | /// keep the row's original store. |
| 1652 | fn for_authority(authority: SettingAuthority) -> Self { |
| 1653 | match authority { |
| 1654 | SettingAuthority::UserSettings => SettingStore::UserSettings, |
| 1655 | SettingAuthority::WorkspaceConfiguration => SettingStore::WorkspaceConfig, |
| 1656 | _ => SettingStore::None, |
| 1657 | } |
| 1658 | } |
| 1659 | } |
| 1660 | |
| 1661 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1662 | struct ConfigRowFacts { |
| 1663 | kind: ConfigRowKind, |
| 1664 | authority: SettingAuthority, |
| 1665 | /// The store this row's saved/startup lanes come from (#5730 Windows CI: |
| 1666 | /// an override-authority row still has a store that can fail to load). |
| 1667 | store: SettingStore, |
| 1668 | apply: SettingApplySemantics, |
| 1669 | /// Value observed in force from an explicit `App` field. `None` means |
| 1670 | /// unobserved; it is never inferred from the persisted value. |
| 1671 | effective: Option<String>, |
| 1672 | /// Snapshot lane supplying the live fact for this row. |
| 1673 | snapshot: Option<SnapshotLane>, |
| 1674 | /// Slash command that activation runs, with the localized verb for it. |
| 1675 | command: Option<(&'static str, MessageId)>, |
| 1676 | /// The concrete environment/terminal token when `authority` is an |
| 1677 | /// override (`NO_ANIMATIONS`, `TERM_PROGRAM=vscode`, …). |
| 1678 | authority_detail: Option<&'static str>, |
| 1679 | /// The load error of the row's store when it could not be read; the saved |
| 1680 | /// and startup lanes are then unavailable, never a default in disguise. |
| 1681 | store_error: Option<String>, |
| 1682 | } |
| 1683 | |
| 1684 | impl ConfigRowFacts { |
| 1685 | /// A `settings.toml` value edited here and applied on save. |
| 1686 | fn saved_setting() -> Self { |
| 1687 | Self { |
| 1688 | kind: ConfigRowKind::Setting, |
| 1689 | authority: SettingAuthority::UserSettings, |
| 1690 | store: SettingStore::UserSettings, |
| 1691 | apply: SettingApplySemantics::Immediate, |
| 1692 | effective: None, |
| 1693 | snapshot: None, |
| 1694 | command: None, |
| 1695 | authority_detail: None, |
| 1696 | store_error: None, |
| 1697 | } |
| 1698 | } |
| 1699 | |
| 1700 | /// The effective value is forced by an environment or terminal override. |
| 1701 | fn overridden(self, environment: bool, detail: &'static str) -> Self { |
| 1702 | Self { |
| 1703 | authority: if environment { |
| 1704 | SettingAuthority::Environment |
| 1705 | } else { |
| 1706 | SettingAuthority::Terminal |
| 1707 | }, |
| 1708 | authority_detail: Some(detail), |
| 1709 | ..self |
| 1710 | } |
| 1711 | } |
| 1712 | |
| 1713 | /// The row's store failed to load: no saved or startup value is known. |
| 1714 | /// The error is folded onto one line so it reads in a single lane. |
| 1715 | fn unavailable(self, error: &str) -> Self { |
| 1716 | Self { |
| 1717 | store_error: Some(error.split_whitespace().collect::<Vec<_>>().join(" ")), |
| 1718 | ..self |
| 1719 | } |
| 1720 | } |
| 1721 | |
| 1722 | /// A value the live session owns; its row value *is* the observed value. |
| 1723 | fn session_setting() -> Self { |
| 1724 | Self { |
| 1725 | authority: SettingAuthority::Session, |
| 1726 | store: SettingStore::None, |
| 1727 | apply: SettingApplySemantics::EffectiveNow, |
| 1728 | ..Self::saved_setting() |
| 1729 | } |
| 1730 | } |
| 1731 | |
| 1732 | /// A persisted setting shown but not editable from this surface. |
| 1733 | fn read_only_setting(authority: SettingAuthority) -> Self { |
| 1734 | Self { |
| 1735 | store: SettingStore::for_authority(authority), |
| 1736 | authority, |
| 1737 | apply: SettingApplySemantics::ReadOnly, |
| 1738 | ..Self::saved_setting() |
| 1739 | } |
| 1740 | } |
| 1741 | |
| 1742 | /// A receipt observed from the running app, never a persisted fact. |
| 1743 | fn diagnostic(authority: SettingAuthority) -> Self { |
| 1744 | Self { |
| 1745 | kind: ConfigRowKind::Diagnostic, |
| 1746 | store: SettingStore::None, |
| 1747 | authority, |
| 1748 | apply: SettingApplySemantics::ReadOnly, |
| 1749 | ..Self::saved_setting() |
| 1750 | } |
| 1751 | } |
| 1752 | |
| 1753 | /// A row whose activation opens another surface; not a persisted fact. |
| 1754 | fn action(command: &'static str, verb: MessageId) -> Self { |
| 1755 | Self { |
| 1756 | kind: ConfigRowKind::Action, |
| 1757 | authority: SettingAuthority::Session, |
| 1758 | store: SettingStore::None, |
| 1759 | apply: SettingApplySemantics::EffectiveNow, |
| 1760 | command: Some((command, verb)), |
| 1761 | ..Self::saved_setting() |
| 1762 | } |
| 1763 | } |
| 1764 | |
| 1765 | fn authority(self, authority: SettingAuthority) -> Self { |
| 1766 | // Callers that relabel a row's authority through this builder are |
| 1767 | // describing the row's store (e.g. a config.toml row), so the store |
| 1768 | // follows. An *override* never goes through this builder — it wins |
| 1769 | // the effective decision while the store stays what it was. |
| 1770 | let store = SettingStore::for_authority(authority); |
| 1771 | Self { |
| 1772 | authority, |
| 1773 | store, |
| 1774 | ..self |
| 1775 | } |
| 1776 | } |
| 1777 | |
| 1778 | fn apply(self, apply: SettingApplySemantics) -> Self { |
| 1779 | Self { apply, ..self } |
| 1780 | } |
| 1781 | |
| 1782 | fn effective(self, value: impl Into<String>) -> Self { |
| 1783 | Self { |
| 1784 | effective: Some(value.into()), |
| 1785 | ..self |
| 1786 | } |
| 1787 | } |
| 1788 | |
| 1789 | fn snapshot(self, lane: SnapshotLane) -> Self { |
| 1790 | Self { |
| 1791 | snapshot: Some(lane), |
| 1792 | ..self |
| 1793 | } |
| 1794 | } |
| 1795 | |
| 1796 | /// A setting fact whose activation opens a picker instead of an editor. |
| 1797 | fn opens(self, command: &'static str, verb: MessageId) -> Self { |
| 1798 | Self { |
| 1799 | command: Some((command, verb)), |
| 1800 | ..self |
| 1801 | } |
| 1802 | } |
| 1803 | } |
| 1804 | |
| 1805 | /// Editor behavior for one Settings entry. This is intentionally independent |
| 1806 | /// from where the value is stored: category/scope describe ownership, while |
| 1807 | /// kind determines the interaction and validation surface. |
| 1808 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1809 | enum SettingKind { |
| 1810 | Boolean, |
| 1811 | Choice, |
| 1812 | Integer, |
| 1813 | Text, |
| 1814 | Action, |
| 1815 | ReadOnly, |
| 1816 | } |
| 1817 | |
| 1818 | #[derive(Debug, Clone)] |
| 1819 | struct SettingMeta { |
| 1820 | kind: SettingKind, |
| 1821 | category: ConfigSection, |
| 1822 | choices: Option<Vec<String>>, |
| 1823 | } |
| 1824 | |
| 1825 | #[derive(Debug, Clone)] |
| 1826 | struct SettingsRegistry { |
| 1827 | provider: ProviderKind, |
| 1828 | base_url: String, |
| 1829 | model: String, |
| 1830 | auto_model: bool, |
| 1831 | } |
| 1832 | |
| 1833 | impl SettingsRegistry { |
| 1834 | fn new(view: &ConfigView) -> Self { |
| 1835 | Self { |
| 1836 | provider: view.api_provider, |
| 1837 | base_url: view.route_base_url.clone(), |
| 1838 | model: view.route_model.clone(), |
| 1839 | auto_model: view.auto_model, |
| 1840 | } |
| 1841 | } |
| 1842 | |
| 1843 | fn reasoning_effort_choices(&self) -> Vec<String> { |
| 1844 | let mut values = vec!["default".to_string()]; |
| 1845 | for effort in crate::tui::model_picker::picker_efforts_for_route( |
| 1846 | self.provider, |
| 1847 | &self.base_url, |
| 1848 | &self.model, |
| 1849 | self.auto_model, |
| 1850 | ) { |
| 1851 | let label = if self.provider == ProviderKind::OpenaiCodex { |
| 1852 | effort.display_label_for_provider(self.provider) |
| 1853 | } else { |
| 1854 | effort.as_setting() |
| 1855 | }; |
| 1856 | if !values.iter().any(|value| value == label) { |
| 1857 | values.push(label.to_string()); |
| 1858 | } |
| 1859 | } |
| 1860 | values |
| 1861 | } |
| 1862 | |
| 1863 | fn meta(&self, row: &ConfigRow) -> SettingMeta { |
| 1864 | let choices = if row.key == "reasoning_effort" { |
| 1865 | Some(self.reasoning_effort_choices()) |
| 1866 | } else { |
| 1867 | config_choice_values(&row.key) |
| 1868 | }; |
| 1869 | let kind = if !row.editable { |
| 1870 | SettingKind::ReadOnly |
| 1871 | } else if row.facts.command.is_some() { |
| 1872 | SettingKind::Action |
| 1873 | } else if config_boolean_key(&row.key) { |
| 1874 | SettingKind::Boolean |
| 1875 | } else if choices.is_some() { |
| 1876 | SettingKind::Choice |
| 1877 | } else if config_integer_key(&row.key) { |
| 1878 | SettingKind::Integer |
| 1879 | } else { |
| 1880 | SettingKind::Text |
| 1881 | }; |
| 1882 | SettingMeta { |
| 1883 | kind, |
| 1884 | category: row.section(), |
| 1885 | choices, |
| 1886 | } |
| 1887 | } |
| 1888 | } |
| 1889 | |
| 1890 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1891 | enum ConfigSection { |
| 1892 | Provider, |
| 1893 | Model, |
| 1894 | Permissions, |
| 1895 | Network, |
| 1896 | Display, |
| 1897 | Composer, |
| 1898 | Sidebar, |
| 1899 | History, |
| 1900 | Mcp, |
| 1901 | Fleet, |
| 1902 | /// Workflow orchestration (`/workflow`). Kept out of Fleet: a Fleet is |
| 1903 | /// *who*, a Workflow is *what order* the work follows over it. |
| 1904 | Workflow, |
| 1905 | /// Session-scoped drivers such as `/goal`. |
| 1906 | Session, |
| 1907 | /// Explicitly legacy compatibility settings that are not a live choice — |
| 1908 | /// e.g. the DeepSeek-only `default_model` fallback (#4751). |
| 1909 | Legacy, |
| 1910 | Experimental, |
| 1911 | } |
| 1912 | |
| 1913 | /// The seven Tideline settings categories in rail order |
| 1914 | /// (`docs/design/tideline-redesign.html`, "Settings categories"). |
| 1915 | /// |
| 1916 | /// A category is a projection over the existing [`ConfigRow`] store: rows keep |
| 1917 | /// their fine-grained [`ConfigSection`]; the category follows the section |
| 1918 | /// unless the row's typed facts file it elsewhere (motion keys, telemetry, |
| 1919 | /// low-level receipts). |
| 1920 | /// |
| 1921 | /// This enum is the single taxonomy: the `ConfigView` rail/strip and the |
| 1922 | /// Tideline settings stage scaffold both iterate [`ConfigCategory::ALL`]. |
| 1923 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1924 | pub(crate) enum ConfigCategory { |
| 1925 | Appearance, |
| 1926 | ModelsProviders, |
| 1927 | Work, |
| 1928 | ToolsMcp, |
| 1929 | Trust, |
| 1930 | Motion, |
| 1931 | Advanced, |
| 1932 | } |
| 1933 | |
| 1934 | impl ConfigCategory { |
| 1935 | /// The schema tab id this category renders. |
| 1936 | fn id(self) -> &'static str { |
| 1937 | match self { |
| 1938 | ConfigCategory::Appearance => codewhale_config::settings_schema::TAB_APPEARANCE, |
| 1939 | ConfigCategory::ModelsProviders => codewhale_config::settings_schema::TAB_MODELS, |
| 1940 | ConfigCategory::Work => codewhale_config::settings_schema::TAB_WORK, |
| 1941 | ConfigCategory::ToolsMcp => codewhale_config::settings_schema::TAB_TOOLS, |
| 1942 | ConfigCategory::Trust => codewhale_config::settings_schema::TAB_TRUST, |
| 1943 | ConfigCategory::Motion => codewhale_config::settings_schema::TAB_MOTION, |
| 1944 | ConfigCategory::Advanced => codewhale_config::settings_schema::TAB_ADVANCED, |
| 1945 | } |
| 1946 | } |
| 1947 | |
| 1948 | fn from_id(id: &str) -> Option<Self> { |
| 1949 | Self::ALL.into_iter().find(|category| category.id() == id) |
| 1950 | } |
| 1951 | |
| 1952 | const ALL: [ConfigCategory; 7] = [ |
| 1953 | ConfigCategory::Appearance, |
| 1954 | ConfigCategory::ModelsProviders, |
| 1955 | ConfigCategory::Work, |
| 1956 | ConfigCategory::ToolsMcp, |
| 1957 | ConfigCategory::Trust, |
| 1958 | ConfigCategory::Motion, |
| 1959 | ConfigCategory::Advanced, |
| 1960 | ]; |
| 1961 | |
| 1962 | fn label(self, locale: Locale) -> Cow<'static, str> { |
| 1963 | tr( |
| 1964 | locale, |
| 1965 | match self { |
| 1966 | ConfigCategory::Appearance => MessageId::ConfigCategoryAppearance, |
| 1967 | ConfigCategory::ModelsProviders => MessageId::ConfigCategoryModelsProviders, |
| 1968 | ConfigCategory::Work => MessageId::ConfigCategoryWork, |
| 1969 | ConfigCategory::ToolsMcp => MessageId::ConfigCategoryToolsMcp, |
| 1970 | ConfigCategory::Trust => MessageId::ConfigCategoryTrust, |
| 1971 | ConfigCategory::Motion => MessageId::ConfigCategoryMotion, |
| 1972 | ConfigCategory::Advanced => MessageId::ConfigCategoryAdvanced, |
| 1973 | }, |
| 1974 | ) |
| 1975 | } |
| 1976 | |
| 1977 | /// The rail category the schema files this row under. |
| 1978 | fn for_row(row: &ConfigRow) -> Self { |
| 1979 | row.ui() |
| 1980 | .and_then(|ui| Self::from_id(ui.tab)) |
| 1981 | .unwrap_or(ConfigCategory::Advanced) |
| 1982 | } |
| 1983 | |
| 1984 | fn contains(self, row: &ConfigRow) -> bool { |
| 1985 | Self::for_row(row) == self |
| 1986 | } |
| 1987 | |
| 1988 | fn position(self) -> usize { |
| 1989 | Self::ALL |
| 1990 | .iter() |
| 1991 | .position(|category| *category == self) |
| 1992 | .unwrap_or(0) |
| 1993 | } |
| 1994 | |
| 1995 | fn next(self) -> Self { |
| 1996 | Self::ALL[(self.position() + 1) % Self::ALL.len()] |
| 1997 | } |
| 1998 | |
| 1999 | fn prev(self) -> Self { |
| 2000 | Self::ALL[(self.position() + Self::ALL.len() - 1) % Self::ALL.len()] |
| 2001 | } |
| 2002 | } |
| 2003 | |
| 2004 | impl ConfigSection { |
| 2005 | const ALL: [ConfigSection; 14] = [ |
| 2006 | ConfigSection::Provider, |
| 2007 | ConfigSection::Model, |
| 2008 | ConfigSection::Permissions, |
| 2009 | ConfigSection::Network, |
| 2010 | ConfigSection::Display, |
| 2011 | ConfigSection::Composer, |
| 2012 | ConfigSection::Sidebar, |
| 2013 | ConfigSection::History, |
| 2014 | ConfigSection::Mcp, |
| 2015 | ConfigSection::Fleet, |
| 2016 | ConfigSection::Workflow, |
| 2017 | ConfigSection::Session, |
| 2018 | ConfigSection::Legacy, |
| 2019 | ConfigSection::Experimental, |
| 2020 | ]; |
| 2021 | |
| 2022 | /// The schema group id this section heads. |
| 2023 | fn id(self) -> &'static str { |
| 2024 | match self { |
| 2025 | ConfigSection::Provider => "provider", |
| 2026 | ConfigSection::Model => "model", |
| 2027 | ConfigSection::Permissions => "permissions", |
| 2028 | ConfigSection::Network => "network", |
| 2029 | ConfigSection::Display => "display", |
| 2030 | ConfigSection::Composer => "composer", |
| 2031 | ConfigSection::Sidebar => "workbar", |
| 2032 | ConfigSection::History => "history", |
| 2033 | ConfigSection::Mcp => "mcp", |
| 2034 | ConfigSection::Fleet => "fleet", |
| 2035 | ConfigSection::Workflow => "workflow", |
| 2036 | ConfigSection::Session => "session", |
| 2037 | ConfigSection::Legacy => "legacy", |
| 2038 | ConfigSection::Experimental => "experimental", |
| 2039 | } |
| 2040 | } |
| 2041 | |
| 2042 | fn from_id(id: &str) -> Option<Self> { |
| 2043 | Self::ALL.into_iter().find(|section| section.id() == id) |
| 2044 | } |
| 2045 | |
| 2046 | fn label(self, locale: Locale) -> Cow<'static, str> { |
| 2047 | tr( |
| 2048 | locale, |
| 2049 | match self { |
| 2050 | ConfigSection::Provider => MessageId::ConfigSectionProvider, |
| 2051 | ConfigSection::Model => MessageId::ConfigSectionModel, |
| 2052 | ConfigSection::Permissions => MessageId::ConfigSectionPermissions, |
| 2053 | ConfigSection::Network => MessageId::ConfigSectionNetwork, |
| 2054 | ConfigSection::Display => MessageId::ConfigSectionDisplay, |
| 2055 | ConfigSection::Composer => MessageId::ConfigSectionComposer, |
| 2056 | ConfigSection::Sidebar => MessageId::ConfigSectionSidebar, |
| 2057 | ConfigSection::History => MessageId::ConfigSectionHistory, |
| 2058 | ConfigSection::Mcp => MessageId::ConfigSectionMcp, |
| 2059 | ConfigSection::Fleet => MessageId::ConfigSectionFleet, |
| 2060 | ConfigSection::Workflow => MessageId::ConfigSectionWorkflow, |
| 2061 | ConfigSection::Session => MessageId::ConfigSectionSession, |
| 2062 | ConfigSection::Legacy => MessageId::ConfigSectionLegacy, |
| 2063 | ConfigSection::Experimental => MessageId::ConfigSectionExperimental, |
| 2064 | }, |
| 2065 | ) |
| 2066 | } |
| 2067 | } |
| 2068 | |
| 2069 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 2070 | enum ConfigListItem { |
| 2071 | Section(ConfigSection), |
| 2072 | Row(usize), |
| 2073 | } |
| 2074 | |
| 2075 | /// Clickable editor controls; keyboard Enter/Esc do the same thing. |
| 2076 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 2077 | enum EditorControl { |
| 2078 | Apply, |
| 2079 | Cancel, |
| 2080 | } |
| 2081 | |
| 2082 | /// Clickable overflow markers of the category strip. |
| 2083 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 2084 | pub(crate) enum NavStep { |
| 2085 | Previous, |
| 2086 | Next, |
| 2087 | } |
| 2088 | |
| 2089 | #[derive(Debug, Clone)] |
| 2090 | struct ConfigEdit { |
| 2091 | key: String, |
| 2092 | original_value: String, |
| 2093 | buffer: Vec<char>, |
| 2094 | cursor: usize, |
| 2095 | select_all: bool, |
| 2096 | scope: ConfigScope, |
| 2097 | choices: Option<Vec<String>>, |
| 2098 | selected_choice: usize, |
| 2099 | } |
| 2100 | |
| 2101 | pub struct ConfigView { |
| 2102 | rows: Vec<ConfigRow>, |
| 2103 | selected: usize, |
| 2104 | scroll: usize, |
| 2105 | editing: Option<ConfigEdit>, |
| 2106 | /// The editor as it was committed, kept until the host answers: a rejected |
| 2107 | /// value reopens it with the typed buffer instead of dropping it (U09-m1). |
| 2108 | pending_commit: Option<ConfigEdit>, |
| 2109 | filter: String, |
| 2110 | status: Option<String>, |
| 2111 | locale: Locale, |
| 2112 | last_visible_rows: Cell<usize>, |
| 2113 | /// Selection-anchored scroll actually used by the last render; keeps the |
| 2114 | /// panel scroll rail truthful when the stored scroll predates a resize. |
| 2115 | last_render_scroll: Cell<usize>, |
| 2116 | /// Exact painted cells of each list row; a click selects a row only when |
| 2117 | /// it lands inside one of these rects. |
| 2118 | last_row_hitboxes: RefCell<Vec<(Rect, usize)>>, |
| 2119 | /// Exact painted cells of each visible editor choice. |
| 2120 | last_choice_hitboxes: RefCell<Vec<(Rect, usize)>>, |
| 2121 | /// Exact painted cells of the editor's Apply / Cancel controls. |
| 2122 | last_editor_controls: RefCell<Vec<(Rect, EditorControl)>>, |
| 2123 | /// Exact painted cells of each category in the rail or strip. |
| 2124 | last_rail_hitboxes: RefCell<Vec<(Rect, ConfigCategory)>>, |
| 2125 | /// Exact painted cells of the strip's ‹ / › overflow markers. |
| 2126 | last_nav_controls: RefCell<Vec<(Rect, NavStep)>>, |
| 2127 | last_mouse_selected: Option<usize>, |
| 2128 | /// Pointer hover state, repainted from the shared hover style. Hover |
| 2129 | /// never moves the keyboard selection; it only tints what the pointer |
| 2130 | /// is over so every clickable element answers visibly. |
| 2131 | hovered_row: Option<usize>, |
| 2132 | hovered_rail: Option<ConfigCategory>, |
| 2133 | hovered_nav: Option<NavStep>, |
| 2134 | hovered_editor: Option<EditorControl>, |
| 2135 | hovered_choice: Option<usize>, |
| 2136 | api_provider: ProviderKind, |
| 2137 | route_base_url: String, |
| 2138 | route_model: String, |
| 2139 | auto_model: bool, |
| 2140 | /// Selected rail category of the Tideline settings shell. |
| 2141 | category: ConfigCategory, |
| 2142 | /// Read-only session projection for the provider/model facts; the detail |
| 2143 | /// pane shows its lanes verbatim instead of guessing a saved default. |
| 2144 | snapshot: UiSnapshot, |
| 2145 | } |
| 2146 | |
| 2147 | const CONFIG_MIN_KEY_COLUMN_WIDTH: usize = 19; |
| 2148 | const CONFIG_VALUE_COLUMN_WIDTH: usize = 44; |
| 2149 | const CONFIG_MIN_VALUE_COLUMN_WIDTH: usize = 10; |
| 2150 | const CONFIG_SCOPE_COLUMN_WIDTH: usize = 7; |
| 2151 | const CONFIG_ROW_PREFIX_WIDTH: usize = 2; |
| 2152 | /// The two two-column gaps painted between key, value, and affordance. |
| 2153 | const CONFIG_COLUMN_GAPS_WIDTH: usize = 4; |
| 2154 | /// Affordance glyph column (`[x]`, `‹ ›`, `✎`, `›`, `⊘`) plus its gap. |
| 2155 | const CONFIG_AFFORDANCE_COLUMN_WIDTH: usize = 5; |
| 2156 | /// List width below which the scope badge sheds in favour of the value. |
| 2157 | const CONFIG_SCOPE_BADGE_MIN_WIDTH: usize = 60; |
| 2158 | |
| 2159 | impl ConfigView { |
| 2160 | pub fn new_for_app(app: &App) -> Self { |
| 2161 | // A store that fails to load yields no saved facts. The defaults below |
| 2162 | // only shape the row list; every row backed by a failed store is |
| 2163 | // marked unavailable before the view is returned. |
| 2164 | let (settings, settings_error) = match Settings::load_persisted() { |
| 2165 | Ok(settings) => { |
| 2166 | let error = settings.load_error.clone(); |
| 2167 | (settings, error) |
| 2168 | } |
| 2169 | Err(error) => (Settings::default(), Some(error.to_string())), |
| 2170 | }; |
| 2171 | let (config, config_error) = |
| 2172 | match Config::load(app.config_path.clone(), app.config_profile.as_deref()) { |
| 2173 | Ok(config) => (config, None), |
| 2174 | Err(error) => (Config::default(), Some(error.to_string())), |
| 2175 | }; |
| 2176 | let motion_override = crate::settings::detect_low_motion_override(); |
| 2177 | let motion_facts = |facts: ConfigRowFacts| match motion_override { |
| 2178 | Some(source) => facts.overridden(source.is_environment(), source.label()), |
| 2179 | None => facts, |
| 2180 | }; |
| 2181 | let permission_control = config.approval_policy_control( |
| 2182 | app.config_path.as_deref(), |
| 2183 | app.config_profile.as_deref(), |
| 2184 | &app.workspace, |
| 2185 | ); |
| 2186 | let saved_permission_row = match permission_control { |
| 2187 | ApprovalPolicyControl::Unset => ConfigRow { |
| 2188 | key: "permission_posture".to_string(), |
| 2189 | value: settings |
| 2190 | .permission_posture |
| 2191 | .as_deref() |
| 2192 | .unwrap_or("ask") |
| 2193 | .to_string(), |
| 2194 | editable: true, |
| 2195 | scope: ConfigScope::Saved, |
| 2196 | facts: ConfigRowFacts::saved_setting(), |
| 2197 | }, |
| 2198 | ApprovalPolicyControl::RootConfig => ConfigRow { |
| 2199 | key: "approval_policy".to_string(), |
| 2200 | value: config |
| 2201 | .approval_policy |
| 2202 | .as_deref() |
| 2203 | .unwrap_or("ask") |
| 2204 | .to_string(), |
| 2205 | editable: permission_control.editable_root(), |
| 2206 | scope: ConfigScope::Saved, |
| 2207 | facts: ConfigRowFacts::saved_setting() |
| 2208 | .authority(SettingAuthority::WorkspaceConfiguration), |
| 2209 | }, |
| 2210 | source => ConfigRow { |
| 2211 | key: "managed_approval_policy".to_string(), |
| 2212 | value: format!( |
| 2213 | "{} · {}", |
| 2214 | app.approval_mode.permission_chip_label(), |
| 2215 | source.label() |
| 2216 | ), |
| 2217 | editable: false, |
| 2218 | scope: ConfigScope::Saved, |
| 2219 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy), |
| 2220 | }, |
| 2221 | }; |
| 2222 | let approval_session_editable = matches!(permission_control, ApprovalPolicyControl::Unset); |
| 2223 | let shell_control = config.allow_shell_control( |
| 2224 | app.config_path.as_deref(), |
| 2225 | app.config_profile.as_deref(), |
| 2226 | &app.workspace, |
| 2227 | ); |
| 2228 | let shell_row = if shell_control.editable_root() { |
| 2229 | ConfigRow { |
| 2230 | key: "allow_shell".to_string(), |
| 2231 | value: app.allow_shell.to_string(), |
| 2232 | editable: true, |
| 2233 | scope: ConfigScope::Saved, |
| 2234 | facts: ConfigRowFacts::saved_setting() |
| 2235 | .authority(SettingAuthority::WorkspaceConfiguration), |
| 2236 | } |
| 2237 | } else { |
| 2238 | ConfigRow { |
| 2239 | key: "managed_allow_shell".to_string(), |
| 2240 | value: format!("{} · {}", app.allow_shell, shell_control.label()), |
| 2241 | editable: false, |
| 2242 | scope: ConfigScope::Saved, |
| 2243 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy), |
| 2244 | } |
| 2245 | }; |
| 2246 | let (active_route_provider, _) = app.effective_route_display(); |
| 2247 | let (active_provider_identity, active_route_model) = app.effective_route_identity_display(); |
| 2248 | let mut rows = vec![ |
| 2249 | ConfigRow { |
| 2250 | key: "provider".to_string(), |
| 2251 | value: active_provider_identity.clone(), |
| 2252 | editable: true, |
| 2253 | scope: ConfigScope::Session, |
| 2254 | facts: ConfigRowFacts::session_setting() |
| 2255 | .snapshot(SnapshotLane::Provider) |
| 2256 | .opens("/provider", MessageId::ConfigActionOpenProvider), |
| 2257 | }, |
| 2258 | ConfigRow { |
| 2259 | key: config_base_url_row_key(active_route_provider).to_string(), |
| 2260 | value: config_base_url_row_value(app), |
| 2261 | // An endpoint is a route receipt, not a loose global knob. |
| 2262 | // `/provider` owns changing the credential, model, and endpoint |
| 2263 | // together; this row must not pretend that editing a live |
| 2264 | // receipt can mutate an already-running client. |
| 2265 | editable: false, |
| 2266 | scope: ConfigScope::Session, |
| 2267 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 2268 | }, |
| 2269 | ConfigRow { |
| 2270 | key: "context_window".to_string(), |
| 2271 | value: config |
| 2272 | .active_provider_identity() |
| 2273 | .ok() |
| 2274 | .filter(|identity| { |
| 2275 | // Display names are labels; saved facts require the |
| 2276 | // exact provider identity admitted by this App. |
| 2277 | app.admitted_provider_identity() |
| 2278 | .is_ok_and(|active| active == identity) |
| 2279 | && identity.provider == active_route_provider |
| 2280 | && (!app.auto_model |
| 2281 | || app.last_effective_provider.is_none() |
| 2282 | || app.last_effective_provider_identity.as_deref() |
| 2283 | == Some(identity.key.as_str())) |
| 2284 | }) |
| 2285 | .and_then(|identity| config.context_window_for_provider_config(&identity)) |
| 2286 | .map_or_else(|| "(not set)".to_string(), |tokens| tokens.to_string()), |
| 2287 | editable: false, |
| 2288 | scope: ConfigScope::Saved, |
| 2289 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration), |
| 2290 | }, |
| 2291 | ConfigRow { |
| 2292 | key: "effective_context_window".to_string(), |
| 2293 | value: format!( |
| 2294 | "{} tokens · {}", |
| 2295 | crate::route_budget::route_context_window_tokens( |
| 2296 | app.api_provider, |
| 2297 | app.effective_model_for_budget(), |
| 2298 | app.active_route_limits, |
| 2299 | ), |
| 2300 | app.active_context_window_source.display_label() |
| 2301 | ), |
| 2302 | editable: false, |
| 2303 | scope: ConfigScope::Session, |
| 2304 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 2305 | }, |
| 2306 | ConfigRow { |
| 2307 | key: "model".to_string(), |
| 2308 | // `·` keeps the row unambiguous when a provider display name |
| 2309 | // itself contains `/` (e.g. `Zhipu AI / Z.ai`). |
| 2310 | value: format!("{active_provider_identity} · {active_route_model}"), |
| 2311 | editable: true, |
| 2312 | scope: ConfigScope::Session, |
| 2313 | facts: ConfigRowFacts::session_setting() |
| 2314 | .snapshot(SnapshotLane::Model) |
| 2315 | .opens("/model", MessageId::ConfigActionOpenModel), |
| 2316 | }, |
| 2317 | // DeepSeek-only legacy fallback: hide on non-DeepSeek providers so |
| 2318 | // it is not misread as an active setting (#4717). Keep the field |
| 2319 | // and routing behavior; surface the row only for DeepSeek routes |
| 2320 | // (or when an explicit value is set and the operator needs to see it). |
| 2321 | // Built below after provider check so non-DeepSeek menus stay clean. |
| 2322 | ConfigRow { |
| 2323 | key: "reasoning_effort".to_string(), |
| 2324 | value: settings.reasoning_effort.as_deref().map_or_else( |
| 2325 | || tr(app.ui_locale, MessageId::ConfigDefaultReasoning).to_string(), |
| 2326 | |value| { |
| 2327 | crate::reasoning_preference::ReasoningEffort::from_setting_for_provider( |
| 2328 | value, |
| 2329 | app.api_provider, |
| 2330 | ) |
| 2331 | .as_setting_for_provider(app.api_provider) |
| 2332 | .to_string() |
| 2333 | }, |
| 2334 | ), |
| 2335 | editable: true, |
| 2336 | scope: ConfigScope::Saved, |
| 2337 | facts: ConfigRowFacts::saved_setting(), |
| 2338 | }, |
| 2339 | ConfigRow { |
| 2340 | key: "approval_mode".to_string(), |
| 2341 | value: app.approval_mode.permission_chip_label().to_string(), |
| 2342 | editable: approval_session_editable, |
| 2343 | scope: ConfigScope::Session, |
| 2344 | facts: ConfigRowFacts::session_setting(), |
| 2345 | }, |
| 2346 | saved_permission_row, |
| 2347 | ConfigRow { |
| 2348 | key: "default_mode".to_string(), |
| 2349 | value: settings.default_mode.clone(), |
| 2350 | editable: true, |
| 2351 | scope: ConfigScope::Saved, |
| 2352 | // The startup mode is read once when a session begins. |
| 2353 | facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession), |
| 2354 | }, |
| 2355 | shell_row, |
| 2356 | ConfigRow { |
| 2357 | key: "telemetry".to_string(), |
| 2358 | value: crate::telemetry_notice::saved_preference_enabled(&config).to_string(), |
| 2359 | editable: true, |
| 2360 | scope: ConfigScope::Saved, |
| 2361 | // Telemetry is a trust decision, not a network knob. |
| 2362 | facts: ConfigRowFacts::saved_setting() |
| 2363 | .authority(SettingAuthority::WorkspaceConfiguration), |
| 2364 | }, |
| 2365 | ConfigRow { |
| 2366 | key: "stream_chunk_timeout_secs".to_string(), |
| 2367 | value: app.stream_chunk_timeout_secs.to_string(), |
| 2368 | editable: true, |
| 2369 | scope: ConfigScope::Session, |
| 2370 | facts: ConfigRowFacts::session_setting(), |
| 2371 | }, |
| 2372 | ConfigRow { |
| 2373 | key: "theme".to_string(), |
| 2374 | value: settings.theme.clone(), |
| 2375 | editable: true, |
| 2376 | scope: ConfigScope::Saved, |
| 2377 | // The live theme is the one the app is painting with. |
| 2378 | facts: ConfigRowFacts::saved_setting().effective(app.theme_id.name()), |
| 2379 | }, |
| 2380 | ConfigRow { |
| 2381 | key: "locale".to_string(), |
| 2382 | value: settings.locale.clone(), |
| 2383 | editable: true, |
| 2384 | scope: ConfigScope::Saved, |
| 2385 | facts: ConfigRowFacts::saved_setting().effective(app.ui_locale.tag()), |
| 2386 | }, |
| 2387 | ConfigRow { |
| 2388 | key: "background_color".to_string(), |
| 2389 | value: settings.background_color.clone().unwrap_or_else(|| { |
| 2390 | tr(app.ui_locale, MessageId::ConfigDefaultValue).to_string() |
| 2391 | }), |
| 2392 | editable: true, |
| 2393 | scope: ConfigScope::Saved, |
| 2394 | facts: ConfigRowFacts::saved_setting(), |
| 2395 | }, |
| 2396 | ConfigRow { |
| 2397 | key: "focus_texture".to_string(), |
| 2398 | value: settings.focus_texture.clone(), |
| 2399 | editable: true, |
| 2400 | scope: ConfigScope::Saved, |
| 2401 | facts: ConfigRowFacts::saved_setting(), |
| 2402 | }, |
| 2403 | ConfigRow { |
| 2404 | key: "calm_mode".to_string(), |
| 2405 | value: settings.calm_mode.to_string(), |
| 2406 | editable: true, |
| 2407 | scope: ConfigScope::Saved, |
| 2408 | facts: ConfigRowFacts::saved_setting(), |
| 2409 | }, |
| 2410 | ConfigRow { |
| 2411 | key: "low_motion".to_string(), |
| 2412 | value: settings.low_motion.to_string(), |
| 2413 | editable: true, |
| 2414 | scope: ConfigScope::Saved, |
| 2415 | // `low_motion` always wins over fancy animations; both are |
| 2416 | // Motion, and their live values come from `App`, not disk. |
| 2417 | facts: motion_facts( |
| 2418 | ConfigRowFacts::saved_setting().effective(app.low_motion.to_string()), |
| 2419 | ), |
| 2420 | }, |
| 2421 | ConfigRow { |
| 2422 | key: "fancy_animations".to_string(), |
| 2423 | value: settings.fancy_animations.to_string(), |
| 2424 | editable: true, |
| 2425 | scope: ConfigScope::Saved, |
| 2426 | facts: motion_facts( |
| 2427 | ConfigRowFacts::saved_setting().effective(app.fancy_animations.to_string()), |
| 2428 | ), |
| 2429 | }, |
| 2430 | // `launch_screen` is a retired setting: accepted on load, dropped |
| 2431 | // on save — no config row (main's retirement wins over the |
| 2432 | // branch's stale row). |
| 2433 | ConfigRow { |
| 2434 | key: "show_thinking".to_string(), |
| 2435 | value: settings.show_thinking.to_string(), |
| 2436 | editable: true, |
| 2437 | scope: ConfigScope::Saved, |
| 2438 | facts: ConfigRowFacts::saved_setting(), |
| 2439 | }, |
| 2440 | ConfigRow { |
| 2441 | key: "thinking_default_expanded".to_string(), |
| 2442 | value: settings.thinking_default_expanded.to_string(), |
| 2443 | editable: true, |
| 2444 | scope: ConfigScope::Saved, |
| 2445 | facts: ConfigRowFacts::saved_setting(), |
| 2446 | }, |
| 2447 | ConfigRow { |
| 2448 | key: "thinking_preview_lines".to_string(), |
| 2449 | value: settings.thinking_preview_lines.to_string(), |
| 2450 | editable: true, |
| 2451 | scope: ConfigScope::Saved, |
| 2452 | facts: ConfigRowFacts::saved_setting(), |
| 2453 | }, |
| 2454 | ConfigRow { |
| 2455 | key: "thinking_highlight".to_string(), |
| 2456 | value: settings.thinking_highlight.to_string(), |
| 2457 | editable: true, |
| 2458 | scope: ConfigScope::Saved, |
| 2459 | facts: ConfigRowFacts::saved_setting(), |
| 2460 | }, |
| 2461 | ConfigRow { |
| 2462 | key: "help_expand_groups".to_string(), |
| 2463 | value: settings.help_expand_groups.to_string(), |
| 2464 | editable: true, |
| 2465 | scope: ConfigScope::Saved, |
| 2466 | facts: ConfigRowFacts::saved_setting(), |
| 2467 | }, |
| 2468 | ConfigRow { |
| 2469 | key: "contextual_tips".to_string(), |
| 2470 | value: settings.contextual_tips.to_string(), |
| 2471 | editable: true, |
| 2472 | scope: ConfigScope::Saved, |
| 2473 | facts: ConfigRowFacts::saved_setting() |
| 2474 | .effective(app.behavioral_tips.enabled().to_string()), |
| 2475 | }, |
| 2476 | ConfigRow { |
| 2477 | key: "pin_last_prompt".to_string(), |
| 2478 | value: settings.pin_last_prompt.to_string(), |
| 2479 | editable: true, |
| 2480 | scope: ConfigScope::Saved, |
| 2481 | facts: ConfigRowFacts::saved_setting(), |
| 2482 | }, |
| 2483 | ConfigRow { |
| 2484 | key: "show_tool_details".to_string(), |
| 2485 | value: settings.show_tool_details.to_string(), |
| 2486 | editable: true, |
| 2487 | scope: ConfigScope::Saved, |
| 2488 | facts: ConfigRowFacts::saved_setting(), |
| 2489 | }, |
| 2490 | ConfigRow { |
| 2491 | key: "inline_diffs".to_string(), |
| 2492 | value: settings.inline_diffs.clone(), |
| 2493 | editable: true, |
| 2494 | scope: ConfigScope::Saved, |
| 2495 | facts: ConfigRowFacts::saved_setting(), |
| 2496 | }, |
| 2497 | ConfigRow { |
| 2498 | key: "status_indicator".to_string(), |
| 2499 | value: settings.status_indicator.clone(), |
| 2500 | editable: true, |
| 2501 | scope: ConfigScope::Saved, |
| 2502 | facts: ConfigRowFacts::saved_setting(), |
| 2503 | }, |
| 2504 | ConfigRow { |
| 2505 | key: "synchronized_output".to_string(), |
| 2506 | value: settings.synchronized_output.clone(), |
| 2507 | editable: true, |
| 2508 | scope: ConfigScope::Saved, |
| 2509 | facts: ConfigRowFacts::saved_setting(), |
| 2510 | }, |
| 2511 | ConfigRow { |
| 2512 | key: "cost_currency".to_string(), |
| 2513 | value: settings.cost_currency.clone(), |
| 2514 | editable: true, |
| 2515 | scope: ConfigScope::Saved, |
| 2516 | facts: ConfigRowFacts::saved_setting().effective(cost_currency_config_value(app)), |
| 2517 | }, |
| 2518 | ConfigRow { |
| 2519 | key: "transcript_spacing".to_string(), |
| 2520 | value: settings.transcript_spacing.clone(), |
| 2521 | editable: true, |
| 2522 | scope: ConfigScope::Saved, |
| 2523 | facts: ConfigRowFacts::saved_setting(), |
| 2524 | }, |
| 2525 | ConfigRow { |
| 2526 | key: "tool_collapse".to_string(), |
| 2527 | value: settings.tool_collapse_mode.clone(), |
| 2528 | editable: true, |
| 2529 | scope: ConfigScope::Saved, |
| 2530 | facts: ConfigRowFacts::saved_setting(), |
| 2531 | }, |
| 2532 | ConfigRow { |
| 2533 | key: "composer_density".to_string(), |
| 2534 | value: settings.composer_density.clone(), |
| 2535 | editable: true, |
| 2536 | scope: ConfigScope::Saved, |
| 2537 | facts: ConfigRowFacts::saved_setting(), |
| 2538 | }, |
| 2539 | ConfigRow { |
| 2540 | key: "composer_border".to_string(), |
| 2541 | value: settings.composer_border.to_string(), |
| 2542 | editable: true, |
| 2543 | scope: ConfigScope::Saved, |
| 2544 | facts: ConfigRowFacts::saved_setting(), |
| 2545 | }, |
| 2546 | ConfigRow { |
| 2547 | key: "composer_multiline_mode".to_string(), |
| 2548 | value: settings.composer_multiline_mode.to_string(), |
| 2549 | editable: true, |
| 2550 | scope: ConfigScope::Saved, |
| 2551 | facts: ConfigRowFacts::saved_setting(), |
| 2552 | }, |
| 2553 | ConfigRow { |
| 2554 | key: "composer_vim_mode".to_string(), |
| 2555 | value: settings.composer_vim_mode.clone(), |
| 2556 | editable: true, |
| 2557 | scope: ConfigScope::Saved, |
| 2558 | facts: ConfigRowFacts::saved_setting(), |
| 2559 | }, |
| 2560 | ConfigRow { |
| 2561 | key: "bracketed_paste".to_string(), |
| 2562 | value: settings.bracketed_paste.to_string(), |
| 2563 | editable: true, |
| 2564 | scope: ConfigScope::Saved, |
| 2565 | facts: ConfigRowFacts::saved_setting(), |
| 2566 | }, |
| 2567 | ConfigRow { |
| 2568 | key: "paste_burst_detection".to_string(), |
| 2569 | value: settings.paste_burst_detection.to_string(), |
| 2570 | editable: true, |
| 2571 | scope: ConfigScope::Saved, |
| 2572 | facts: ConfigRowFacts::saved_setting(), |
| 2573 | }, |
| 2574 | ConfigRow { |
| 2575 | key: "mention_menu_limit".to_string(), |
| 2576 | value: settings.mention_menu_limit.to_string(), |
| 2577 | editable: true, |
| 2578 | scope: ConfigScope::Saved, |
| 2579 | facts: ConfigRowFacts::saved_setting(), |
| 2580 | }, |
| 2581 | ConfigRow { |
| 2582 | key: "mention_menu_behavior".to_string(), |
| 2583 | value: settings.mention_menu_behavior.clone(), |
| 2584 | editable: true, |
| 2585 | scope: ConfigScope::Saved, |
| 2586 | facts: ConfigRowFacts::saved_setting(), |
| 2587 | }, |
| 2588 | ConfigRow { |
| 2589 | key: "mention_walk_depth".to_string(), |
| 2590 | value: settings.mention_walk_depth.to_string(), |
| 2591 | editable: true, |
| 2592 | scope: ConfigScope::Saved, |
| 2593 | facts: ConfigRowFacts::saved_setting(), |
| 2594 | }, |
| 2595 | ConfigRow { |
| 2596 | key: "workspace_follow_symlinks".to_string(), |
| 2597 | value: settings.workspace_follow_symlinks.to_string(), |
| 2598 | editable: true, |
| 2599 | scope: ConfigScope::Saved, |
| 2600 | // Mention menus follow it now; engine tools read it at startup. |
| 2601 | facts: ConfigRowFacts::saved_setting() |
| 2602 | .apply(SettingApplySemantics::UiNowEngineRestart), |
| 2603 | }, |
| 2604 | ConfigRow { |
| 2605 | key: "work_surface_placement".to_string(), |
| 2606 | value: settings.work_surface_placement.clone(), |
| 2607 | editable: true, |
| 2608 | scope: ConfigScope::Saved, |
| 2609 | facts: ConfigRowFacts::saved_setting(), |
| 2610 | }, |
| 2611 | ConfigRow { |
| 2612 | key: "work_surface_top_height".to_string(), |
| 2613 | value: settings.work_surface_top_height.to_string(), |
| 2614 | editable: true, |
| 2615 | scope: ConfigScope::Saved, |
| 2616 | facts: ConfigRowFacts::saved_setting(), |
| 2617 | }, |
| 2618 | ConfigRow { |
| 2619 | key: "work_surface_side_width".to_string(), |
| 2620 | value: settings.work_surface_side_width.to_string(), |
| 2621 | editable: true, |
| 2622 | scope: ConfigScope::Saved, |
| 2623 | facts: ConfigRowFacts::saved_setting(), |
| 2624 | }, |
| 2625 | ConfigRow { |
| 2626 | key: "rail_panel".to_string(), |
| 2627 | value: settings.rail_panel.clone(), |
| 2628 | editable: true, |
| 2629 | scope: ConfigScope::Saved, |
| 2630 | facts: ConfigRowFacts::saved_setting(), |
| 2631 | }, |
| 2632 | ConfigRow { |
| 2633 | key: "context_panel".to_string(), |
| 2634 | value: settings.context_panel.to_string(), |
| 2635 | editable: true, |
| 2636 | scope: ConfigScope::Saved, |
| 2637 | facts: ConfigRowFacts::saved_setting(), |
| 2638 | }, |
| 2639 | ConfigRow { |
| 2640 | key: "sessions_rail".to_string(), |
| 2641 | value: settings.sessions_rail.to_string(), |
| 2642 | editable: true, |
| 2643 | scope: ConfigScope::Saved, |
| 2644 | facts: ConfigRowFacts::saved_setting(), |
| 2645 | }, |
| 2646 | // Read at startup by `main`, not held on `App`, so the row reflects |
| 2647 | // the persisted value rather than a live field (#2934). |
| 2648 | ConfigRow { |
| 2649 | key: "session_auto_resume".to_string(), |
| 2650 | value: settings.session_auto_resume.to_string(), |
| 2651 | editable: true, |
| 2652 | scope: ConfigScope::Saved, |
| 2653 | facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession), |
| 2654 | }, |
| 2655 | ConfigRow { |
| 2656 | key: "auto_compact".to_string(), |
| 2657 | value: settings.auto_compact.to_string(), |
| 2658 | editable: true, |
| 2659 | scope: ConfigScope::Saved, |
| 2660 | facts: ConfigRowFacts::saved_setting(), |
| 2661 | }, |
| 2662 | ConfigRow { |
| 2663 | key: "auto_compact_threshold_percent".to_string(), |
| 2664 | value: format!("{:.0}", settings.auto_compact_threshold_percent), |
| 2665 | editable: true, |
| 2666 | scope: ConfigScope::Saved, |
| 2667 | facts: ConfigRowFacts::saved_setting(), |
| 2668 | }, |
| 2669 | ConfigRow { |
| 2670 | key: "max_history".to_string(), |
| 2671 | value: settings.max_input_history.to_string(), |
| 2672 | editable: true, |
| 2673 | scope: ConfigScope::Saved, |
| 2674 | facts: ConfigRowFacts::saved_setting(), |
| 2675 | }, |
| 2676 | ConfigRow { |
| 2677 | key: "mcp_open".to_string(), |
| 2678 | value: "/mcp".to_string(), |
| 2679 | editable: true, |
| 2680 | scope: ConfigScope::Session, |
| 2681 | facts: ConfigRowFacts::action("/mcp", MessageId::ConfigActionOpenMcp), |
| 2682 | }, |
| 2683 | ConfigRow { |
| 2684 | key: "mcp_reconnect".to_string(), |
| 2685 | value: "/mcp reload".to_string(), |
| 2686 | editable: true, |
| 2687 | scope: ConfigScope::Session, |
| 2688 | facts: ConfigRowFacts::action("/mcp reload", MessageId::ConfigActionMcpReconnect), |
| 2689 | }, |
| 2690 | ConfigRow { |
| 2691 | key: "mcp_diagnose".to_string(), |
| 2692 | value: "/mcp validate".to_string(), |
| 2693 | editable: true, |
| 2694 | scope: ConfigScope::Session, |
| 2695 | facts: ConfigRowFacts::action("/mcp validate", MessageId::ConfigActionMcpDiagnose), |
| 2696 | }, |
| 2697 | ConfigRow { |
| 2698 | key: "plugins_open".to_string(), |
| 2699 | value: "/plugin".to_string(), |
| 2700 | editable: true, |
| 2701 | scope: ConfigScope::Session, |
| 2702 | facts: ConfigRowFacts::action("/plugin", MessageId::ConfigActionOpenPlugins), |
| 2703 | }, |
| 2704 | ConfigRow { |
| 2705 | key: "mcp_config_path".to_string(), |
| 2706 | value: app.mcp_config_path.display().to_string(), |
| 2707 | editable: true, |
| 2708 | scope: ConfigScope::Saved, |
| 2709 | // The live path changes on save; running servers keep their |
| 2710 | // old config until `/mcp reload`. |
| 2711 | facts: ConfigRowFacts::saved_setting() |
| 2712 | .authority(SettingAuthority::WorkspaceConfiguration) |
| 2713 | .apply(SettingApplySemantics::ReloadRequired), |
| 2714 | }, |
| 2715 | ConfigRow { |
| 2716 | key: "fleet.exec.max_spawn_depth".to_string(), |
| 2717 | value: config |
| 2718 | .fleet |
| 2719 | .as_ref() |
| 2720 | .map(|fleet| fleet.exec.max_spawn_depth) |
| 2721 | .unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth) |
| 2722 | .to_string(), |
| 2723 | editable: false, |
| 2724 | scope: ConfigScope::Saved, |
| 2725 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration), |
| 2726 | }, |
| 2727 | ]; |
| 2728 | // The DeepSeek-only legacy fallback stays a persisted runtime key but |
| 2729 | // has no settings row: it is not a live choice on any provider, and |
| 2730 | // a leftover value is cleared with `/set default_model` instead of |
| 2731 | // a Legacy table section. |
| 2732 | let external_status_rows = [ProviderKind::OpenaiCodex, ProviderKind::Xai] |
| 2733 | .into_iter() |
| 2734 | .filter_map(|provider| { |
| 2735 | config |
| 2736 | .builtin_provider_identity(provider) |
| 2737 | .ok() |
| 2738 | .and_then(|identity| config.external_credential_consent_status(&identity)) |
| 2739 | .map(|status| { |
| 2740 | let state = if status.route_state == "active" { |
| 2741 | tr(app.ui_locale, MessageId::CtxInspActive) |
| 2742 | } else { |
| 2743 | tr(app.ui_locale, MessageId::ProviderExternalDormant) |
| 2744 | }; |
| 2745 | let scope = tr(app.ui_locale, MessageId::ProviderExternalDetailScope) |
| 2746 | .replace("{access}", status.access.as_str()) |
| 2747 | .replace("{provider}", &status.provider) |
| 2748 | .replace("{source}", status.source.as_str()) |
| 2749 | .replace("{version}", &status.consent_version.to_string()) |
| 2750 | .replace("{state}", &state); |
| 2751 | let owner_path = tr(app.ui_locale, MessageId::ProviderExternalOwnerPath) |
| 2752 | .replace("{owner}", status.owner) |
| 2753 | .replace("{path}", &codewhale_config::quote_os_path(&status.path)); |
| 2754 | let pinned_warning = status.ambient_path_changed.then(|| { |
| 2755 | tr(app.ui_locale, MessageId::ProviderExternalPinnedPathWarning) |
| 2756 | .replace("{owner}", status.owner) |
| 2757 | .replace("{path}", &codewhale_config::quote_os_path(&status.path)) |
| 2758 | }); |
| 2759 | let semantics = match status.access { |
| 2760 | codewhale_config::ExternalCredentialAccess::Disabled => { |
| 2761 | tr(app.ui_locale, MessageId::ProviderExternalDisabledDetail) |
| 2762 | } |
| 2763 | codewhale_config::ExternalCredentialAccess::ReadOnly => { |
| 2764 | tr(app.ui_locale, MessageId::ProviderExternalReadOnlySemantics) |
| 2765 | } |
| 2766 | codewhale_config::ExternalCredentialAccess::Managed => { |
| 2767 | tr(app.ui_locale, MessageId::ProviderExternalManagedDetail) |
| 2768 | } |
| 2769 | }; |
| 2770 | let semantics_revoke = |
| 2771 | tr(app.ui_locale, MessageId::ProviderExternalSemanticsRevoke) |
| 2772 | .replace("{semantics}", &semantics) |
| 2773 | .replace("{revoke}", &status.revoke_command); |
| 2774 | ConfigRow { |
| 2775 | key: format!("external_credentials.{}", provider.as_str()), |
| 2776 | value: match pinned_warning { |
| 2777 | Some(warning) => format!( |
| 2778 | "{scope} · {owner_path} · {warning} · {semantics_revoke}" |
| 2779 | ), |
| 2780 | None => format!("{scope} · {owner_path} · {semantics_revoke}"), |
| 2781 | }, |
| 2782 | editable: false, |
| 2783 | scope: ConfigScope::Saved, |
| 2784 | facts: ConfigRowFacts::diagnostic( |
| 2785 | SettingAuthority::WorkspaceConfiguration, |
| 2786 | ), |
| 2787 | } |
| 2788 | }) |
| 2789 | }); |
| 2790 | rows.splice(2..2, external_status_rows); |
| 2791 | // An explanation route, never an editable sandbox policy. The existing |
| 2792 | // status report owns the observed platform/backend enforcement facts. |
| 2793 | rows.push(ConfigRow { |
| 2794 | key: "sandbox_details".into(), |
| 2795 | value: "/status".into(), |
| 2796 | editable: true, |
| 2797 | scope: ConfigScope::Session, |
| 2798 | facts: ConfigRowFacts::action("/status", MessageId::AutomationActionInspect), |
| 2799 | }); |
| 2800 | rows.extend(experimental_config_rows(&config)); |
| 2801 | rows.extend( |
| 2802 | codewhale_config::notifications::NotificationSetting::ALL |
| 2803 | .into_iter() |
| 2804 | .map(|setting| ConfigRow { |
| 2805 | key: format!("notifications.{}", setting.key()), |
| 2806 | value: config.notifications_config().display(setting), |
| 2807 | editable: true, |
| 2808 | scope: ConfigScope::Saved, |
| 2809 | facts: ConfigRowFacts::saved_setting() |
| 2810 | .authority(SettingAuthority::WorkspaceConfiguration) |
| 2811 | .effective(app.notification_settings.display(setting)), |
| 2812 | }), |
| 2813 | ); |
| 2814 | |
| 2815 | // The schema decides what is shown and in what order. A row whose key |
| 2816 | // carries no `ui` block is declared but not browsable (it stays |
| 2817 | // settable through `/set`); a row the schema does not declare at all |
| 2818 | // cannot be placed, labelled, or edited, so it is not painted either. |
| 2819 | rows.retain(|row| row.ui().is_some()); |
| 2820 | rows.sort_by_key(|row| codewhale_config::setting_index(&row.key).unwrap_or(usize::MAX)); |
| 2821 | |
| 2822 | // A row whose store failed to load carries the error instead of a |
| 2823 | // default: its value reads unavailable, it is not editable (writing |
| 2824 | // through an unreadable store could clobber it), and its saved and |
| 2825 | // startup lanes are reported as unavailable. Keyed on the row's |
| 2826 | // *store*, not its authority: an environment/terminal override wins |
| 2827 | // the effective decision without making the store any less broken |
| 2828 | // (caught by Windows CI, where the legacy-console probe relabels the |
| 2829 | // motion rows' authority and a broken store then went unreported). |
| 2830 | let unavailable = tr(app.ui_locale, MessageId::ConfigUnavailable).into_owned(); |
| 2831 | for row in &mut rows { |
| 2832 | let error = match row.facts.store { |
| 2833 | SettingStore::UserSettings => settings_error.as_deref(), |
| 2834 | SettingStore::WorkspaceConfig => config_error.as_deref(), |
| 2835 | SettingStore::None => None, |
| 2836 | }; |
| 2837 | if let Some(error) = error |
| 2838 | && row.facts.kind == ConfigRowKind::Setting |
| 2839 | { |
| 2840 | row.facts = row.facts.clone().unavailable(error); |
| 2841 | row.editable = false; |
| 2842 | row.value = unavailable.clone(); |
| 2843 | } |
| 2844 | } |
| 2845 | |
| 2846 | let mut view = Self { |
| 2847 | rows, |
| 2848 | selected: 0, |
| 2849 | scroll: 0, |
| 2850 | editing: None, |
| 2851 | pending_commit: None, |
| 2852 | filter: String::new(), |
| 2853 | status: None, |
| 2854 | locale: app.ui_locale, |
| 2855 | last_visible_rows: Cell::new(0), |
| 2856 | last_render_scroll: Cell::new(0), |
| 2857 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 2858 | last_choice_hitboxes: RefCell::new(Vec::new()), |
| 2859 | last_editor_controls: RefCell::new(Vec::new()), |
| 2860 | last_rail_hitboxes: RefCell::new(Vec::new()), |
| 2861 | last_nav_controls: RefCell::new(Vec::new()), |
| 2862 | last_mouse_selected: None, |
| 2863 | hovered_row: None, |
| 2864 | hovered_rail: None, |
| 2865 | hovered_nav: None, |
| 2866 | hovered_editor: None, |
| 2867 | hovered_choice: None, |
| 2868 | api_provider: app.api_provider, |
| 2869 | route_base_url: app.active_route_base_url.clone(), |
| 2870 | route_model: app.model.clone(), |
| 2871 | auto_model: app.auto_model, |
| 2872 | // Settings opens on Appearance (design: first rail category). |
| 2873 | category: ConfigCategory::Appearance, |
| 2874 | snapshot: UiSnapshot::from_app(app), |
| 2875 | }; |
| 2876 | view.select_first_visible_row(); |
| 2877 | view |
| 2878 | } |
| 2879 | |
| 2880 | fn tr(&self, id: MessageId) -> Cow<'static, str> { |
| 2881 | tr(self.locale, id) |
| 2882 | } |
| 2883 | |
| 2884 | /// Keep the user's place when the host rebuilds this view after applying |
| 2885 | /// a setting to the live app. |
| 2886 | pub(crate) fn focus_key(&mut self, key: &str) { |
| 2887 | if let Some(index) = self.rows.iter().position(|row| row.key == key) { |
| 2888 | self.category = ConfigCategory::for_row(&self.rows[index]); |
| 2889 | self.selected = index; |
| 2890 | self.last_mouse_selected = None; |
| 2891 | self.clear_hover(); |
| 2892 | self.adjust_scroll(self.visible_rows_cached()); |
| 2893 | } |
| 2894 | } |
| 2895 | |
| 2896 | /// Snapshot the active search so live config updates can rebuild the |
| 2897 | /// modal without making the user's filtered result set jump away. |
| 2898 | pub(crate) fn filter_query(&self) -> &str { |
| 2899 | &self.filter |
| 2900 | } |
| 2901 | |
| 2902 | /// The key whose inline editor is open, if any. Exposes the transient |
| 2903 | /// editing state to the host-level tests in `tui::ui::tests` that drive |
| 2904 | /// the real `refresh_config_view_if_open` path (#theme-nav-exit). |
| 2905 | /// |
| 2906 | /// Test-only: production code reads `editing` directly, and a non-test lib |
| 2907 | /// build would flag this as dead code under `-D warnings`. |
| 2908 | #[cfg(test)] |
| 2909 | pub(crate) fn editing_key(&self) -> Option<&str> { |
| 2910 | self.editing.as_ref().map(|edit| edit.key.as_str()) |
| 2911 | } |
| 2912 | |
| 2913 | /// The highlighted choice index inside the open editor, if any. |
| 2914 | /// |
| 2915 | /// Test-only; see [`Self::editing_key`]. |
| 2916 | #[cfg(test)] |
| 2917 | pub(crate) fn editing_selected_choice(&self) -> Option<usize> { |
| 2918 | self.editing.as_ref().map(|edit| edit.selected_choice) |
| 2919 | } |
| 2920 | |
| 2921 | /// Rebuild after the host applied a setting (`refresh_config_view_if_open`) |
| 2922 | /// while keeping the open editor alive. |
| 2923 | /// |
| 2924 | /// The theme editor live-previews on every highlight, and each preview is a |
| 2925 | /// `ConfigUpdated` that lands here again. A bare `new_for_app` dropped |
| 2926 | /// `editing`, so the first arrow key closed the editor, the next one fell |
| 2927 | /// through to the non-editing key map (where Left/Right switch category), |
| 2928 | /// and `selected_choice` snapped back to 0. The user saw the highlight leap |
| 2929 | /// away from the row they were on — the theme never moved (#theme-nav-exit). |
| 2930 | /// |
| 2931 | /// The rows themselves must come from the refreshed snapshot: a persisted |
| 2932 | /// commit changes the value on disk and the row has to show it. Only the |
| 2933 | /// transient editing state is carried over. |
| 2934 | pub(crate) fn rebuild_preserving(app: &App, previous: &Self, focus_key: &str) -> Self { |
| 2935 | let mut view = Self::new_for_app(app); |
| 2936 | view.restore_filter(previous.filter_query().to_string()); |
| 2937 | view.focus_key(focus_key); |
| 2938 | let carried = match &previous.editing { |
| 2939 | // A row that vanished from the refreshed snapshot (filter, scope or |
| 2940 | // availability changed underneath) has no editor to belong to. |
| 2941 | Some(edit) => view |
| 2942 | .rows |
| 2943 | .iter() |
| 2944 | .position(|row| row.key == edit.key) |
| 2945 | .map(|index| (index, edit.clone())), |
| 2946 | None => None, |
| 2947 | }; |
| 2948 | match carried { |
| 2949 | Some((index, mut edit)) => { |
| 2950 | // The highlight is the user's cursor, not a disk fact: keep it |
| 2951 | // inside the refreshed choice list instead of resetting it. |
| 2952 | let choices_len = edit.choices.as_ref().map_or(0, Vec::len); |
| 2953 | if edit.selected_choice >= choices_len { |
| 2954 | edit.selected_choice = choices_len.saturating_sub(1); |
| 2955 | } |
| 2956 | view.selected = index; |
| 2957 | view.editing = Some(edit); |
| 2958 | } |
| 2959 | None => view.editing = None, |
| 2960 | } |
| 2961 | view |
| 2962 | } |
| 2963 | |
| 2964 | /// Reopen the editor the host just rejected, with the value the user |
| 2965 | /// typed, on a view rebuilt from disk truth. A row that no longer exists |
| 2966 | /// has nothing to reopen. |
| 2967 | pub(crate) fn restore_rejected_commit(&mut self, previous: &Self) { |
| 2968 | let Some(edit) = previous.pending_commit.clone() else { |
| 2969 | return; |
| 2970 | }; |
| 2971 | if let Some(index) = self.rows.iter().position(|row| row.key == edit.key) { |
| 2972 | self.selected = index; |
| 2973 | self.editing = Some(edit); |
| 2974 | } |
| 2975 | } |
| 2976 | |
| 2977 | pub(crate) fn restore_filter(&mut self, filter: String) { |
| 2978 | self.update_filter(|current| *current = filter); |
| 2979 | } |
| 2980 | |
| 2981 | fn visible_rows_cached(&self) -> usize { |
| 2982 | let cached = self.last_visible_rows.get(); |
| 2983 | if cached == 0 { 8 } else { cached } |
| 2984 | } |
| 2985 | |
| 2986 | /// The lowercased search terms for the current filter, computed once per |
| 2987 | /// interaction instead of once per row per pass (#6213 T6). |
| 2988 | fn filter_terms(&self) -> Vec<String> { |
| 2989 | self.filter |
| 2990 | .trim() |
| 2991 | .to_lowercase() |
| 2992 | .split_whitespace() |
| 2993 | .map(str::to_string) |
| 2994 | .collect() |
| 2995 | } |
| 2996 | |
| 2997 | fn row_matches_filter(&self, row: &ConfigRow, terms: &[String]) -> bool { |
| 2998 | if terms.is_empty() { |
| 2999 | return true; |
| 3000 | } |
| 3001 | |
| 3002 | let meta = SettingsRegistry::new(self).meta(row); |
| 3003 | let section = meta.category.label(self.locale).to_lowercase(); |
| 3004 | let section_en = meta.category.label(Locale::En).to_lowercase(); |
| 3005 | let category = ConfigCategory::for_row(row); |
| 3006 | let category_label = category.label(self.locale).to_lowercase(); |
| 3007 | let category_en = category.label(Locale::En).to_lowercase(); |
| 3008 | let label = config_label_for_key_for_locale(self.locale, &row.key).to_lowercase(); |
| 3009 | let key = row.key.to_lowercase(); |
| 3010 | let raw_value = row.value.to_lowercase(); |
| 3011 | let value = self.row_display_value(row).to_lowercase(); |
| 3012 | let scope = row.scope.label(self.locale).to_lowercase(); |
| 3013 | let scope_en = row.scope.label(Locale::En).to_lowercase(); |
| 3014 | let hint = config_hint_for_key(self.locale, &row.key).to_lowercase(); |
| 3015 | |
| 3016 | let explanation_terms = if row.key == "sandbox_details" { |
| 3017 | "sandbox filesystem unenforced isolation bubblewrap bwrap doctor" |
| 3018 | } else { |
| 3019 | "" |
| 3020 | }; |
| 3021 | terms.iter().all(|term| { |
| 3022 | explanation_terms.contains(term) |
| 3023 | || section.contains(term) |
| 3024 | || section_en.contains(term) |
| 3025 | || category_label.contains(term) |
| 3026 | || category_en.contains(term) |
| 3027 | || label.contains(term) |
| 3028 | || key.contains(term) |
| 3029 | || raw_value.contains(term) |
| 3030 | || value.contains(term) |
| 3031 | || scope.contains(term) |
| 3032 | || scope_en.contains(term) |
| 3033 | || hint.contains(term) |
| 3034 | }) |
| 3035 | } |
| 3036 | |
| 3037 | fn matching_row_indices(&self) -> Vec<usize> { |
| 3038 | let filtering = !self.filter.is_empty(); |
| 3039 | let terms = self.filter_terms(); |
| 3040 | self.rows |
| 3041 | .iter() |
| 3042 | .enumerate() |
| 3043 | .filter_map(|(idx, row)| { |
| 3044 | (self.row_matches_filter(row, &terms) && (filtering || self.category.contains(row))) |
| 3045 | .then_some(idx) |
| 3046 | }) |
| 3047 | .collect() |
| 3048 | } |
| 3049 | |
| 3050 | fn visible_items(&self) -> Vec<ConfigListItem> { |
| 3051 | let mut items = Vec::new(); |
| 3052 | let mut current_section = None; |
| 3053 | let filtering = !self.filter.is_empty(); |
| 3054 | |
| 3055 | let terms = self.filter_terms(); |
| 3056 | for (idx, row) in self.rows.iter().enumerate() { |
| 3057 | if !self.row_matches_filter(row, &terms) { |
| 3058 | continue; |
| 3059 | } |
| 3060 | // The rail category filters rows unless the user is searching. |
| 3061 | if !filtering && !self.category.contains(row) { |
| 3062 | continue; |
| 3063 | } |
| 3064 | |
| 3065 | if current_section != Some(row.section()) { |
| 3066 | current_section = Some(row.section()); |
| 3067 | items.push(ConfigListItem::Section(row.section())); |
| 3068 | } |
| 3069 | items.push(ConfigListItem::Row(idx)); |
| 3070 | } |
| 3071 | |
| 3072 | items |
| 3073 | } |
| 3074 | |
| 3075 | fn select_first_visible_row(&mut self) { |
| 3076 | if let Some(idx) = self |
| 3077 | .visible_items() |
| 3078 | .into_iter() |
| 3079 | .find_map(|item| match item { |
| 3080 | ConfigListItem::Row(i) => Some(i), |
| 3081 | ConfigListItem::Section(_) => None, |
| 3082 | }) |
| 3083 | { |
| 3084 | self.selected = idx; |
| 3085 | self.scroll = 0; |
| 3086 | } |
| 3087 | self.last_mouse_selected = None; |
| 3088 | self.clear_hover(); |
| 3089 | } |
| 3090 | |
| 3091 | fn key_column_width(&self) -> usize { |
| 3092 | self.rows |
| 3093 | .iter() |
| 3094 | .map(|row| { |
| 3095 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 3096 | UnicodeWidthStr::width(label.as_str()) |
| 3097 | }) |
| 3098 | .max() |
| 3099 | .unwrap_or(CONFIG_MIN_KEY_COLUMN_WIDTH) |
| 3100 | .max(CONFIG_MIN_KEY_COLUMN_WIDTH) |
| 3101 | } |
| 3102 | |
| 3103 | fn table_column_widths(&self, content_width: usize) -> (usize, usize, usize) { |
| 3104 | // The affordance glyph is the interaction; the scope badge is |
| 3105 | // secondary and sheds first so narrow lists keep a readable value. |
| 3106 | let scope_width = if content_width >= CONFIG_SCOPE_BADGE_MIN_WIDTH { |
| 3107 | CONFIG_SCOPE_COLUMN_WIDTH |
| 3108 | } else { |
| 3109 | 0 |
| 3110 | }; |
| 3111 | let fixed_width = CONFIG_ROW_PREFIX_WIDTH |
| 3112 | + CONFIG_COLUMN_GAPS_WIDTH |
| 3113 | + CONFIG_AFFORDANCE_COLUMN_WIDTH |
| 3114 | + scope_width; |
| 3115 | let key_value_width = content_width.saturating_sub(fixed_width); |
| 3116 | let desired_key_width = self.key_column_width(); |
| 3117 | |
| 3118 | if key_value_width == 0 { |
| 3119 | return (0, 0, scope_width); |
| 3120 | } |
| 3121 | |
| 3122 | let minimum_key_width = CONFIG_MIN_KEY_COLUMN_WIDTH.min(key_value_width); |
| 3123 | let key_width = desired_key_width |
| 3124 | .min(key_value_width.saturating_sub(CONFIG_MIN_VALUE_COLUMN_WIDTH)) |
| 3125 | .max(minimum_key_width); |
| 3126 | let value_width = key_value_width |
| 3127 | .saturating_sub(key_width) |
| 3128 | .min(CONFIG_VALUE_COLUMN_WIDTH); |
| 3129 | |
| 3130 | (key_width, value_width, scope_width) |
| 3131 | } |
| 3132 | |
| 3133 | fn selected_row_index(&self) -> Option<usize> { |
| 3134 | let selected = self.selected; |
| 3135 | self.matching_row_indices() |
| 3136 | .into_iter() |
| 3137 | .any(|idx| idx == selected) |
| 3138 | .then_some(selected) |
| 3139 | } |
| 3140 | |
| 3141 | fn selected_display_position(&self, items: &[ConfigListItem]) -> Option<usize> { |
| 3142 | items |
| 3143 | .iter() |
| 3144 | .position(|item| matches!(item, ConfigListItem::Row(idx) if *idx == self.selected)) |
| 3145 | } |
| 3146 | |
| 3147 | fn sync_selection_to_filter(&mut self) { |
| 3148 | let matches = self.matching_row_indices(); |
| 3149 | if matches.is_empty() { |
| 3150 | self.selected = 0; |
| 3151 | self.scroll = 0; |
| 3152 | return; |
| 3153 | } |
| 3154 | |
| 3155 | if !matches.contains(&self.selected) { |
| 3156 | self.selected = matches[0]; |
| 3157 | } |
| 3158 | } |
| 3159 | |
| 3160 | /// Clear every hover tint: selection moves, filters, and scrolls can all |
| 3161 | /// shift painted rows out from under a stationary pointer. |
| 3162 | fn clear_hover(&mut self) { |
| 3163 | self.hovered_row = None; |
| 3164 | self.hovered_rail = None; |
| 3165 | self.hovered_nav = None; |
| 3166 | self.hovered_editor = None; |
| 3167 | self.hovered_choice = None; |
| 3168 | } |
| 3169 | |
| 3170 | /// Hover pass: tint whatever the pointer is over using the shared hover |
| 3171 | /// style. Hover never moves the keyboard selection and never activates. |
| 3172 | fn track_hover(&mut self, mouse: MouseEvent) { |
| 3173 | let position = Position::new(mouse.column, mouse.row); |
| 3174 | if self.editing.is_some() { |
| 3175 | self.hovered_choice = self |
| 3176 | .last_choice_hitboxes |
| 3177 | .borrow() |
| 3178 | .iter() |
| 3179 | .find_map(|(rect, choice)| rect.contains(position).then_some(*choice)); |
| 3180 | self.hovered_editor = self |
| 3181 | .last_editor_controls |
| 3182 | .borrow() |
| 3183 | .iter() |
| 3184 | .find_map(|(rect, control)| rect.contains(position).then_some(*control)); |
| 3185 | self.hovered_row = None; |
| 3186 | self.hovered_rail = None; |
| 3187 | self.hovered_nav = None; |
| 3188 | return; |
| 3189 | } |
| 3190 | self.hovered_row = self |
| 3191 | .last_row_hitboxes |
| 3192 | .borrow() |
| 3193 | .iter() |
| 3194 | .find_map(|(rect, row_idx)| rect.contains(position).then_some(*row_idx)); |
| 3195 | self.hovered_rail = self |
| 3196 | .last_rail_hitboxes |
| 3197 | .borrow() |
| 3198 | .iter() |
| 3199 | .find_map(|(rect, category)| rect.contains(position).then_some(*category)); |
| 3200 | self.hovered_nav = self |
| 3201 | .last_nav_controls |
| 3202 | .borrow() |
| 3203 | .iter() |
| 3204 | .find_map(|(rect, step)| rect.contains(position).then_some(*step)); |
| 3205 | self.hovered_editor = None; |
| 3206 | self.hovered_choice = None; |
| 3207 | } |
| 3208 | |
| 3209 | fn update_filter(&mut self, update: impl FnOnce(&mut String)) { |
| 3210 | update(&mut self.filter); |
| 3211 | self.status = None; |
| 3212 | self.last_mouse_selected = None; |
| 3213 | self.clear_hover(); |
| 3214 | self.sync_selection_to_filter(); |
| 3215 | self.adjust_scroll(self.visible_rows_cached()); |
| 3216 | } |
| 3217 | |
| 3218 | fn adjust_scroll(&mut self, visible_rows: usize) { |
| 3219 | self.sync_selection_to_filter(); |
| 3220 | |
| 3221 | let items = self.visible_items(); |
| 3222 | if items.is_empty() { |
| 3223 | self.scroll = 0; |
| 3224 | return; |
| 3225 | } |
| 3226 | |
| 3227 | let visible_rows = visible_rows.max(1); |
| 3228 | let max_scroll = items.len().saturating_sub(visible_rows); |
| 3229 | self.scroll = self.scroll.min(max_scroll); |
| 3230 | |
| 3231 | let Some(selected_pos) = self.selected_display_position(&items) else { |
| 3232 | self.scroll = 0; |
| 3233 | return; |
| 3234 | }; |
| 3235 | |
| 3236 | if selected_pos < self.scroll { |
| 3237 | self.scroll = selected_pos; |
| 3238 | } |
| 3239 | |
| 3240 | if selected_pos >= self.scroll + visible_rows { |
| 3241 | self.scroll = selected_pos.saturating_sub(visible_rows.saturating_sub(1)); |
| 3242 | } |
| 3243 | } |
| 3244 | |
| 3245 | fn move_selection(&mut self, delta: isize) { |
| 3246 | let matches = self.matching_row_indices(); |
| 3247 | if matches.is_empty() { |
| 3248 | return; |
| 3249 | } |
| 3250 | |
| 3251 | let current = matches |
| 3252 | .iter() |
| 3253 | .position(|idx| *idx == self.selected) |
| 3254 | .unwrap_or(0); |
| 3255 | let next = crate::tui::list_nav::wrap_index(current, matches.len(), delta); |
| 3256 | |
| 3257 | self.selected = matches[next]; |
| 3258 | self.clear_hover(); |
| 3259 | let visible_rows = self.visible_rows_cached(); |
| 3260 | self.adjust_scroll(visible_rows); |
| 3261 | } |
| 3262 | |
| 3263 | fn toggle_selected_boolean(&self) -> Option<ViewAction> { |
| 3264 | let row = self.rows.get(self.selected_row_index()?)?; |
| 3265 | if SettingsRegistry::new(self).meta(row).kind != SettingKind::Boolean { |
| 3266 | return None; |
| 3267 | } |
| 3268 | let value = if canonical_config_choice(&row.key, row.edit_value()) == "true" { |
| 3269 | "false" |
| 3270 | } else { |
| 3271 | "true" |
| 3272 | }; |
| 3273 | Some(ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 3274 | key: row.key.clone(), |
| 3275 | value: value.to_string(), |
| 3276 | persist: row.scope.persist(), |
| 3277 | })) |
| 3278 | } |
| 3279 | |
| 3280 | fn open_selected_catalog_picker(&self) -> Option<ViewAction> { |
| 3281 | let row = self.rows.get(self.selected_row_index()?)?; |
| 3282 | if !row.editable { |
| 3283 | return None; |
| 3284 | } |
| 3285 | let (command, _) = row.facts.command?; |
| 3286 | if row.key == "sandbox_details" { |
| 3287 | return Some(ViewAction::Emit(ViewEvent::ExecutePanelCommand { |
| 3288 | command: command.to_string(), |
| 3289 | pager_title: Some(config_label_for_key_for_locale(self.locale, &row.key)), |
| 3290 | })); |
| 3291 | } |
| 3292 | Some(ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 3293 | action: CommandPaletteAction::ExecuteCommand { |
| 3294 | command: command.to_string(), |
| 3295 | }, |
| 3296 | })) |
| 3297 | } |
| 3298 | |
| 3299 | fn move_choice(&mut self, delta: isize) { |
| 3300 | let Some(edit) = self.editing.as_mut() else { |
| 3301 | return; |
| 3302 | }; |
| 3303 | let Some(choices) = edit.choices.as_ref() else { |
| 3304 | return; |
| 3305 | }; |
| 3306 | let max = choices.len().saturating_sub(1); |
| 3307 | edit.selected_choice = if delta.is_negative() { |
| 3308 | edit.selected_choice.saturating_sub(delta.unsigned_abs()) |
| 3309 | } else { |
| 3310 | (edit.selected_choice + delta as usize).min(max) |
| 3311 | }; |
| 3312 | self.hovered_choice = None; |
| 3313 | } |
| 3314 | |
| 3315 | /// Live-preview the edited choice when the edited key is the theme: |
| 3316 | /// highlighting a theme row applies it session-only (`persist:false`) |
| 3317 | /// so the surface behind the editor repaints immediately, while only |
| 3318 | /// Enter/Apply persists. Other keys preview nothing. |
| 3319 | fn preview_edited_choice(&self) -> ViewAction { |
| 3320 | let Some(edit) = self.editing.as_ref() else { |
| 3321 | return ViewAction::None; |
| 3322 | }; |
| 3323 | if edit.key != "theme" { |
| 3324 | return ViewAction::None; |
| 3325 | } |
| 3326 | let Some(value) = edit |
| 3327 | .choices |
| 3328 | .as_ref() |
| 3329 | .and_then(|choices| choices.get(edit.selected_choice).cloned()) |
| 3330 | else { |
| 3331 | return ViewAction::None; |
| 3332 | }; |
| 3333 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 3334 | key: edit.key.clone(), |
| 3335 | value, |
| 3336 | persist: false, |
| 3337 | }) |
| 3338 | } |
| 3339 | |
| 3340 | /// Leave the editor without applying (Esc or the Cancel control). When |
| 3341 | /// the theme highlight moved, the live surface already previews the |
| 3342 | /// highlighted theme, so Esc reverts it to the exact value the editor |
| 3343 | /// opened with (session-only, mirroring the `/theme` picker rollback). |
| 3344 | fn cancel_edit(&mut self) -> ViewAction { |
| 3345 | let revert = self |
| 3346 | .editing |
| 3347 | .as_ref() |
| 3348 | .filter(|edit| edit.key == "theme") |
| 3349 | .and_then(|edit| { |
| 3350 | let highlighted = edit.choices.as_ref()?.get(edit.selected_choice)?; |
| 3351 | (canonical_config_choice(&edit.key, highlighted) |
| 3352 | != canonical_config_choice(&edit.key, &edit.original_value)) |
| 3353 | .then(|| ViewEvent::ConfigUpdated { |
| 3354 | key: edit.key.clone(), |
| 3355 | value: edit.original_value.clone(), |
| 3356 | persist: false, |
| 3357 | }) |
| 3358 | }); |
| 3359 | self.editing = None; |
| 3360 | self.status = Some(self.tr(MessageId::ConfigEditCancelled).to_string()); |
| 3361 | self.last_mouse_selected = None; |
| 3362 | self.clear_hover(); |
| 3363 | revert.map_or(ViewAction::None, ViewAction::Emit) |
| 3364 | } |
| 3365 | |
| 3366 | /// Hover-follow for the editor's choice rows (the global hover rule): |
| 3367 | /// the pointer highlights the hovered row, painted with the shared |
| 3368 | /// selected-row style. On the theme editor it also live-previews, |
| 3369 | /// exactly like ↑/↓. |
| 3370 | fn hover_edited_choice(&mut self, mouse: MouseEvent) -> ViewAction { |
| 3371 | let position = Position::new(mouse.column, mouse.row); |
| 3372 | let hovered = self |
| 3373 | .last_choice_hitboxes |
| 3374 | .borrow() |
| 3375 | .iter() |
| 3376 | .find_map(|(rect, choice)| rect.contains(position).then_some(*choice)); |
| 3377 | let Some(hovered) = hovered else { |
| 3378 | return ViewAction::None; |
| 3379 | }; |
| 3380 | let changed = match self.editing.as_mut() { |
| 3381 | Some(edit) if edit.selected_choice != hovered => { |
| 3382 | edit.selected_choice = hovered; |
| 3383 | true |
| 3384 | } |
| 3385 | _ => false, |
| 3386 | }; |
| 3387 | if changed { |
| 3388 | self.preview_edited_choice() |
| 3389 | } else { |
| 3390 | ViewAction::None |
| 3391 | } |
| 3392 | } |
| 3393 | |
| 3394 | /// Apply the editor's value (Enter or the Apply control): the selected |
| 3395 | /// choice, or the trimmed text buffer. |
| 3396 | fn commit_edit(&mut self) -> ViewAction { |
| 3397 | let Some(edit) = self.editing.take() else { |
| 3398 | return ViewAction::None; |
| 3399 | }; |
| 3400 | self.pending_commit = Some(edit.clone()); |
| 3401 | self.last_mouse_selected = None; |
| 3402 | self.clear_hover(); |
| 3403 | let value = match edit.choices.as_ref() { |
| 3404 | Some(choices) => match choices.get(edit.selected_choice).cloned() { |
| 3405 | Some(value) => value, |
| 3406 | None => return ViewAction::None, |
| 3407 | }, |
| 3408 | None => edit.buffer.iter().collect::<String>().trim().to_string(), |
| 3409 | }; |
| 3410 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 3411 | key: edit.key, |
| 3412 | value, |
| 3413 | persist: edit.scope.persist(), |
| 3414 | }) |
| 3415 | } |
| 3416 | |
| 3417 | fn handle_choice_key(&mut self, key: KeyEvent) -> ViewAction { |
| 3418 | match key.code { |
| 3419 | KeyCode::Esc => self.cancel_edit(), |
| 3420 | KeyCode::Enter => self.commit_edit(), |
| 3421 | KeyCode::Up | KeyCode::Left | KeyCode::Char('k') => { |
| 3422 | self.move_choice(-1); |
| 3423 | self.preview_edited_choice() |
| 3424 | } |
| 3425 | KeyCode::Down | KeyCode::Right | KeyCode::Char('j') => { |
| 3426 | self.move_choice(1); |
| 3427 | self.preview_edited_choice() |
| 3428 | } |
| 3429 | KeyCode::PageUp => { |
| 3430 | self.move_choice(-5); |
| 3431 | self.preview_edited_choice() |
| 3432 | } |
| 3433 | KeyCode::PageDown => { |
| 3434 | self.move_choice(5); |
| 3435 | self.preview_edited_choice() |
| 3436 | } |
| 3437 | KeyCode::Home => { |
| 3438 | if let Some(edit) = self.editing.as_mut() { |
| 3439 | edit.selected_choice = 0; |
| 3440 | } |
| 3441 | self.preview_edited_choice() |
| 3442 | } |
| 3443 | KeyCode::End => { |
| 3444 | if let Some(edit) = self.editing.as_mut() |
| 3445 | && let Some(choices) = edit.choices.as_ref() |
| 3446 | { |
| 3447 | edit.selected_choice = choices.len().saturating_sub(1); |
| 3448 | } |
| 3449 | self.preview_edited_choice() |
| 3450 | } |
| 3451 | KeyCode::Char(digit @ '1'..='9') => { |
| 3452 | if let Some(edit) = self.editing.as_mut() |
| 3453 | && let Some(choices) = edit.choices.as_ref() |
| 3454 | { |
| 3455 | let index = digit as usize - '1' as usize; |
| 3456 | if index < choices.len() { |
| 3457 | edit.selected_choice = index; |
| 3458 | } |
| 3459 | } |
| 3460 | self.preview_edited_choice() |
| 3461 | } |
| 3462 | KeyCode::Char(' ') => { |
| 3463 | self.move_choice(1); |
| 3464 | self.preview_edited_choice() |
| 3465 | } |
| 3466 | _ => ViewAction::None, |
| 3467 | } |
| 3468 | } |
| 3469 | |
| 3470 | fn handle_editing_key(&mut self, key: KeyEvent) -> ViewAction { |
| 3471 | if self |
| 3472 | .editing |
| 3473 | .as_ref() |
| 3474 | .is_some_and(|edit| edit.choices.is_some()) |
| 3475 | { |
| 3476 | return self.handle_choice_key(key); |
| 3477 | } |
| 3478 | match key.code { |
| 3479 | KeyCode::Esc => self.cancel_edit(), |
| 3480 | KeyCode::Enter => self.commit_edit(), |
| 3481 | KeyCode::Backspace => { |
| 3482 | if let Some(edit) = self.editing.as_mut() { |
| 3483 | if edit.select_all { |
| 3484 | edit.buffer.clear(); |
| 3485 | edit.cursor = 0; |
| 3486 | edit.select_all = false; |
| 3487 | } else if edit.cursor > 0 { |
| 3488 | edit.cursor = edit.cursor.saturating_sub(1); |
| 3489 | edit.buffer.remove(edit.cursor); |
| 3490 | } |
| 3491 | } |
| 3492 | ViewAction::None |
| 3493 | } |
| 3494 | KeyCode::Delete => { |
| 3495 | if let Some(edit) = self.editing.as_mut() { |
| 3496 | if edit.select_all { |
| 3497 | edit.buffer.clear(); |
| 3498 | edit.cursor = 0; |
| 3499 | edit.select_all = false; |
| 3500 | } else if edit.cursor < edit.buffer.len() { |
| 3501 | edit.buffer.remove(edit.cursor); |
| 3502 | } |
| 3503 | } |
| 3504 | ViewAction::None |
| 3505 | } |
| 3506 | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 3507 | if let Some(edit) = self.editing.as_mut() { |
| 3508 | edit.buffer.clear(); |
| 3509 | edit.cursor = 0; |
| 3510 | edit.select_all = false; |
| 3511 | } |
| 3512 | ViewAction::None |
| 3513 | } |
| 3514 | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 3515 | if let Some(edit) = self.editing.as_mut() { |
| 3516 | edit.cursor = edit.buffer.len(); |
| 3517 | edit.select_all = true; |
| 3518 | } |
| 3519 | ViewAction::None |
| 3520 | } |
| 3521 | KeyCode::Left => { |
| 3522 | if let Some(edit) = self.editing.as_mut() { |
| 3523 | if edit.select_all { |
| 3524 | edit.cursor = 0; |
| 3525 | edit.select_all = false; |
| 3526 | } else { |
| 3527 | edit.cursor = edit.cursor.saturating_sub(1); |
| 3528 | } |
| 3529 | } |
| 3530 | ViewAction::None |
| 3531 | } |
| 3532 | KeyCode::Right => { |
| 3533 | if let Some(edit) = self.editing.as_mut() { |
| 3534 | if edit.select_all { |
| 3535 | edit.cursor = edit.buffer.len(); |
| 3536 | edit.select_all = false; |
| 3537 | } else { |
| 3538 | edit.cursor = (edit.cursor + 1).min(edit.buffer.len()); |
| 3539 | } |
| 3540 | } |
| 3541 | ViewAction::None |
| 3542 | } |
| 3543 | KeyCode::Home => { |
| 3544 | if let Some(edit) = self.editing.as_mut() { |
| 3545 | edit.cursor = 0; |
| 3546 | edit.select_all = false; |
| 3547 | } |
| 3548 | ViewAction::None |
| 3549 | } |
| 3550 | KeyCode::End => { |
| 3551 | if let Some(edit) = self.editing.as_mut() { |
| 3552 | edit.cursor = edit.buffer.len(); |
| 3553 | edit.select_all = false; |
| 3554 | } |
| 3555 | ViewAction::None |
| 3556 | } |
| 3557 | KeyCode::Char(ch) |
| 3558 | if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() => |
| 3559 | { |
| 3560 | if let Some(edit) = self.editing.as_mut() { |
| 3561 | if edit.select_all { |
| 3562 | edit.buffer.clear(); |
| 3563 | edit.cursor = 0; |
| 3564 | edit.select_all = false; |
| 3565 | } |
| 3566 | edit.buffer.insert(edit.cursor, ch); |
| 3567 | edit.cursor += 1; |
| 3568 | } |
| 3569 | ViewAction::None |
| 3570 | } |
| 3571 | _ => ViewAction::None, |
| 3572 | } |
| 3573 | } |
| 3574 | |
| 3575 | fn start_edit(&mut self) { |
| 3576 | let Some(row_idx) = self.selected_row_index() else { |
| 3577 | return; |
| 3578 | }; |
| 3579 | let Some(row) = self.rows.get(row_idx) else { |
| 3580 | return; |
| 3581 | }; |
| 3582 | let key = row.key.clone(); |
| 3583 | let original_value = row.edit_value().to_string(); |
| 3584 | let initial_value = match config_default_placeholder_message(&key) { |
| 3585 | Some(message_id) |
| 3586 | if original_value == tr(self.locale, message_id) |
| 3587 | || original_value == tr(Locale::En, message_id) => |
| 3588 | { |
| 3589 | String::new() |
| 3590 | } |
| 3591 | _ => original_value.clone(), |
| 3592 | }; |
| 3593 | |
| 3594 | let meta = SettingsRegistry::new(self).meta(row); |
| 3595 | let choices = meta.choices; |
| 3596 | let selected_choice = choices |
| 3597 | .as_ref() |
| 3598 | .and_then(|choices| { |
| 3599 | let current = canonical_config_choice(&key, &initial_value); |
| 3600 | choices |
| 3601 | .iter() |
| 3602 | .position(|choice| canonical_config_choice(&key, choice) == current) |
| 3603 | }) |
| 3604 | .unwrap_or(0); |
| 3605 | let buffer: Vec<char> = initial_value.chars().collect(); |
| 3606 | self.last_mouse_selected = None; |
| 3607 | self.editing = Some(ConfigEdit { |
| 3608 | key, |
| 3609 | original_value, |
| 3610 | cursor: buffer.len(), |
| 3611 | buffer, |
| 3612 | select_all: true, |
| 3613 | scope: row.scope, |
| 3614 | choices, |
| 3615 | selected_choice, |
| 3616 | }); |
| 3617 | self.status = None; |
| 3618 | } |
| 3619 | |
| 3620 | fn clear_filter(&mut self) { |
| 3621 | if self.filter.is_empty() { |
| 3622 | return; |
| 3623 | } |
| 3624 | |
| 3625 | self.update_filter(|filter| filter.clear()); |
| 3626 | } |
| 3627 | |
| 3628 | fn row_display_value(&self, row: &ConfigRow) -> String { |
| 3629 | if row.key.starts_with("notifications.") { |
| 3630 | return config_choice_label(self.locale, &row.key, row.edit_value()); |
| 3631 | } |
| 3632 | // The effective lane is only ever an explicit `App` observation carried |
| 3633 | // on the row's typed facts; a persisted value never stands in for it. |
| 3634 | let effective = row.facts.effective.as_deref(); |
| 3635 | if row.key == "cost_currency" |
| 3636 | && row.scope == ConfigScope::Saved |
| 3637 | && let Some(effective_currency) = effective |
| 3638 | { |
| 3639 | let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value); |
| 3640 | let effective_cost_currency = |
| 3641 | crate::pricing::CostCurrency::from_setting(effective_currency); |
| 3642 | if saved_cost_currency != effective_cost_currency { |
| 3643 | return format!( |
| 3644 | "{}{}", |
| 3645 | row.value, |
| 3646 | self.tr(MessageId::ConfigRowEffective) |
| 3647 | .replace("{currency}", effective_currency) |
| 3648 | ); |
| 3649 | } |
| 3650 | } |
| 3651 | |
| 3652 | let runtime_value = effective.and_then(|value| value.parse::<bool>().ok()); |
| 3653 | if let Some(runtime_value) = runtime_value |
| 3654 | && row.value.parse::<bool>().ok() != Some(runtime_value) |
| 3655 | { |
| 3656 | let saved = config_choice_label( |
| 3657 | self.locale, |
| 3658 | &row.key, |
| 3659 | &canonical_config_choice(&row.key, &row.value), |
| 3660 | ); |
| 3661 | let effective = config_choice_label(self.locale, &row.key, &runtime_value.to_string()); |
| 3662 | return format!( |
| 3663 | "{}{}", |
| 3664 | saved, |
| 3665 | self.tr(MessageId::ConfigRowEffective) |
| 3666 | .replace("{currency}", &effective) |
| 3667 | ); |
| 3668 | } |
| 3669 | |
| 3670 | // Preserve the exact saved currency alias in the table (for example |
| 3671 | // `rmb`) while the chooser highlights its canonical `cny` option. |
| 3672 | if row.key == "cost_currency" { |
| 3673 | return row.value.clone(); |
| 3674 | } |
| 3675 | |
| 3676 | if SettingsRegistry::new(self).meta(row).choices.is_some() { |
| 3677 | if config_default_placeholder_message(&row.key).is_some_and(|message_id| { |
| 3678 | row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id) |
| 3679 | }) { |
| 3680 | return self.tr(MessageId::ConfigValueProviderDefault).into_owned(); |
| 3681 | } |
| 3682 | let canonical = canonical_config_choice(&row.key, &row.value); |
| 3683 | return config_choice_label(self.locale, &row.key, &canonical); |
| 3684 | } |
| 3685 | |
| 3686 | row.value.clone() |
| 3687 | } |
| 3688 | } |
| 3689 | |
| 3690 | fn config_base_url_row_key(provider: ProviderKind) -> &'static str { |
| 3691 | if matches!(provider, ProviderKind::Deepseek) { |
| 3692 | "base_url" |
| 3693 | } else { |
| 3694 | "provider_url" |
| 3695 | } |
| 3696 | } |
| 3697 | |
| 3698 | fn config_base_url_row_value(app: &App) -> String { |
| 3699 | app.active_route_base_url.clone() |
| 3700 | } |
| 3701 | |
| 3702 | fn cost_currency_config_value(app: &App) -> String { |
| 3703 | match app.cost_currency { |
| 3704 | crate::pricing::CostCurrency::Usd => "usd", |
| 3705 | crate::pricing::CostCurrency::Cny => "cny", |
| 3706 | } |
| 3707 | .to_string() |
| 3708 | } |
| 3709 | |
| 3710 | fn experimental_config_rows(config: &Config) -> Vec<ConfigRow> { |
| 3711 | let features = config.features(); |
| 3712 | let configured = config.features.as_ref().map(|table| &table.entries); |
| 3713 | let mut rows = Vec::new(); |
| 3714 | |
| 3715 | for spec in FEATURES |
| 3716 | .iter() |
| 3717 | .filter(|spec| matches!(spec.stage, Stage::Experimental | Stage::Beta)) |
| 3718 | { |
| 3719 | let effective = features.enabled(spec.id); |
| 3720 | let configured_value = configured |
| 3721 | .and_then(|entries| entries.get(spec.key)) |
| 3722 | .copied(); |
| 3723 | rows.push(ConfigRow { |
| 3724 | key: format!("features.{}", spec.key), |
| 3725 | value: experimental_feature_value( |
| 3726 | effective, |
| 3727 | spec.default_enabled, |
| 3728 | configured_value.is_some(), |
| 3729 | ), |
| 3730 | editable: false, |
| 3731 | scope: ConfigScope::Saved, |
| 3732 | facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration), |
| 3733 | }); |
| 3734 | } |
| 3735 | |
| 3736 | rows.push(ConfigRow { |
| 3737 | key: "goal_command".to_string(), |
| 3738 | value: |
| 3739 | "/goal sets session objectives with optional token budgets; state shows in Work context" |
| 3740 | .to_string(), |
| 3741 | editable: false, |
| 3742 | scope: ConfigScope::Saved, |
| 3743 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 3744 | }); |
| 3745 | rows.push(ConfigRow { |
| 3746 | // Workflow orchestration is its own section, not a Fleet concern. |
| 3747 | key: "workflow".to_string(), |
| 3748 | value: |
| 3749 | "/workflow runs scripted fan-out/fan-in operations with run cards and cancel support" |
| 3750 | .to_string(), |
| 3751 | editable: false, |
| 3752 | scope: ConfigScope::Saved, |
| 3753 | facts: ConfigRowFacts::diagnostic(SettingAuthority::Session), |
| 3754 | }); |
| 3755 | |
| 3756 | rows |
| 3757 | } |
| 3758 | |
| 3759 | fn experimental_feature_value(effective: bool, default_enabled: bool, configured: bool) -> String { |
| 3760 | let state = if effective { "enabled" } else { "disabled" }; |
| 3761 | let default_state = if default_enabled { |
| 3762 | "enabled" |
| 3763 | } else { |
| 3764 | "disabled" |
| 3765 | }; |
| 3766 | if configured { |
| 3767 | format!("{state} (configured; default {default_state})") |
| 3768 | } else { |
| 3769 | format!("{state} (default {default_state})") |
| 3770 | } |
| 3771 | } |
| 3772 | |
| 3773 | /// Localized label for a setting key. |
| 3774 | /// |
| 3775 | /// The schema names the string; the locale pack owns the text. A setting |
| 3776 | /// declared without a label message humanizes its key, and a `features.*` key |
| 3777 | /// wears the localized feature prefix. |
| 3778 | fn config_label_for_key_for_locale(locale: Locale, key: &str) -> String { |
| 3779 | let declared = codewhale_config::setting(key) |
| 3780 | .and_then(|def| def.ui.as_ref()) |
| 3781 | .map(|ui| ui.label) |
| 3782 | .unwrap_or(""); |
| 3783 | if !declared.is_empty() { |
| 3784 | return tr_key(locale, declared).to_string(); |
| 3785 | } |
| 3786 | let humanized = humanize_config_key(key.strip_prefix("features.").unwrap_or(key)); |
| 3787 | if key.starts_with("features.") { |
| 3788 | tr(locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", &humanized) |
| 3789 | } else { |
| 3790 | humanized |
| 3791 | } |
| 3792 | } |
| 3793 | |
| 3794 | #[cfg(test)] |
| 3795 | fn config_label_for_key(key: &str) -> String { |
| 3796 | config_label_for_key_for_locale(Locale::En, key) |
| 3797 | } |
| 3798 | |
| 3799 | fn humanize_config_key(key: &str) -> String { |
| 3800 | key.split(['.', '_', '-']) |
| 3801 | .filter(|part| !part.is_empty()) |
| 3802 | .map(|part| { |
| 3803 | let mut chars = part.chars(); |
| 3804 | let Some(first) = chars.next() else { |
| 3805 | return String::new(); |
| 3806 | }; |
| 3807 | let mut word = first.to_uppercase().collect::<String>(); |
| 3808 | word.push_str(chars.as_str()); |
| 3809 | word |
| 3810 | }) |
| 3811 | .collect::<Vec<_>>() |
| 3812 | .join(" ") |
| 3813 | } |
| 3814 | |
| 3815 | /// Localized description for a setting key. |
| 3816 | /// |
| 3817 | /// Theme and locale describe themselves with their shipped value lists (value |
| 3818 | /// lists, not prose, so they cannot go stale); every other sentence is the |
| 3819 | /// message the schema names. |
| 3820 | fn config_hint_for_key(locale: Locale, key: &str) -> Cow<'static, str> { |
| 3821 | match key { |
| 3822 | "theme" => { |
| 3823 | static THEME_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); |
| 3824 | return Cow::Borrowed(THEME_HINT.get_or_init(|| { |
| 3825 | codewhale_palette::SELECTABLE_THEMES |
| 3826 | .iter() |
| 3827 | .map(|id| id.name()) |
| 3828 | .collect::<Vec<_>>() |
| 3829 | .join(" | ") |
| 3830 | })); |
| 3831 | } |
| 3832 | "locale" => { |
| 3833 | static LOCALE_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); |
| 3834 | return Cow::Borrowed( |
| 3835 | LOCALE_HINT.get_or_init(|| codewhale_localization::configured_locale_values(" | ")), |
| 3836 | ); |
| 3837 | } |
| 3838 | _ => {} |
| 3839 | } |
| 3840 | let declared = codewhale_config::setting(key) |
| 3841 | .and_then(|def| def.ui.as_ref()) |
| 3842 | .map(|ui| ui.description) |
| 3843 | .unwrap_or(""); |
| 3844 | if declared.is_empty() { |
| 3845 | return Cow::Borrowed(""); |
| 3846 | } |
| 3847 | tr_key(locale, declared) |
| 3848 | } |
| 3849 | |
| 3850 | fn config_default_placeholder_message(key: &str) -> Option<MessageId> { |
| 3851 | match key { |
| 3852 | "default_model" | "background_color" => Some(MessageId::ConfigDefaultValue), |
| 3853 | "reasoning_effort" => Some(MessageId::ConfigDefaultReasoning), |
| 3854 | _ => None, |
| 3855 | } |
| 3856 | } |
| 3857 | |
| 3858 | fn config_boolean_key(key: &str) -> bool { |
| 3859 | codewhale_config::setting(key).is_some_and(|def| def.is_bool()) |
| 3860 | } |
| 3861 | |
| 3862 | fn config_integer_key(key: &str) -> bool { |
| 3863 | codewhale_config::setting(key).is_some_and(|def| def.is_int()) |
| 3864 | } |
| 3865 | |
| 3866 | /// Selectable values for a key. |
| 3867 | /// |
| 3868 | /// Two settings take their values from a live registry instead of the schema: |
| 3869 | /// the shipped palettes and the shipped locale packs. `reasoning_effort` is a |
| 3870 | /// third, and `SettingsRegistry::reasoning_effort_choices` owns it because the |
| 3871 | /// answer depends on the active route, not just the provider. Everything else |
| 3872 | /// is the declared value set. |
| 3873 | fn config_choice_values(key: &str) -> Option<Vec<String>> { |
| 3874 | match key { |
| 3875 | "theme" => { |
| 3876 | return Some( |
| 3877 | codewhale_palette::SELECTABLE_THEMES |
| 3878 | .iter() |
| 3879 | .map(|id| id.name().to_string()) |
| 3880 | .collect(), |
| 3881 | ); |
| 3882 | } |
| 3883 | "locale" => { |
| 3884 | let mut values = vec!["auto".to_string()]; |
| 3885 | values.extend( |
| 3886 | Locale::shipped() |
| 3887 | .iter() |
| 3888 | .map(|locale| locale.tag().to_string()), |
| 3889 | ); |
| 3890 | return Some(values); |
| 3891 | } |
| 3892 | "reasoning_effort" => { |
| 3893 | // Settings-canonical vocabulary; the live settings screen uses |
| 3894 | // `reasoning_effort_choices()` which narrows this by route/provider. |
| 3895 | return Some(vec![ |
| 3896 | "default".to_string(), |
| 3897 | "off".to_string(), |
| 3898 | "low".to_string(), |
| 3899 | "medium".to_string(), |
| 3900 | "high".to_string(), |
| 3901 | "xhigh".to_string(), |
| 3902 | "auto".to_string(), |
| 3903 | "ultra".to_string(), |
| 3904 | "max".to_string(), |
| 3905 | ]); |
| 3906 | } |
| 3907 | _ => {} |
| 3908 | } |
| 3909 | codewhale_config::setting(key) |
| 3910 | .and_then(|def| def.values()) |
| 3911 | .map(|values| values.into_iter().map(str::to_string).collect()) |
| 3912 | } |
| 3913 | |
| 3914 | fn canonical_config_choice(key: &str, value: &str) -> String { |
| 3915 | let normalized = value.trim().to_ascii_lowercase().replace([' ', '_'], "-"); |
| 3916 | match key { |
| 3917 | key if config_boolean_key(key) => match normalized.as_str() { |
| 3918 | "true" | "on" | "yes" | "1" | "enabled" => "true".to_string(), |
| 3919 | _ => "false".to_string(), |
| 3920 | }, |
| 3921 | "approval_mode" | "permission_posture" => match normalized.as_str() { |
| 3922 | "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(), |
| 3923 | "auto" | "auto-review" => "auto-review".to_string(), |
| 3924 | "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(), |
| 3925 | _ => normalized, |
| 3926 | }, |
| 3927 | "approval_policy" => match normalized.as_str() { |
| 3928 | "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(), |
| 3929 | "auto" | "auto-review" => "auto-review".to_string(), |
| 3930 | "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(), |
| 3931 | "never" | "deny" => "never".to_string(), |
| 3932 | _ => normalized, |
| 3933 | }, |
| 3934 | "reasoning_effort" => { |
| 3935 | if matches!(normalized.as_str(), "" | "(default)" | "config-default") { |
| 3936 | "default".to_string() |
| 3937 | } else if normalized == "max" && value.trim().eq_ignore_ascii_case("xhigh") { |
| 3938 | "xhigh".to_string() |
| 3939 | } else { |
| 3940 | normalized |
| 3941 | } |
| 3942 | } |
| 3943 | "cost_currency" => match normalized.as_str() { |
| 3944 | "rmb" | "yuan" | "cny" => "cny".to_string(), |
| 3945 | _ => "usd".to_string(), |
| 3946 | }, |
| 3947 | "default_mode" => match normalized.as_str() { |
| 3948 | "plan" => "plan".to_string(), |
| 3949 | "operate" | "operation" | "ops" => "operate".to_string(), |
| 3950 | _ => "agent".to_string(), |
| 3951 | }, |
| 3952 | "locale" => normalize_configured_locale(value) |
| 3953 | .unwrap_or(value) |
| 3954 | .to_string(), |
| 3955 | _ => normalized, |
| 3956 | } |
| 3957 | } |
| 3958 | |
| 3959 | /// Localized label for one value of a setting. |
| 3960 | /// |
| 3961 | /// The schema declares per-value labels; a boolean with none uses the shared |
| 3962 | /// on/off pair, and any other undeclared value shows itself. |
| 3963 | fn config_choice_label(locale: Locale, key: &str, value: &str) -> String { |
| 3964 | // The runtime "default" choice for reasoning_effort is the unset sentinel; |
| 3965 | // keep its localized placeholder label instead of showing the raw word. |
| 3966 | if key == "reasoning_effort" && value == "default" { |
| 3967 | return tr(locale, MessageId::ConfigDefaultReasoning).into_owned(); |
| 3968 | } |
| 3969 | let declared = codewhale_config::setting(key); |
| 3970 | let message = declared |
| 3971 | .and_then(|def| def.option(value)) |
| 3972 | .map(|option| option.label) |
| 3973 | .filter(|label| !label.is_empty()); |
| 3974 | let label = match message { |
| 3975 | Some(message) => tr_key(locale, message).into_owned(), |
| 3976 | None if declared.is_some_and(|def| def.is_bool()) => match value { |
| 3977 | "true" => tr(locale, MessageId::ConfigValueOn).into_owned(), |
| 3978 | "false" => tr(locale, MessageId::ConfigValueOff).into_owned(), |
| 3979 | other => other.to_string(), |
| 3980 | }, |
| 3981 | None => value.to_string(), |
| 3982 | }; |
| 3983 | |
| 3984 | if key == "locale" && configured_locale_is_partial_pack(value) { |
| 3985 | format!( |
| 3986 | "{label} ({})", |
| 3987 | tr(locale, MessageId::ConfigLocalePartialBadge) |
| 3988 | ) |
| 3989 | } else { |
| 3990 | label |
| 3991 | } |
| 3992 | } |
| 3993 | |
| 3994 | /// Localized one-line detail for one value of a setting. |
| 3995 | fn config_choice_detail(locale: Locale, key: &str, value: &str) -> Cow<'static, str> { |
| 3996 | if key == "locale" && configured_locale_is_partial_pack(value) { |
| 3997 | return tr(locale, MessageId::ConfigLocalePartialDetail); |
| 3998 | } |
| 3999 | let declared = codewhale_config::setting(key) |
| 4000 | .and_then(|def| def.option(value)) |
| 4001 | .map(|option| option.description) |
| 4002 | .filter(|description| !description.is_empty()); |
| 4003 | match declared { |
| 4004 | Some(message) => tr_key(locale, message), |
| 4005 | None => Cow::Borrowed(""), |
| 4006 | } |
| 4007 | } |
| 4008 | |
| 4009 | fn render_config_editor_value_line( |
| 4010 | edit: &ConfigEdit, |
| 4011 | locale: Locale, |
| 4012 | ) -> ratatui::text::Line<'static> { |
| 4013 | use ratatui::{ |
| 4014 | style::Style, |
| 4015 | text::{Line, Span}, |
| 4016 | }; |
| 4017 | |
| 4018 | let mut spans = Vec::new(); |
| 4019 | spans.push(Span::styled( |
| 4020 | tr(locale, MessageId::ConfigEditNewLabel), |
| 4021 | Style::default().fg(palette::TEXT_MUTED), |
| 4022 | )); |
| 4023 | |
| 4024 | let cursor_style = Style::default() |
| 4025 | .fg(palette::WHALE_BG) |
| 4026 | .bg(palette::WHALE_ACTION) |
| 4027 | .bold(); |
| 4028 | let selected_style = Style::default() |
| 4029 | .fg(palette::SELECTION_TEXT) |
| 4030 | .bg(palette::SELECTION_BG); |
| 4031 | |
| 4032 | if edit.select_all && !edit.buffer.is_empty() { |
| 4033 | let text = edit.buffer.iter().collect::<String>(); |
| 4034 | spans.push(Span::styled(text, selected_style)); |
| 4035 | spans.push(Span::styled(" ", cursor_style)); |
| 4036 | return Line::from(spans); |
| 4037 | } |
| 4038 | |
| 4039 | let before = edit.buffer.iter().take(edit.cursor).collect::<String>(); |
| 4040 | spans.push(Span::raw(before)); |
| 4041 | if edit.cursor < edit.buffer.len() { |
| 4042 | let ch = edit.buffer[edit.cursor]; |
| 4043 | spans.push(Span::styled(ch.to_string(), cursor_style)); |
| 4044 | let after = edit |
| 4045 | .buffer |
| 4046 | .iter() |
| 4047 | .skip(edit.cursor.saturating_add(1)) |
| 4048 | .collect::<String>(); |
| 4049 | spans.push(Span::raw(after)); |
| 4050 | } else { |
| 4051 | spans.push(Span::styled(" ", cursor_style)); |
| 4052 | } |
| 4053 | |
| 4054 | Line::from(spans) |
| 4055 | } |
| 4056 | |
| 4057 | impl ModalView for ConfigView { |
| 4058 | fn kind(&self) -> ModalKind { |
| 4059 | ModalKind::Config |
| 4060 | } |
| 4061 | |
| 4062 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 4063 | self |
| 4064 | } |
| 4065 | |
| 4066 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 4067 | // Any key is a state change: a following single click must select, |
| 4068 | // never activate, whatever the pointer touched before. |
| 4069 | self.last_mouse_selected = None; |
| 4070 | if self.editing.is_some() { |
| 4071 | return self.handle_editing_key(key); |
| 4072 | } |
| 4073 | // A status line ("Edit cancelled", …) is transient: navigation gives |
| 4074 | // the row back to its activation copy. |
| 4075 | self.status = None; |
| 4076 | |
| 4077 | match key.code { |
| 4078 | KeyCode::Esc => { |
| 4079 | if self.filter.is_empty() { |
| 4080 | ViewAction::Close |
| 4081 | } else { |
| 4082 | self.clear_filter(); |
| 4083 | ViewAction::None |
| 4084 | } |
| 4085 | } |
| 4086 | KeyCode::Tab | KeyCode::Right |
| 4087 | if !key.modifiers.contains(KeyModifiers::SHIFT) && self.filter.is_empty() => |
| 4088 | { |
| 4089 | self.category = self.category.next(); |
| 4090 | self.select_first_visible_row(); |
| 4091 | ViewAction::None |
| 4092 | } |
| 4093 | KeyCode::BackTab | KeyCode::Tab | KeyCode::Left if self.filter.is_empty() => { |
| 4094 | self.category = self.category.prev(); |
| 4095 | self.select_first_visible_row(); |
| 4096 | ViewAction::None |
| 4097 | } |
| 4098 | KeyCode::Up => { |
| 4099 | self.move_selection(-1); |
| 4100 | ViewAction::None |
| 4101 | } |
| 4102 | KeyCode::Down => { |
| 4103 | self.move_selection(1); |
| 4104 | ViewAction::None |
| 4105 | } |
| 4106 | KeyCode::PageUp => { |
| 4107 | self.move_selection(-5); |
| 4108 | ViewAction::None |
| 4109 | } |
| 4110 | KeyCode::PageDown => { |
| 4111 | self.move_selection(5); |
| 4112 | ViewAction::None |
| 4113 | } |
| 4114 | KeyCode::Backspace => { |
| 4115 | if !self.filter.is_empty() { |
| 4116 | self.update_filter(|filter| { |
| 4117 | filter.pop(); |
| 4118 | }); |
| 4119 | } |
| 4120 | ViewAction::None |
| 4121 | } |
| 4122 | // Ctrl+H is the legacy ASCII backspace many terminals emit. |
| 4123 | KeyCode::Char('h') |
| 4124 | if key.modifiers.contains(KeyModifiers::CONTROL) |
| 4125 | && !key.modifiers.contains(KeyModifiers::ALT) => |
| 4126 | { |
| 4127 | if !self.filter.is_empty() { |
| 4128 | self.update_filter(|filter| { |
| 4129 | filter.pop(); |
| 4130 | }); |
| 4131 | } |
| 4132 | ViewAction::None |
| 4133 | } |
| 4134 | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 4135 | self.clear_filter(); |
| 4136 | ViewAction::None |
| 4137 | } |
| 4138 | KeyCode::Enter => { |
| 4139 | if self |
| 4140 | .selected_row_index() |
| 4141 | .and_then(|idx| self.rows.get(idx)) |
| 4142 | .is_some_and(|row| row.editable) |
| 4143 | { |
| 4144 | if let Some(action) = self.open_selected_catalog_picker() { |
| 4145 | return action; |
| 4146 | } |
| 4147 | if let Some(action) = self.toggle_selected_boolean() { |
| 4148 | return action; |
| 4149 | } |
| 4150 | self.start_edit(); |
| 4151 | } |
| 4152 | ViewAction::None |
| 4153 | } |
| 4154 | KeyCode::Char(ch) |
| 4155 | if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() => |
| 4156 | { |
| 4157 | self.update_filter(|filter| filter.push(ch)); |
| 4158 | ViewAction::None |
| 4159 | } |
| 4160 | _ => ViewAction::None, |
| 4161 | } |
| 4162 | } |
| 4163 | |
| 4164 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 4165 | if matches!(mouse.kind, MouseEventKind::Moved) { |
| 4166 | let has_choices = self |
| 4167 | .editing |
| 4168 | .as_ref() |
| 4169 | .is_some_and(|edit| edit.choices.is_some()); |
| 4170 | if has_choices { |
| 4171 | return self.hover_edited_choice(mouse); |
| 4172 | } |
| 4173 | self.track_hover(mouse); |
| 4174 | return ViewAction::None; |
| 4175 | } |
| 4176 | if self.editing.is_some() { |
| 4177 | let has_choices = self |
| 4178 | .editing |
| 4179 | .as_ref() |
| 4180 | .is_some_and(|edit| edit.choices.is_some()); |
| 4181 | match mouse.kind { |
| 4182 | MouseEventKind::ScrollUp if has_choices => { |
| 4183 | self.move_choice(-1); |
| 4184 | return self.preview_edited_choice(); |
| 4185 | } |
| 4186 | MouseEventKind::ScrollDown if has_choices => { |
| 4187 | self.move_choice(1); |
| 4188 | return self.preview_edited_choice(); |
| 4189 | } |
| 4190 | MouseEventKind::Down(MouseButton::Left) => { |
| 4191 | let position = Position::new(mouse.column, mouse.row); |
| 4192 | let control = self |
| 4193 | .last_editor_controls |
| 4194 | .borrow() |
| 4195 | .iter() |
| 4196 | .find_map(|(rect, control)| rect.contains(position).then_some(*control)); |
| 4197 | match control { |
| 4198 | Some(EditorControl::Apply) => return self.commit_edit(), |
| 4199 | Some(EditorControl::Cancel) => return self.cancel_edit(), |
| 4200 | None => {} |
| 4201 | } |
| 4202 | let choice = self |
| 4203 | .last_choice_hitboxes |
| 4204 | .borrow() |
| 4205 | .iter() |
| 4206 | .find_map(|(rect, choice)| rect.contains(position).then_some(*choice)); |
| 4207 | let picked = match (choice, self.editing.as_mut()) { |
| 4208 | (Some(choice), Some(edit)) => { |
| 4209 | edit.selected_choice = choice; |
| 4210 | true |
| 4211 | } |
| 4212 | _ => false, |
| 4213 | }; |
| 4214 | if picked { |
| 4215 | return self.preview_edited_choice(); |
| 4216 | } |
| 4217 | } |
| 4218 | _ => {} |
| 4219 | } |
| 4220 | return ViewAction::None; |
| 4221 | } |
| 4222 | match mouse.kind { |
| 4223 | MouseEventKind::ScrollUp => { |
| 4224 | self.move_selection(-3); |
| 4225 | self.last_mouse_selected = None; |
| 4226 | self.clear_hover(); |
| 4227 | return ViewAction::None; |
| 4228 | } |
| 4229 | MouseEventKind::ScrollDown => { |
| 4230 | self.move_selection(3); |
| 4231 | self.last_mouse_selected = None; |
| 4232 | self.clear_hover(); |
| 4233 | return ViewAction::None; |
| 4234 | } |
| 4235 | _ => {} |
| 4236 | } |
| 4237 | if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { |
| 4238 | return ViewAction::None; |
| 4239 | } |
| 4240 | |
| 4241 | let position = Position::new(mouse.column, mouse.row); |
| 4242 | let clicked_category = self |
| 4243 | .last_rail_hitboxes |
| 4244 | .borrow() |
| 4245 | .iter() |
| 4246 | .find_map(|(rect, category)| rect.contains(position).then_some(*category)); |
| 4247 | let stepped = self |
| 4248 | .last_nav_controls |
| 4249 | .borrow() |
| 4250 | .iter() |
| 4251 | .find_map(|(rect, step)| rect.contains(position).then_some(*step)) |
| 4252 | .map(|step| match step { |
| 4253 | NavStep::Previous => self.category.prev(), |
| 4254 | NavStep::Next => self.category.next(), |
| 4255 | }); |
| 4256 | if let Some(category) = clicked_category.or(stepped) { |
| 4257 | // A category click (or an overflow marker) is an explicit |
| 4258 | // navigation: it leaves search so the list shows exactly that |
| 4259 | // category, never a stale filtered mix. |
| 4260 | self.clear_filter(); |
| 4261 | self.status = None; |
| 4262 | if self.category != category { |
| 4263 | self.category = category; |
| 4264 | self.select_first_visible_row(); |
| 4265 | } |
| 4266 | self.last_mouse_selected = None; |
| 4267 | return ViewAction::None; |
| 4268 | } |
| 4269 | |
| 4270 | // Only the painted cells of a list row select it; the rail, dividers, |
| 4271 | // detail pane, status row, and footer never do. |
| 4272 | let selected = self |
| 4273 | .last_row_hitboxes |
| 4274 | .borrow() |
| 4275 | .iter() |
| 4276 | .find_map(|(rect, row_idx)| rect.contains(position).then_some(*row_idx)); |
| 4277 | if let Some(row_idx) = selected { |
| 4278 | let activate = self.last_mouse_selected == Some(row_idx) && self.selected == row_idx; |
| 4279 | self.selected = row_idx; |
| 4280 | self.status = None; |
| 4281 | self.adjust_scroll(self.visible_rows_cached()); |
| 4282 | self.last_mouse_selected = Some(row_idx); |
| 4283 | if activate && self.rows.get(row_idx).is_some_and(|row| row.editable) { |
| 4284 | if let Some(action) = self.open_selected_catalog_picker() { |
| 4285 | return action; |
| 4286 | } |
| 4287 | if let Some(action) = self.toggle_selected_boolean() { |
| 4288 | return action; |
| 4289 | } |
| 4290 | self.start_edit(); |
| 4291 | } |
| 4292 | } |
| 4293 | ViewAction::None |
| 4294 | } |
| 4295 | |
| 4296 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 4297 | use ratatui::{ |
| 4298 | style::Style, |
| 4299 | text::{Line, Span}, |
| 4300 | widgets::{Paragraph, Widget}, |
| 4301 | }; |
| 4302 | |
| 4303 | let inner = |
| 4304 | render_underwater_surface(area, buf, self.tr(MessageId::ConfigModalTitle).to_string()); |
| 4305 | let (lines, footer) = if let Some(edit) = self.editing.as_ref() { |
| 4306 | *self.last_choice_hitboxes.borrow_mut() = Vec::new(); |
| 4307 | *self.last_editor_controls.borrow_mut() = Vec::new(); |
| 4308 | *self.last_rail_hitboxes.borrow_mut() = Vec::new(); |
| 4309 | *self.last_nav_controls.borrow_mut() = Vec::new(); |
| 4310 | let footer_text = if edit.choices.is_some() { |
| 4311 | if inner.width < 56 || inner.height <= 8 { |
| 4312 | self.tr(MessageId::ConfigChoiceFooterCompact).to_string() |
| 4313 | } else { |
| 4314 | self.tr(MessageId::ConfigChoiceFooter).to_string() |
| 4315 | } |
| 4316 | } else { |
| 4317 | self.tr(MessageId::ConfigEditFooter).to_string() |
| 4318 | }; |
| 4319 | let reserved_footer_lines = |
| 4320 | wrapped_footer_lines(&footer_text, inner.width, Style::default()).len(); |
| 4321 | // The clickable Apply / Cancel controls always own the last body |
| 4322 | // row above the footer. |
| 4323 | const CONTROL_ROWS: usize = 1; |
| 4324 | // Spacer rows are secondary chrome: give them up before the |
| 4325 | // editable value line falls below the wrapped footer on compact |
| 4326 | // terminals (#40x12). |
| 4327 | let body_rows = |
| 4328 | usize::from(inner.height).saturating_sub(reserved_footer_lines + CONTROL_ROWS); |
| 4329 | // The expanded header costs six rows before the options. Reserve |
| 4330 | // at least three choices plus their detail before adding spacers; |
| 4331 | // a slightly taller compact shell must not show fewer options. |
| 4332 | let spacious = body_rows >= if edit.choices.is_some() { 10 } else { 8 }; |
| 4333 | let mut lines: Vec<Line> = Vec::new(); |
| 4334 | let edit_label = config_label_for_key_for_locale(self.locale, &edit.key); |
| 4335 | let edit_title = if edit_label == edit.key { |
| 4336 | format!("{}{}", self.tr(MessageId::ConfigEditTitlePrefix), edit.key) |
| 4337 | } else { |
| 4338 | format!( |
| 4339 | "{}{} [{}]", |
| 4340 | self.tr(MessageId::ConfigEditTitlePrefix), |
| 4341 | edit_label, |
| 4342 | edit.key |
| 4343 | ) |
| 4344 | }; |
| 4345 | lines.push(Line::from(vec![Span::styled( |
| 4346 | edit_title, |
| 4347 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 4348 | )])); |
| 4349 | if spacious { |
| 4350 | lines.push(Line::from("")); |
| 4351 | } |
| 4352 | let muted = Style::default().fg(palette::TEXT_MUTED); |
| 4353 | let scope_spans = vec![ |
| 4354 | Span::styled(self.tr(MessageId::ConfigEditScopeLabel), muted), |
| 4355 | Span::raw(edit.scope.label(self.locale)), |
| 4356 | ]; |
| 4357 | let current_spans = vec![ |
| 4358 | Span::styled(self.tr(MessageId::ConfigEditCurrentLabel), muted), |
| 4359 | Span::raw(truncate_view_text(&edit.original_value, 60)), |
| 4360 | ]; |
| 4361 | if spacious { |
| 4362 | lines.push(Line::from(scope_spans)); |
| 4363 | lines.push(Line::from(current_spans)); |
| 4364 | lines.push(Line::from("")); |
| 4365 | } else { |
| 4366 | // Compact: scope and current share one row so the choices |
| 4367 | // and the controls both stay visible at 40x12. |
| 4368 | let mut merged = scope_spans; |
| 4369 | merged.push(Span::styled(" · ", muted)); |
| 4370 | merged.extend(current_spans); |
| 4371 | lines.push(Line::from(merged)); |
| 4372 | } |
| 4373 | if let Some(choices) = edit.choices.as_ref() { |
| 4374 | lines.push(Line::from(Span::styled( |
| 4375 | self.tr(MessageId::ConfigEditChooseLabel), |
| 4376 | Style::default().fg(palette::TEXT_MUTED), |
| 4377 | ))); |
| 4378 | |
| 4379 | // Large catalogs (providers and themes) remain bounded by the |
| 4380 | // terminal. Keep the active option centered and mouse-hitbox |
| 4381 | // only the slice that is actually visible. |
| 4382 | let selected_detail = choices |
| 4383 | .get(edit.selected_choice) |
| 4384 | .map(|choice| config_choice_detail(self.locale, &edit.key, choice)) |
| 4385 | .unwrap_or_default(); |
| 4386 | let available_rows = usize::from(inner.height) |
| 4387 | .saturating_sub(reserved_footer_lines + CONTROL_ROWS + lines.len()); |
| 4388 | // At the minimum supported height, the choices themselves are |
| 4389 | // the primary object. Shed the explanatory detail before any |
| 4390 | // option; larger surfaces keep one row for that detail. |
| 4391 | let detail_rows = usize::from(!selected_detail.is_empty() && available_rows > 3); |
| 4392 | let option_budget = available_rows.saturating_sub(detail_rows).max(1); |
| 4393 | let visible_options = option_budget.min(choices.len()); |
| 4394 | let max_start = choices.len().saturating_sub(visible_options); |
| 4395 | let start = edit |
| 4396 | .selected_choice |
| 4397 | .saturating_sub(visible_options / 2) |
| 4398 | .min(max_start); |
| 4399 | let end = (start + visible_options).min(choices.len()); |
| 4400 | let mut hitboxes = Vec::new(); |
| 4401 | |
| 4402 | for (choice_idx, choice) in choices.iter().enumerate().take(end).skip(start) { |
| 4403 | let selected = choice_idx == edit.selected_choice; |
| 4404 | let marker = crate::tui::glyphs::selection_marker(selected); |
| 4405 | let label = config_choice_label(self.locale, &edit.key, choice); |
| 4406 | let line_y = inner.y.saturating_add(lines.len() as u16); |
| 4407 | hitboxes.push(( |
| 4408 | Rect { |
| 4409 | x: inner.x, |
| 4410 | y: line_y, |
| 4411 | width: inner.width, |
| 4412 | height: 1, |
| 4413 | }, |
| 4414 | choice_idx, |
| 4415 | )); |
| 4416 | let mut line = Line::from(format!( |
| 4417 | " {marker} {:>2}. {}", |
| 4418 | choice_idx + 1, |
| 4419 | truncate_view_text(&label, usize::from(inner.width).saturating_sub(8)) |
| 4420 | )); |
| 4421 | line.style = if selected { |
| 4422 | menu_style::selected_row_style() |
| 4423 | } else if self.hovered_choice == Some(choice_idx) { |
| 4424 | Style::default() |
| 4425 | .fg(palette::TEXT_PRIMARY) |
| 4426 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4427 | } else { |
| 4428 | Style::default().fg(palette::TEXT_PRIMARY) |
| 4429 | }; |
| 4430 | lines.push(line); |
| 4431 | } |
| 4432 | *self.last_choice_hitboxes.borrow_mut() = hitboxes; |
| 4433 | |
| 4434 | if !selected_detail.is_empty() |
| 4435 | && lines.len() + reserved_footer_lines + CONTROL_ROWS |
| 4436 | < usize::from(inner.height) |
| 4437 | { |
| 4438 | lines.push(Line::from(Span::styled( |
| 4439 | crate::tui::ui_text::semantic_truncate( |
| 4440 | selected_detail.as_ref(), |
| 4441 | usize::from(inner.width), |
| 4442 | ), |
| 4443 | Style::default().fg(palette::TEXT_MUTED), |
| 4444 | ))); |
| 4445 | } |
| 4446 | } else { |
| 4447 | lines.push(render_config_editor_value_line(edit, self.locale)); |
| 4448 | if spacious { |
| 4449 | lines.push(Line::from("")); |
| 4450 | } |
| 4451 | let hint = config_hint_for_key(self.locale, &edit.key); |
| 4452 | if !hint.is_empty() { |
| 4453 | lines.push(Line::from(vec![ |
| 4454 | Span::styled( |
| 4455 | self.tr(MessageId::ConfigEditHintLabel), |
| 4456 | Style::default().fg(palette::TEXT_MUTED), |
| 4457 | ), |
| 4458 | Span::raw(hint), |
| 4459 | ])); |
| 4460 | } |
| 4461 | } |
| 4462 | (lines, footer_text) |
| 4463 | } else { |
| 4464 | self.render_settings_shell(inner, buf); |
| 4465 | return; |
| 4466 | }; |
| 4467 | |
| 4468 | // Footer wraps inside the body so its hints can never run off the modal |
| 4469 | // edge (#3732); the editor renders into the area above it, and the |
| 4470 | // Apply / Cancel controls own the last body row. |
| 4471 | let content = render_modal_text_footer( |
| 4472 | inner, |
| 4473 | buf, |
| 4474 | &footer, |
| 4475 | Style::default().fg(palette::TEXT_MUTED), |
| 4476 | ); |
| 4477 | let body = Rect { |
| 4478 | height: content.height.saturating_sub(1), |
| 4479 | ..content |
| 4480 | }; |
| 4481 | Paragraph::new(lines) |
| 4482 | .style(Style::default().fg(palette::TEXT_PRIMARY)) |
| 4483 | .scroll((0, 0)) |
| 4484 | .render(body, buf); |
| 4485 | if content.height > 0 { |
| 4486 | self.render_editor_controls( |
| 4487 | Rect { |
| 4488 | y: content.bottom().saturating_sub(1), |
| 4489 | height: 1, |
| 4490 | ..content |
| 4491 | }, |
| 4492 | buf, |
| 4493 | ); |
| 4494 | } |
| 4495 | } |
| 4496 | } |
| 4497 | |
| 4498 | impl ConfigView { |
| 4499 | /// Paint `[ Apply ] [ Cancel ]` and record their exact hitboxes. |
| 4500 | fn render_editor_controls(&self, row: Rect, buf: &mut Buffer) { |
| 4501 | use crate::tui::ui_text::text_display_width; |
| 4502 | |
| 4503 | let mut controls = Vec::new(); |
| 4504 | let mut x = row.x; |
| 4505 | for (control, id, style) in [ |
| 4506 | ( |
| 4507 | EditorControl::Apply, |
| 4508 | MessageId::ConfigEditorApply, |
| 4509 | // The filled Apply control answers hover with an underline: |
| 4510 | // a bg tint would erase its button fill. |
| 4511 | if self.hovered_editor == Some(EditorControl::Apply) { |
| 4512 | menu_style::selected_row_style().add_modifier(Modifier::UNDERLINED) |
| 4513 | } else { |
| 4514 | menu_style::selected_row_style() |
| 4515 | }, |
| 4516 | ), |
| 4517 | ( |
| 4518 | EditorControl::Cancel, |
| 4519 | MessageId::ConfigEditorCancel, |
| 4520 | if self.hovered_editor == Some(EditorControl::Cancel) { |
| 4521 | Style::default() |
| 4522 | .fg(palette::TEXT_PRIMARY) |
| 4523 | .add_modifier(Modifier::BOLD) |
| 4524 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4525 | } else { |
| 4526 | Style::default() |
| 4527 | .fg(palette::TEXT_PRIMARY) |
| 4528 | .add_modifier(Modifier::BOLD) |
| 4529 | }, |
| 4530 | ), |
| 4531 | ] { |
| 4532 | let label = format!("[ {} ]", self.tr(id)); |
| 4533 | let width = u16::try_from(text_display_width(&label)).unwrap_or(u16::MAX); |
| 4534 | let limit = row.right().saturating_sub(x); |
| 4535 | if limit == 0 { |
| 4536 | break; |
| 4537 | } |
| 4538 | buf.set_stringn(x, row.y, &label, usize::from(limit), style); |
| 4539 | controls.push(( |
| 4540 | Rect { |
| 4541 | x, |
| 4542 | y: row.y, |
| 4543 | width: width.min(limit), |
| 4544 | height: 1, |
| 4545 | }, |
| 4546 | control, |
| 4547 | )); |
| 4548 | x = x.saturating_add(width).saturating_add(2); |
| 4549 | } |
| 4550 | *self.last_editor_controls.borrow_mut() = controls; |
| 4551 | } |
| 4552 | } |
| 4553 | |
| 4554 | // --------------------------------------------------------------------------- |
| 4555 | // Tideline settings shell: category rail │ setting + action list │ detail at |
| 4556 | // ≥100 columns; a horizontally windowed category strip over a full-width list |
| 4557 | // below that. Every fact painted here comes from a row's typed |
| 4558 | // `ConfigRowFacts`; nothing is re-derived from the key at render time. |
| 4559 | |
| 4560 | /// Inner width at which the detail pane is painted beside the list and the |
| 4561 | /// groups column appears. Below it the panel is tabs over one full-width |
| 4562 | /// list, and the group headings inside the list carry the grouping. |
| 4563 | const CONFIG_SHELL_DETAIL_MIN_WIDTH: u16 = 100; |
| 4564 | /// Groups column width (the active tab's `ui.group` names). |
| 4565 | const CONFIG_SHELL_GROUPS_WIDTH: u16 = 18; |
| 4566 | |
| 4567 | /// Pane geometry for one render of the settings shell. |
| 4568 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 4569 | struct ConfigShellPanes { |
| 4570 | groups: Option<Rect>, |
| 4571 | list: Rect, |
| 4572 | detail: Option<Rect>, |
| 4573 | } |
| 4574 | |
| 4575 | fn config_shell_panes(body: Rect, use_rail: bool) -> ConfigShellPanes { |
| 4576 | let rail_width = if use_rail { |
| 4577 | CONFIG_SHELL_GROUPS_WIDTH |
| 4578 | } else { |
| 4579 | 0 |
| 4580 | }; |
| 4581 | let detail_width = if body.width >= CONFIG_SHELL_DETAIL_MIN_WIDTH { |
| 4582 | // A third of the body, floored so the list keeps a legible value |
| 4583 | // column at the 100-column blocker size. |
| 4584 | (body.width.saturating_mul(34) / 100).clamp(28, 44) |
| 4585 | } else { |
| 4586 | 0 |
| 4587 | }; |
| 4588 | let rail_gap = u16::from(rail_width > 0); |
| 4589 | let detail_gap = u16::from(detail_width > 0); |
| 4590 | let list_width = body |
| 4591 | .width |
| 4592 | .saturating_sub(rail_width + rail_gap + detail_width + detail_gap) |
| 4593 | .max(1); |
| 4594 | let groups = (rail_width > 0).then_some(Rect { |
| 4595 | width: rail_width, |
| 4596 | ..body |
| 4597 | }); |
| 4598 | let list = Rect { |
| 4599 | x: body.x.saturating_add(rail_width + rail_gap), |
| 4600 | width: list_width, |
| 4601 | ..body |
| 4602 | }; |
| 4603 | let detail = (detail_width > 0).then_some(Rect { |
| 4604 | x: list.right().saturating_add(detail_gap), |
| 4605 | width: detail_width, |
| 4606 | ..body |
| 4607 | }); |
| 4608 | ConfigShellPanes { |
| 4609 | groups, |
| 4610 | list, |
| 4611 | detail, |
| 4612 | } |
| 4613 | } |
| 4614 | |
| 4615 | fn setting_authority_label( |
| 4616 | locale: Locale, |
| 4617 | authority: SettingAuthority, |
| 4618 | detail: Option<&str>, |
| 4619 | ) -> Cow<'static, str> { |
| 4620 | let name = detail.unwrap_or_default(); |
| 4621 | match authority { |
| 4622 | SettingAuthority::Environment => { |
| 4623 | Cow::Owned(tr(locale, MessageId::ConfigSourceEnvironment).replace("{name}", name)) |
| 4624 | } |
| 4625 | SettingAuthority::Terminal => { |
| 4626 | Cow::Owned(tr(locale, MessageId::ConfigSourceTerminal).replace("{name}", name)) |
| 4627 | } |
| 4628 | SettingAuthority::Session => tr(locale, MessageId::ConfigSourceSession), |
| 4629 | SettingAuthority::UserSettings => tr(locale, MessageId::ConfigSourceUserSettings), |
| 4630 | SettingAuthority::WorkspaceConfiguration => tr(locale, MessageId::ConfigSourceConfig), |
| 4631 | SettingAuthority::ManagedPolicy => tr(locale, MessageId::ConfigSourceManaged), |
| 4632 | } |
| 4633 | } |
| 4634 | |
| 4635 | fn setting_apply_label(locale: Locale, apply: SettingApplySemantics) -> Cow<'static, str> { |
| 4636 | tr( |
| 4637 | locale, |
| 4638 | match apply { |
| 4639 | SettingApplySemantics::EffectiveNow => MessageId::ConfigApplyEffectiveNow, |
| 4640 | SettingApplySemantics::Immediate => MessageId::ConfigApplyOnSave, |
| 4641 | SettingApplySemantics::NextSession => MessageId::ConfigApplyNextSession, |
| 4642 | SettingApplySemantics::RestartRequired => MessageId::ConfigApplyRestart, |
| 4643 | SettingApplySemantics::ReadOnly => MessageId::ConfigApplyReadOnly, |
| 4644 | SettingApplySemantics::ReloadRequired => MessageId::ConfigApplyReload, |
| 4645 | SettingApplySemantics::UiNowEngineRestart => MessageId::ConfigApplyUiNowEngineRestart, |
| 4646 | }, |
| 4647 | ) |
| 4648 | } |
| 4649 | |
| 4650 | fn setting_kind_label(locale: Locale, kind: SettingKind) -> Cow<'static, str> { |
| 4651 | tr( |
| 4652 | locale, |
| 4653 | match kind { |
| 4654 | SettingKind::Boolean => MessageId::ConfigKindToggle, |
| 4655 | SettingKind::Choice => MessageId::ConfigKindChoice, |
| 4656 | SettingKind::Integer => MessageId::ConfigKindNumber, |
| 4657 | SettingKind::Text => MessageId::ConfigKindText, |
| 4658 | SettingKind::Action => MessageId::ConfigKindAction, |
| 4659 | SettingKind::ReadOnly => MessageId::ConfigKindReadOnly, |
| 4660 | }, |
| 4661 | ) |
| 4662 | } |
| 4663 | |
| 4664 | /// Locale-neutral affordance glyph painted beside every list row so the |
| 4665 | /// interaction (toggle / choose / edit / open / none) is visible before the |
| 4666 | /// row is selected. |
| 4667 | fn setting_affordance(kind: SettingKind, on: Option<bool>) -> &'static str { |
| 4668 | match kind { |
| 4669 | SettingKind::Boolean => { |
| 4670 | if on == Some(true) { |
| 4671 | "[x]" |
| 4672 | } else { |
| 4673 | "[ ]" |
| 4674 | } |
| 4675 | } |
| 4676 | SettingKind::Choice => "‹ ›", |
| 4677 | SettingKind::Integer | SettingKind::Text => "✎", |
| 4678 | SettingKind::Action => "›", |
| 4679 | SettingKind::ReadOnly => "⊘", |
| 4680 | } |
| 4681 | } |
| 4682 | |
| 4683 | /// Styles a category navigator paints with; `ConfigView` and the Tideline |
| 4684 | /// stage scaffold each supply their own palette. |
| 4685 | #[derive(Debug, Clone, Copy)] |
| 4686 | pub(crate) struct CategoryNavStyle { |
| 4687 | pub selected: Style, |
| 4688 | pub normal: Style, |
| 4689 | pub marker: Style, |
| 4690 | pub ascii_safe: bool, |
| 4691 | } |
| 4692 | |
| 4693 | /// Window of chips `[start, end)` that fits `width` columns while always |
| 4694 | /// containing `selected`. Chips are separated by one column and two columns |
| 4695 | /// are reserved on each side that hides chips, for the overflow markers. |
| 4696 | fn category_strip_window(widths: &[usize], selected: usize, width: usize) -> (usize, usize) { |
| 4697 | let count = widths.len(); |
| 4698 | let mut start = 0; |
| 4699 | loop { |
| 4700 | let mut used = if start > 0 { 2 } else { 0 }; |
| 4701 | let mut end = start; |
| 4702 | while end < count { |
| 4703 | let separator = usize::from(end > start); |
| 4704 | let tail = if end + 1 < count { 2 } else { 0 }; |
| 4705 | if end > start && used + separator + widths[end] + tail > width { |
| 4706 | break; |
| 4707 | } |
| 4708 | used += separator + widths[end]; |
| 4709 | end += 1; |
| 4710 | } |
| 4711 | let end = end.max((start + 1).min(count)); |
| 4712 | if selected < end || start + 1 >= count { |
| 4713 | return (start, end); |
| 4714 | } |
| 4715 | start += 1; |
| 4716 | } |
| 4717 | } |
| 4718 | |
| 4719 | /// Painted cells of a category strip: the visible chips and the ‹ / › |
| 4720 | /// overflow markers, which are themselves pointer targets so every category |
| 4721 | /// is reachable by clicking alone at any width. |
| 4722 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 4723 | pub(crate) struct CategoryStripHitboxes { |
| 4724 | pub chips: Vec<(Rect, ConfigCategory)>, |
| 4725 | pub previous: Option<Rect>, |
| 4726 | pub next: Option<Rect>, |
| 4727 | } |
| 4728 | |
| 4729 | /// Paint the horizontally windowed category strip (the narrow-width |
| 4730 | /// navigator from the design's `.settings-nav` rule) and return the painted |
| 4731 | /// rect of every visible category and overflow marker. |
| 4732 | /// |
| 4733 | /// `hovered` tints the chip under the pointer (and `hovered_nav` the overflow |
| 4734 | /// marker) with the shared hover style so every strip target answers |
| 4735 | /// visibly; hover never moves `selected`. |
| 4736 | pub(crate) fn render_settings_category_strip( |
| 4737 | area: Rect, |
| 4738 | buf: &mut Buffer, |
| 4739 | selected: ConfigCategory, |
| 4740 | locale: Locale, |
| 4741 | style: CategoryNavStyle, |
| 4742 | hovered: Option<ConfigCategory>, |
| 4743 | hovered_nav: Option<NavStep>, |
| 4744 | ) -> CategoryStripHitboxes { |
| 4745 | use crate::tui::ui_text::{text_display_width, truncate_line_to_width}; |
| 4746 | |
| 4747 | let mut hitboxes = CategoryStripHitboxes::default(); |
| 4748 | if area.width < 4 || area.height == 0 { |
| 4749 | return hitboxes; |
| 4750 | } |
| 4751 | // At phone-width terminals, show one complete category and its position. |
| 4752 | // Both arrows retain the same wraparound navigation and measured targets. |
| 4753 | if area.width < 50 { |
| 4754 | let previous = Rect::new(area.x, area.y, 2, 1); |
| 4755 | let next = Rect::new(area.right().saturating_sub(2), area.y, 2, 1); |
| 4756 | let label_area = Rect::new(area.x + 2, area.y, area.width.saturating_sub(4), 1); |
| 4757 | let label = format!( |
| 4758 | "{} {}/{}", |
| 4759 | selected.label(locale), |
| 4760 | selected.position() + 1, |
| 4761 | ConfigCategory::ALL.len() |
| 4762 | ); |
| 4763 | for (rect, glyph, step) in [ |
| 4764 | ( |
| 4765 | previous, |
| 4766 | if style.ascii_safe { "< " } else { "‹ " }, |
| 4767 | NavStep::Previous, |
| 4768 | ), |
| 4769 | ( |
| 4770 | next, |
| 4771 | if style.ascii_safe { " >" } else { " ›" }, |
| 4772 | NavStep::Next, |
| 4773 | ), |
| 4774 | ] { |
| 4775 | let marker_style = if hovered_nav == Some(step) { |
| 4776 | style.marker.patch(menu_style::hovered_row_style()) |
| 4777 | } else { |
| 4778 | style.marker |
| 4779 | }; |
| 4780 | buf.set_stringn(rect.x, rect.y, glyph, 2, marker_style); |
| 4781 | } |
| 4782 | buf.set_stringn( |
| 4783 | label_area.x, |
| 4784 | label_area.y, |
| 4785 | truncate_line_to_width(&label, usize::from(label_area.width)), |
| 4786 | usize::from(label_area.width), |
| 4787 | style.selected, |
| 4788 | ); |
| 4789 | hitboxes.chips.push((label_area, selected)); |
| 4790 | hitboxes.previous = Some(previous); |
| 4791 | hitboxes.next = Some(next); |
| 4792 | return hitboxes; |
| 4793 | } |
| 4794 | let labels: Vec<String> = ConfigCategory::ALL |
| 4795 | .iter() |
| 4796 | .map(|category| category.label(locale).into_owned()) |
| 4797 | .collect(); |
| 4798 | let widths: Vec<usize> = labels |
| 4799 | .iter() |
| 4800 | .map(|label| text_display_width(label) + 2) |
| 4801 | .collect(); |
| 4802 | let (start, end) = category_strip_window(&widths, selected.position(), usize::from(area.width)); |
| 4803 | let (prev, next) = if style.ascii_safe { |
| 4804 | ("< ", " >") |
| 4805 | } else { |
| 4806 | ("‹ ", " ›") |
| 4807 | }; |
| 4808 | let y = area.y; |
| 4809 | let right = area.right(); |
| 4810 | let mut x = area.x; |
| 4811 | if start > 0 { |
| 4812 | let prev_style = if hovered_nav == Some(NavStep::Previous) { |
| 4813 | style |
| 4814 | .marker |
| 4815 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4816 | } else { |
| 4817 | style.marker |
| 4818 | }; |
| 4819 | buf.set_stringn(x, y, prev, 2, prev_style); |
| 4820 | hitboxes.previous = Some(Rect { |
| 4821 | x, |
| 4822 | y, |
| 4823 | width: 2, |
| 4824 | height: 1, |
| 4825 | }); |
| 4826 | x = x.saturating_add(2); |
| 4827 | } |
| 4828 | let tail_reserve: u16 = if end < labels.len() { 2 } else { 0 }; |
| 4829 | for (index, label) in labels.iter().enumerate().take(end).skip(start) { |
| 4830 | let limit = right.saturating_sub(tail_reserve).saturating_sub(x); |
| 4831 | if limit == 0 { |
| 4832 | break; |
| 4833 | } |
| 4834 | let chip = format!(" {label} "); |
| 4835 | let painted = truncate_line_to_width(&chip, usize::from(limit)); |
| 4836 | let painted_width = u16::try_from(text_display_width(&painted)).unwrap_or(limit); |
| 4837 | if painted_width == 0 { |
| 4838 | break; |
| 4839 | } |
| 4840 | let category = ConfigCategory::ALL[index]; |
| 4841 | let chip_style = if category == selected { |
| 4842 | style.selected |
| 4843 | } else if hovered == Some(category) { |
| 4844 | style |
| 4845 | .normal |
| 4846 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4847 | } else { |
| 4848 | style.normal |
| 4849 | }; |
| 4850 | buf.set_stringn(x, y, &painted, usize::from(limit), chip_style); |
| 4851 | hitboxes.chips.push(( |
| 4852 | Rect { |
| 4853 | x, |
| 4854 | y, |
| 4855 | width: painted_width, |
| 4856 | height: 1, |
| 4857 | }, |
| 4858 | category, |
| 4859 | )); |
| 4860 | x = x.saturating_add(painted_width).saturating_add(1); |
| 4861 | } |
| 4862 | if end < labels.len() { |
| 4863 | let marker_x = right.saturating_sub(2); |
| 4864 | let next_style = if hovered_nav == Some(NavStep::Next) { |
| 4865 | style |
| 4866 | .marker |
| 4867 | .patch(crate::tui::menu_style::hovered_row_style()) |
| 4868 | } else { |
| 4869 | style.marker |
| 4870 | }; |
| 4871 | buf.set_stringn(marker_x, y, next, 2, next_style); |
| 4872 | hitboxes.next = Some(Rect { |
| 4873 | x: marker_x, |
| 4874 | y, |
| 4875 | width: 2, |
| 4876 | height: 1, |
| 4877 | }); |
| 4878 | } |
| 4879 | hitboxes |
| 4880 | } |
| 4881 | |
| 4882 | impl ConfigView { |
| 4883 | /// Display label of the exact persisted value, without any effective |
| 4884 | /// suffix: the `saved` lane of the detail pane. |
| 4885 | fn saved_display_value(&self, row: &ConfigRow) -> String { |
| 4886 | // Preserve the exact saved currency alias (for example `rmb`). |
| 4887 | if row.key == "cost_currency" { |
| 4888 | return row.value.clone(); |
| 4889 | } |
| 4890 | if SettingsRegistry::new(self).meta(row).choices.is_some() { |
| 4891 | if config_default_placeholder_message(&row.key).is_some_and(|message_id| { |
| 4892 | row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id) |
| 4893 | }) { |
| 4894 | return self.tr(MessageId::ConfigValueProviderDefault).into_owned(); |
| 4895 | } |
| 4896 | let canonical = canonical_config_choice(&row.key, &row.value); |
| 4897 | return config_choice_label(self.locale, &row.key, &canonical); |
| 4898 | } |
| 4899 | row.value.clone() |
| 4900 | } |
| 4901 | |
| 4902 | /// Editor kind from the existing registry (its boolean/choice/integer |
| 4903 | /// tables plus the row's typed activation command). |
| 4904 | fn editor_kind(&self, row: &ConfigRow) -> SettingKind { |
| 4905 | SettingsRegistry::new(self).meta(row).kind |
| 4906 | } |
| 4907 | |
| 4908 | /// Project a setting row onto the shared Tideline fact. Action and |
| 4909 | /// diagnostic rows are not persisted facts and project to `None`. |
| 4910 | /// |
| 4911 | /// A lane is filled only from an explicit observation: the session |
| 4912 | /// snapshot, the live session value, the persisted value (saved and |
| 4913 | /// startup), or the `App` field carried on the row as `effective`. |
| 4914 | /// Nothing is inferred across lanes. |
| 4915 | fn setting_fact(&self, row: &ConfigRow) -> Option<SettingFact<String>> { |
| 4916 | if row.facts.kind != ConfigRowKind::Setting { |
| 4917 | return None; |
| 4918 | } |
| 4919 | let mut fact = match row.facts.snapshot { |
| 4920 | Some(SnapshotLane::Provider) => self.snapshot.provider.clone(), |
| 4921 | Some(SnapshotLane::Model) => self.snapshot.model.clone(), |
| 4922 | None => match row.scope { |
| 4923 | ConfigScope::Session => SettingFact::active_session(self.saved_display_value(row)), |
| 4924 | ConfigScope::Saved => { |
| 4925 | // An unreadable store yields no saved or startup value. |
| 4926 | let saved = row |
| 4927 | .facts |
| 4928 | .store_error |
| 4929 | .is_none() |
| 4930 | .then(|| self.saved_display_value(row)); |
| 4931 | let effective = row.facts.effective.as_deref().map(|value| { |
| 4932 | config_choice_label( |
| 4933 | self.locale, |
| 4934 | &row.key, |
| 4935 | &canonical_config_choice(&row.key, value), |
| 4936 | ) |
| 4937 | }); |
| 4938 | SettingFact { |
| 4939 | current: effective.clone(), |
| 4940 | effective, |
| 4941 | startup: saved.clone(), |
| 4942 | saved, |
| 4943 | authority: row.facts.authority, |
| 4944 | apply: row.facts.apply, |
| 4945 | } |
| 4946 | } |
| 4947 | }, |
| 4948 | }; |
| 4949 | fact.authority = row.facts.authority; |
| 4950 | fact.apply = row.facts.apply; |
| 4951 | Some(fact) |
| 4952 | } |
| 4953 | |
| 4954 | /// Verb for activating the selected row (`Enter opens…`, `Space toggles`). |
| 4955 | fn setting_action_label(&self, row: &ConfigRow) -> Cow<'static, str> { |
| 4956 | if let Some((_, verb)) = row.facts.command { |
| 4957 | return self.tr(verb); |
| 4958 | } |
| 4959 | self.tr(match self.editor_kind(row) { |
| 4960 | SettingKind::Boolean => MessageId::ConfigActionToggle, |
| 4961 | SettingKind::Choice => MessageId::ConfigActionChoose, |
| 4962 | SettingKind::Integer | SettingKind::Text => MessageId::ConfigActionEdit, |
| 4963 | SettingKind::Action | SettingKind::ReadOnly => MessageId::ConfigActionReadOnly, |
| 4964 | }) |
| 4965 | } |
| 4966 | |
| 4967 | /// What activating the selected row does, spelled out: second click or |
| 4968 | /// Enter is the activation model, so the row says so. |
| 4969 | fn activation_copy(&self, row: &ConfigRow) -> String { |
| 4970 | if row.editable { |
| 4971 | format!( |
| 4972 | "{} {}", |
| 4973 | self.tr(MessageId::ConfigActivateAgain), |
| 4974 | self.setting_action_label(row) |
| 4975 | ) |
| 4976 | } else { |
| 4977 | self.tr(MessageId::ConfigActionReadOnly).into_owned() |
| 4978 | } |
| 4979 | } |
| 4980 | |
| 4981 | fn lane_or_unobserved(&self, lane: Option<&String>) -> String { |
| 4982 | lane.cloned() |
| 4983 | .unwrap_or_else(|| self.tr(MessageId::ConfigLaneUnobserved).into_owned()) |
| 4984 | } |
| 4985 | |
| 4986 | /// A persisted lane: the value, or the store's load error, or unobserved. |
| 4987 | fn store_lane(&self, lane: Option<&String>, store_error: Option<&str>) -> String { |
| 4988 | match (lane, store_error) { |
| 4989 | (Some(value), _) => value.clone(), |
| 4990 | (None, Some(error)) => self |
| 4991 | .tr(MessageId::ConfigLaneUnavailable) |
| 4992 | .replace("{error}", error), |
| 4993 | (None, None) => self.tr(MessageId::ConfigLaneUnobserved).into_owned(), |
| 4994 | } |
| 4995 | } |
| 4996 | |
| 4997 | /// The detail pane: label and key, then the typed facts for the row's |
| 4998 | /// kind, then the description and the activation copy. |
| 4999 | fn setting_detail_lines(&self, row: &ConfigRow, width: usize) -> Vec<Line<'static>> { |
| 5000 | use crate::tui::ui_text::semantic_truncate; |
| 5001 | |
| 5002 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 5003 | let kind = self.editor_kind(row); |
| 5004 | let muted = Style::default().fg(palette::TEXT_MUTED); |
| 5005 | let primary = Style::default().fg(palette::TEXT_PRIMARY); |
| 5006 | let value_width = width.saturating_sub(10); |
| 5007 | let fact_line = |name: MessageId, value: &str| { |
| 5008 | Line::from(vec![ |
| 5009 | Span::styled(format!("{:<10}", self.tr(name)), muted), |
| 5010 | Span::styled(semantic_truncate(value, value_width), primary), |
| 5011 | ]) |
| 5012 | }; |
| 5013 | let mut lines = vec![ |
| 5014 | Line::from(Span::styled( |
| 5015 | semantic_truncate(&label, width), |
| 5016 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 5017 | )), |
| 5018 | Line::from(Span::styled( |
| 5019 | semantic_truncate(&row.key, width), |
| 5020 | Style::default().fg(palette::TEXT_DIM), |
| 5021 | )), |
| 5022 | Line::from(""), |
| 5023 | ]; |
| 5024 | let source = |
| 5025 | setting_authority_label(self.locale, row.facts.authority, row.facts.authority_detail); |
| 5026 | let kind_label = setting_kind_label(self.locale, kind); |
| 5027 | match row.facts.kind { |
| 5028 | ConfigRowKind::Setting => { |
| 5029 | let fact = self |
| 5030 | .setting_fact(row) |
| 5031 | .unwrap_or_else(|| SettingFact::active_session(self.saved_display_value(row))); |
| 5032 | let store_error = row.facts.store_error.as_deref(); |
| 5033 | lines.push(fact_line( |
| 5034 | MessageId::ConfigFactCurrent, |
| 5035 | &self.lane_or_unobserved(fact.effective.as_ref().or(fact.current.as_ref())), |
| 5036 | )); |
| 5037 | lines.push(fact_line( |
| 5038 | MessageId::ConfigFactSaved, |
| 5039 | &self.store_lane(fact.saved.as_ref(), store_error), |
| 5040 | )); |
| 5041 | lines.push(fact_line( |
| 5042 | MessageId::ConfigFactStartup, |
| 5043 | &self.store_lane(fact.startup.as_ref(), store_error), |
| 5044 | )); |
| 5045 | lines.push(fact_line(MessageId::ConfigFactSource, &source)); |
| 5046 | lines.push(fact_line( |
| 5047 | MessageId::ConfigFactScope, |
| 5048 | row.scope.label(self.locale).as_ref(), |
| 5049 | )); |
| 5050 | lines.push(fact_line( |
| 5051 | MessageId::ConfigFactApply, |
| 5052 | &setting_apply_label(self.locale, fact.apply), |
| 5053 | )); |
| 5054 | lines.push(fact_line(MessageId::ConfigFactKind, &kind_label)); |
| 5055 | // No existing source reports availability; say so rather |
| 5056 | // than implying the setting is known to be usable. |
| 5057 | lines.push(fact_line( |
| 5058 | MessageId::ConfigFactAvailable, |
| 5059 | &self.tr(MessageId::ConfigLaneUnobserved), |
| 5060 | )); |
| 5061 | } |
| 5062 | ConfigRowKind::Action => { |
| 5063 | lines.push(Line::from(Span::styled( |
| 5064 | self.tr(MessageId::ConfigRowActionNote).into_owned(), |
| 5065 | muted, |
| 5066 | ))); |
| 5067 | if let Some((command, _)) = row.facts.command { |
| 5068 | lines.push(fact_line(MessageId::ConfigFactOpens, command)); |
| 5069 | } |
| 5070 | lines.push(fact_line(MessageId::ConfigFactSource, &source)); |
| 5071 | lines.push(fact_line(MessageId::ConfigFactKind, &kind_label)); |
| 5072 | } |
| 5073 | ConfigRowKind::Diagnostic => { |
| 5074 | lines.push(Line::from(Span::styled( |
| 5075 | self.tr(MessageId::ConfigRowDiagnosticNote).into_owned(), |
| 5076 | muted, |
| 5077 | ))); |
| 5078 | lines.push(fact_line(MessageId::ConfigFactObserved, &row.value)); |
| 5079 | lines.push(fact_line(MessageId::ConfigFactSource, &source)); |
| 5080 | lines.push(fact_line(MessageId::ConfigFactKind, &kind_label)); |
| 5081 | } |
| 5082 | } |
| 5083 | lines.push(Line::from("")); |
| 5084 | let hint = config_hint_for_key(self.locale, &row.key); |
| 5085 | let description = if hint.is_empty() { |
| 5086 | self.tr(MessageId::ConfigDescriptionDefault) |
| 5087 | } else { |
| 5088 | hint |
| 5089 | }; |
| 5090 | lines.push(Line::from(Span::styled(description.into_owned(), muted))); |
| 5091 | lines.push(Line::from(Span::styled( |
| 5092 | self.activation_copy(row), |
| 5093 | Style::default().fg(palette::TEXT_HINT), |
| 5094 | ))); |
| 5095 | lines |
| 5096 | } |
| 5097 | |
| 5098 | /// One-row fold of the detail for surfaces too narrow for the pane: the |
| 5099 | /// activation copy first, then the lanes that still fit. |
| 5100 | fn setting_detail_summary(&self, row: &ConfigRow) -> String { |
| 5101 | let activation = self.activation_copy(row); |
| 5102 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 5103 | match row.facts.kind { |
| 5104 | ConfigRowKind::Setting => { |
| 5105 | let fact = self |
| 5106 | .setting_fact(row) |
| 5107 | .unwrap_or_else(|| SettingFact::active_session(self.saved_display_value(row))); |
| 5108 | format!( |
| 5109 | "{activation} · {label}: {} {} · {} {} · {}", |
| 5110 | self.tr(MessageId::ConfigFactCurrent), |
| 5111 | self.lane_or_unobserved(fact.effective.as_ref().or(fact.current.as_ref())), |
| 5112 | self.tr(MessageId::ConfigFactSaved), |
| 5113 | self.store_lane(fact.saved.as_ref(), row.facts.store_error.as_deref()), |
| 5114 | setting_apply_label(self.locale, fact.apply) |
| 5115 | ) |
| 5116 | } |
| 5117 | ConfigRowKind::Action => { |
| 5118 | format!("{activation} · {}", self.tr(MessageId::ConfigRowActionNote)) |
| 5119 | } |
| 5120 | ConfigRowKind::Diagnostic => { |
| 5121 | format!( |
| 5122 | "{label}: {} · {}", |
| 5123 | row.value, |
| 5124 | self.tr(MessageId::ConfigRowDiagnosticNote) |
| 5125 | ) |
| 5126 | } |
| 5127 | } |
| 5128 | } |
| 5129 | |
| 5130 | fn render_pane_divider(area: Rect, buf: &mut Buffer, x: u16) { |
| 5131 | if x < area.x || x >= area.right() { |
| 5132 | return; |
| 5133 | } |
| 5134 | for y in area.top()..area.bottom() { |
| 5135 | buf[(x, y)] |
| 5136 | .set_symbol("│") |
| 5137 | .set_style(Style::default().fg(palette::BORDER_COLOR)); |
| 5138 | } |
| 5139 | } |
| 5140 | |
| 5141 | fn render_setting_detail(&self, area: Rect, buf: &mut Buffer) { |
| 5142 | let Some(row) = self.selected_row_index().and_then(|idx| self.rows.get(idx)) else { |
| 5143 | Paragraph::new(Line::from(Span::styled( |
| 5144 | self.tr(MessageId::ConfigNoSettings).into_owned(), |
| 5145 | Style::default().fg(palette::TEXT_MUTED), |
| 5146 | ))) |
| 5147 | .wrap(Wrap { trim: false }) |
| 5148 | .render(area, buf); |
| 5149 | return; |
| 5150 | }; |
| 5151 | Paragraph::new(self.setting_detail_lines(row, usize::from(area.width))) |
| 5152 | .wrap(Wrap { trim: false }) |
| 5153 | .render(area, buf); |
| 5154 | } |
| 5155 | |
| 5156 | /// Paint the three-pane shell into the surface body. |
| 5157 | /// The active tab's groups, in schema order, with the group holding the |
| 5158 | /// selected row lit. It is the same projection the centre list paints as |
| 5159 | /// headings — at ≥100 columns it also gets a column of its own. |
| 5160 | fn render_group_column(&self, area: Rect, buf: &mut Buffer) { |
| 5161 | let selected_group = self |
| 5162 | .selected_row_index() |
| 5163 | .and_then(|idx| self.rows.get(idx)) |
| 5164 | .map(|row| row.section()); |
| 5165 | let mut lines: Vec<Line> = Vec::new(); |
| 5166 | for group in self.visible_groups() { |
| 5167 | let selected = Some(group) == selected_group; |
| 5168 | let style = if selected { |
| 5169 | Style::default() |
| 5170 | .fg(palette::WHALE_ACTION) |
| 5171 | .add_modifier(Modifier::BOLD) |
| 5172 | } else { |
| 5173 | Style::default().fg(palette::TEXT_MUTED) |
| 5174 | }; |
| 5175 | lines.push(Line::from(vec![ |
| 5176 | Span::styled( |
| 5177 | if selected { "❯ " } else { " " }, |
| 5178 | Style::default().fg(palette::WHALE_ACTION), |
| 5179 | ), |
| 5180 | Span::styled( |
| 5181 | fit_config_column( |
| 5182 | &group.label(self.locale), |
| 5183 | usize::from(area.width).saturating_sub(2), |
| 5184 | ), |
| 5185 | style, |
| 5186 | ), |
| 5187 | ])); |
| 5188 | } |
| 5189 | Paragraph::new(lines).render(area, buf); |
| 5190 | } |
| 5191 | |
| 5192 | /// The groups of the active tab that actually have rows, in schema order. |
| 5193 | fn visible_groups(&self) -> Vec<ConfigSection> { |
| 5194 | let mut groups: Vec<ConfigSection> = Vec::new(); |
| 5195 | for item in self.visible_items() { |
| 5196 | if let ConfigListItem::Section(section) = item |
| 5197 | && !groups.contains(§ion) |
| 5198 | { |
| 5199 | groups.push(section); |
| 5200 | } |
| 5201 | } |
| 5202 | groups |
| 5203 | } |
| 5204 | |
| 5205 | /// A live preview of the footer the current values paint, drawn by the |
| 5206 | /// real footer renderer rather than a mock of it: the theme is the one |
| 5207 | /// the theme row selects, the chips are the mode and permission rows, and |
| 5208 | /// the depth line is this session's own context reading. |
| 5209 | fn render_footer_preview(&self, area: Rect, buf: &mut Buffer) { |
| 5210 | let label = self.tr(MessageId::ConfigPreviewLabel); |
| 5211 | let label_width = u16::try_from(UnicodeWidthStr::width(label.as_ref())).unwrap_or(0); |
| 5212 | if area.width <= label_width { |
| 5213 | return; |
| 5214 | } |
| 5215 | Paragraph::new(Line::from(Span::styled( |
| 5216 | label.into_owned(), |
| 5217 | Style::default().fg(palette::TEXT_HINT), |
| 5218 | ))) |
| 5219 | .render(Rect { height: 1, ..area }, buf); |
| 5220 | |
| 5221 | let value_of = |key: &str| { |
| 5222 | self.rows |
| 5223 | .iter() |
| 5224 | .find(|row| row.key == key) |
| 5225 | .map(|row| canonical_config_choice(key, &row.value)) |
| 5226 | }; |
| 5227 | let theme = value_of("theme") |
| 5228 | .and_then(|name| { |
| 5229 | palette::SELECTABLE_THEMES |
| 5230 | .iter() |
| 5231 | .find(|id| id.name() == name) |
| 5232 | .copied() |
| 5233 | }) |
| 5234 | .map_or(palette::UI_THEME, palette::ThemeId::ui_theme); |
| 5235 | let context_percent = self.snapshot.context_budget.as_ref().map_or(0, |budget| { |
| 5236 | u8::try_from(budget.percent_basis_points / 100).unwrap_or(100) |
| 5237 | }); |
| 5238 | let mode = value_of("default_mode").unwrap_or_else(|| "agent".to_string()); |
| 5239 | let mode_ink = match mode.as_str() { |
| 5240 | "plan" => palette::ChromeInk::PolicyPlan, |
| 5241 | "operate" => palette::ChromeInk::PolicyOperate, |
| 5242 | _ => palette::ChromeInk::PolicyAct, |
| 5243 | }; |
| 5244 | let permission = value_of("approval_mode") |
| 5245 | .or_else(|| value_of("permission_posture")) |
| 5246 | .unwrap_or_else(|| "ask".to_string()); |
| 5247 | let permission_ink = match permission.as_str() { |
| 5248 | "auto-review" => palette::ChromeInk::PermissionAutoReview, |
| 5249 | "full-access" => palette::ChromeInk::PermissionFullAccess, |
| 5250 | _ => palette::ChromeInk::PermissionAsk, |
| 5251 | }; |
| 5252 | let footer = crate::tui::phase_strip::TidelineFooter::new( |
| 5253 | &theme, |
| 5254 | (permission.as_str(), permission_ink), |
| 5255 | ) |
| 5256 | .mode_chip(Some((mode.as_str(), mode_ink))) |
| 5257 | .context_percent(context_percent); |
| 5258 | crate::tui::phase_strip::render_tideline_footer( |
| 5259 | Rect { |
| 5260 | x: area.x.saturating_add(label_width), |
| 5261 | width: area.width - label_width, |
| 5262 | height: 1, |
| 5263 | ..area |
| 5264 | }, |
| 5265 | buf, |
| 5266 | &footer, |
| 5267 | ); |
| 5268 | } |
| 5269 | |
| 5270 | fn render_settings_shell(&self, inner: Rect, buf: &mut Buffer) { |
| 5271 | *self.last_choice_hitboxes.borrow_mut() = Vec::new(); |
| 5272 | *self.last_editor_controls.borrow_mut() = Vec::new(); |
| 5273 | *self.last_nav_controls.borrow_mut() = Vec::new(); |
| 5274 | let items = self.visible_items(); |
| 5275 | let match_count = self.matching_row_indices().len(); |
| 5276 | |
| 5277 | let compact = inner.width < 50; |
| 5278 | // Keys and symbols stay legible in one row; the search field above |
| 5279 | // already explains typing. Shed verbose copy before editable content. |
| 5280 | let ascii_safe = crate::tui::color_compat::ascii_safe_enabled(); |
| 5281 | let compact_hints = if ascii_safe { |
| 5282 | [ |
| 5283 | ActionHint::new("Tab", "<>"), |
| 5284 | ActionHint::new("Up/Dn", ""), |
| 5285 | ActionHint::new("Enter", ""), |
| 5286 | ActionHint::new("Esc", ""), |
| 5287 | ] |
| 5288 | } else { |
| 5289 | [ |
| 5290 | ActionHint::new("Tab", "⇆"), |
| 5291 | ActionHint::new("↑↓", ""), |
| 5292 | ActionHint::new("Enter", "↵"), |
| 5293 | ActionHint::new("Esc", "×"), |
| 5294 | ] |
| 5295 | }; |
| 5296 | // Reserve the action footer by its actual wrapped height so no list |
| 5297 | // row silently falls off the bottom on compact terminals. |
| 5298 | let footer_height = |id: MessageId| -> usize { |
| 5299 | wrapped_footer_lines(&self.tr(id), inner.width, Style::default()).len() |
| 5300 | }; |
| 5301 | let footer_lines = if compact { |
| 5302 | 1 |
| 5303 | } else if !self.filter.is_empty() { |
| 5304 | footer_height(MessageId::ConfigFooterFiltered) |
| 5305 | } else { |
| 5306 | footer_height(MessageId::ConfigFooterScrollable) |
| 5307 | .max(footer_height(MessageId::ConfigFooterDefault)) |
| 5308 | } |
| 5309 | .max(1); |
| 5310 | let content_height = usize::from(inner.height).saturating_sub(footer_lines); |
| 5311 | |
| 5312 | // Header: the tab row over the search line. The tabs are always the |
| 5313 | // top row — a settings panel that hides which tab you are on is the |
| 5314 | // thing this layout exists to fix. |
| 5315 | const HEADER_LINES: usize = 2; |
| 5316 | // ≥100 columns spends 18 cells on the groups column and a detail |
| 5317 | // pane; below that the tab row and the in-list group headings carry |
| 5318 | // the same structure at 80 columns. |
| 5319 | let show_detail = inner.width >= CONFIG_SHELL_DETAIL_MIN_WIDTH; |
| 5320 | // Bottom band: the selected row's sentence, then a preview of the |
| 5321 | // footer these settings paint. The preview sheds first on short |
| 5322 | // terminals; the sentence holds while two list lines remain. |
| 5323 | let preview_lines = usize::from(content_height >= HEADER_LINES + 10); |
| 5324 | let sentence_lines = if compact { |
| 5325 | usize::from(content_height >= HEADER_LINES + 3) |
| 5326 | } else if content_height.saturating_sub(HEADER_LINES + preview_lines) >= 3 { |
| 5327 | // Without a detail pane the band also carries the lanes that pane |
| 5328 | // would have shown, on a second line so neither is truncated away. |
| 5329 | if show_detail { |
| 5330 | 1 |
| 5331 | } else if content_height >= HEADER_LINES + 9 { |
| 5332 | 3 |
| 5333 | } else { |
| 5334 | 2 |
| 5335 | } |
| 5336 | } else { |
| 5337 | usize::from(self.status.is_some()) |
| 5338 | }; |
| 5339 | let bottom_lines = sentence_lines + preview_lines; |
| 5340 | let body_height = content_height |
| 5341 | .saturating_sub(HEADER_LINES + bottom_lines) |
| 5342 | .max(1); |
| 5343 | self.last_visible_rows.set(body_height); |
| 5344 | let use_rail = show_detail && body_height >= 3; |
| 5345 | |
| 5346 | let clamp_height = |y: u16, wanted: usize| -> u16 { |
| 5347 | u16::try_from(wanted) |
| 5348 | .unwrap_or(u16::MAX) |
| 5349 | .min(inner.bottom().saturating_sub(y)) |
| 5350 | }; |
| 5351 | let header = Rect { |
| 5352 | height: clamp_height(inner.y, HEADER_LINES), |
| 5353 | ..inner |
| 5354 | }; |
| 5355 | let body = Rect { |
| 5356 | y: header.bottom(), |
| 5357 | height: clamp_height(header.bottom(), body_height), |
| 5358 | ..inner |
| 5359 | }; |
| 5360 | let bottom = Rect { |
| 5361 | y: body.bottom(), |
| 5362 | height: clamp_height(body.bottom(), bottom_lines), |
| 5363 | ..inner |
| 5364 | }; |
| 5365 | let panes = config_shell_panes(body, use_rail); |
| 5366 | |
| 5367 | // Selection-anchored scroll: the row being manipulated always renders. |
| 5368 | let list_line_budget = usize::from(body.height).max(1); |
| 5369 | // A section caption costs itself plus a blank spacer, except at the |
| 5370 | // top of the window where no spacer is painted. |
| 5371 | let item_line_cost = |item: &ConfigListItem, first: bool| match item { |
| 5372 | ConfigListItem::Section(_) if first => 1usize, |
| 5373 | ConfigListItem::Section(_) => 2usize, |
| 5374 | ConfigListItem::Row(_) => 1usize, |
| 5375 | }; |
| 5376 | let visible_end = |start: usize| { |
| 5377 | let mut used = 0usize; |
| 5378 | let mut end = start; |
| 5379 | while end < items.len() { |
| 5380 | let cost = item_line_cost(&items[end], end == start); |
| 5381 | if end > start && used.saturating_add(cost) > list_line_budget { |
| 5382 | break; |
| 5383 | } |
| 5384 | used = used.saturating_add(cost); |
| 5385 | end += 1; |
| 5386 | } |
| 5387 | end |
| 5388 | }; |
| 5389 | let mut start = self.scroll.min(items.len().saturating_sub(1)); |
| 5390 | if let Some(selected_pos) = self.selected_display_position(&items) { |
| 5391 | start = start.min(selected_pos); |
| 5392 | while selected_pos >= visible_end(start) && start < selected_pos { |
| 5393 | start += 1; |
| 5394 | } |
| 5395 | } |
| 5396 | let end = visible_end(start); |
| 5397 | let scrollable = start > 0 || end < items.len(); |
| 5398 | self.last_render_scroll.set(start); |
| 5399 | |
| 5400 | // Header. |
| 5401 | let search_value = if self.filter.is_empty() { |
| 5402 | self.tr(MessageId::ConfigSearchPlaceholder).to_string() |
| 5403 | } else { |
| 5404 | self.filter.clone() |
| 5405 | }; |
| 5406 | let search_line = Line::from(vec![ |
| 5407 | Span::styled( |
| 5408 | self.tr(MessageId::ConfigSearchLabel), |
| 5409 | Style::default().fg(palette::TEXT_MUTED), |
| 5410 | ), |
| 5411 | Span::raw(search_value), |
| 5412 | Span::styled( |
| 5413 | format!(" ({match_count}/{})", self.rows.len()), |
| 5414 | Style::default().fg(palette::TEXT_MUTED), |
| 5415 | ), |
| 5416 | ]); |
| 5417 | *self.last_rail_hitboxes.borrow_mut() = Vec::new(); |
| 5418 | if header.height > 0 { |
| 5419 | let nav_row = Rect { |
| 5420 | height: 1, |
| 5421 | ..header |
| 5422 | }; |
| 5423 | { |
| 5424 | let strip_style = CategoryNavStyle { |
| 5425 | selected: menu_style::selected_row_style(), |
| 5426 | normal: Style::default().fg(palette::TEXT_MUTED), |
| 5427 | marker: Style::default().fg(palette::TEXT_HINT), |
| 5428 | ascii_safe, |
| 5429 | }; |
| 5430 | let strip = render_settings_category_strip( |
| 5431 | nav_row, |
| 5432 | buf, |
| 5433 | self.category, |
| 5434 | self.locale, |
| 5435 | strip_style, |
| 5436 | self.hovered_rail, |
| 5437 | self.hovered_nav, |
| 5438 | ); |
| 5439 | *self.last_rail_hitboxes.borrow_mut() = strip.chips; |
| 5440 | *self.last_nav_controls.borrow_mut() = strip |
| 5441 | .previous |
| 5442 | .map(|rect| (rect, NavStep::Previous)) |
| 5443 | .into_iter() |
| 5444 | .chain(strip.next.map(|rect| (rect, NavStep::Next))) |
| 5445 | .collect(); |
| 5446 | } |
| 5447 | } |
| 5448 | if header.height > 1 { |
| 5449 | Paragraph::new(search_line).render( |
| 5450 | Rect { |
| 5451 | y: header.y.saturating_add(1), |
| 5452 | height: 1, |
| 5453 | ..header |
| 5454 | }, |
| 5455 | buf, |
| 5456 | ); |
| 5457 | } |
| 5458 | |
| 5459 | // Groups column: the active tab's `ui.group` names, the one holding |
| 5460 | // the selected row lit. |
| 5461 | if let Some(column) = panes.groups { |
| 5462 | self.render_group_column(column, buf); |
| 5463 | Self::render_pane_divider(body, buf, column.right()); |
| 5464 | } |
| 5465 | |
| 5466 | // List. |
| 5467 | let list = |
| 5468 | render_panel_scroll_rail(panes.list, buf, items.len(), start, list_line_budget, true); |
| 5469 | let (key_column_width, value_column_width, scope_column_width) = |
| 5470 | self.table_column_widths(usize::from(list.width)); |
| 5471 | let mut lines: Vec<Line> = Vec::new(); |
| 5472 | let mut row_hitboxes = Vec::new(); |
| 5473 | for item in &items[start..end] { |
| 5474 | match item { |
| 5475 | ConfigListItem::Section(section) => { |
| 5476 | if !lines.is_empty() { |
| 5477 | lines.push(Line::from("")); |
| 5478 | } |
| 5479 | lines.push(Line::from(Span::styled( |
| 5480 | format!(" {}", section.label(self.locale)), |
| 5481 | Style::default() |
| 5482 | .fg(palette::TEXT_HINT) |
| 5483 | .bold() |
| 5484 | .add_modifier(Modifier::UNDERLINED), |
| 5485 | ))); |
| 5486 | } |
| 5487 | ConfigListItem::Row(idx) => { |
| 5488 | let Some(row) = self.rows.get(*idx) else { |
| 5489 | continue; |
| 5490 | }; |
| 5491 | let line_y = list.y.saturating_add(lines.len() as u16); |
| 5492 | if line_y >= list.bottom() { |
| 5493 | break; |
| 5494 | } |
| 5495 | row_hitboxes.push(( |
| 5496 | Rect { |
| 5497 | x: list.x, |
| 5498 | y: line_y, |
| 5499 | width: list.width, |
| 5500 | height: 1, |
| 5501 | }, |
| 5502 | *idx, |
| 5503 | )); |
| 5504 | let selected = *idx == self.selected; |
| 5505 | // Hover tints but never steals the keyboard selection. |
| 5506 | let hovered = !selected && self.hovered_row == Some(*idx); |
| 5507 | let style = if selected { |
| 5508 | menu_style::selected_row_style() |
| 5509 | } else if row.editable { |
| 5510 | Style::default().fg(palette::TEXT_PRIMARY) |
| 5511 | } else { |
| 5512 | // Read-only rows look distinct from editable ones. |
| 5513 | Style::default() |
| 5514 | .fg(palette::TEXT_MUTED) |
| 5515 | .add_modifier(Modifier::DIM) |
| 5516 | }; |
| 5517 | let label = config_label_for_key_for_locale(self.locale, &row.key); |
| 5518 | let (key_width, value_width) = if compact { |
| 5519 | let available = usize::from(list.width).saturating_sub( |
| 5520 | CONFIG_ROW_PREFIX_WIDTH |
| 5521 | + CONFIG_COLUMN_GAPS_WIDTH |
| 5522 | + CONFIG_AFFORDANCE_COLUMN_WIDTH, |
| 5523 | ); |
| 5524 | let key_width = UnicodeWidthStr::width(label.as_str()).min(available / 2); |
| 5525 | (key_width, available.saturating_sub(key_width)) |
| 5526 | } else { |
| 5527 | (key_column_width, value_column_width) |
| 5528 | }; |
| 5529 | let key = fit_config_column(&label, key_width); |
| 5530 | let value = fit_config_column(&self.row_display_value(row), value_width); |
| 5531 | let kind = self.editor_kind(row); |
| 5532 | let on = (kind == SettingKind::Boolean) |
| 5533 | .then(|| canonical_config_choice(&row.key, row.edit_value()) == "true"); |
| 5534 | let affordance = setting_affordance(kind, on); |
| 5535 | // Action and diagnostic rows are not persisted facts, so |
| 5536 | // they carry no scope badge. |
| 5537 | let badge = match row.facts.kind { |
| 5538 | ConfigRowKind::Setting if scope_column_width > 0 => { |
| 5539 | row.scope.label(self.locale) |
| 5540 | } |
| 5541 | _ => Cow::Borrowed(""), |
| 5542 | }; |
| 5543 | let rail = if selected { "❯" } else { " " }; |
| 5544 | let mut line = Line::from(vec![ |
| 5545 | Span::styled( |
| 5546 | rail, |
| 5547 | if selected { |
| 5548 | style |
| 5549 | } else { |
| 5550 | Style::default().fg(palette::TEXT_DIM) |
| 5551 | }, |
| 5552 | ), |
| 5553 | Span::styled(format!("{key} {value} "), style), |
| 5554 | Span::styled( |
| 5555 | format!("{affordance:<3} "), |
| 5556 | if selected { |
| 5557 | style |
| 5558 | } else if row.editable { |
| 5559 | Style::default().fg(palette::WHALE_ACTION) |
| 5560 | } else { |
| 5561 | Style::default() |
| 5562 | .fg(palette::TEXT_DIM) |
| 5563 | .add_modifier(Modifier::DIM) |
| 5564 | }, |
| 5565 | ), |
| 5566 | Span::styled( |
| 5567 | badge.into_owned(), |
| 5568 | if selected { |
| 5569 | style |
| 5570 | } else { |
| 5571 | Style::default() |
| 5572 | .fg(palette::TEXT_HINT) |
| 5573 | .add_modifier(Modifier::DIM) |
| 5574 | }, |
| 5575 | ), |
| 5576 | ]); |
| 5577 | if selected { |
| 5578 | line.style = menu_style::selected_row_bg_style(); |
| 5579 | } else if hovered { |
| 5580 | line.style = menu_style::hovered_row_style(); |
| 5581 | } |
| 5582 | lines.push(line); |
| 5583 | } |
| 5584 | } |
| 5585 | } |
| 5586 | *self.last_row_hitboxes.borrow_mut() = row_hitboxes; |
| 5587 | if items.is_empty() { |
| 5588 | let message = if self.filter.is_empty() { |
| 5589 | self.tr(MessageId::ConfigNoSettings).to_string() |
| 5590 | } else { |
| 5591 | format!( |
| 5592 | "{}\"{}\".", |
| 5593 | self.tr(MessageId::ConfigNoMatchesPrefix), |
| 5594 | self.filter |
| 5595 | ) |
| 5596 | }; |
| 5597 | lines.push(Line::from(Span::styled( |
| 5598 | message, |
| 5599 | Style::default().fg(palette::TEXT_MUTED), |
| 5600 | ))); |
| 5601 | } |
| 5602 | Paragraph::new(lines) |
| 5603 | .style(Style::default().fg(palette::TEXT_PRIMARY)) |
| 5604 | .render(list, buf); |
| 5605 | |
| 5606 | // Detail. |
| 5607 | if let Some(detail) = panes.detail { |
| 5608 | Self::render_pane_divider(body, buf, detail.x.saturating_sub(1)); |
| 5609 | self.render_setting_detail(detail, buf); |
| 5610 | } |
| 5611 | |
| 5612 | // Status row: an explicit status wins; otherwise the selected row's |
| 5613 | // activation copy, with the detail facts folded in when no pane |
| 5614 | // shows them. |
| 5615 | let sentence_height = u16::try_from(sentence_lines) |
| 5616 | .unwrap_or(0) |
| 5617 | .min(bottom.height); |
| 5618 | if sentence_height > 0 { |
| 5619 | let selected_row = self.selected_row_index().and_then(|idx| self.rows.get(idx)); |
| 5620 | // The band says what the selected setting *is*: the schema's |
| 5621 | // sentence, in one plain line. A live status or an active filter |
| 5622 | // is more urgent and takes the row while it lasts. |
| 5623 | let bottom_text = if let Some(status) = self.status.as_ref() { |
| 5624 | status.clone() |
| 5625 | } else if let Some(row) = selected_row.filter(|_| compact) { |
| 5626 | format!( |
| 5627 | "{}: {}", |
| 5628 | config_label_for_key_for_locale(self.locale, &row.key), |
| 5629 | self.row_display_value(row) |
| 5630 | ) |
| 5631 | } else if !self.filter.is_empty() { |
| 5632 | format!( |
| 5633 | "{}: {match_count}", |
| 5634 | self.tr(MessageId::ConfigFilteredSettings) |
| 5635 | ) |
| 5636 | } else if let Some(row) = selected_row { |
| 5637 | let sentence = config_hint_for_key(self.locale, &row.key); |
| 5638 | if sentence.is_empty() { |
| 5639 | self.activation_copy(row) |
| 5640 | } else { |
| 5641 | sentence.into_owned() |
| 5642 | } |
| 5643 | } else { |
| 5644 | String::new() |
| 5645 | }; |
| 5646 | let band = vec![Line::from(Span::styled( |
| 5647 | crate::tui::ui_text::semantic_truncate(&bottom_text, usize::from(inner.width)), |
| 5648 | Style::default().fg(palette::TEXT_MUTED), |
| 5649 | ))]; |
| 5650 | // With a detail pane the lanes are already on screen; without one |
| 5651 | // the band's second line carries what that pane would have shown. |
| 5652 | Paragraph::new(band).render( |
| 5653 | Rect { |
| 5654 | height: 1, |
| 5655 | ..bottom |
| 5656 | }, |
| 5657 | buf, |
| 5658 | ); |
| 5659 | if sentence_height > 1 |
| 5660 | && self.status.is_none() |
| 5661 | && self.filter.is_empty() |
| 5662 | && let Some(row) = selected_row |
| 5663 | { |
| 5664 | let folded = Rect { |
| 5665 | y: bottom.y.saturating_add(1), |
| 5666 | height: sentence_height - 1, |
| 5667 | ..bottom |
| 5668 | }; |
| 5669 | Paragraph::new(Line::from(Span::styled( |
| 5670 | crate::tui::ui_text::semantic_truncate( |
| 5671 | &self.setting_detail_summary(row), |
| 5672 | usize::from(inner.width).saturating_mul(usize::from(folded.height)), |
| 5673 | ), |
| 5674 | Style::default().fg(palette::TEXT_HINT), |
| 5675 | ))) |
| 5676 | .wrap(Wrap { trim: true }) |
| 5677 | .render(folded, buf); |
| 5678 | } |
| 5679 | } |
| 5680 | if bottom.height > sentence_height { |
| 5681 | self.render_footer_preview( |
| 5682 | Rect { |
| 5683 | y: bottom.y.saturating_add(sentence_height), |
| 5684 | height: bottom.height - sentence_height, |
| 5685 | ..bottom |
| 5686 | }, |
| 5687 | buf, |
| 5688 | ); |
| 5689 | } |
| 5690 | |
| 5691 | let footer = if !self.filter.is_empty() { |
| 5692 | self.tr(MessageId::ConfigFooterFiltered) |
| 5693 | } else if scrollable { |
| 5694 | self.tr(MessageId::ConfigFooterScrollable) |
| 5695 | } else { |
| 5696 | self.tr(MessageId::ConfigFooterDefault) |
| 5697 | }; |
| 5698 | if compact { |
| 5699 | render_modal_footer(inner, buf, &compact_hints); |
| 5700 | } else { |
| 5701 | render_modal_text_footer( |
| 5702 | inner, |
| 5703 | buf, |
| 5704 | &footer, |
| 5705 | Style::default().fg(palette::TEXT_MUTED), |
| 5706 | ); |
| 5707 | } |
| 5708 | } |
| 5709 | } |
| 5710 | |
| 5711 | pub mod help; |
| 5712 | |
| 5713 | pub use help::HelpView; |
| 5714 | |
| 5715 | pub struct SubAgentsView { |
| 5716 | agents: Vec<SubAgentResult>, |
| 5717 | scroll: usize, |
| 5718 | /// Index into the render-ordered agent list (`ordered` on `grouped`). |
| 5719 | /// Enter/click open the selected agent's transcript — the same primary |
| 5720 | /// destination every other agent surface resolves to (v0.9.7). |
| 5721 | selected: usize, |
| 5722 | /// Rendered agent blocks from the last frame: `(first_line, line_count, |
| 5723 | /// agent_id)` in render order. Interior-mutable because `render` takes |
| 5724 | /// `&self`; consumed by click resolution and selection scroll-follow. |
| 5725 | row_lines: std::cell::RefCell<Vec<(usize, usize, String)>>, |
| 5726 | /// Body area of the last render, for mapping click rows onto lines. |
| 5727 | body_area: std::cell::Cell<Rect>, |
| 5728 | /// Effective (clamped) scroll of the last render. |
| 5729 | last_render_scroll: std::cell::Cell<usize>, |
| 5730 | /// Visible body height of the last render. |
| 5731 | last_visible_lines: std::cell::Cell<usize>, |
| 5732 | /// Motion policy at open: the Whale Teams working wake animates only |
| 5733 | /// under `MotionMode::Full` (Reduced/Still hold the poster frame). |
| 5734 | motion: crate::tui::motion::mode::MotionMode, |
| 5735 | /// UI locale for the whale state words. |
| 5736 | locale: Locale, |
| 5737 | /// Wall clock anchor for the working-wake frame. |
| 5738 | opened_at: std::time::Instant, |
| 5739 | /// True when the Fleet roster is parked directly underneath on the view |
| 5740 | /// stack (#5954), i.e. this view was reached with `Tab`/`w` from the |
| 5741 | /// roster. `Esc` still pops exactly one view — the flag only decides |
| 5742 | /// whether the footer promises `back` or `close`, and lets `F` return to |
| 5743 | /// the parked roster instead of stacking a second one. Direct entry |
| 5744 | /// (`/fleet workers`, the Work dock) leaves it false, so `Esc` closes. |
| 5745 | back_to_fleet_roster: bool, |
| 5746 | /// Agent whose Stop is armed (addendum F4). Stopping an agent that can |
| 5747 | /// change files takes two presses of `X` (or `X` then `Enter`); `Esc` |
| 5748 | /// or moving the selection disarms it. |
| 5749 | armed_stop: Option<String>, |
| 5750 | } |
| 5751 | |
| 5752 | /// Whether stopping this agent can strand file work, so `X` asks twice: it |
| 5753 | /// is still running and may write files (write permission or a full shell). |
| 5754 | /// Rows without a permission snapshot (live progress rows) stop on one press. |
| 5755 | fn subagent_stop_needs_confirm(agent: &SubAgentResult) -> bool { |
| 5756 | agent.status == SubAgentStatus::Running |
| 5757 | && agent |
| 5758 | .runtime_permissions |
| 5759 | .as_ref() |
| 5760 | .is_some_and(|permissions| permissions.write || permissions.shell == "full") |
| 5761 | } |
| 5762 | |
| 5763 | /// Build the agent rows shown by `/subagents`. |
| 5764 | /// |
| 5765 | /// The engine manager is the durable source of truth, but live UI cards can |
| 5766 | /// briefly be ahead of the manager-list refresh. Include those live rows so |
| 5767 | /// the command does not say "no agents" while the footer/sidebar already show |
| 5768 | /// active delegated work. |
| 5769 | pub(crate) fn subagent_view_agents( |
| 5770 | app: &App, |
| 5771 | manager_agents: &[SubAgentResult], |
| 5772 | ) -> Vec<SubAgentResult> { |
| 5773 | let mut agents = manager_agents.to_vec(); |
| 5774 | let manager_agent_count = agents.len(); |
| 5775 | let mut seen: std::collections::HashSet<String> = |
| 5776 | agents.iter().map(|agent| agent.agent_id.clone()).collect(); |
| 5777 | |
| 5778 | for (agent_id, progress) in &app.agent_progress { |
| 5779 | if seen.insert(agent_id.clone()) { |
| 5780 | agents.push(live_subagent_result( |
| 5781 | agent_id, |
| 5782 | FleetRole::Worker, |
| 5783 | SubAgentStatus::Running, |
| 5784 | progress, |
| 5785 | Some("live"), |
| 5786 | None, // live rows compute nickname from agent manager on render |
| 5787 | )); |
| 5788 | } |
| 5789 | } |
| 5790 | |
| 5791 | for cell in &app.history { |
| 5792 | match cell { |
| 5793 | HistoryCell::SubAgent(SubAgentCell::Delegate(card)) |
| 5794 | if seen.insert(card.agent_id.clone()) => |
| 5795 | { |
| 5796 | let agent_type = FleetRole::from_str(&card.agent_type).unwrap_or(FleetRole::Worker); |
| 5797 | let mut row = live_subagent_result( |
| 5798 | &card.agent_id, |
| 5799 | agent_type, |
| 5800 | lifecycle_to_subagent_status(card.status, card.summary.as_deref()), |
| 5801 | card.summary.as_deref().unwrap_or(card.agent_type.as_str()), |
| 5802 | Some("transcript"), |
| 5803 | None, // transcript-derived rows get nickname from manager on render |
| 5804 | ); |
| 5805 | row.worker_status = lifecycle_worker_status(card.status); |
| 5806 | agents.push(row); |
| 5807 | } |
| 5808 | HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => { |
| 5809 | for worker in &card.workers { |
| 5810 | if seen.insert(worker.agent_id.clone()) { |
| 5811 | let objective = format!( |
| 5812 | "{} worker {}", |
| 5813 | summarize_tool_output(&card.kind), |
| 5814 | summarize_tool_output(&worker.worker_id) |
| 5815 | ); |
| 5816 | let mut row = live_subagent_result( |
| 5817 | &worker.agent_id, |
| 5818 | FleetRole::Worker, |
| 5819 | lifecycle_to_subagent_status(worker.status, None), |
| 5820 | &objective, |
| 5821 | Some(card.kind.as_str()), |
| 5822 | None, // fanout worker rows get nickname from manager on render |
| 5823 | ); |
| 5824 | row.worker_status = lifecycle_worker_status(worker.status); |
| 5825 | agents.push(row); |
| 5826 | } |
| 5827 | } |
| 5828 | } |
| 5829 | _ => {} |
| 5830 | } |
| 5831 | } |
| 5832 | |
| 5833 | let mut display_names = localized_whale_display_names( |
| 5834 | agents[..manager_agent_count] |
| 5835 | .iter() |
| 5836 | .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), |
| 5837 | app.ui_locale.tag(), |
| 5838 | ); |
| 5839 | for agent in &mut agents[..manager_agent_count] { |
| 5840 | // The row headline reads `nickname`, so the dispatch name lands there |
| 5841 | // when the agent has one; the generated whale names the rest (#5287). |
| 5842 | // The view is handed manager rows that may not be in |
| 5843 | // `subagent_cache` yet, so the row's own dispatch name backs up the |
| 5844 | // app-wide lookup. |
| 5845 | let own_name = agent.name.trim(); |
| 5846 | let display_name = app |
| 5847 | .agent_given_name(&agent.agent_id) |
| 5848 | .or_else(|| { |
| 5849 | (!own_name.is_empty() && own_name != agent.agent_id).then(|| own_name.to_string()) |
| 5850 | }) |
| 5851 | .or_else(|| display_names.remove(&agent.agent_id)); |
| 5852 | agent.nickname = display_name; |
| 5853 | } |
| 5854 | for agent in &mut agents[manager_agent_count..] { |
| 5855 | // Progress and transcript rows can arrive before ListSubAgents. Keep |
| 5856 | // their stable Agent-N placeholder until the manager snapshot supplies |
| 5857 | // the locale-neutral identity needed for generated whale display. |
| 5858 | agent.nickname = app.agent_label_map.get(&agent.agent_id).cloned(); |
| 5859 | } |
| 5860 | |
| 5861 | agents |
| 5862 | } |
| 5863 | |
| 5864 | /// Project a transcript card's lifecycle onto the manager status vocabulary. |
| 5865 | /// A failure or interruption keeps the reason the card recorded from its |
| 5866 | /// terminal envelope; only a card that recorded none gets the generic line. |
| 5867 | /// |
| 5868 | /// `SubAgentStatus` has no pending state; callers carry a pending worker as |
| 5869 | /// `worker_status: Queued` (see [`lifecycle_worker_status`]) so it is not |
| 5870 | /// shown as running. |
| 5871 | fn lifecycle_to_subagent_status(status: AgentLifecycle, reason: Option<&str>) -> SubAgentStatus { |
| 5872 | let reason = |fallback: &str| { |
| 5873 | reason |
| 5874 | .map(str::trim) |
| 5875 | .filter(|reason| !reason.is_empty()) |
| 5876 | .unwrap_or(fallback) |
| 5877 | .to_string() |
| 5878 | }; |
| 5879 | match status { |
| 5880 | AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running, |
| 5881 | AgentLifecycle::Completed => SubAgentStatus::Completed, |
| 5882 | AgentLifecycle::Failed => SubAgentStatus::Failed(reason("failed in transcript")), |
| 5883 | AgentLifecycle::Cancelled => SubAgentStatus::Cancelled, |
| 5884 | AgentLifecycle::Interrupted => { |
| 5885 | SubAgentStatus::Interrupted(reason("interrupted in transcript")) |
| 5886 | } |
| 5887 | } |
| 5888 | } |
| 5889 | |
| 5890 | /// The worker-status detail a transcript lifecycle adds to its projected |
| 5891 | /// manager status: a spawned but not yet started worker is `Queued`, which the |
| 5892 | /// row label and whale state show instead of the coarse `Running`. |
| 5893 | fn lifecycle_worker_status( |
| 5894 | status: AgentLifecycle, |
| 5895 | ) -> Option<crate::tools::subagent::AgentWorkerStatus> { |
| 5896 | (status == AgentLifecycle::Pending).then_some(crate::tools::subagent::AgentWorkerStatus::Queued) |
| 5897 | } |
| 5898 | |
| 5899 | fn live_subagent_result( |
| 5900 | agent_id: &str, |
| 5901 | agent_type: FleetRole, |
| 5902 | status: SubAgentStatus, |
| 5903 | objective: &str, |
| 5904 | role: Option<&str>, |
| 5905 | nickname: Option<String>, |
| 5906 | ) -> SubAgentResult { |
| 5907 | SubAgentResult { |
| 5908 | usage: None, |
| 5909 | name: agent_id.to_string(), |
| 5910 | agent_id: agent_id.to_string(), |
| 5911 | context_mode: "fresh".to_string(), |
| 5912 | fork_context: false, |
| 5913 | workspace: None, |
| 5914 | git_branch: None, |
| 5915 | agent_type, |
| 5916 | assignment: SubAgentAssignment { |
| 5917 | native_preset: None, |
| 5918 | objective: summarize_tool_output(objective), |
| 5919 | role: role.map(str::to_string), |
| 5920 | }, |
| 5921 | model: String::new(), |
| 5922 | nickname, |
| 5923 | status, |
| 5924 | worker_status: None, |
| 5925 | runtime_permissions: None, |
| 5926 | parent_run_id: None, |
| 5927 | spawn_depth: 0, |
| 5928 | child_route: None, |
| 5929 | result: None, |
| 5930 | steps_taken: 0, |
| 5931 | checkpoint: None, |
| 5932 | needs_input: None, |
| 5933 | duration_ms: 0, |
| 5934 | started_at: None, |
| 5935 | from_prior_session: false, |
| 5936 | idle_ms: None, |
| 5937 | heartbeat_timeout_ms: None, |
| 5938 | } |
| 5939 | } |
| 5940 | |
| 5941 | impl SubAgentsView { |
| 5942 | pub fn new(agents: Vec<SubAgentResult>) -> Self { |
| 5943 | Self { |
| 5944 | agents, |
| 5945 | scroll: 0, |
| 5946 | selected: 0, |
| 5947 | row_lines: std::cell::RefCell::new(Vec::new()), |
| 5948 | body_area: std::cell::Cell::new(Rect::default()), |
| 5949 | last_render_scroll: std::cell::Cell::new(0), |
| 5950 | last_visible_lines: std::cell::Cell::new(0), |
| 5951 | motion: crate::tui::motion::mode::MotionMode::Still, |
| 5952 | locale: Locale::En, |
| 5953 | opened_at: std::time::Instant::now(), |
| 5954 | back_to_fleet_roster: false, |
| 5955 | armed_stop: None, |
| 5956 | } |
| 5957 | } |
| 5958 | |
| 5959 | /// Open with the app's motion policy and locale so the whale rows follow |
| 5960 | /// the user's reduced-motion setting and language. |
| 5961 | pub fn for_app(app: &App, agents: Vec<SubAgentResult>) -> Self { |
| 5962 | let mut view = Self::new(agents); |
| 5963 | view.motion = app.motion_policy().mode(); |
| 5964 | view.locale = app.ui_locale; |
| 5965 | view |
| 5966 | } |
| 5967 | |
| 5968 | fn selected_agent(&self) -> Option<&SubAgentResult> { |
| 5969 | let id = self.ordered_agent_ids().get(self.selected).cloned()?; |
| 5970 | self.agents.iter().find(|agent| agent.agent_id == id) |
| 5971 | } |
| 5972 | |
| 5973 | /// `X`: stop the selected agent. One that can change files arms first and |
| 5974 | /// stops on the second press, so a stray key cannot end a writer. |
| 5975 | fn press_stop(&mut self) -> ViewAction { |
| 5976 | let Some(agent) = self.selected_agent() else { |
| 5977 | return ViewAction::None; |
| 5978 | }; |
| 5979 | let agent_id = agent.agent_id.clone(); |
| 5980 | if subagent_stop_needs_confirm(agent) && self.armed_stop.as_deref() != Some(&agent_id) { |
| 5981 | self.armed_stop = Some(agent_id); |
| 5982 | return ViewAction::None; |
| 5983 | } |
| 5984 | self.armed_stop = None; |
| 5985 | ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }) |
| 5986 | } |
| 5987 | |
| 5988 | /// Mark this view as pushed on top of the Fleet roster (#5954), so the |
| 5989 | /// footer says `back` and `F` pops to the parked roster. |
| 5990 | #[must_use] |
| 5991 | pub fn over_fleet_roster(mut self) -> Self { |
| 5992 | self.back_to_fleet_roster = true; |
| 5993 | self |
| 5994 | } |
| 5995 | |
| 5996 | /// Footer label for `Esc`: `back` while the roster is parked underneath, |
| 5997 | /// `close` at the root. The hint has to name what the key actually does. |
| 5998 | fn esc_hint_label(&self) -> std::borrow::Cow<'static, str> { |
| 5999 | if self.back_to_fleet_roster { |
| 6000 | tr(self.locale, MessageId::SetupActionBack) |
| 6001 | } else { |
| 6002 | tr(self.locale, MessageId::SessionsActionClose) |
| 6003 | } |
| 6004 | } |
| 6005 | |
| 6006 | /// Working-wake frame for this render: 0 unless motion is Full. |
| 6007 | fn whale_frame(&self) -> usize { |
| 6008 | let now_ms = u64::try_from(self.opened_at.elapsed().as_millis()).unwrap_or(0); |
| 6009 | crate::tui::whales::working_frame(now_ms, self.motion) |
| 6010 | } |
| 6011 | |
| 6012 | /// The five status groups in render order, each sorted the way the view |
| 6013 | /// paints them. Selection, Enter, and click resolution all consume this |
| 6014 | /// so the highlighted row and the opened agent can never diverge. |
| 6015 | fn grouped(agents: &[SubAgentResult]) -> [Vec<&SubAgentResult>; 5] { |
| 6016 | let mut running = Vec::new(); |
| 6017 | let mut completed = Vec::new(); |
| 6018 | let mut interrupted = Vec::new(); |
| 6019 | let mut failed = Vec::new(); |
| 6020 | let mut cancelled = Vec::new(); |
| 6021 | |
| 6022 | for agent in agents { |
| 6023 | match agent.status { |
| 6024 | SubAgentStatus::Running => running.push(agent), |
| 6025 | SubAgentStatus::Completed => completed.push(agent), |
| 6026 | SubAgentStatus::Interrupted(_) => interrupted.push(agent), |
| 6027 | SubAgentStatus::Failed(_) => failed.push(agent), |
| 6028 | SubAgentStatus::Cancelled => cancelled.push(agent), |
| 6029 | SubAgentStatus::BudgetExhausted => failed.push(agent), |
| 6030 | } |
| 6031 | } |
| 6032 | for group in [ |
| 6033 | &mut running, |
| 6034 | &mut completed, |
| 6035 | &mut interrupted, |
| 6036 | &mut failed, |
| 6037 | &mut cancelled, |
| 6038 | ] { |
| 6039 | group.sort_by(|a, b| { |
| 6040 | agent_type_order(&a.agent_type) |
| 6041 | .cmp(&agent_type_order(&b.agent_type)) |
| 6042 | .then_with(|| a.agent_id.cmp(&b.agent_id)) |
| 6043 | }); |
| 6044 | } |
| 6045 | [running, completed, interrupted, failed, cancelled] |
| 6046 | } |
| 6047 | |
| 6048 | fn ordered_agent_ids(&self) -> Vec<String> { |
| 6049 | Self::grouped(&self.agents) |
| 6050 | .iter() |
| 6051 | .flatten() |
| 6052 | .map(|agent| agent.agent_id.clone()) |
| 6053 | .collect() |
| 6054 | } |
| 6055 | |
| 6056 | /// Keep the selected agent's block inside the visible body, using the |
| 6057 | /// last render's layout (stale by at most one frame). |
| 6058 | fn follow_selection(&mut self) { |
| 6059 | let row_lines = self.row_lines.borrow(); |
| 6060 | let Some((first, count, _)) = row_lines.get(self.selected) else { |
| 6061 | return; |
| 6062 | }; |
| 6063 | let visible = self.last_visible_lines.get().max(1); |
| 6064 | let end = first + count; |
| 6065 | if *first < self.scroll { |
| 6066 | self.scroll = *first; |
| 6067 | } else if end > self.scroll + visible { |
| 6068 | self.scroll = end.saturating_sub(visible); |
| 6069 | } |
| 6070 | } |
| 6071 | } |
| 6072 | |
| 6073 | impl ModalView for SubAgentsView { |
| 6074 | fn kind(&self) -> ModalKind { |
| 6075 | ModalKind::SubAgents |
| 6076 | } |
| 6077 | |
| 6078 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 6079 | self |
| 6080 | } |
| 6081 | |
| 6082 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 6083 | use crossterm::event::KeyCode; |
| 6084 | |
| 6085 | // An armed Stop (F4) owns Esc and Enter: Esc disarms without closing, |
| 6086 | // Enter confirms — the same two-step the Work inspector's Stop uses. |
| 6087 | if self.armed_stop.is_some() { |
| 6088 | match key.code { |
| 6089 | KeyCode::Esc => { |
| 6090 | self.armed_stop = None; |
| 6091 | return ViewAction::None; |
| 6092 | } |
| 6093 | KeyCode::Enter => return self.press_stop(), |
| 6094 | KeyCode::Char('x') | KeyCode::Char('X') => {} |
| 6095 | _ => self.armed_stop = None, |
| 6096 | } |
| 6097 | } |
| 6098 | |
| 6099 | match key.code { |
| 6100 | KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, |
| 6101 | // Enter opens the selected agent's transcript — the same primary |
| 6102 | // destination the Work strip and sidebar resolve to (v0.9.7). On |
| 6103 | // an empty register Enter keeps its old refresh meaning. |
| 6104 | KeyCode::Enter => match self.ordered_agent_ids().get(self.selected).cloned() { |
| 6105 | Some(agent_id) => ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }), |
| 6106 | None => ViewAction::Emit(ViewEvent::SubAgentsRefresh), |
| 6107 | }, |
| 6108 | KeyCode::Char('r') | KeyCode::Char('R') => { |
| 6109 | ViewAction::Emit(ViewEvent::SubAgentsRefresh) |
| 6110 | } |
| 6111 | // Manage: stop the selected agent. Terminal agents ignore the |
| 6112 | // key; the cancel receipt names what happened either way. A |
| 6113 | // running agent that can change files asks twice (F4). |
| 6114 | KeyCode::Char('x') | KeyCode::Char('X') => self.press_stop(), |
| 6115 | // The roster is the same destination either way: pop back to the |
| 6116 | // parked one when there is one (#5954) — re-running `/fleet` |
| 6117 | // would stack a duplicate roster and lose its cursor. |
| 6118 | KeyCode::Char('f') | KeyCode::Char('F') if self.back_to_fleet_roster => { |
| 6119 | ViewAction::Close |
| 6120 | } |
| 6121 | KeyCode::Char('f') | KeyCode::Char('F') => { |
| 6122 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 6123 | action: CommandPaletteAction::ExecuteCommand { |
| 6124 | command: "/fleet".to_string(), |
| 6125 | }, |
| 6126 | }) |
| 6127 | } |
| 6128 | KeyCode::Up | KeyCode::Char('k') => { |
| 6129 | self.selected = self.selected.saturating_sub(1); |
| 6130 | self.follow_selection(); |
| 6131 | ViewAction::None |
| 6132 | } |
| 6133 | KeyCode::Down | KeyCode::Char('j') => { |
| 6134 | self.selected = self |
| 6135 | .selected |
| 6136 | .saturating_add(1) |
| 6137 | .min(self.agents.len().saturating_sub(1)); |
| 6138 | self.follow_selection(); |
| 6139 | ViewAction::None |
| 6140 | } |
| 6141 | _ => ViewAction::None, |
| 6142 | } |
| 6143 | } |
| 6144 | |
| 6145 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 6146 | match mouse.kind { |
| 6147 | MouseEventKind::ScrollUp => { |
| 6148 | self.scroll = self.scroll.saturating_sub(3); |
| 6149 | ViewAction::None |
| 6150 | } |
| 6151 | MouseEventKind::ScrollDown => { |
| 6152 | // Clamped to the real maximum at render time. |
| 6153 | self.scroll = self.scroll.saturating_add(3); |
| 6154 | ViewAction::None |
| 6155 | } |
| 6156 | MouseEventKind::Down(MouseButton::Left) => { |
| 6157 | let area = self.body_area.get(); |
| 6158 | if mouse.column < area.x |
| 6159 | || mouse.column >= area.x.saturating_add(area.width) |
| 6160 | || mouse.row < area.y |
| 6161 | || mouse.row >= area.y.saturating_add(area.height) |
| 6162 | { |
| 6163 | return ViewAction::None; |
| 6164 | } |
| 6165 | let line = usize::from(mouse.row - area.y) + self.last_render_scroll.get(); |
| 6166 | let hit = self |
| 6167 | .row_lines |
| 6168 | .borrow() |
| 6169 | .iter() |
| 6170 | .enumerate() |
| 6171 | .find(|(_, (first, count, _))| line >= *first && line < first + count) |
| 6172 | .map(|(index, (_, _, agent_id))| (index, agent_id.clone())); |
| 6173 | match hit { |
| 6174 | Some((index, agent_id)) => { |
| 6175 | self.selected = index; |
| 6176 | // Click opens the same door Enter does. |
| 6177 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) |
| 6178 | } |
| 6179 | None => ViewAction::None, |
| 6180 | } |
| 6181 | } |
| 6182 | _ => ViewAction::None, |
| 6183 | } |
| 6184 | } |
| 6185 | |
| 6186 | fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool { |
| 6187 | let selected_id = self.ordered_agent_ids().get(self.selected).cloned(); |
| 6188 | self.agents = agents.to_vec(); |
| 6189 | let last = self.agents.len().saturating_sub(1); |
| 6190 | self.scroll = self.scroll.min(last); |
| 6191 | self.selected = selected_id |
| 6192 | .and_then(|id| { |
| 6193 | self.ordered_agent_ids() |
| 6194 | .iter() |
| 6195 | .position(|candidate| candidate == &id) |
| 6196 | }) |
| 6197 | .unwrap_or_else(|| self.selected.min(last)); |
| 6198 | // An armed Stop only survives while its agent is still selected and |
| 6199 | // still needs the confirm (it may have finished meanwhile). |
| 6200 | let still_armed = self.armed_stop.as_deref().is_some_and(|armed| { |
| 6201 | self.selected_agent() |
| 6202 | .is_some_and(|agent| agent.agent_id == armed && subagent_stop_needs_confirm(agent)) |
| 6203 | }); |
| 6204 | if !still_armed { |
| 6205 | self.armed_stop = None; |
| 6206 | } |
| 6207 | true |
| 6208 | } |
| 6209 | |
| 6210 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 6211 | Clear.render(area, buf); |
| 6212 | Block::default() |
| 6213 | .style(Style::default().bg(palette::WHALE_BG)) |
| 6214 | .render(area, buf); |
| 6215 | |
| 6216 | let mut lines: Vec<Line> = Vec::new(); |
| 6217 | let mut row_lines: Vec<(usize, usize, String)> = Vec::new(); |
| 6218 | let content_width = area.width.saturating_sub(4) as usize; |
| 6219 | |
| 6220 | if self.agents.is_empty() { |
| 6221 | lines.push(Line::from(Span::styled( |
| 6222 | tr( |
| 6223 | self.locale, |
| 6224 | MessageId::SubagentsNoCurrentSessionFleetWorkers, |
| 6225 | ), |
| 6226 | Style::default().fg(palette::TEXT_MUTED), |
| 6227 | ))); |
| 6228 | lines.push(Line::from(Span::styled( |
| 6229 | tr(self.locale, MessageId::SubagentsEmptyGuidance), |
| 6230 | Style::default().fg(palette::TEXT_DIM), |
| 6231 | ))); |
| 6232 | } else { |
| 6233 | let [running, completed, interrupted, failed, cancelled] = Self::grouped(&self.agents); |
| 6234 | let selected_id = self |
| 6235 | .ordered_agent_ids() |
| 6236 | .get(self.selected) |
| 6237 | .cloned() |
| 6238 | .unwrap_or_default(); |
| 6239 | |
| 6240 | let status_summary = [ |
| 6241 | ( |
| 6242 | MessageId::SubagentsStatusRunning, |
| 6243 | running.len(), |
| 6244 | palette::STATUS_WARNING, |
| 6245 | ), |
| 6246 | ( |
| 6247 | MessageId::SubagentsStatusCompleted, |
| 6248 | completed.len(), |
| 6249 | palette::STATUS_SUCCESS, |
| 6250 | ), |
| 6251 | ( |
| 6252 | MessageId::SubagentsStatusInterrupted, |
| 6253 | interrupted.len(), |
| 6254 | palette::STATUS_WARNING, |
| 6255 | ), |
| 6256 | ( |
| 6257 | MessageId::SubagentsStatusFailed, |
| 6258 | failed.len(), |
| 6259 | palette::WHALE_ERROR, |
| 6260 | ), |
| 6261 | ( |
| 6262 | MessageId::SubagentsStatusCancelled, |
| 6263 | cancelled.len(), |
| 6264 | palette::TEXT_MUTED, |
| 6265 | ), |
| 6266 | ]; |
| 6267 | |
| 6268 | lines.push(Line::from(Span::styled( |
| 6269 | tr( |
| 6270 | self.locale, |
| 6271 | MessageId::SubagentsCurrentSessionFleetWorkersTitle, |
| 6272 | ), |
| 6273 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 6274 | ))); |
| 6275 | lines.push(Line::from(Span::styled( |
| 6276 | tr( |
| 6277 | self.locale, |
| 6278 | MessageId::SubagentsCurrentSessionFleetWorkerRoles, |
| 6279 | ), |
| 6280 | Style::default().fg(palette::TEXT_DIM), |
| 6281 | ))); |
| 6282 | |
| 6283 | let mut summary_parts = Vec::new(); |
| 6284 | for (label_id, count, color) in status_summary { |
| 6285 | let label = tr(self.locale, label_id); |
| 6286 | let count = count.to_string(); |
| 6287 | summary_parts.push(Line::from(Span::styled( |
| 6288 | tr(self.locale, MessageId::SubagentsSummaryItem) |
| 6289 | .replace("{label}", label.as_ref()) |
| 6290 | .replace("{count}", &count), |
| 6291 | Style::default().fg(color), |
| 6292 | ))); |
| 6293 | } |
| 6294 | |
| 6295 | let mut summary = vec![Span::styled(" ", Style::default().fg(palette::TEXT_DIM))]; |
| 6296 | for (idx, part) in summary_parts.into_iter().enumerate() { |
| 6297 | if idx > 0 { |
| 6298 | summary.push(Span::raw(" · ")); |
| 6299 | } |
| 6300 | summary.extend(part); |
| 6301 | } |
| 6302 | lines.push(Line::from(summary)); |
| 6303 | lines.push(Line::from(Span::styled( |
| 6304 | "", |
| 6305 | Style::default().fg(palette::TEXT_DIM), |
| 6306 | ))); |
| 6307 | |
| 6308 | for (title_id, style, group) in [ |
| 6309 | ( |
| 6310 | MessageId::SubagentsStatusRunning, |
| 6311 | ratatui::style::Style::from(palette::STATUS_WARNING), |
| 6312 | &running, |
| 6313 | ), |
| 6314 | ( |
| 6315 | MessageId::SubagentsStatusCompleted, |
| 6316 | palette::STATUS_SUCCESS.into(), |
| 6317 | &completed, |
| 6318 | ), |
| 6319 | ( |
| 6320 | MessageId::SubagentsStatusInterrupted, |
| 6321 | palette::STATUS_WARNING.into(), |
| 6322 | &interrupted, |
| 6323 | ), |
| 6324 | ( |
| 6325 | MessageId::SubagentsStatusFailed, |
| 6326 | palette::WHALE_ERROR.into(), |
| 6327 | &failed, |
| 6328 | ), |
| 6329 | ( |
| 6330 | MessageId::SubagentsStatusCancelled, |
| 6331 | palette::TEXT_MUTED.into(), |
| 6332 | &cancelled, |
| 6333 | ), |
| 6334 | ] { |
| 6335 | let title = tr(self.locale, title_id); |
| 6336 | append_subagent_group( |
| 6337 | &mut lines, |
| 6338 | &mut row_lines, |
| 6339 | title.as_ref(), |
| 6340 | style, |
| 6341 | group, |
| 6342 | content_width, |
| 6343 | &selected_id, |
| 6344 | WhaleRowContext { |
| 6345 | locale: self.locale, |
| 6346 | frame: self.whale_frame(), |
| 6347 | }, |
| 6348 | ); |
| 6349 | } |
| 6350 | } |
| 6351 | |
| 6352 | let content = render_modal_footer( |
| 6353 | area, |
| 6354 | buf, |
| 6355 | &[ |
| 6356 | ActionHint::new("Esc", self.esc_hint_label()), |
| 6357 | ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)), |
| 6358 | ActionHint::new("Enter", tr(self.locale, MessageId::ExtensionsActionFocus)), |
| 6359 | if self.armed_stop.is_some() { |
| 6360 | ActionHint::new( |
| 6361 | "X/Enter", |
| 6362 | tr(self.locale, MessageId::WorkSurfaceStopConfirmHint), |
| 6363 | ) |
| 6364 | } else { |
| 6365 | ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)) |
| 6366 | }, |
| 6367 | ActionHint::new("R", tr(self.locale, MessageId::SubagentsActionRefresh)), |
| 6368 | ActionHint::new("F", tr(self.locale, MessageId::SubagentsActionRosterSetup)), |
| 6369 | ], |
| 6370 | ); |
| 6371 | let shell = ratatui::layout::Layout::default() |
| 6372 | .direction(ratatui::layout::Direction::Vertical) |
| 6373 | .constraints([ |
| 6374 | ratatui::layout::Constraint::Length(3), |
| 6375 | ratatui::layout::Constraint::Min(1), |
| 6376 | ]) |
| 6377 | .split(content); |
| 6378 | Paragraph::new(vec![ |
| 6379 | Line::from(vec![ |
| 6380 | Span::styled( |
| 6381 | format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)), |
| 6382 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 6383 | ), |
| 6384 | Span::styled( |
| 6385 | "──────────────────────── ", |
| 6386 | Style::default().fg(palette::BORDER_COLOR), |
| 6387 | ), |
| 6388 | Span::styled( |
| 6389 | format!( |
| 6390 | "{} {} ", |
| 6391 | tr(self.locale, MessageId::SubagentsHeaderRoster), |
| 6392 | tr(self.locale, MessageId::FleetRosterTabSetup), |
| 6393 | ), |
| 6394 | Style::default().fg(palette::TEXT_MUTED), |
| 6395 | ), |
| 6396 | Span::styled( |
| 6397 | tr(self.locale, MessageId::FleetRosterWorkers), |
| 6398 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 6399 | ), |
| 6400 | Span::styled( |
| 6401 | " ─────────────────", |
| 6402 | Style::default().fg(palette::BORDER_COLOR), |
| 6403 | ), |
| 6404 | ]), |
| 6405 | Line::from(""), |
| 6406 | Line::from(Span::styled( |
| 6407 | format!(" {}", tr(self.locale, MessageId::SubagentsHeaderColumns)), |
| 6408 | Style::default().fg(palette::TEXT_MUTED), |
| 6409 | )), |
| 6410 | ]) |
| 6411 | .render(shell[0], buf); |
| 6412 | |
| 6413 | let total_lines = lines.len(); |
| 6414 | let visible_lines = usize::from(shell[1].height).max(1); |
| 6415 | let max_scroll = total_lines.saturating_sub(visible_lines); |
| 6416 | let scroll = self.scroll.min(max_scroll); |
| 6417 | |
| 6418 | // Cache the layout for Enter/click resolution and scroll-follow. |
| 6419 | self.row_lines.replace(row_lines); |
| 6420 | self.body_area.set(shell[1]); |
| 6421 | self.last_render_scroll.set(scroll); |
| 6422 | self.last_visible_lines.set(visible_lines); |
| 6423 | |
| 6424 | Paragraph::new(lines) |
| 6425 | .scroll((scroll as u16, 0)) |
| 6426 | .render(shell[1], buf); |
| 6427 | } |
| 6428 | } |
| 6429 | |
| 6430 | /// Locale and working-wake frame for the whale badge on each worker row. |
| 6431 | #[derive(Debug, Clone, Copy)] |
| 6432 | struct WhaleRowContext { |
| 6433 | locale: Locale, |
| 6434 | frame: usize, |
| 6435 | } |
| 6436 | |
| 6437 | #[allow(clippy::too_many_arguments)] |
| 6438 | fn append_subagent_group( |
| 6439 | lines: &mut Vec<ratatui::text::Line<'static>>, |
| 6440 | row_lines: &mut Vec<(usize, usize, String)>, |
| 6441 | title: &str, |
| 6442 | section_style: ratatui::style::Style, |
| 6443 | agents: &[&SubAgentResult], |
| 6444 | content_width: usize, |
| 6445 | selected_id: &str, |
| 6446 | whale: WhaleRowContext, |
| 6447 | ) { |
| 6448 | use ratatui::{ |
| 6449 | style::Style, |
| 6450 | text::{Line, Span}, |
| 6451 | }; |
| 6452 | if agents.is_empty() { |
| 6453 | return; |
| 6454 | } |
| 6455 | |
| 6456 | lines.push(Line::from(Span::styled( |
| 6457 | tr(whale.locale, MessageId::SubagentsGroupHeading) |
| 6458 | .replace("{label}", title) |
| 6459 | .replace("{count}", &agents.len().to_string()), |
| 6460 | section_style.bold(), |
| 6461 | ))); |
| 6462 | |
| 6463 | for agent in agents { |
| 6464 | let block_start = lines.len(); |
| 6465 | let is_selected = agent.agent_id == selected_id; |
| 6466 | let id = truncate_view_text(&agent.agent_id, 11); |
| 6467 | let display_name = agent |
| 6468 | .nickname |
| 6469 | .as_deref() |
| 6470 | .map(|nick| format!("{nick:<12}")) |
| 6471 | .unwrap_or_else(|| format!("{id:<12}")); |
| 6472 | let kind = format_agent_type(whale.locale, &agent.agent_type); |
| 6473 | let (status, status_style, status_detail) = |
| 6474 | format_agent_status(whale.locale, &agent.status, agent.worker_status); |
| 6475 | |
| 6476 | let name_style = if is_selected { |
| 6477 | Style::default().fg(palette::WHALE_ACTION).bold() |
| 6478 | } else { |
| 6479 | Style::default().fg(palette::TEXT_PRIMARY) |
| 6480 | }; |
| 6481 | // Whale Teams: species badge from the worker's Fleet role (or its |
| 6482 | // advisory role hint), then the six-state word derived from the |
| 6483 | // child's real status — never from elapsed time. |
| 6484 | let species = agent |
| 6485 | .assignment |
| 6486 | .role |
| 6487 | .as_deref() |
| 6488 | .map(crate::tui::whales::WhaleSpecies::for_role_id) |
| 6489 | .filter(|species| *species != crate::tui::whales::WhaleSpecies::Plain) |
| 6490 | .unwrap_or_else(|| crate::tui::whales::WhaleSpecies::for_fleet_role(&agent.agent_type)); |
| 6491 | let whale_state = crate::tui::whales::WhaleState::for_subagent(agent); |
| 6492 | let mut row = vec![ |
| 6493 | // The selection cursor: Enter (or a click) opens this agent's |
| 6494 | // transcript, matching every other agent surface. |
| 6495 | Span::styled( |
| 6496 | if is_selected { "\u{25B8} " } else { " " }, |
| 6497 | Style::default().fg(palette::WHALE_ACTION), |
| 6498 | ), |
| 6499 | ]; |
| 6500 | row.extend(crate::tui::whales::badge(species, &palette::UI_THEME)); |
| 6501 | row.push(Span::raw(" ")); |
| 6502 | row.extend([ |
| 6503 | Span::styled(display_name, name_style), |
| 6504 | Span::raw(" "), |
| 6505 | Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)), |
| 6506 | Span::styled( |
| 6507 | format!("{kind:<9}"), |
| 6508 | Style::default().fg(palette::TEXT_MUTED), |
| 6509 | ), |
| 6510 | Span::raw(" "), |
| 6511 | Span::styled(format!("{status:<10}"), status_style), |
| 6512 | Span::raw(" "), |
| 6513 | Span::styled( |
| 6514 | format!("{:>4}✦", agent.steps_taken), |
| 6515 | Style::default().fg(palette::TEXT_DIM), |
| 6516 | ), |
| 6517 | Span::raw(" "), |
| 6518 | Span::styled( |
| 6519 | format!("{:>6}ms", agent.duration_ms), |
| 6520 | Style::default().fg(palette::TEXT_DIM), |
| 6521 | ), |
| 6522 | ]); |
| 6523 | lines.push(Line::from(row)); |
| 6524 | |
| 6525 | // The whale's own state word, paired with its glyph cue, so the row |
| 6526 | // says "Waiting for you" / "Blocked" in the user's language next to |
| 6527 | // the raw runtime status above. No caption text beyond that. |
| 6528 | let mut whale_line = vec![Span::raw(" ")]; |
| 6529 | whale_line.extend(crate::tui::whales::badge_with_state_frame( |
| 6530 | species, |
| 6531 | Some(whale_state), |
| 6532 | whale.frame, |
| 6533 | &palette::UI_THEME, |
| 6534 | whale.locale, |
| 6535 | )); |
| 6536 | lines.push(Line::from(whale_line)); |
| 6537 | |
| 6538 | if let Some(detail) = status_detail { |
| 6539 | let max_len = content_width.saturating_sub(10); |
| 6540 | let detail = truncate_view_text(detail, max_len); |
| 6541 | lines.push(Line::from(vec![ |
| 6542 | Span::styled( |
| 6543 | tr(whale.locale, MessageId::SubagentsLabelReason), |
| 6544 | Style::default().fg(palette::TEXT_MUTED), |
| 6545 | ), |
| 6546 | Span::styled(detail, Style::default().fg(palette::WHALE_ERROR)), |
| 6547 | ])); |
| 6548 | } |
| 6549 | |
| 6550 | if let Some(role) = agent.assignment.role.as_deref() { |
| 6551 | let max_len = content_width.saturating_sub(14); |
| 6552 | let role = truncate_view_text(role, max_len); |
| 6553 | lines.push(Line::from(vec![ |
| 6554 | Span::styled( |
| 6555 | tr(whale.locale, MessageId::SubagentsLabelRole), |
| 6556 | Style::default().fg(palette::TEXT_MUTED), |
| 6557 | ), |
| 6558 | Span::styled(role, Style::default().fg(palette::WHALE_ACTION)), |
| 6559 | ])); |
| 6560 | } |
| 6561 | |
| 6562 | if let Some(permissions) = agent.runtime_permissions.as_ref() { |
| 6563 | let network = tr( |
| 6564 | whale.locale, |
| 6565 | if permissions.network { |
| 6566 | MessageId::SubagentsValueOn |
| 6567 | } else { |
| 6568 | MessageId::SubagentsValueOff |
| 6569 | }, |
| 6570 | ); |
| 6571 | let shell = format_subagent_shell(whale.locale, &permissions.shell); |
| 6572 | let write = tr( |
| 6573 | whale.locale, |
| 6574 | if permissions.write { |
| 6575 | MessageId::SubagentsValueOn |
| 6576 | } else { |
| 6577 | MessageId::SubagentsValueOff |
| 6578 | }, |
| 6579 | ); |
| 6580 | let posture = tr(whale.locale, MessageId::SubagentsPostureDetails) |
| 6581 | .replace("{network}", network.as_ref()) |
| 6582 | .replace("{shell}", shell.as_ref()) |
| 6583 | .replace("{write}", write.as_ref()); |
| 6584 | let max_len = content_width.saturating_sub(18); |
| 6585 | let posture = truncate_view_text(&posture, max_len); |
| 6586 | lines.push(Line::from(vec![ |
| 6587 | Span::styled( |
| 6588 | tr(whale.locale, MessageId::SubagentsLabelPosture), |
| 6589 | Style::default().fg(palette::TEXT_MUTED), |
| 6590 | ), |
| 6591 | Span::styled(posture, Style::default().fg(palette::WHALE_ACTION)), |
| 6592 | ])); |
| 6593 | } |
| 6594 | |
| 6595 | if let Some(branch) = agent.git_branch.as_deref() { |
| 6596 | let workspace = agent |
| 6597 | .workspace |
| 6598 | .as_deref() |
| 6599 | .and_then(|path| path.file_name()) |
| 6600 | .and_then(|name| name.to_str()) |
| 6601 | .filter(|name| !name.is_empty()); |
| 6602 | let branch_detail = match workspace { |
| 6603 | Some(workspace) => tr(whale.locale, MessageId::SubagentsBranchWithWorkspace) |
| 6604 | .replace("{branch}", branch) |
| 6605 | .replace("{workspace}", workspace), |
| 6606 | None => tr(whale.locale, MessageId::SubagentsBranch).replace("{branch}", branch), |
| 6607 | }; |
| 6608 | let max_len = content_width.saturating_sub(14); |
| 6609 | let branch_detail = truncate_view_text(&branch_detail, max_len); |
| 6610 | lines.push(Line::from(vec![ |
| 6611 | Span::styled( |
| 6612 | tr(whale.locale, MessageId::SubagentsLabelGit), |
| 6613 | Style::default().fg(palette::TEXT_MUTED), |
| 6614 | ), |
| 6615 | Span::styled(branch_detail, Style::default().fg(palette::WHALE_ACTION)), |
| 6616 | ])); |
| 6617 | } |
| 6618 | |
| 6619 | let max_len = content_width.saturating_sub(18); |
| 6620 | let objective = truncate_view_text(&agent.assignment.objective, max_len); |
| 6621 | lines.push(Line::from(vec![ |
| 6622 | Span::styled( |
| 6623 | tr(whale.locale, MessageId::SubagentsLabelObjective), |
| 6624 | Style::default().fg(palette::TEXT_MUTED), |
| 6625 | ), |
| 6626 | Span::styled(objective, Style::default().fg(palette::TEXT_DIM)), |
| 6627 | ])); |
| 6628 | |
| 6629 | if let Some(result) = agent.result.as_ref() { |
| 6630 | let max_len = content_width.saturating_sub(16); |
| 6631 | let preview = truncate_view_text(result, max_len); |
| 6632 | lines.push(Line::from(vec![ |
| 6633 | Span::styled( |
| 6634 | tr(whale.locale, MessageId::SubagentsLabelResult), |
| 6635 | Style::default().fg(palette::TEXT_MUTED), |
| 6636 | ), |
| 6637 | Span::styled(preview, Style::default().fg(palette::TEXT_DIM)), |
| 6638 | ])); |
| 6639 | } |
| 6640 | |
| 6641 | row_lines.push(( |
| 6642 | block_start, |
| 6643 | lines.len() - block_start, |
| 6644 | agent.agent_id.clone(), |
| 6645 | )); |
| 6646 | } |
| 6647 | |
| 6648 | lines.push(Line::from("")); |
| 6649 | } |
| 6650 | |
| 6651 | fn agent_type_order(agent_type: &FleetRole) -> u8 { |
| 6652 | match agent_type { |
| 6653 | FleetRole::Worker => 0, |
| 6654 | FleetRole::Scout => 1, |
| 6655 | FleetRole::Planner => 2, |
| 6656 | FleetRole::Builder => 3, |
| 6657 | FleetRole::Verifier => 4, |
| 6658 | FleetRole::Reviewer => 5, |
| 6659 | FleetRole::Consultant => 6, |
| 6660 | FleetRole::Custom => 7, |
| 6661 | } |
| 6662 | } |
| 6663 | |
| 6664 | fn format_agent_type(locale: Locale, agent_type: &FleetRole) -> Cow<'static, str> { |
| 6665 | // `FleetRole::as_str()` is the durable runtime/schema identifier. The |
| 6666 | // register is a localized user surface, so map only the known roles here |
| 6667 | // and leave the internal identifier untouched everywhere else. |
| 6668 | let message_id = match agent_type { |
| 6669 | FleetRole::Worker => MessageId::SubagentsRoleWorker, |
| 6670 | FleetRole::Scout => MessageId::SubagentsRoleScout, |
| 6671 | FleetRole::Planner => MessageId::SubagentsRolePlanner, |
| 6672 | FleetRole::Builder => MessageId::SubagentsRoleBuilder, |
| 6673 | FleetRole::Verifier => MessageId::SubagentsRoleVerifier, |
| 6674 | FleetRole::Reviewer => MessageId::SubagentsRoleReviewer, |
| 6675 | FleetRole::Consultant => MessageId::SubagentsRoleConsultant, |
| 6676 | FleetRole::Custom => MessageId::SubagentsRoleCustom, |
| 6677 | }; |
| 6678 | tr(locale, message_id) |
| 6679 | } |
| 6680 | |
| 6681 | fn format_agent_status( |
| 6682 | locale: Locale, |
| 6683 | status: &SubAgentStatus, |
| 6684 | worker_status: Option<crate::tools::subagent::AgentWorkerStatus>, |
| 6685 | ) -> (Cow<'static, str>, ratatui::style::Style, Option<&str>) { |
| 6686 | use ratatui::style::Style; |
| 6687 | |
| 6688 | match status { |
| 6689 | // A worker that has not started yet waits; it is not running. |
| 6690 | SubAgentStatus::Running |
| 6691 | if worker_status == Some(crate::tools::subagent::AgentWorkerStatus::Queued) => |
| 6692 | { |
| 6693 | ( |
| 6694 | tr(locale, MessageId::AutomationRunStatusQueued), |
| 6695 | Style::default().fg(palette::STATUS_WARNING), |
| 6696 | None, |
| 6697 | ) |
| 6698 | } |
| 6699 | SubAgentStatus::Running => ( |
| 6700 | tr(locale, MessageId::AutomationRunStatusRunning), |
| 6701 | Style::default().fg(palette::WHALE_ACTION), |
| 6702 | None, |
| 6703 | ), |
| 6704 | SubAgentStatus::Completed => ( |
| 6705 | tr(locale, MessageId::AutomationRunStatusCompleted), |
| 6706 | Style::default().fg(palette::STATUS_SUCCESS), |
| 6707 | None, |
| 6708 | ), |
| 6709 | SubAgentStatus::Interrupted(reason) => ( |
| 6710 | tr(locale, MessageId::SubagentsRowStatusInterrupted), |
| 6711 | Style::default().fg(palette::STATUS_WARNING), |
| 6712 | Some(reason.as_str()), |
| 6713 | ), |
| 6714 | SubAgentStatus::Cancelled => ( |
| 6715 | tr(locale, MessageId::SubagentsRowStatusCancelled), |
| 6716 | Style::default().fg(palette::TEXT_MUTED), |
| 6717 | None, |
| 6718 | ), |
| 6719 | SubAgentStatus::BudgetExhausted => ( |
| 6720 | tr(locale, MessageId::SubagentsRowStatusBudgetExhausted), |
| 6721 | Style::default().fg(palette::STATUS_WARNING), |
| 6722 | None, |
| 6723 | ), |
| 6724 | SubAgentStatus::Failed(reason) => ( |
| 6725 | tr(locale, MessageId::AutomationRunStatusFailed), |
| 6726 | Style::default().fg(palette::WHALE_ERROR), |
| 6727 | Some(reason.as_str()), |
| 6728 | ), |
| 6729 | } |
| 6730 | } |
| 6731 | |
| 6732 | fn format_subagent_shell(locale: Locale, shell: &str) -> Cow<'static, str> { |
| 6733 | match shell { |
| 6734 | "none" => tr(locale, MessageId::SubagentsShellNone), |
| 6735 | "read_only" => tr(locale, MessageId::SubagentsShellReadOnly), |
| 6736 | "full" => tr(locale, MessageId::SubagentsShellFull), |
| 6737 | // A future runtime may add a posture before the TUI knows how to |
| 6738 | // localize it. Preserve that exact runtime value instead of guessing. |
| 6739 | _ => Cow::Owned(shell.to_string()), |
| 6740 | } |
| 6741 | } |
| 6742 | |
| 6743 | fn truncate_view_text(text: &str, max_chars: usize) -> String { |
| 6744 | if max_chars == 0 { |
| 6745 | return String::new(); |
| 6746 | } |
| 6747 | match text.char_indices().nth(max_chars) { |
| 6748 | Some((idx, _)) => text[..idx].to_string(), |
| 6749 | None => text.to_string(), |
| 6750 | } |
| 6751 | } |
| 6752 | |
| 6753 | fn fit_config_column(text: &str, width: usize) -> String { |
| 6754 | let mut fitted = crate::tui::ui_text::truncate_line_to_width(text, width); |
| 6755 | let padding = width.saturating_sub(crate::tui::ui_text::text_display_width(&fitted)); |
| 6756 | fitted.push_str(&" ".repeat(padding)); |
| 6757 | fitted |
| 6758 | } |
| 6759 | |
| 6760 | #[cfg(test)] |
| 6761 | mod tests { |
| 6762 | use super::{ |
| 6763 | ActionHint, ConfigCategory, ConfigListItem, ConfigRowKind, ConfigScope, ConfigView, |
| 6764 | EmptyState, FocusTextureMode, HelpView, ListDetailLayout, ModalKind, ModalView, |
| 6765 | SettingKind, SettingStore, SettingsRegistry, ViewAction, ViewEvent, ViewStack, |
| 6766 | action_footer_lines, canonical_config_choice, centered_modal_area, config_choice_detail, |
| 6767 | config_choice_label, config_choice_values, config_label_for_key, |
| 6768 | config_label_for_key_for_locale, render_modal_footer_with_gutter, |
| 6769 | render_underwater_surface, subagent_view_agents, truncate_view_text, |
| 6770 | }; |
| 6771 | use crate::config::Config; |
| 6772 | use crate::settings::Settings; |
| 6773 | use crate::tools::subagent::{FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus}; |
| 6774 | use crate::tui::app::{App, TuiOptions}; |
| 6775 | use crate::tui::history::{HistoryCell, SubAgentCell}; |
| 6776 | use crate::tui::views::{CommandPaletteAction, SubAgentsView}; |
| 6777 | use crate::tui::widgets::agent_card::{AgentLifecycle, FanoutCard}; |
| 6778 | use codewhale_localization::{Locale, MessageId, tr, tr_key}; |
| 6779 | use codewhale_palette as palette; |
| 6780 | use crossterm::event::{ |
| 6781 | KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, |
| 6782 | }; |
| 6783 | use ratatui::{ |
| 6784 | buffer::Buffer, |
| 6785 | layout::Rect, |
| 6786 | style::{Color, Style}, |
| 6787 | }; |
| 6788 | use std::borrow::Cow; |
| 6789 | use std::fs; |
| 6790 | use std::path::PathBuf; |
| 6791 | use tempfile::TempDir; |
| 6792 | use unicode_width::UnicodeWidthStr; |
| 6793 | |
| 6794 | /// Terminal sizes the v0.8.66 modal blocker (#3732) requires every overlay |
| 6795 | /// to remain readable and fully operable at. |
| 6796 | const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)]; |
| 6797 | |
| 6798 | /// Render a modal through the `ViewStack` (so the shared opaque backdrop is |
| 6799 | /// painted exactly as in production) over a sentinel-filled buffer, then |
| 6800 | /// assert: every `required_label` is visible, no sentinel `X` survives |
| 6801 | /// anywhere (fully opaque), the center cell carries the modal ink, and no |
| 6802 | /// row overflows the frame width. |
| 6803 | fn assert_modal_usable_and_opaque<V: ModalView + 'static>( |
| 6804 | make: impl Fn() -> V, |
| 6805 | required_labels: &[&str], |
| 6806 | ) { |
| 6807 | for (w, h) in BLOCKER_SIZES { |
| 6808 | let area = Rect::new(0, 0, w, h); |
| 6809 | let mut buf = Buffer::empty(area); |
| 6810 | let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green); |
| 6811 | for y in 0..h { |
| 6812 | for x in 0..w { |
| 6813 | buf[(x, y)].set_symbol("X").set_style(sentinel_style); |
| 6814 | } |
| 6815 | } |
| 6816 | let mut stack = ViewStack::new(); |
| 6817 | stack.push(make()); |
| 6818 | stack.render(area, &mut buf); |
| 6819 | |
| 6820 | let rows: Vec<String> = (0..h) |
| 6821 | .map(|y| { |
| 6822 | (0..w) |
| 6823 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 6824 | .collect::<String>() |
| 6825 | }) |
| 6826 | .collect(); |
| 6827 | let text = rows.join("\n"); |
| 6828 | |
| 6829 | for label in required_labels { |
| 6830 | assert!(text.contains(label), "{w}x{h}: missing '{label}'"); |
| 6831 | } |
| 6832 | let unpainted = (0..h).find_map(|y| { |
| 6833 | (0..w).find_map(|x| { |
| 6834 | let cell = &buf[(x, y)]; |
| 6835 | (cell.symbol() == "X" && cell.fg == Color::Magenta && cell.bg == Color::Green) |
| 6836 | .then_some((x, y)) |
| 6837 | }) |
| 6838 | }); |
| 6839 | assert!( |
| 6840 | unpainted.is_none(), |
| 6841 | "{w}x{h}: background bleed-through at {unpainted:?}" |
| 6842 | ); |
| 6843 | assert_eq!( |
| 6844 | buf[(w / 2, h / 2)].bg, |
| 6845 | palette::WHALE_BG, |
| 6846 | "{w}x{h}: modal interior must be opaque" |
| 6847 | ); |
| 6848 | for (y, row) in rows.iter().enumerate() { |
| 6849 | assert!( |
| 6850 | UnicodeWidthStr::width(row.trim_end()) <= w as usize, |
| 6851 | "{w}x{h}: row {y} overflows width: {row:?}" |
| 6852 | ); |
| 6853 | } |
| 6854 | } |
| 6855 | } |
| 6856 | |
| 6857 | #[test] |
| 6858 | fn config_modal_is_usable_and_opaque_at_blocker_sizes() { |
| 6859 | let _lock = crate::test_support::lock_test_env(); |
| 6860 | // "Search" is the hardcoded English search-row label; asserting it (plus |
| 6861 | // the opacity/overflow checks) proves the modal renders fully and its |
| 6862 | // footer wraps inside bounds rather than clipping. |
| 6863 | assert_modal_usable_and_opaque(|| create_config_view(Locale::En), &["Search"]); |
| 6864 | } |
| 6865 | |
| 6866 | #[test] |
| 6867 | fn subagents_modal_is_usable_and_opaque_at_blocker_sizes() { |
| 6868 | assert_modal_usable_and_opaque( |
| 6869 | || SubAgentsView::new(Vec::new()), |
| 6870 | &["close", "refresh", "setup"], |
| 6871 | ); |
| 6872 | } |
| 6873 | |
| 6874 | /// #5954: the `Esc` hint has to name what the key does — `back` while the |
| 6875 | /// Fleet roster is parked underneath, `close` on direct entry. |
| 6876 | #[test] |
| 6877 | fn subagents_esc_hint_says_back_over_the_roster_and_close_at_the_root() { |
| 6878 | let area = Rect::new(0, 0, 160, 40); |
| 6879 | |
| 6880 | let direct = SubAgentsView::new(Vec::new()); |
| 6881 | let mut direct_buf = Buffer::empty(area); |
| 6882 | direct.render(area, &mut direct_buf); |
| 6883 | let direct_text = buffer_text(&direct_buf, area); |
| 6884 | assert!( |
| 6885 | direct_text.contains("Esc close"), |
| 6886 | "direct entry must still promise close: {direct_text}" |
| 6887 | ); |
| 6888 | |
| 6889 | let over_roster = SubAgentsView::new(Vec::new()).over_fleet_roster(); |
| 6890 | let mut back_buf = Buffer::empty(area); |
| 6891 | over_roster.render(area, &mut back_buf); |
| 6892 | let back_text = buffer_text(&back_buf, area); |
| 6893 | assert!( |
| 6894 | back_text.contains("Esc back"), |
| 6895 | "over the roster the hint must promise back: {back_text}" |
| 6896 | ); |
| 6897 | assert!( |
| 6898 | !back_text.contains("Esc close"), |
| 6899 | "over the roster the hint must not still promise close: {back_text}" |
| 6900 | ); |
| 6901 | } |
| 6902 | |
| 6903 | /// #5954: workers opened directly (`/fleet workers`, the Work dock) is the |
| 6904 | /// root of its own stack, so `Esc` closes the window as before. |
| 6905 | #[test] |
| 6906 | fn subagents_opened_directly_closes_on_esc() { |
| 6907 | let mut stack = ViewStack::new(); |
| 6908 | stack.push(SubAgentsView::new(Vec::new())); |
| 6909 | stack.handle_key(KeyEvent::new( |
| 6910 | crossterm::event::KeyCode::Esc, |
| 6911 | crossterm::event::KeyModifiers::NONE, |
| 6912 | )); |
| 6913 | assert!(stack.is_empty(), "direct entry must close on Esc"); |
| 6914 | } |
| 6915 | |
| 6916 | /// #5954: `F` in workers is the roster door. With a roster parked below, |
| 6917 | /// it pops back to it instead of re-running `/fleet` and stacking a |
| 6918 | /// duplicate roster. |
| 6919 | #[test] |
| 6920 | fn subagents_f_pops_back_to_the_parked_roster() { |
| 6921 | let mut over_roster = SubAgentsView::new(Vec::new()).over_fleet_roster(); |
| 6922 | assert!(matches!( |
| 6923 | over_roster.handle_key(KeyEvent::new( |
| 6924 | crossterm::event::KeyCode::Char('F'), |
| 6925 | crossterm::event::KeyModifiers::NONE, |
| 6926 | )), |
| 6927 | ViewAction::Close |
| 6928 | )); |
| 6929 | |
| 6930 | let mut direct = SubAgentsView::new(Vec::new()); |
| 6931 | assert!(matches!( |
| 6932 | direct.handle_key(KeyEvent::new( |
| 6933 | crossterm::event::KeyCode::Char('F'), |
| 6934 | crossterm::event::KeyModifiers::NONE, |
| 6935 | )), |
| 6936 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { .. }) |
| 6937 | )); |
| 6938 | } |
| 6939 | |
| 6940 | #[test] |
| 6941 | fn subagents_modal_names_current_session_pod_workers_in_each_locale() { |
| 6942 | let area = Rect::new(0, 0, 160, 40); |
| 6943 | let app = create_test_app(); |
| 6944 | |
| 6945 | let empty = SubAgentsView::for_app(&app, Vec::new()); |
| 6946 | let mut empty_buf = Buffer::empty(area); |
| 6947 | empty.render(area, &mut empty_buf); |
| 6948 | let empty_text = buffer_text(&empty_buf, area); |
| 6949 | assert!( |
| 6950 | empty_text.contains("No agents in this session."), |
| 6951 | "{empty_text}" |
| 6952 | ); |
| 6953 | assert!( |
| 6954 | empty_text.contains("Set up roles with /fleet."), |
| 6955 | "{empty_text}" |
| 6956 | ); |
| 6957 | |
| 6958 | let english = SubAgentsView::for_app( |
| 6959 | &app, |
| 6960 | vec![manager_agent("agent_live", SubAgentStatus::Running)], |
| 6961 | ); |
| 6962 | let mut english_buf = Buffer::empty(area); |
| 6963 | english.render(area, &mut english_buf); |
| 6964 | let english_text = buffer_text(&english_buf, area); |
| 6965 | assert!( |
| 6966 | english_text.contains("Agents in this session"), |
| 6967 | "{english_text}" |
| 6968 | ); |
| 6969 | assert!( |
| 6970 | english_text.contains("Roles shown are this session's agent roles."), |
| 6971 | "{english_text}" |
| 6972 | ); |
| 6973 | |
| 6974 | let mut zh_hans_app = create_test_app(); |
| 6975 | zh_hans_app.ui_locale = Locale::ZhHans; |
| 6976 | let zh_hans = SubAgentsView::for_app( |
| 6977 | &zh_hans_app, |
| 6978 | vec![manager_agent("agent_live", SubAgentStatus::Running)], |
| 6979 | ); |
| 6980 | let mut zh_hans_buf = Buffer::empty(area); |
| 6981 | zh_hans.render(area, &mut zh_hans_buf); |
| 6982 | let zh_hans_text = buffer_text(&zh_hans_buf, area); |
| 6983 | // Ratatui gives each CJK glyph a trailing buffer cell. Strip those |
| 6984 | // layout spaces before asserting the actual rendered copy. |
| 6985 | let zh_hans_compact = zh_hans_text |
| 6986 | .chars() |
| 6987 | .filter(|ch| !ch.is_whitespace()) |
| 6988 | .collect::<String>(); |
| 6989 | assert_eq!( |
| 6990 | tr( |
| 6991 | Locale::ZhHans, |
| 6992 | MessageId::SubagentsCurrentSessionFleetWorkersTitle |
| 6993 | ), |
| 6994 | "本会话的智能体" |
| 6995 | ); |
| 6996 | assert!(zh_hans_compact.contains("本会话的智能体"), "{zh_hans_text}"); |
| 6997 | assert!( |
| 6998 | zh_hans_compact.contains("所示角色为本会话中智能体的角色。"), |
| 6999 | "{zh_hans_text}" |
| 7000 | ); |
| 7001 | assert!( |
| 7002 | !zh_hans_text.contains("Agents in this session"), |
| 7003 | "{zh_hans_text}" |
| 7004 | ); |
| 7005 | } |
| 7006 | |
| 7007 | #[test] |
| 7008 | fn subagents_modal_localizes_worker_rows_and_preserves_english_status_rendering() { |
| 7009 | let area = Rect::new(0, 0, 200, 60); |
| 7010 | let mut agent = manager_agent("agent_live", SubAgentStatus::Running); |
| 7011 | agent.agent_type = FleetRole::Builder; |
| 7012 | agent.assignment.role = Some("release".to_string()); |
| 7013 | agent.assignment.objective = "verify localized row".to_string(); |
| 7014 | agent.runtime_permissions = Some(codewhale_protocol::fleet::FleetEffectivePermissions { |
| 7015 | write: true, |
| 7016 | network: true, |
| 7017 | shell: "read_only".to_string(), |
| 7018 | tool_scope: "inherit".to_string(), |
| 7019 | tools: Vec::new(), |
| 7020 | background: false, |
| 7021 | max_spawn_depth: 0, |
| 7022 | profile_id: None, |
| 7023 | profile_origin: None, |
| 7024 | source: "test".to_string(), |
| 7025 | }); |
| 7026 | agent.git_branch = Some("feature/localize".to_string()); |
| 7027 | agent.workspace = Some(PathBuf::from("/tmp/fleet-workers")); |
| 7028 | agent.result = Some("all checks passed".to_string()); |
| 7029 | let mut interrupted = manager_agent( |
| 7030 | "agent_interrupted", |
| 7031 | SubAgentStatus::Interrupted("manual review".to_string()), |
| 7032 | ); |
| 7033 | interrupted.agent_type = FleetRole::Reviewer; |
| 7034 | |
| 7035 | let app = create_test_app(); |
| 7036 | let english = SubAgentsView::for_app(&app, vec![agent.clone(), interrupted.clone()]); |
| 7037 | let mut english_buf = Buffer::empty(area); |
| 7038 | english.render(area, &mut english_buf); |
| 7039 | let english_text = buffer_text(&english_buf, area); |
| 7040 | for expected in [ |
| 7041 | "Agents in this session", |
| 7042 | "Running: 1", |
| 7043 | "Completed: 0", |
| 7044 | "Interrupted: 1", |
| 7045 | "Failed: 0", |
| 7046 | "Cancelled: 0", |
| 7047 | "Running (1)", |
| 7048 | "implement", |
| 7049 | "running", |
| 7050 | "reason: manual review", |
| 7051 | "role: release", |
| 7052 | "access: network=on · shell=read-only · write=on", |
| 7053 | "git: branch feature/localize @ fleet-workers", |
| 7054 | "objective: verify localized row", |
| 7055 | "result: all checks passed", |
| 7056 | "live agent status · role · objective · model · elapsed", |
| 7057 | "close", |
| 7058 | "select", |
| 7059 | "focus", |
| 7060 | "stop", |
| 7061 | "refresh", |
| 7062 | "fleet/setup", |
| 7063 | ] { |
| 7064 | assert!( |
| 7065 | english_text.contains(expected), |
| 7066 | "missing {expected:?}: {english_text}" |
| 7067 | ); |
| 7068 | } |
| 7069 | |
| 7070 | let mut zh_hans_app = create_test_app(); |
| 7071 | zh_hans_app.ui_locale = Locale::ZhHans; |
| 7072 | let zh_hans = SubAgentsView::for_app(&zh_hans_app, vec![agent, interrupted]); |
| 7073 | let mut zh_hans_buf = Buffer::empty(area); |
| 7074 | zh_hans.render(area, &mut zh_hans_buf); |
| 7075 | let zh_hans_text = buffer_text(&zh_hans_buf, area); |
| 7076 | let zh_hans_compact = zh_hans_text |
| 7077 | .chars() |
| 7078 | .filter(|ch| !ch.is_whitespace()) |
| 7079 | .collect::<String>(); |
| 7080 | for expected in [ |
| 7081 | "本会话的智能体", |
| 7082 | "运行中:1", |
| 7083 | "已中断:1", |
| 7084 | "Fleet设置智能体", |
| 7085 | "实时智能体状态·角色·目标·模型·已用时间", |
| 7086 | "运行中(1)", |
| 7087 | "构建者", |
| 7088 | "原因:manualreview", |
| 7089 | "角色:release", |
| 7090 | "访问级别:网络=开·Shell=只读·写入=开", |
| 7091 | "Git:分支feature/localize@fleet-workers", |
| 7092 | "目标:verifylocalizedrow", |
| 7093 | "结果:allcheckspassed", |
| 7094 | "刷新", |
| 7095 | "fleet/setup", |
| 7096 | ] { |
| 7097 | assert!( |
| 7098 | zh_hans_compact.contains(expected), |
| 7099 | "missing {expected:?}: {zh_hans_text}" |
| 7100 | ); |
| 7101 | } |
| 7102 | assert!( |
| 7103 | !zh_hans_text.contains("Running: 1"), |
| 7104 | "English status leaked into zh-Hans modal: {zh_hans_text}" |
| 7105 | ); |
| 7106 | } |
| 7107 | |
| 7108 | /// Focus-texture prototype (#4823): with a mode forced on, a real |
| 7109 | /// full-screen modal must render exactly as before — the texture pass |
| 7110 | /// no-ops because the focus region covers (nearly) the whole frame. |
| 7111 | /// The default `Off` case is pinned by the existing |
| 7112 | /// `*_modal_is_usable_and_opaque_at_blocker_sizes` tests above: they run |
| 7113 | /// unmodified because `ViewStack::new()` defaults to `Off`, which leaves |
| 7114 | /// the buffer byte-identical to the pre-prototype render. |
| 7115 | #[test] |
| 7116 | fn focus_texture_modes_keep_fullscreen_modal_usable_and_opaque() { |
| 7117 | let _lock = crate::test_support::lock_test_env(); |
| 7118 | let theme = codewhale_palette::ThemeId::Whale.ui_theme(); |
| 7119 | for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] { |
| 7120 | for (w, h) in BLOCKER_SIZES { |
| 7121 | let area = Rect::new(0, 0, w, h); |
| 7122 | let mut buf = Buffer::empty(area); |
| 7123 | let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green); |
| 7124 | for y in 0..h { |
| 7125 | for x in 0..w { |
| 7126 | buf[(x, y)].set_symbol("X").set_style(sentinel_style); |
| 7127 | } |
| 7128 | } |
| 7129 | let mut stack = ViewStack::new(); |
| 7130 | stack.push(create_config_view(Locale::En)); |
| 7131 | stack.set_focus_texture(mode, theme); |
| 7132 | stack.render(area, &mut buf); |
| 7133 | |
| 7134 | let rows: Vec<String> = (0..h) |
| 7135 | .map(|y| { |
| 7136 | (0..w) |
| 7137 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 7138 | .collect::<String>() |
| 7139 | }) |
| 7140 | .collect(); |
| 7141 | let text = rows.join("\n"); |
| 7142 | |
| 7143 | assert!( |
| 7144 | text.contains("Search"), |
| 7145 | "{mode:?} {w}x{h}: missing 'Search'" |
| 7146 | ); |
| 7147 | let unpainted = (0..h).find_map(|y| { |
| 7148 | (0..w).find_map(|x| { |
| 7149 | let cell = &buf[(x, y)]; |
| 7150 | (cell.symbol() == "X" |
| 7151 | && cell.fg == Color::Magenta |
| 7152 | && cell.bg == Color::Green) |
| 7153 | .then_some((x, y)) |
| 7154 | }) |
| 7155 | }); |
| 7156 | assert!( |
| 7157 | unpainted.is_none(), |
| 7158 | "{mode:?} {w}x{h}: background bleed-through at {unpainted:?}" |
| 7159 | ); |
| 7160 | assert_eq!( |
| 7161 | buf[(w / 2, h / 2)].bg, |
| 7162 | palette::WHALE_BG, |
| 7163 | "{mode:?} {w}x{h}: modal interior must be opaque" |
| 7164 | ); |
| 7165 | } |
| 7166 | } |
| 7167 | } |
| 7168 | |
| 7169 | /// The texture actually engages outside an *inline* modal's band: the |
| 7170 | /// approval prompt only occupies a bottom strip, so the sentinel field |
| 7171 | /// above it goes through the scrim/grain pass. The modal is painted |
| 7172 | /// after the texture, so its band stays fully opaque and its labels |
| 7173 | /// survive at every blocker size. |
| 7174 | #[test] |
| 7175 | fn focus_texture_modes_keep_inline_modal_usable() { |
| 7176 | let theme = codewhale_palette::ThemeId::Whale.ui_theme(); |
| 7177 | for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] { |
| 7178 | for (w, h) in BLOCKER_SIZES { |
| 7179 | let area = Rect::new(0, 0, w, h); |
| 7180 | let mut buf = Buffer::empty(area); |
| 7181 | let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green); |
| 7182 | for y in 0..h { |
| 7183 | for x in 0..w { |
| 7184 | buf[(x, y)].set_symbol("X").set_style(sentinel_style); |
| 7185 | } |
| 7186 | } |
| 7187 | let request = crate::tui::approval::ApprovalRequest::new( |
| 7188 | "test-id", |
| 7189 | "read_file", |
| 7190 | "Read a file from disk", |
| 7191 | &serde_json::json!({"path": "src/main.rs"}), |
| 7192 | "tool:read_file", |
| 7193 | ); |
| 7194 | let mut stack = ViewStack::new(); |
| 7195 | stack.push(crate::tui::approval::ApprovalView::new(request)); |
| 7196 | stack.set_focus_texture(mode, theme); |
| 7197 | let focus = stack |
| 7198 | .top_occupied_region(area) |
| 7199 | .expect("approval view on the stack"); |
| 7200 | stack.render(area, &mut buf); |
| 7201 | |
| 7202 | let rows: Vec<String> = (0..h) |
| 7203 | .map(|y| { |
| 7204 | (0..w) |
| 7205 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 7206 | .collect::<String>() |
| 7207 | }) |
| 7208 | .collect(); |
| 7209 | let text = rows.join("\n"); |
| 7210 | |
| 7211 | // The card heading is the plain summary of the call (E6, |
| 7212 | // mark 4), not the raw tool name. |
| 7213 | assert!( |
| 7214 | text.contains("Do you want to proceed?") && text.contains("Read src/main.rs"), |
| 7215 | "{mode:?} {w}x{h}: approval prompt must survive the texture" |
| 7216 | ); |
| 7217 | // Zero sentinel bleed INSIDE the focused band: the backdrop |
| 7218 | // and the modal own every cell there. Outside the band the |
| 7219 | // texture intentionally leaves the sentinel glyphs in place |
| 7220 | // (Scrim only re-colors; Grain never overwrites text). |
| 7221 | let mut whale_bg_cells = 0_u32; |
| 7222 | for y in focus.top()..focus.bottom() { |
| 7223 | for x in focus.left()..focus.right() { |
| 7224 | let cell = &buf[(x, y)]; |
| 7225 | assert!( |
| 7226 | !(cell.symbol() == "X" |
| 7227 | && cell.fg == Color::Magenta |
| 7228 | && cell.bg == Color::Green), |
| 7229 | "{mode:?} {w}x{h}: sentinel bleed inside focus at ({x},{y})" |
| 7230 | ); |
| 7231 | if cell.bg == palette::WHALE_BG { |
| 7232 | whale_bg_cells += 1; |
| 7233 | } |
| 7234 | } |
| 7235 | } |
| 7236 | // The band keeps the opaque modal ink. (Not every cell: the |
| 7237 | // selected option row carries its own highlight background.) |
| 7238 | assert!( |
| 7239 | whale_bg_cells > 0, |
| 7240 | "{mode:?} {w}x{h}: modal band lost its opaque WHALE_BG surface" |
| 7241 | ); |
| 7242 | } |
| 7243 | } |
| 7244 | } |
| 7245 | |
| 7246 | #[test] |
| 7247 | fn centered_modal_area_clamps_and_centers() { |
| 7248 | // Roomy frame: preferred size honoured, centered. |
| 7249 | let area = Rect::new(0, 0, 160, 40); |
| 7250 | let rect = centered_modal_area(area, 80, 20, 40, 10); |
| 7251 | assert_eq!((rect.width, rect.height), (80, 20)); |
| 7252 | assert_eq!(rect.x, (160 - 80) / 2); |
| 7253 | assert_eq!(rect.y, (40 - 20) / 2); |
| 7254 | |
| 7255 | // Tiny frame: never exceeds the frame even below the requested minimum. |
| 7256 | let tiny = Rect::new(0, 0, 30, 8); |
| 7257 | let rect = centered_modal_area(tiny, 80, 20, 40, 10); |
| 7258 | assert!(rect.width <= tiny.width, "width must fit frame"); |
| 7259 | assert!(rect.height <= tiny.height, "height must fit frame"); |
| 7260 | assert!(rect.x + rect.width <= tiny.width); |
| 7261 | assert!(rect.y + rect.height <= tiny.height); |
| 7262 | } |
| 7263 | |
| 7264 | #[test] |
| 7265 | fn action_footer_wraps_instead_of_overflowing() { |
| 7266 | let hints = [ |
| 7267 | ActionHint::new("↑↓", "move"), |
| 7268 | ActionHint::new("a-z", "jump"), |
| 7269 | ActionHint::new("Enter", "apply"), |
| 7270 | ActionHint::new("R", "edit key"), |
| 7271 | ActionHint::new("M", "models"), |
| 7272 | ActionHint::new("Esc", "cancel"), |
| 7273 | ]; |
| 7274 | |
| 7275 | // Wide enough for a single row. |
| 7276 | let wide = action_footer_lines(&hints, 120); |
| 7277 | assert_eq!(wide.len(), 1); |
| 7278 | assert!(wide[0].width() <= 120); |
| 7279 | |
| 7280 | // Narrow forces wrapping but never truncates: every action survives and |
| 7281 | // no produced line exceeds the available width. |
| 7282 | let narrow = action_footer_lines(&hints, 28); |
| 7283 | assert!(narrow.len() >= 2, "narrow footer should wrap to >1 row"); |
| 7284 | for line in &narrow { |
| 7285 | assert!( |
| 7286 | line.width() <= 28, |
| 7287 | "wrapped footer row overflows: {} cols", |
| 7288 | line.width() |
| 7289 | ); |
| 7290 | } |
| 7291 | let joined: String = narrow |
| 7292 | .iter() |
| 7293 | .flat_map(|l| l.spans.iter()) |
| 7294 | .map(|s| s.content.as_ref()) |
| 7295 | .collect(); |
| 7296 | for label in ["move", "jump", "apply", "edit key", "models", "cancel"] { |
| 7297 | assert!(joined.contains(label), "footer dropped action: {label}"); |
| 7298 | } |
| 7299 | } |
| 7300 | |
| 7301 | #[test] |
| 7302 | fn render_modal_footer_reserves_rows_and_returns_body() { |
| 7303 | let inner = Rect::new(2, 2, 40, 10); |
| 7304 | let mut buf = Buffer::empty(Rect::new(0, 0, 44, 14)); |
| 7305 | let hints = [ |
| 7306 | ActionHint::new("Enter", "save"), |
| 7307 | ActionHint::new("Esc", "cancel"), |
| 7308 | ]; |
| 7309 | let body = render_modal_footer_with_gutter(inner, &mut buf, &hints); |
| 7310 | // Normal-height overlays reserve a single quiet gutter above the |
| 7311 | // one-row footer, so body prose never runs into the action rail. |
| 7312 | assert_eq!(body.y, inner.y); |
| 7313 | assert_eq!(body.height, inner.height - 2); |
| 7314 | assert_eq!(body.y + body.height, inner.y + inner.height - 2); |
| 7315 | let gutter_y = inner.y + inner.height - 2; |
| 7316 | assert!( |
| 7317 | (inner.x..inner.right()).all(|x| buf[(x, gutter_y)].symbol().trim().is_empty()), |
| 7318 | "modal footer gutter should stay visually quiet" |
| 7319 | ); |
| 7320 | } |
| 7321 | |
| 7322 | #[test] |
| 7323 | fn list_detail_layout_splits_wide_and_stacks_narrow() { |
| 7324 | let wide = ListDetailLayout::split(Rect::new(0, 0, 120, 24), 34); |
| 7325 | assert!(!wide.stacked); |
| 7326 | assert!(wide.list.width >= 30); |
| 7327 | assert!(wide.detail.width >= 34); |
| 7328 | assert_eq!(wide.list.height, 24); |
| 7329 | assert_eq!(wide.detail.height, 24); |
| 7330 | assert!(wide.list.right() < wide.detail.left()); |
| 7331 | |
| 7332 | let narrow = ListDetailLayout::split(Rect::new(0, 0, 80, 20), 34); |
| 7333 | assert!(narrow.stacked); |
| 7334 | assert_eq!(narrow.list.width, 80); |
| 7335 | assert_eq!(narrow.detail.width, 80); |
| 7336 | assert!(narrow.list.bottom() <= narrow.detail.top()); |
| 7337 | assert!(narrow.list.height > 0); |
| 7338 | } |
| 7339 | |
| 7340 | #[test] |
| 7341 | fn empty_state_renders_copy_and_actions() { |
| 7342 | let area = Rect::new(0, 0, 48, 8); |
| 7343 | let mut buf = Buffer::empty(area); |
| 7344 | EmptyState::new("Nothing here", "Use search or switch categories.") |
| 7345 | .primary_action("/", "filter") |
| 7346 | .secondary_action("Esc", "cancel") |
| 7347 | .render(area, &mut buf); |
| 7348 | |
| 7349 | let text = (0..area.height) |
| 7350 | .map(|y| { |
| 7351 | (0..area.width) |
| 7352 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 7353 | .collect::<String>() |
| 7354 | }) |
| 7355 | .collect::<Vec<_>>() |
| 7356 | .join("\n"); |
| 7357 | for expected in ["Nothing here", "Use search", "filter", "cancel"] { |
| 7358 | assert!( |
| 7359 | text.contains(expected), |
| 7360 | "empty state missing {expected:?}: {text:?}" |
| 7361 | ); |
| 7362 | } |
| 7363 | } |
| 7364 | |
| 7365 | struct ConfigSettingsEnvGuard { |
| 7366 | _config_path: crate::test_support::EnvVarGuard, |
| 7367 | _tmp: TempDir, |
| 7368 | _lock: crate::test_support::TestEnvLock, |
| 7369 | } |
| 7370 | |
| 7371 | impl ConfigSettingsEnvGuard { |
| 7372 | fn new(settings_toml: &str) -> Self { |
| 7373 | let lock = crate::test_support::lock_test_env(); |
| 7374 | let tmp = TempDir::new().expect("settings tempdir"); |
| 7375 | let config_path = tmp.path().join(".deepseek").join("config.toml"); |
| 7376 | let settings_path = config_path |
| 7377 | .parent() |
| 7378 | .expect("settings parent") |
| 7379 | .join("settings.toml"); |
| 7380 | std::fs::create_dir_all(config_path.parent().expect("config parent")) |
| 7381 | .expect("config dir"); |
| 7382 | std::fs::write(&settings_path, settings_toml).expect("settings file"); |
| 7383 | let config_path_guard = |
| 7384 | crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 7385 | Self { |
| 7386 | _config_path: config_path_guard, |
| 7387 | _tmp: tmp, |
| 7388 | _lock: lock, |
| 7389 | } |
| 7390 | } |
| 7391 | } |
| 7392 | |
| 7393 | fn create_test_app() -> App { |
| 7394 | static NEXT_CONFIG_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| 7395 | let config_id = NEXT_CONFIG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 7396 | let isolated_config_path = std::env::temp_dir().join(format!( |
| 7397 | "codewhale-config-view-test-{}-{config_id}.toml", |
| 7398 | std::process::id() |
| 7399 | )); |
| 7400 | let options = TuiOptions { |
| 7401 | // ConfigView consults the app's persisted config. Point generic |
| 7402 | // tests at a unique absent file so developer or concurrent test |
| 7403 | // settings cannot silently change which controls are editable. |
| 7404 | config_path: Some(isolated_config_path), |
| 7405 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 7406 | }; |
| 7407 | let mut app = App::new(options, &Config::default()); |
| 7408 | app.api_provider = crate::config::ProviderKind::Deepseek; |
| 7409 | app |
| 7410 | } |
| 7411 | |
| 7412 | fn cost_currency_row_for_settings( |
| 7413 | settings_toml: &str, |
| 7414 | ) -> (String, String, crate::pricing::CostCurrency, Locale) { |
| 7415 | let _guard = ConfigSettingsEnvGuard::new(settings_toml); |
| 7416 | let app = create_test_app(); |
| 7417 | let view = ConfigView::new_for_app(&app); |
| 7418 | let row = view |
| 7419 | .rows |
| 7420 | .iter() |
| 7421 | .find(|row| row.key == "cost_currency") |
| 7422 | .expect("cost_currency row"); |
| 7423 | |
| 7424 | ( |
| 7425 | row.value.clone(), |
| 7426 | view.row_display_value(row), |
| 7427 | app.cost_currency, |
| 7428 | app.ui_locale, |
| 7429 | ) |
| 7430 | } |
| 7431 | |
| 7432 | fn type_filter(view: &mut ConfigView, text: &str) { |
| 7433 | for ch in text.chars() { |
| 7434 | let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 7435 | assert!(matches!(action, ViewAction::None)); |
| 7436 | } |
| 7437 | } |
| 7438 | |
| 7439 | fn manager_agent(id: &str, status: SubAgentStatus) -> SubAgentResult { |
| 7440 | SubAgentResult { |
| 7441 | usage: None, |
| 7442 | name: id.to_string(), |
| 7443 | agent_id: id.to_string(), |
| 7444 | context_mode: "fresh".to_string(), |
| 7445 | fork_context: false, |
| 7446 | workspace: None, |
| 7447 | git_branch: None, |
| 7448 | agent_type: FleetRole::Scout, |
| 7449 | assignment: SubAgentAssignment { |
| 7450 | native_preset: None, |
| 7451 | objective: "read the docs".to_string(), |
| 7452 | role: None, |
| 7453 | }, |
| 7454 | model: "deepseek-v4-flash".to_string(), |
| 7455 | nickname: None, |
| 7456 | status, |
| 7457 | worker_status: None, |
| 7458 | runtime_permissions: None, |
| 7459 | parent_run_id: None, |
| 7460 | spawn_depth: 0, |
| 7461 | child_route: None, |
| 7462 | result: None, |
| 7463 | steps_taken: 1, |
| 7464 | checkpoint: None, |
| 7465 | needs_input: None, |
| 7466 | duration_ms: 10, |
| 7467 | started_at: None, |
| 7468 | from_prior_session: false, |
| 7469 | idle_ms: None, |
| 7470 | heartbeat_timeout_ms: None, |
| 7471 | } |
| 7472 | } |
| 7473 | |
| 7474 | fn writer_agent(id: &str) -> SubAgentResult { |
| 7475 | let mut agent = manager_agent(id, SubAgentStatus::Running); |
| 7476 | agent.runtime_permissions = Some(codewhale_protocol::fleet::FleetEffectivePermissions { |
| 7477 | write: true, |
| 7478 | network: false, |
| 7479 | shell: "read_only".to_string(), |
| 7480 | tool_scope: "inherit".to_string(), |
| 7481 | tools: Vec::new(), |
| 7482 | background: false, |
| 7483 | max_spawn_depth: 0, |
| 7484 | profile_id: None, |
| 7485 | profile_origin: None, |
| 7486 | source: "test".to_string(), |
| 7487 | }); |
| 7488 | agent |
| 7489 | } |
| 7490 | |
| 7491 | fn press(view: &mut SubAgentsView, code: KeyCode) -> ViewAction { |
| 7492 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 7493 | } |
| 7494 | |
| 7495 | fn is_stop_of(action: &ViewAction, id: &str) -> bool { |
| 7496 | matches!( |
| 7497 | action, |
| 7498 | ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }) if agent_id == id |
| 7499 | ) |
| 7500 | } |
| 7501 | |
| 7502 | #[test] |
| 7503 | fn stopping_a_writing_agent_takes_two_presses_and_esc_disarms() { |
| 7504 | let mut view = SubAgentsView::new(vec![writer_agent("w")]); |
| 7505 | |
| 7506 | // First X arms; nothing is stopped yet and the footer asks to confirm. |
| 7507 | assert!(matches!( |
| 7508 | press(&mut view, KeyCode::Char('x')), |
| 7509 | ViewAction::None |
| 7510 | )); |
| 7511 | let area = Rect::new(0, 0, 100, 20); |
| 7512 | let mut buf = Buffer::empty(area); |
| 7513 | view.render(area, &mut buf); |
| 7514 | assert!(buffer_text(&buf, area).contains("X/Enter")); |
| 7515 | |
| 7516 | // Esc disarms without closing the register. |
| 7517 | assert!(matches!(press(&mut view, KeyCode::Esc), ViewAction::None)); |
| 7518 | assert!(view.armed_stop.is_none()); |
| 7519 | |
| 7520 | // X, X stops; X then Enter stops too. |
| 7521 | assert!(matches!( |
| 7522 | press(&mut view, KeyCode::Char('X')), |
| 7523 | ViewAction::None |
| 7524 | )); |
| 7525 | assert!(is_stop_of(&press(&mut view, KeyCode::Char('X')), "w")); |
| 7526 | assert!(matches!( |
| 7527 | press(&mut view, KeyCode::Char('x')), |
| 7528 | ViewAction::None |
| 7529 | )); |
| 7530 | assert!(is_stop_of(&press(&mut view, KeyCode::Enter), "w")); |
| 7531 | assert!(view.armed_stop.is_none()); |
| 7532 | } |
| 7533 | |
| 7534 | #[test] |
| 7535 | fn stopping_a_read_only_or_moved_selection_does_not_need_the_armed_press() { |
| 7536 | // A read-only running agent (no write, no full shell) stops at once. |
| 7537 | let mut read_only = writer_agent("r"); |
| 7538 | if let Some(permissions) = read_only.runtime_permissions.as_mut() { |
| 7539 | permissions.write = false; |
| 7540 | } |
| 7541 | let mut view = SubAgentsView::new(vec![read_only]); |
| 7542 | assert!(is_stop_of(&press(&mut view, KeyCode::Char('x')), "r")); |
| 7543 | |
| 7544 | // Moving the selection disarms: the next X on the other writer arms |
| 7545 | // afresh instead of stopping it. |
| 7546 | let mut view = SubAgentsView::new(vec![writer_agent("a"), writer_agent("b")]); |
| 7547 | assert!(matches!( |
| 7548 | press(&mut view, KeyCode::Char('x')), |
| 7549 | ViewAction::None |
| 7550 | )); |
| 7551 | press(&mut view, KeyCode::Down); |
| 7552 | assert!(view.armed_stop.is_none()); |
| 7553 | assert!(matches!( |
| 7554 | press(&mut view, KeyCode::Char('x')), |
| 7555 | ViewAction::None |
| 7556 | )); |
| 7557 | assert!(is_stop_of(&press(&mut view, KeyCode::Char('x')), "b")); |
| 7558 | |
| 7559 | // An armed agent that finishes before the confirm is disarmed. |
| 7560 | let mut view = SubAgentsView::new(vec![writer_agent("w")]); |
| 7561 | press(&mut view, KeyCode::Char('x')); |
| 7562 | let mut done = writer_agent("w"); |
| 7563 | done.status = SubAgentStatus::Completed; |
| 7564 | view.update_subagents(&[done]); |
| 7565 | assert!(view.armed_stop.is_none()); |
| 7566 | } |
| 7567 | |
| 7568 | #[test] |
| 7569 | fn worker_register_update_preserves_selected_agent_across_new_spawns() { |
| 7570 | let mut view = SubAgentsView::new(vec![manager_agent("b", SubAgentStatus::Running)]); |
| 7571 | view.update_subagents(&[ |
| 7572 | manager_agent("a", SubAgentStatus::Running), |
| 7573 | manager_agent("b", SubAgentStatus::Running), |
| 7574 | ]); |
| 7575 | assert_eq!(view.ordered_agent_ids()[view.selected], "b"); |
| 7576 | view.update_subagents(&[ |
| 7577 | manager_agent("a", SubAgentStatus::Running), |
| 7578 | manager_agent("b", SubAgentStatus::Completed), |
| 7579 | ]); |
| 7580 | assert_eq!(view.ordered_agent_ids()[view.selected], "b"); |
| 7581 | } |
| 7582 | |
| 7583 | #[test] |
| 7584 | fn subagent_view_agents_includes_progress_only_running_agent() { |
| 7585 | let mut app = create_test_app(); |
| 7586 | app.ensure_agent_label("agent_live"); |
| 7587 | app.agent_progress |
| 7588 | .insert("agent_live".to_string(), "reading code".to_string()); |
| 7589 | |
| 7590 | let agents = subagent_view_agents(&app, &[]); |
| 7591 | |
| 7592 | assert_eq!(agents.len(), 1); |
| 7593 | assert_eq!(agents[0].agent_id, "agent_live"); |
| 7594 | assert!(matches!(agents[0].status, SubAgentStatus::Running)); |
| 7595 | assert_eq!(agents[0].assignment.role.as_deref(), Some("live")); |
| 7596 | assert!(agents[0].assignment.objective.contains("reading code")); |
| 7597 | assert_eq!(agents[0].nickname.as_deref(), Some("Agent 1")); |
| 7598 | } |
| 7599 | |
| 7600 | #[test] |
| 7601 | fn subagent_view_replaces_progress_placeholder_after_manager_snapshot() { |
| 7602 | let mut app = create_test_app(); |
| 7603 | app.ui_locale = Locale::En; |
| 7604 | app.ensure_agent_label("agent_live"); |
| 7605 | app.agent_progress |
| 7606 | .insert("agent_live".to_string(), "reading code".to_string()); |
| 7607 | |
| 7608 | let progress_only = subagent_view_agents(&app, &[]); |
| 7609 | assert_eq!(progress_only[0].nickname.as_deref(), Some("Agent 1")); |
| 7610 | |
| 7611 | let mut manager = manager_agent("agent_live", SubAgentStatus::Running); |
| 7612 | manager.nickname = Some(crate::tools::subagent::whale_name_for_id_in_locale( |
| 7613 | "agent_live", |
| 7614 | "ja", |
| 7615 | )); |
| 7616 | let manager_backed = subagent_view_agents(&app, &[manager]); |
| 7617 | assert_eq!( |
| 7618 | manager_backed[0].nickname.as_deref(), |
| 7619 | Some(crate::tools::subagent::whale_name_for_id_in_locale("agent_live", "en").as_str()) |
| 7620 | ); |
| 7621 | } |
| 7622 | |
| 7623 | #[test] |
| 7624 | fn subagent_view_headlines_the_dispatch_name_over_the_whale() { |
| 7625 | // #5287: `/subagents` spells the identity column from `nickname`, so a |
| 7626 | // named dispatch lands there and only an unnamed one gets a whale. |
| 7627 | let mut app = create_test_app(); |
| 7628 | app.ui_locale = Locale::En; |
| 7629 | let mut named = manager_agent("agent_named_lane", SubAgentStatus::Running); |
| 7630 | named.name = "branch-triage".to_string(); |
| 7631 | let plain = manager_agent("agent_plain_lane", SubAgentStatus::Running); |
| 7632 | |
| 7633 | let agents = subagent_view_agents(&app, &[named, plain]); |
| 7634 | assert_eq!(agents[0].nickname.as_deref(), Some("branch-triage")); |
| 7635 | assert_eq!( |
| 7636 | agents[1].nickname.as_deref(), |
| 7637 | Some( |
| 7638 | crate::tools::subagent::whale_name_for_id_in_locale("agent_plain_lane", "en") |
| 7639 | .as_str() |
| 7640 | ) |
| 7641 | ); |
| 7642 | } |
| 7643 | |
| 7644 | #[test] |
| 7645 | fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() { |
| 7646 | let mut app = create_test_app(); |
| 7647 | let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]); |
| 7648 | card.upsert_worker("chunk_1", AgentLifecycle::Completed); |
| 7649 | card.upsert_worker("chunk_2", AgentLifecycle::Running); |
| 7650 | app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); |
| 7651 | app.last_fanout_card_index = Some(app.history.len().saturating_sub(1)); |
| 7652 | |
| 7653 | let agents = subagent_view_agents(&app, &[]); |
| 7654 | |
| 7655 | assert_eq!(agents.len(), 2); |
| 7656 | assert_eq!(agents[0].agent_id, "chunk_1"); |
| 7657 | assert!(matches!(agents[0].status, SubAgentStatus::Completed)); |
| 7658 | assert_eq!(agents[1].agent_id, "chunk_2"); |
| 7659 | assert!(matches!(agents[1].status, SubAgentStatus::Running)); |
| 7660 | assert_eq!(agents[1].assignment.role.as_deref(), Some("rlm")); |
| 7661 | } |
| 7662 | |
| 7663 | #[test] |
| 7664 | fn subagent_view_agents_deduplicates_manager_rows_over_live_rows() { |
| 7665 | let mut app = create_test_app(); |
| 7666 | app.agent_progress |
| 7667 | .insert("agent_cached".to_string(), "live duplicate".to_string()); |
| 7668 | let manager = vec![manager_agent("agent_cached", SubAgentStatus::Running)]; |
| 7669 | |
| 7670 | let agents = subagent_view_agents(&app, &manager); |
| 7671 | |
| 7672 | assert_eq!(agents.len(), 1); |
| 7673 | assert_eq!(agents[0].agent_type, FleetRole::Scout); |
| 7674 | assert_eq!(agents[0].assignment.objective, "read the docs"); |
| 7675 | } |
| 7676 | |
| 7677 | #[test] |
| 7678 | fn fleet_worker_status_view_can_jump_to_fleet_setup() { |
| 7679 | let mut view = SubAgentsView::new(Vec::new()); |
| 7680 | |
| 7681 | let action = view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); |
| 7682 | |
| 7683 | match action { |
| 7684 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 7685 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 7686 | }) => assert_eq!(command, "/fleet"), |
| 7687 | other => panic!("expected /fleet jump action, got {other:?}"), |
| 7688 | } |
| 7689 | } |
| 7690 | |
| 7691 | /// One agent, one destination (v0.9.7): Enter on a `/agents` row opens |
| 7692 | /// the selected agent's transcript — the same destination the Work strip |
| 7693 | /// and sidebar resolve to. Selection follows render order (running before |
| 7694 | /// completed), and an empty register keeps Enter's refresh meaning. |
| 7695 | #[test] |
| 7696 | fn subagents_enter_opens_the_selected_agents_transcript() { |
| 7697 | let mut view = SubAgentsView::new(vec![ |
| 7698 | manager_agent("agent_done", SubAgentStatus::Completed), |
| 7699 | manager_agent("agent_live", SubAgentStatus::Running), |
| 7700 | ]); |
| 7701 | |
| 7702 | // Render order groups running first, so the initial selection is the |
| 7703 | // running agent even though the completed one was pushed first. |
| 7704 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7705 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => { |
| 7706 | assert_eq!(agent_id, "agent_live"); |
| 7707 | } |
| 7708 | other => panic!("expected transcript open, got {other:?}"), |
| 7709 | } |
| 7710 | |
| 7711 | let _ = view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); |
| 7712 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 7713 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => { |
| 7714 | assert_eq!(agent_id, "agent_done"); |
| 7715 | } |
| 7716 | other => panic!("expected transcript open, got {other:?}"), |
| 7717 | } |
| 7718 | |
| 7719 | let mut empty = SubAgentsView::new(Vec::new()); |
| 7720 | assert!(matches!( |
| 7721 | empty.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 7722 | ViewAction::Emit(ViewEvent::SubAgentsRefresh) |
| 7723 | )); |
| 7724 | } |
| 7725 | |
| 7726 | /// Whale Teams rows: every worker carries its species badge and a state |
| 7727 | /// word derived from the real status (running → Working, completed → |
| 7728 | /// Resting, failed → Blocked, interrupted → Waiting for you), and the |
| 7729 | /// working wake holds the poster frame outside Full motion. |
| 7730 | #[test] |
| 7731 | fn subagents_rows_carry_species_badges_and_truthful_state_words() { |
| 7732 | let mut interrupted = manager_agent("agent_wait", SubAgentStatus::Interrupted("q".into())); |
| 7733 | interrupted.agent_type = FleetRole::Builder; |
| 7734 | let mut failed = manager_agent("agent_fail", SubAgentStatus::Failed("boom".into())); |
| 7735 | failed.agent_type = FleetRole::Reviewer; |
| 7736 | let view = SubAgentsView::new(vec![ |
| 7737 | manager_agent("agent_done", SubAgentStatus::Completed), |
| 7738 | manager_agent("agent_live", SubAgentStatus::Running), |
| 7739 | interrupted, |
| 7740 | failed, |
| 7741 | ]); |
| 7742 | assert_eq!(view.whale_frame(), 0, "Still motion holds the poster frame"); |
| 7743 | let area = Rect::new(0, 0, 100, 40); |
| 7744 | let mut buf = Buffer::empty(area); |
| 7745 | view.render(area, &mut buf); |
| 7746 | let text = buffer_text(&buf, area); |
| 7747 | // Scout (manager_agent default role) → beak badge; Builder → Patch |
| 7748 | // bracket; Reviewer → Lantern lens. |
| 7749 | assert!(text.contains("◂▰ agent_live"), "{text}"); |
| 7750 | assert!(text.contains("◂▰ · Working"), "{text}"); |
| 7751 | assert!(text.contains("◂▰ Resting"), "{text}"); |
| 7752 | assert!(text.contains("▰] ◆ Waiting for you"), "{text}"); |
| 7753 | assert!(text.contains("◇▰ ▌ Blocked"), "{text}"); |
| 7754 | assert!( |
| 7755 | !text.contains("Scout · research"), |
| 7756 | "no caption labels: {text}" |
| 7757 | ); |
| 7758 | assert!( |
| 7759 | !text.contains("Lantern · review"), |
| 7760 | "no caption labels: {text}" |
| 7761 | ); |
| 7762 | } |
| 7763 | |
| 7764 | /// A click on a rendered `/agents` row opens the clicked agent's |
| 7765 | /// transcript and moves the selection cursor onto it. |
| 7766 | #[test] |
| 7767 | fn subagents_click_opens_the_clicked_agents_transcript() { |
| 7768 | let mut view = SubAgentsView::new(vec![ |
| 7769 | manager_agent("agent_done", SubAgentStatus::Completed), |
| 7770 | manager_agent("agent_live", SubAgentStatus::Running), |
| 7771 | ]); |
| 7772 | let area = Rect::new(0, 0, 100, 30); |
| 7773 | let mut buf = Buffer::empty(area); |
| 7774 | view.render(area, &mut buf); |
| 7775 | |
| 7776 | // Resolve the completed agent's on-screen row from the recorded |
| 7777 | // layout, exactly as a click does in reverse. |
| 7778 | let (first_line, _, agent_id) = view |
| 7779 | .row_lines |
| 7780 | .borrow() |
| 7781 | .iter() |
| 7782 | .find(|(_, _, id)| id == "agent_done") |
| 7783 | .cloned() |
| 7784 | .expect("completed agent block recorded"); |
| 7785 | assert_eq!(agent_id, "agent_done"); |
| 7786 | let body = view.body_area.get(); |
| 7787 | let scroll = view.last_render_scroll.get(); |
| 7788 | let click_row = body.y + u16::try_from(first_line - scroll).expect("visible row"); |
| 7789 | |
| 7790 | let action = view.handle_mouse(MouseEvent { |
| 7791 | kind: MouseEventKind::Down(MouseButton::Left), |
| 7792 | column: body.x + 2, |
| 7793 | row: click_row, |
| 7794 | modifiers: KeyModifiers::NONE, |
| 7795 | }); |
| 7796 | match action { |
| 7797 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => { |
| 7798 | assert_eq!(agent_id, "agent_done"); |
| 7799 | } |
| 7800 | other => panic!("expected transcript open, got {other:?}"), |
| 7801 | } |
| 7802 | assert_eq!(view.ordered_agent_ids()[view.selected], "agent_done"); |
| 7803 | |
| 7804 | // The selection cursor is visible after a re-render. |
| 7805 | let mut buf = Buffer::empty(area); |
| 7806 | view.render(area, &mut buf); |
| 7807 | let text = (0..area.height) |
| 7808 | .map(|y| { |
| 7809 | (0..area.width) |
| 7810 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 7811 | .collect::<String>() |
| 7812 | }) |
| 7813 | .collect::<Vec<_>>() |
| 7814 | .join("\n"); |
| 7815 | assert!( |
| 7816 | text.contains('\u{25B8}'), |
| 7817 | "selection cursor missing:\n{text}" |
| 7818 | ); |
| 7819 | } |
| 7820 | |
| 7821 | fn visible_section_labels(view: &ConfigView) -> Vec<Cow<'static, str>> { |
| 7822 | view.visible_items() |
| 7823 | .into_iter() |
| 7824 | .filter_map(|item| match item { |
| 7825 | ConfigListItem::Section(section) => Some(section.label(view.locale)), |
| 7826 | ConfigListItem::Row(_) => None, |
| 7827 | }) |
| 7828 | .collect() |
| 7829 | } |
| 7830 | |
| 7831 | fn create_config_view(locale: Locale) -> ConfigView { |
| 7832 | let mut app = create_test_app(); |
| 7833 | app.ui_locale = locale; |
| 7834 | ConfigView::new_for_app(&app) |
| 7835 | } |
| 7836 | |
| 7837 | fn visible_row_keys(view: &ConfigView) -> Vec<&str> { |
| 7838 | view.visible_items() |
| 7839 | .into_iter() |
| 7840 | .filter_map(|item| match item { |
| 7841 | ConfigListItem::Row(idx) => Some(view.rows[idx].key.as_str()), |
| 7842 | ConfigListItem::Section(_) => None, |
| 7843 | }) |
| 7844 | .collect() |
| 7845 | } |
| 7846 | |
| 7847 | #[test] |
| 7848 | fn truncate_view_text_handles_unicode() { |
| 7849 | let text = "abc😀é"; |
| 7850 | assert_eq!(truncate_view_text(text, 0), ""); |
| 7851 | assert_eq!(truncate_view_text(text, 1), "a"); |
| 7852 | assert_eq!(truncate_view_text(text, 3), "abc"); |
| 7853 | assert_eq!(truncate_view_text(text, 4), "abc😀"); |
| 7854 | assert_eq!(truncate_view_text(text, 5), "abc😀é"); |
| 7855 | } |
| 7856 | |
| 7857 | #[test] |
| 7858 | fn underwater_surface_ellipsizes_narrow_titles() { |
| 7859 | let area = Rect::new(0, 0, 24, 8); |
| 7860 | let mut buf = Buffer::empty(area); |
| 7861 | render_underwater_surface(area, &mut buf, "Help — Concepts, commands, and keybindings"); |
| 7862 | let top = (0..area.width) |
| 7863 | .map(|x| buf[(x, 0)].symbol()) |
| 7864 | .collect::<String>(); |
| 7865 | assert!( |
| 7866 | top.contains('…'), |
| 7867 | "narrow title should signal truncation: {top}" |
| 7868 | ); |
| 7869 | } |
| 7870 | |
| 7871 | #[test] |
| 7872 | fn config_view_groups_rows_by_expected_sections() { |
| 7873 | let view = create_config_view(Locale::En); |
| 7874 | assert_eq!( |
| 7875 | visible_section_labels(&view), |
| 7876 | vec!["Display"], |
| 7877 | "Settings opens on Appearance" |
| 7878 | ); |
| 7879 | } |
| 7880 | |
| 7881 | #[test] |
| 7882 | fn config_view_includes_expected_editable_rows() { |
| 7883 | let app = create_test_app(); |
| 7884 | let view = ConfigView::new_for_app(&app); |
| 7885 | let keys = view |
| 7886 | .rows |
| 7887 | .iter() |
| 7888 | .map(|row| row.key.as_str()) |
| 7889 | .collect::<Vec<_>>(); |
| 7890 | assert!(keys.contains(&"provider")); |
| 7891 | assert!(keys.contains(&"model")); |
| 7892 | assert!(keys.contains(&"reasoning_effort")); |
| 7893 | assert!(keys.contains(&"base_url")); |
| 7894 | assert!(keys.contains(&"external_credentials.openai-codex")); |
| 7895 | assert!(keys.contains(&"external_credentials.xai")); |
| 7896 | assert!(keys.contains(&"approval_mode")); |
| 7897 | assert!(keys.contains(&"permission_posture")); |
| 7898 | assert!(keys.contains(&"allow_shell")); |
| 7899 | assert!(keys.contains(&"theme")); |
| 7900 | assert!(keys.contains(&"locale")); |
| 7901 | assert!(keys.contains(&"background_color")); |
| 7902 | assert!(keys.contains(&"fancy_animations")); |
| 7903 | assert!(keys.contains(&"thinking_default_expanded")); |
| 7904 | assert!(keys.contains(&"synchronized_output")); |
| 7905 | assert!(keys.contains(&"auto_compact")); |
| 7906 | assert!(keys.contains(&"tool_collapse")); |
| 7907 | assert!(keys.contains(&"composer_border")); |
| 7908 | assert!(keys.contains(&"composer_multiline_mode")); |
| 7909 | assert!(keys.contains(&"cost_currency")); |
| 7910 | assert!(keys.contains(&"mcp_open")); |
| 7911 | assert!(keys.contains(&"mcp_reconnect")); |
| 7912 | assert!(keys.contains(&"mcp_diagnose")); |
| 7913 | assert!(keys.contains(&"plugins_open")); |
| 7914 | assert!(keys.contains(&"mcp_config_path")); |
| 7915 | assert!(keys.contains(&"fleet.exec.max_spawn_depth")); |
| 7916 | // Retired rows: the backends stay live (`default_model` routing, |
| 7917 | // the `vision_model` feature flag) or were derived receipts |
| 7918 | // (`fast_model`), but none keeps a table row. |
| 7919 | assert!(!keys.contains(&"features.vision_model")); |
| 7920 | assert!(!keys.contains(&"fast_model")); |
| 7921 | assert!(!keys.contains(&"default_model")); |
| 7922 | assert!(keys.contains(&"goal_command")); |
| 7923 | assert!(keys.contains(&"workflow")); |
| 7924 | assert!(!keys.contains(&"features.subagents")); |
| 7925 | assert!(!keys.contains(&"features.web_search")); |
| 7926 | assert!(!keys.contains(&"features.apply_patch")); |
| 7927 | assert!(!keys.contains(&"features.mcp")); |
| 7928 | assert!(!keys.contains(&"features.exec_policy")); |
| 7929 | assert!(!keys.contains(&"whaleflow")); |
| 7930 | // Diagnostic-only rows, managed permission rows, and live route |
| 7931 | // receipts are not editable; everything else outside the |
| 7932 | // read-only sections should be. |
| 7933 | const DIAGNOSTIC_ONLY: &[&str] = &[ |
| 7934 | "context_window", |
| 7935 | "effective_context_window", |
| 7936 | "external_credentials.openai-codex", |
| 7937 | "external_credentials.xai", |
| 7938 | "base_url", |
| 7939 | "provider_url", |
| 7940 | // Sub-agent depth stays a read-only config.toml receipt in its |
| 7941 | // new Model home; it is edited in the fleet config, not here. |
| 7942 | "fleet.exec.max_spawn_depth", |
| 7943 | ]; |
| 7944 | assert!( |
| 7945 | view.rows |
| 7946 | .iter() |
| 7947 | .filter(|row| { |
| 7948 | !matches!( |
| 7949 | row.section(), |
| 7950 | super::ConfigSection::Experimental |
| 7951 | | super::ConfigSection::Fleet |
| 7952 | | super::ConfigSection::Workflow |
| 7953 | | super::ConfigSection::Session |
| 7954 | | super::ConfigSection::Legacy |
| 7955 | ) && !DIAGNOSTIC_ONLY.contains(&row.key.as_str()) |
| 7956 | && !row.key.starts_with("managed_") |
| 7957 | }) |
| 7958 | .all(|row| row.editable) |
| 7959 | ); |
| 7960 | assert!( |
| 7961 | view.rows |
| 7962 | .iter() |
| 7963 | .filter(|row| { |
| 7964 | matches!( |
| 7965 | row.section(), |
| 7966 | super::ConfigSection::Experimental |
| 7967 | | super::ConfigSection::Fleet |
| 7968 | | super::ConfigSection::Workflow |
| 7969 | | super::ConfigSection::Session |
| 7970 | | super::ConfigSection::Legacy |
| 7971 | ) |
| 7972 | }) |
| 7973 | .all(|row| !row.editable || row.key.starts_with("notifications.")) |
| 7974 | ); |
| 7975 | // Route endpoint rows are provider-specific: DeepSeek routes expose |
| 7976 | // `base_url`, every other provider exposes `provider_url`. Whichever |
| 7977 | // exists must be a read-only route receipt. |
| 7978 | const ROUTE_RECEIPT_KEYS: &[&str] = &["base_url", "provider_url"]; |
| 7979 | for key in DIAGNOSTIC_ONLY |
| 7980 | .iter() |
| 7981 | .filter(|key| !ROUTE_RECEIPT_KEYS.contains(key)) |
| 7982 | { |
| 7983 | assert!( |
| 7984 | view.rows.iter().any(|row| row.key == *key && !row.editable), |
| 7985 | "{key} must remain diagnostic-only" |
| 7986 | ); |
| 7987 | } |
| 7988 | let receipt_rows: Vec<_> = view |
| 7989 | .rows |
| 7990 | .iter() |
| 7991 | .filter(|row| ROUTE_RECEIPT_KEYS.contains(&row.key.as_str())) |
| 7992 | .collect(); |
| 7993 | assert_eq!( |
| 7994 | receipt_rows.len(), |
| 7995 | 1, |
| 7996 | "exactly one endpoint receipt row must exist for the active route" |
| 7997 | ); |
| 7998 | assert!( |
| 7999 | !receipt_rows[0].editable, |
| 8000 | "endpoint receipt rows must be read-only" |
| 8001 | ); |
| 8002 | } |
| 8003 | |
| 8004 | #[test] |
| 8005 | fn config_view_surfaces_structural_external_consent_without_io() { |
| 8006 | let _env = crate::test_support::lock_test_env(); |
| 8007 | let temp = tempfile::tempdir().expect("config view fixture"); |
| 8008 | let config_path = temp.path().join("config.toml"); |
| 8009 | let auth_path = temp.path().join("codex-auth.json"); |
| 8010 | fs::write(&auth_path, "external-secret-must-not-be-read").expect("auth trap"); |
| 8011 | fs::write( |
| 8012 | &config_path, |
| 8013 | format!( |
| 8014 | r#"provider = "openai-codex" |
| 8015 | [providers.openai_codex] |
| 8016 | auth_mode = "oauth" |
| 8017 | [providers.openai_codex.external_credentials] |
| 8018 | access = "read_only" |
| 8019 | provider = "openai-codex" |
| 8020 | source = "codex_cli" |
| 8021 | path = {:?} |
| 8022 | consent_version = 1 |
| 8023 | "#, |
| 8024 | auth_path.display().to_string() |
| 8025 | ), |
| 8026 | ) |
| 8027 | .expect("config fixture"); |
| 8028 | let ambient_path = temp.path().join("new-ambient-codex-auth.json"); |
| 8029 | let _path = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &ambient_path); |
| 8030 | let mut app = create_test_app(); |
| 8031 | app.config_path = Some(config_path); |
| 8032 | crate::external_credentials::reset_side_effect_trap(); |
| 8033 | let view = ConfigView::new_for_app(&app); |
| 8034 | let row = view |
| 8035 | .rows |
| 8036 | .iter() |
| 8037 | .find(|row| row.key == "external_credentials.openai-codex") |
| 8038 | .expect("structural consent row"); |
| 8039 | assert!(row.value.contains("access=read_only"), "{}", row.value); |
| 8040 | assert!(row.value.contains("source=codex_cli"), "{}", row.value); |
| 8041 | assert!(row.value.contains("version=1"), "{}", row.value); |
| 8042 | assert!(row.value.contains("active"), "{}", row.value); |
| 8043 | assert!(row.value.contains("remains pinned"), "{}", row.value); |
| 8044 | assert!( |
| 8045 | row.value |
| 8046 | .contains(&codewhale_config::quote_os_path(&auth_path)), |
| 8047 | "{}", |
| 8048 | row.value |
| 8049 | ); |
| 8050 | assert!( |
| 8051 | !row.value.contains(&ambient_path.display().to_string()), |
| 8052 | "{}", |
| 8053 | row.value |
| 8054 | ); |
| 8055 | assert!( |
| 8056 | row.value |
| 8057 | .contains("external-revoke --provider openai-codex") |
| 8058 | ); |
| 8059 | assert_eq!( |
| 8060 | crate::external_credentials::complete_side_effect_trap_counts(), |
| 8061 | (0, 0, 0, 0, 0) |
| 8062 | ); |
| 8063 | } |
| 8064 | |
| 8065 | #[test] |
| 8066 | fn config_view_permission_row_tracks_the_controlling_saved_source() { |
| 8067 | let explicit_dir = TempDir::new().expect("explicit config tempdir"); |
| 8068 | let explicit_path = explicit_dir.path().join("config.toml"); |
| 8069 | fs::write(&explicit_path, "approval_policy = \"auto\"\n").expect("explicit config"); |
| 8070 | let mut app = create_test_app(); |
| 8071 | app.config_path = Some(explicit_path); |
| 8072 | |
| 8073 | let mut explicit = ConfigView::new_for_app(&app); |
| 8074 | let row = explicit |
| 8075 | .rows |
| 8076 | .iter() |
| 8077 | .find(|row| row.key == "approval_policy") |
| 8078 | .expect("explicit approval policy row"); |
| 8079 | assert_eq!(row.value, "auto"); |
| 8080 | assert!(row.editable); |
| 8081 | assert_eq!(row.scope, ConfigScope::Saved); |
| 8082 | assert!( |
| 8083 | explicit |
| 8084 | .rows |
| 8085 | .iter() |
| 8086 | .all(|row| row.key != "permission_posture") |
| 8087 | ); |
| 8088 | explicit.focus_key("approval_policy"); |
| 8089 | explicit.start_edit(); |
| 8090 | let choices = explicit |
| 8091 | .editing |
| 8092 | .as_ref() |
| 8093 | .and_then(|edit| edit.choices.as_ref()) |
| 8094 | .expect("approval posture choices"); |
| 8095 | assert_eq!( |
| 8096 | choices, |
| 8097 | &vec![ |
| 8098 | "use-tui-default".to_string(), |
| 8099 | "ask".to_string(), |
| 8100 | "auto-review".to_string(), |
| 8101 | "full-access".to_string(), |
| 8102 | ] |
| 8103 | ); |
| 8104 | let area = Rect::new(0, 0, 110, 30); |
| 8105 | let mut buf = Buffer::empty(area); |
| 8106 | explicit.render(area, &mut buf); |
| 8107 | let dump = buffer_text(&buf, area); |
| 8108 | assert!( |
| 8109 | dump.contains("4. Full Access"), |
| 8110 | "root permission chooser must expose the product posture:\n{dump}" |
| 8111 | ); |
| 8112 | assert!( |
| 8113 | !dump.contains("4. Never"), |
| 8114 | "root permission chooser leaked the raw fail-closed policy token:\n{dump}" |
| 8115 | ); |
| 8116 | let use_tui_default = explicit |
| 8117 | .editing |
| 8118 | .as_ref() |
| 8119 | .and_then(|edit| edit.choices.as_ref()) |
| 8120 | .and_then(|choices| { |
| 8121 | choices |
| 8122 | .iter() |
| 8123 | .position(|choice| choice == "use-tui-default") |
| 8124 | }) |
| 8125 | .expect("TUI default choice"); |
| 8126 | explicit |
| 8127 | .editing |
| 8128 | .as_mut() |
| 8129 | .expect("choice editor") |
| 8130 | .selected_choice = use_tui_default; |
| 8131 | match explicit.handle_choice_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 8132 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 8133 | key, |
| 8134 | value, |
| 8135 | persist, |
| 8136 | }) => { |
| 8137 | assert_eq!(key, "approval_policy"); |
| 8138 | assert_eq!(value, "use-tui-default"); |
| 8139 | assert!(persist); |
| 8140 | } |
| 8141 | other => panic!("expected saved ConfigUpdated event, got {other:?}"), |
| 8142 | } |
| 8143 | |
| 8144 | let managed_dir = TempDir::new().expect("managed config tempdir"); |
| 8145 | let requirements_path = managed_dir.path().join("requirements.toml"); |
| 8146 | fs::write( |
| 8147 | &requirements_path, |
| 8148 | "allowed_approval_policies = [\"never\"]\n", |
| 8149 | ) |
| 8150 | .expect("requirements config"); |
| 8151 | let config_path = managed_dir.path().join("config.toml"); |
| 8152 | let requirements_value = |
| 8153 | toml::Value::String(requirements_path.to_string_lossy().into_owned()).to_string(); |
| 8154 | fs::write( |
| 8155 | &config_path, |
| 8156 | format!("approval_policy = \"never\"\nrequirements_path = {requirements_value}\n"), |
| 8157 | ) |
| 8158 | .expect("managed config"); |
| 8159 | app.config_path = Some(config_path); |
| 8160 | |
| 8161 | let managed = ConfigView::new_for_app(&app); |
| 8162 | let row = managed |
| 8163 | .rows |
| 8164 | .iter() |
| 8165 | .find(|row| row.key == "managed_approval_policy") |
| 8166 | .expect("managed approval policy row"); |
| 8167 | assert!(!row.editable); |
| 8168 | assert_eq!(row.scope, ConfigScope::Saved); |
| 8169 | assert!( |
| 8170 | managed |
| 8171 | .rows |
| 8172 | .iter() |
| 8173 | .all(|row| row.key != "permission_posture" && row.key != "approval_policy") |
| 8174 | ); |
| 8175 | } |
| 8176 | |
| 8177 | #[test] |
| 8178 | fn config_view_provider_uses_full_picker_and_preserves_custom_provider_id() { |
| 8179 | let dir = TempDir::new().expect("custom provider tempdir"); |
| 8180 | let config_path = dir.path().join("config.toml"); |
| 8181 | fs::write( |
| 8182 | &config_path, |
| 8183 | r#" |
| 8184 | provider = "acme_ai" |
| 8185 | |
| 8186 | [providers.acme_ai] |
| 8187 | kind = "openai-compatible" |
| 8188 | base_url = "https://api.example.invalid/v1" |
| 8189 | model = "acme-model" |
| 8190 | api_key_env = "ACME_API_KEY" |
| 8191 | "#, |
| 8192 | ) |
| 8193 | .expect("custom provider config"); |
| 8194 | let mut app = create_test_app(); |
| 8195 | app.config_path = Some(config_path); |
| 8196 | app.set_provider_identity(crate::config::ProviderKind::Custom, "acme_ai"); |
| 8197 | let mut view = ConfigView::new_for_app(&app); |
| 8198 | view.focus_key("provider"); |
| 8199 | |
| 8200 | let row = &view.rows[view.selected]; |
| 8201 | assert_eq!(row.value, "acme_ai"); |
| 8202 | assert_eq!( |
| 8203 | row.scope, |
| 8204 | ConfigScope::Session, |
| 8205 | "the provider row shows the live route identity, not saved config" |
| 8206 | ); |
| 8207 | assert!( |
| 8208 | config_choice_values("provider").is_none(), |
| 8209 | "provider must not be truncated to the generic enum chooser" |
| 8210 | ); |
| 8211 | |
| 8212 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 8213 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 8214 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 8215 | }) => assert_eq!(command, "/provider"), |
| 8216 | other => panic!("expected full provider picker command, got {other:?}"), |
| 8217 | } |
| 8218 | assert!(view.editing.is_none()); |
| 8219 | } |
| 8220 | |
| 8221 | #[test] |
| 8222 | fn config_view_active_model_uses_picker_and_retired_rows_are_gone() { |
| 8223 | let app = create_test_app(); |
| 8224 | let mut view = ConfigView::new_for_app(&app); |
| 8225 | view.focus_key("model"); |
| 8226 | |
| 8227 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 8228 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 8229 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 8230 | }) => assert_eq!(command, "/model"), |
| 8231 | other => panic!("expected full model picker, got {other:?}"), |
| 8232 | } |
| 8233 | assert!(view.editing.is_none()); |
| 8234 | |
| 8235 | // The derived fast-sibling receipt and the legacy DeepSeek fallback |
| 8236 | // have no rows: sibling choice happens in the /model picker and the |
| 8237 | // fallback stays a `/set`-only compatibility key. |
| 8238 | for key in ["fast_model", "default_model"] { |
| 8239 | assert!( |
| 8240 | view.rows.iter().all(|row| row.key != key), |
| 8241 | "{key} must have no settings row" |
| 8242 | ); |
| 8243 | } |
| 8244 | } |
| 8245 | |
| 8246 | #[test] |
| 8247 | fn config_view_zai_model_row_has_no_derived_rows() { |
| 8248 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 8249 | let mut app = create_test_app(); |
| 8250 | app.api_provider = crate::config::ProviderKind::Zai; |
| 8251 | app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); |
| 8252 | |
| 8253 | let view = ConfigView::new_for_app(&app); |
| 8254 | let active = view |
| 8255 | .rows |
| 8256 | .iter() |
| 8257 | .find(|row| row.key == "model") |
| 8258 | .expect("active model row"); |
| 8259 | |
| 8260 | assert_eq!(active.value, "Zhipu AI / Z.ai · GLM-5.2"); |
| 8261 | // Derived receipts retired: the fast sibling is named in the /model |
| 8262 | // picker, and the DeepSeek-only fallback never appears as a row. |
| 8263 | for key in ["fast_model", "default_model"] { |
| 8264 | assert!( |
| 8265 | view.rows.iter().all(|row| row.key != key), |
| 8266 | "{key} row must be gone for zai" |
| 8267 | ); |
| 8268 | } |
| 8269 | } |
| 8270 | |
| 8271 | #[test] |
| 8272 | fn config_view_live_route_never_shows_stale_saved_provider() { |
| 8273 | // The reported defect: the saved config still said `provider = |
| 8274 | // "deepseek"` while the session was actually routed to Z.ai / GLM-5.3, |
| 8275 | // and Settings presented the stale saved value as the "Active |
| 8276 | // provider". Route rows must show the live route identity; saved |
| 8277 | // config is a startup/default fact, never the active receipt. |
| 8278 | let temp_root = std::env::temp_dir().join(format!( |
| 8279 | "codewhale-stale-saved-provider-view-test-{}", |
| 8280 | std::process::id() |
| 8281 | )); |
| 8282 | fs::create_dir_all(&temp_root).unwrap(); |
| 8283 | let config_path = temp_root.join("config.toml"); |
| 8284 | fs::write( |
| 8285 | &config_path, |
| 8286 | "provider = \"deepseek\"\nbase_url = \"https://api.deepseek.com/v1\"\n", |
| 8287 | ) |
| 8288 | .unwrap(); |
| 8289 | |
| 8290 | let mut app = create_test_app(); |
| 8291 | app.config_path = Some(config_path.clone()); |
| 8292 | // Live session route, exactly as a /provider switch would leave it. |
| 8293 | app.api_provider = crate::config::ProviderKind::Zai; |
| 8294 | app.model = "GLM-5.3".to_string(); |
| 8295 | app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string(); |
| 8296 | |
| 8297 | let view = ConfigView::new_for_app(&app); |
| 8298 | |
| 8299 | let provider_row = view |
| 8300 | .rows |
| 8301 | .iter() |
| 8302 | .find(|row| row.key == "provider") |
| 8303 | .expect("provider row"); |
| 8304 | assert!( |
| 8305 | provider_row.value.contains("Z.ai"), |
| 8306 | "provider row must show the live route identity: {}", |
| 8307 | provider_row.value |
| 8308 | ); |
| 8309 | assert!( |
| 8310 | !provider_row.value.to_lowercase().contains("deepseek"), |
| 8311 | "stale saved provider must not appear as the active route: {}", |
| 8312 | provider_row.value |
| 8313 | ); |
| 8314 | assert_eq!(provider_row.scope, ConfigScope::Session); |
| 8315 | |
| 8316 | let model_row = view |
| 8317 | .rows |
| 8318 | .iter() |
| 8319 | .find(|row| row.key == "model") |
| 8320 | .expect("model row"); |
| 8321 | assert_eq!(model_row.value, "Zhipu AI / Z.ai · GLM-5.3"); |
| 8322 | |
| 8323 | let url_row = view |
| 8324 | .rows |
| 8325 | .iter() |
| 8326 | .find(|row| row.key == "provider_url") |
| 8327 | .expect("endpoint row for the live Z.ai route"); |
| 8328 | assert_eq!(url_row.value, crate::config::DEFAULT_ZAI_BASE_URL); |
| 8329 | assert!(!view.rows.iter().any(|row| row.key == "base_url")); |
| 8330 | } |
| 8331 | |
| 8332 | #[test] |
| 8333 | fn config_view_shows_no_deepseek_fallback_row_on_any_provider() { |
| 8334 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 8335 | let mut app = create_test_app(); |
| 8336 | for provider in [ |
| 8337 | crate::config::ProviderKind::Zai, |
| 8338 | crate::config::ProviderKind::Xai, |
| 8339 | crate::config::ProviderKind::Openrouter, |
| 8340 | crate::config::ProviderKind::Ollama, |
| 8341 | crate::config::ProviderKind::Deepseek, |
| 8342 | ] { |
| 8343 | app.api_provider = provider; |
| 8344 | let view = ConfigView::new_for_app(&app); |
| 8345 | assert!( |
| 8346 | view.rows.iter().all(|row| row.key != "default_model"), |
| 8347 | "default_model must have no row for {:?}", |
| 8348 | provider |
| 8349 | ); |
| 8350 | } |
| 8351 | } |
| 8352 | |
| 8353 | #[test] |
| 8354 | fn config_view_saved_deepseek_fallback_is_a_read_only_migration_input() { |
| 8355 | // Old fallback values still parse, but new model choices belong to |
| 8356 | // the canonical config selection writer. |
| 8357 | let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n"); |
| 8358 | let mut app = create_test_app(); |
| 8359 | app.api_provider = crate::config::ProviderKind::Zai; |
| 8360 | |
| 8361 | let view = ConfigView::new_for_app(&app); |
| 8362 | assert!( |
| 8363 | view.rows.iter().all(|row| row.key != "default_model"), |
| 8364 | "saved legacy fallback must not surface a row" |
| 8365 | ); |
| 8366 | let mut settings = Settings::default(); |
| 8367 | let error = settings |
| 8368 | .set("default_model", "deepseek-v4-pro") |
| 8369 | .expect_err("legacy model settings must not become another writer"); |
| 8370 | assert!(error.to_string().contains("config.toml")); |
| 8371 | assert!(settings.default_model.is_none()); |
| 8372 | } |
| 8373 | |
| 8374 | /// Retired rows leave no section behind: sub-agent depth moved into the |
| 8375 | /// Model group, the legacy fallback and the vision flag lost their rows, |
| 8376 | /// and `/goal` + Workflow keep their own sections. Persisted keys are |
| 8377 | /// unchanged. |
| 8378 | #[test] |
| 8379 | fn config_view_settings_rows_land_in_truthful_sections() { |
| 8380 | let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n"); |
| 8381 | let mut app = create_test_app(); |
| 8382 | app.api_provider = crate::config::ProviderKind::Zai; |
| 8383 | let view = ConfigView::new_for_app(&app); |
| 8384 | |
| 8385 | let section_of = |key: &str| { |
| 8386 | view.rows |
| 8387 | .iter() |
| 8388 | .find(|row| row.key == key) |
| 8389 | .unwrap_or_else(|| panic!("{key} row")) |
| 8390 | .section() |
| 8391 | }; |
| 8392 | assert_eq!( |
| 8393 | section_of("fleet.exec.max_spawn_depth"), |
| 8394 | super::ConfigSection::Model |
| 8395 | ); |
| 8396 | assert_eq!(section_of("goal_command"), super::ConfigSection::Session); |
| 8397 | assert_eq!(section_of("workflow"), super::ConfigSection::Workflow); |
| 8398 | |
| 8399 | // The retired rows are gone on every provider, even with a saved |
| 8400 | // fallback value still on disk. |
| 8401 | for key in ["default_model", "fast_model", "features.vision_model"] { |
| 8402 | assert!( |
| 8403 | view.rows.iter().all(|row| row.key != key), |
| 8404 | "{key} must have no row" |
| 8405 | ); |
| 8406 | } |
| 8407 | |
| 8408 | // …and their sections retire with them: no Legacy, Experimental, or |
| 8409 | // Fleet headings may survive with zero rows behind them. |
| 8410 | let retired_sections = [ |
| 8411 | super::ConfigSection::Legacy, |
| 8412 | super::ConfigSection::Experimental, |
| 8413 | super::ConfigSection::Fleet, |
| 8414 | ]; |
| 8415 | for row in &view.rows { |
| 8416 | assert!( |
| 8417 | !retired_sections.contains(&row.section()), |
| 8418 | "{} still files under a retired section", |
| 8419 | row.key |
| 8420 | ); |
| 8421 | } |
| 8422 | |
| 8423 | // Relabelling is presentation only: the persisted key and value |
| 8424 | // round-trip unchanged, so existing config files keep loading |
| 8425 | // identically. |
| 8426 | let depth = view |
| 8427 | .rows |
| 8428 | .iter() |
| 8429 | .find(|row| row.key == "fleet.exec.max_spawn_depth") |
| 8430 | .expect("sub-agent depth row"); |
| 8431 | assert_eq!(depth.scope, ConfigScope::Saved); |
| 8432 | assert!(!depth.editable); |
| 8433 | assert_eq!(config_label_for_key(&depth.key), "agent depth"); |
| 8434 | |
| 8435 | // Workflow keeps its own name and its `/workflow` wording. |
| 8436 | let workflow = view |
| 8437 | .rows |
| 8438 | .iter() |
| 8439 | .find(|row| row.section() == super::ConfigSection::Workflow) |
| 8440 | .expect("workflow row"); |
| 8441 | assert_eq!(workflow.key.as_str(), "workflow"); |
| 8442 | assert!(workflow.value.starts_with("/workflow "), "{workflow:?}"); |
| 8443 | assert_eq!(config_label_for_key("workflow"), "Workflow"); |
| 8444 | } |
| 8445 | |
| 8446 | #[test] |
| 8447 | fn config_view_experimental_features_leave_no_rows() { |
| 8448 | // The vision row retired: even a configured beta flag surfaces no |
| 8449 | // table row. The flag itself stays live in the feature backend, |
| 8450 | // diagnosed where vision runs instead of in Advanced. |
| 8451 | let temp_root = std::env::temp_dir().join(format!( |
| 8452 | "codewhale-experimental-config-view-test-{}", |
| 8453 | std::process::id() |
| 8454 | )); |
| 8455 | fs::create_dir_all(&temp_root).unwrap(); |
| 8456 | let config_path = temp_root.join("config.toml"); |
| 8457 | fs::write( |
| 8458 | &config_path, |
| 8459 | r#" |
| 8460 | [features] |
| 8461 | web_search = false |
| 8462 | vision_model = true |
| 8463 | "#, |
| 8464 | ) |
| 8465 | .unwrap(); |
| 8466 | |
| 8467 | let mut app = create_test_app(); |
| 8468 | app.config_path = Some(config_path); |
| 8469 | let view = ConfigView::new_for_app(&app); |
| 8470 | |
| 8471 | for key in [ |
| 8472 | "features.web_search", |
| 8473 | "features.vision_model", |
| 8474 | "features.subagents", |
| 8475 | ] { |
| 8476 | assert!( |
| 8477 | view.rows.iter().all(|row| row.key != key), |
| 8478 | "{key} must have no settings row" |
| 8479 | ); |
| 8480 | } |
| 8481 | } |
| 8482 | |
| 8483 | #[test] |
| 8484 | fn config_view_shows_fleet_max_spawn_depth_from_config() { |
| 8485 | let temp_root = std::env::temp_dir().join(format!( |
| 8486 | "codewhale-fleet-config-view-test-{}", |
| 8487 | std::process::id() |
| 8488 | )); |
| 8489 | fs::create_dir_all(&temp_root).unwrap(); |
| 8490 | let config_path = temp_root.join("config.toml"); |
| 8491 | fs::write( |
| 8492 | &config_path, |
| 8493 | r#" |
| 8494 | [fleet.exec] |
| 8495 | max_spawn_depth = 2 |
| 8496 | "#, |
| 8497 | ) |
| 8498 | .unwrap(); |
| 8499 | |
| 8500 | let mut app = create_test_app(); |
| 8501 | app.config_path = Some(config_path); |
| 8502 | let view = ConfigView::new_for_app(&app); |
| 8503 | |
| 8504 | let row = view |
| 8505 | .rows |
| 8506 | .iter() |
| 8507 | .find(|row| row.key == "fleet.exec.max_spawn_depth") |
| 8508 | .expect("fleet spawn depth row"); |
| 8509 | assert_eq!(row.value, "2"); |
| 8510 | assert!(!row.editable); |
| 8511 | } |
| 8512 | |
| 8513 | #[test] |
| 8514 | fn config_view_retired_experimental_section_stays_gone() { |
| 8515 | let mut view = create_config_view(Locale::En); |
| 8516 | |
| 8517 | // The Experimental group retired with the vision row: the flag stays |
| 8518 | // live in the backend, but no section or row answers to it anymore. |
| 8519 | view.update_filter(|filter| filter.push_str("experimental")); |
| 8520 | assert!(visible_section_labels(&view).is_empty()); |
| 8521 | assert!(visible_row_keys(&view).is_empty()); |
| 8522 | |
| 8523 | view.clear_filter(); |
| 8524 | type_filter(&mut view, "feature vision"); |
| 8525 | assert!(visible_section_labels(&view).is_empty()); |
| 8526 | assert!(visible_row_keys(&view).is_empty()); |
| 8527 | |
| 8528 | view.clear_filter(); |
| 8529 | type_filter(&mut view, "goal"); |
| 8530 | assert_eq!(visible_section_labels(&view), vec!["Session"]); |
| 8531 | assert_eq!(visible_row_keys(&view), vec!["goal_command"]); |
| 8532 | |
| 8533 | // The `workflow` row keeps its key and its name; #4751 only moved it |
| 8534 | // out of Fleet into its own Workflow section. |
| 8535 | view.clear_filter(); |
| 8536 | type_filter(&mut view, "workflow"); |
| 8537 | assert_eq!(visible_section_labels(&view), vec!["Workflow"]); |
| 8538 | let workflow_keys = visible_row_keys(&view); |
| 8539 | assert_eq!(workflow_keys.first(), Some(&"workflow")); |
| 8540 | assert_eq!( |
| 8541 | workflow_keys.len(), |
| 8542 | 1 + codewhale_config::notifications::NotificationSetting::ALL.len() |
| 8543 | ); |
| 8544 | assert!(workflow_keys[1..].iter().all(|key| { |
| 8545 | codewhale_config::notifications::NotificationSetting::parse(key).is_some() |
| 8546 | })); |
| 8547 | |
| 8548 | view.clear_filter(); |
| 8549 | type_filter(&mut view, "whaleflow"); |
| 8550 | assert!(visible_row_keys(&view).is_empty()); |
| 8551 | } |
| 8552 | |
| 8553 | #[test] |
| 8554 | fn config_view_base_url_reflects_active_route_receipt() { |
| 8555 | let mut app = create_test_app(); |
| 8556 | app.active_route_base_url = "https://ui-config-view.local/v1".to_string(); |
| 8557 | let view = ConfigView::new_for_app(&app); |
| 8558 | |
| 8559 | let row = view |
| 8560 | .rows |
| 8561 | .iter() |
| 8562 | .find(|row| row.key == "base_url") |
| 8563 | .expect("base_url row missing"); |
| 8564 | assert_eq!( |
| 8565 | config_label_for_key(&row.key), |
| 8566 | "Provider API URL (DeepSeek route)" |
| 8567 | ); |
| 8568 | // The endpoint row is a read-only receipt for the live route; it must |
| 8569 | // not re-read config files, which may describe a different saved |
| 8570 | // route than the one the session is actually using. |
| 8571 | assert_eq!(row.value, "https://ui-config-view.local/v1"); |
| 8572 | assert!(!row.editable); |
| 8573 | assert_eq!(row.scope, ConfigScope::Session); |
| 8574 | } |
| 8575 | |
| 8576 | #[test] |
| 8577 | fn config_view_uses_provider_url_for_non_deepseek_provider() { |
| 8578 | let temp_root = std::env::temp_dir().join(format!( |
| 8579 | "codewhale-provider-url-view-test-{}", |
| 8580 | std::process::id() |
| 8581 | )); |
| 8582 | fs::create_dir_all(&temp_root).unwrap(); |
| 8583 | let config_path = temp_root.join("config.toml"); |
| 8584 | fs::write( |
| 8585 | &config_path, |
| 8586 | r#" |
| 8587 | provider = "xiaomi-mimo" |
| 8588 | |
| 8589 | [providers.xiaomi_mimo] |
| 8590 | api_key = "tp-test-token-plan-key" |
| 8591 | base_url = "https://api.xiaomimimo.com/v1" |
| 8592 | "#, |
| 8593 | ) |
| 8594 | .unwrap(); |
| 8595 | |
| 8596 | let mut app = create_test_app(); |
| 8597 | app.api_provider = crate::config::ProviderKind::XiaomiMimo; |
| 8598 | app.active_route_base_url = crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(); |
| 8599 | app.ui_locale = Locale::Es419; |
| 8600 | app.config_path = Some(config_path.clone()); |
| 8601 | let mut view = ConfigView::new_for_app(&app); |
| 8602 | |
| 8603 | let row = view |
| 8604 | .rows |
| 8605 | .iter() |
| 8606 | .find(|row| row.key == "provider_url") |
| 8607 | .expect("provider_url row missing"); |
| 8608 | // The endpoint row reflects the live route identity (the default when |
| 8609 | // nothing overrides it), not a config-file re-read, and is a receipt. |
| 8610 | assert_eq!(row.value, crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL); |
| 8611 | assert!(!row.editable); |
| 8612 | assert!(!view.rows.iter().any(|row| row.key == "base_url")); |
| 8613 | |
| 8614 | view.focus_key("provider_url"); |
| 8615 | let hint = view |
| 8616 | .setting_detail_lines(&view.rows[view.selected], 200) |
| 8617 | .iter() |
| 8618 | .map(ToString::to_string) |
| 8619 | .collect::<Vec<_>>() |
| 8620 | .join("\n"); |
| 8621 | let es_hint = tr(Locale::Es419, MessageId::ConfigHintProviderUrl); |
| 8622 | assert!(hint.contains(es_hint.as_ref()), "{hint}"); |
| 8623 | assert!(hint.contains("pago por uso"), "{hint}"); |
| 8624 | assert!( |
| 8625 | !hint.contains(tr(Locale::En, MessageId::ConfigHintProviderUrl).as_ref()), |
| 8626 | "the Spanish settings view must not leak the English guidance: {hint}" |
| 8627 | ); |
| 8628 | } |
| 8629 | |
| 8630 | #[test] |
| 8631 | fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() { |
| 8632 | let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n"); |
| 8633 | let app = create_test_app(); |
| 8634 | assert_eq!(app.ui_locale, Locale::ZhHans); |
| 8635 | assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny); |
| 8636 | |
| 8637 | let view = ConfigView::new_for_app(&app); |
| 8638 | let row = view |
| 8639 | .rows |
| 8640 | .iter() |
| 8641 | .find(|row| row.key == "cost_currency") |
| 8642 | .expect("cost_currency row"); |
| 8643 | |
| 8644 | assert_eq!(row.value, "usd"); |
| 8645 | assert_eq!(view.row_display_value(row), "usd (实际 cny)"); |
| 8646 | assert_eq!(Settings::load().expect("settings").cost_currency, "usd"); |
| 8647 | } |
| 8648 | |
| 8649 | #[test] |
| 8650 | fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() { |
| 8651 | for alias in ["rmb", "yuan", "¥"] { |
| 8652 | let (saved_value, display_value, effective_currency, locale) = |
| 8653 | cost_currency_row_for_settings(&format!( |
| 8654 | "locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n" |
| 8655 | )); |
| 8656 | |
| 8657 | assert_eq!(locale, Locale::ZhHans); |
| 8658 | assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); |
| 8659 | assert_eq!(saved_value, alias); |
| 8660 | assert_eq!(display_value, alias); |
| 8661 | } |
| 8662 | } |
| 8663 | |
| 8664 | #[test] |
| 8665 | fn config_view_cost_currency_matching_cny_setting_is_silent() { |
| 8666 | let (saved_value, display_value, effective_currency, locale) = |
| 8667 | cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n"); |
| 8668 | |
| 8669 | assert_eq!(locale, Locale::ZhHans); |
| 8670 | assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); |
| 8671 | assert_eq!(saved_value, "cny"); |
| 8672 | assert_eq!(display_value, "cny"); |
| 8673 | } |
| 8674 | |
| 8675 | #[test] |
| 8676 | fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() { |
| 8677 | let (saved_value, display_value, effective_currency, locale) = |
| 8678 | cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n"); |
| 8679 | |
| 8680 | assert_eq!(locale, Locale::En); |
| 8681 | assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); |
| 8682 | assert_eq!(saved_value, "cny"); |
| 8683 | assert_eq!(display_value, "cny"); |
| 8684 | } |
| 8685 | |
| 8686 | /// The panel's contract, cell-exact: tabs across the top, the groups |
| 8687 | /// column beside the list at 120 columns and folded into the headings at |
| 8688 | /// 80, the selected setting's sentence in the band, and the live footer |
| 8689 | /// preview under it. A visual change that cannot show as a golden diff |
| 8690 | /// did not happen. |
| 8691 | #[test] |
| 8692 | fn config_panel_golden_at_eighty_and_one_twenty() { |
| 8693 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 8694 | let app = create_test_app(); |
| 8695 | let view = ConfigView::new_for_app(&app); |
| 8696 | for (width, height) in [(40u16, 12u16), (80u16, 24u16), (120u16, 32u16)] { |
| 8697 | let rendered = crate::tui::golden_harness::render_golden_text(width, height, |buf| { |
| 8698 | view.render(Rect::new(0, 0, width, height), buf); |
| 8699 | }); |
| 8700 | crate::tui::golden_harness::assert_matches_golden( |
| 8701 | &format!("config_panel_{width}x{height}"), |
| 8702 | &rendered, |
| 8703 | ); |
| 8704 | } |
| 8705 | } |
| 8706 | |
| 8707 | /// ASCII-safe terminals: every settings row kind (toggle, choice, number, |
| 8708 | /// text, action, read-only) and the panel chrome around it must narrow |
| 8709 | /// to ASCII through the backend's cell adapter. `✎` used to pass through |
| 8710 | /// untouched on every editable number/text row. |
| 8711 | #[test] |
| 8712 | fn config_panel_every_row_kind_renders_ascii_through_the_adapter() { |
| 8713 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 8714 | let app = create_test_app(); |
| 8715 | let mut view = ConfigView::new_for_app(&app); |
| 8716 | let mut kinds_seen = Vec::new(); |
| 8717 | for category in ConfigCategory::ALL { |
| 8718 | view.category = category; |
| 8719 | view.select_first_visible_row(); |
| 8720 | let items = view.visible_items(); |
| 8721 | for item in &items { |
| 8722 | if let ConfigListItem::Row(idx) = item { |
| 8723 | let kind = view.editor_kind(&view.rows[*idx]); |
| 8724 | if !kinds_seen.contains(&kind) { |
| 8725 | kinds_seen.push(kind); |
| 8726 | } |
| 8727 | } |
| 8728 | } |
| 8729 | // Tall enough that the whole category paints without scrolling. |
| 8730 | let height = u16::try_from(items.len() + 16).unwrap_or(u16::MAX); |
| 8731 | let area = Rect::new(0, 0, 120, height); |
| 8732 | let mut buf = Buffer::empty(area); |
| 8733 | view.render(area, &mut buf); |
| 8734 | for y in area.top()..area.bottom() { |
| 8735 | for x in area.left()..area.right() { |
| 8736 | let mut cell = buf[(x, y)].clone(); |
| 8737 | crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell); |
| 8738 | assert!( |
| 8739 | cell.symbol().is_ascii(), |
| 8740 | "{category:?} ({x},{y}): {:?} has no ASCII fallback", |
| 8741 | buf[(x, y)].symbol() |
| 8742 | ); |
| 8743 | } |
| 8744 | } |
| 8745 | } |
| 8746 | for kind in [ |
| 8747 | SettingKind::Boolean, |
| 8748 | SettingKind::Choice, |
| 8749 | SettingKind::Integer, |
| 8750 | SettingKind::Text, |
| 8751 | SettingKind::Action, |
| 8752 | SettingKind::ReadOnly, |
| 8753 | ] { |
| 8754 | assert!( |
| 8755 | kinds_seen.contains(&kind), |
| 8756 | "no settings row of kind {kind:?} was rendered" |
| 8757 | ); |
| 8758 | } |
| 8759 | } |
| 8760 | |
| 8761 | /// Slice C: cell-exact goldens for Edit Theme with the underwater |
| 8762 | /// default open — title, scope/current lanes, the 14 theme rows, and |
| 8763 | /// the Apply/Cancel controls. Empty settings mean the editor opens on |
| 8764 | /// the default theme, so these goldens pin the default end to end. |
| 8765 | /// Re-bless with `CODEWHALE_BLESS_GOLDENS=1`. |
| 8766 | #[test] |
| 8767 | fn edit_theme_matches_goldens_at_blocker_sizes() { |
| 8768 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 8769 | let app = create_test_app(); |
| 8770 | let mut view = ConfigView::new_for_app(&app); |
| 8771 | view.focus_key("theme"); |
| 8772 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 8773 | assert!( |
| 8774 | view.editing |
| 8775 | .as_ref() |
| 8776 | .is_some_and(|edit| edit.key == "theme"), |
| 8777 | "Enter must open the theme editor" |
| 8778 | ); |
| 8779 | for (width, height) in [(80u16, 24u16), (120u16, 32u16)] { |
| 8780 | let rendered = crate::tui::golden_harness::render_golden_text(width, height, |buf| { |
| 8781 | view.render(Rect::new(0, 0, width, height), buf); |
| 8782 | }); |
| 8783 | crate::tui::golden_harness::assert_matches_golden( |
| 8784 | &format!("edit_theme_{width}x{height}"), |
| 8785 | &trim_golden_rows(&rendered), |
| 8786 | ); |
| 8787 | } |
| 8788 | } |
| 8789 | |
| 8790 | /// Goldens are stored without cell padding: every row is right-trimmed |
| 8791 | /// and trailing empty rows are dropped, so `git diff --check` stays |
| 8792 | /// clean. |
| 8793 | fn trim_golden_rows(text: &str) -> String { |
| 8794 | let mut rows: Vec<&str> = text.lines().map(str::trim_end).collect(); |
| 8795 | while rows.last().is_some_and(|row| row.is_empty()) { |
| 8796 | rows.pop(); |
| 8797 | } |
| 8798 | let mut out = rows.join("\n"); |
| 8799 | out.push('\n'); |
| 8800 | out |
| 8801 | } |
| 8802 | |
| 8803 | #[test] |
| 8804 | fn notification_rows_keep_saved_and_live_values_distinct_after_reopening() { |
| 8805 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 8806 | let temp = tempfile::tempdir().unwrap(); |
| 8807 | let path = temp.path().join("config.toml"); |
| 8808 | std::fs::write(&path, "[notifications]\nquiet = false\nsound = \"off\"\n").unwrap(); |
| 8809 | let mut app = create_test_app(); |
| 8810 | app.config_path = Some(path.clone()); |
| 8811 | let mut config = Config::load(Some(path), None).unwrap(); |
| 8812 | crate::tui::ui::apply_notification_update( |
| 8813 | &mut app, |
| 8814 | &mut config, |
| 8815 | crate::config::NotificationConfigUpdate::Quiet(true), |
| 8816 | ) |
| 8817 | .unwrap(); |
| 8818 | crate::tui::ui::apply_notification_update( |
| 8819 | &mut app, |
| 8820 | &mut config, |
| 8821 | crate::config::NotificationConfigUpdate::Sound(Some( |
| 8822 | crate::config::CompletionSound::Whale, |
| 8823 | )), |
| 8824 | ) |
| 8825 | .unwrap(); |
| 8826 | for _ in 0..2 { |
| 8827 | let mut view = ConfigView::new_for_app(&app); |
| 8828 | let row = view |
| 8829 | .rows |
| 8830 | .iter() |
| 8831 | .find(|row| row.key == "notifications.quiet") |
| 8832 | .unwrap(); |
| 8833 | assert_eq!(row.value, "false"); |
| 8834 | assert_eq!(row.edit_value(), "true"); |
| 8835 | let fact = view.setting_fact(row).unwrap(); |
| 8836 | assert_ne!(fact.saved, fact.current); |
| 8837 | view.focus_key("notifications.quiet"); |
| 8838 | assert!( |
| 8839 | matches!(view.toggle_selected_boolean(), Some(ViewAction::Emit(ViewEvent::ConfigUpdated { value, .. })) if value == "false") |
| 8840 | ); |
| 8841 | view.focus_key("notifications.sound"); |
| 8842 | view.start_edit(); |
| 8843 | let edit = view.editing.as_ref().unwrap(); |
| 8844 | assert_eq!( |
| 8845 | edit.choices.as_ref().unwrap()[edit.selected_choice], |
| 8846 | "whale" |
| 8847 | ); |
| 8848 | } |
| 8849 | app.refresh_notification_settings(&Config::default()); |
| 8850 | } |
| 8851 | |
| 8852 | /// The settings screen is a projection of the schema: its rail tabs, the |
| 8853 | /// group headings inside them, and the row order are the schema's |
| 8854 | /// declaration order, not a second table's. This is the one table test |
| 8855 | /// that keeps the projection honest. |
| 8856 | #[test] |
| 8857 | fn settings_tree_equals_the_schema_projection() { |
| 8858 | let mut view = create_config_view(Locale::En); |
| 8859 | let built: std::collections::HashSet<&str> = |
| 8860 | view.rows.iter().map(|row| row.key.as_str()).collect(); |
| 8861 | let schema_keys: std::collections::HashSet<&str> = |
| 8862 | codewhale_config::schema_rows().map(|def| def.key).collect(); |
| 8863 | |
| 8864 | // Every schema row must appear in the view; `ui: None` settings stay |
| 8865 | // declared but off-screen, and anything the schema says is visible must |
| 8866 | // have a row here. A few rows are mutually exclusive: exactly one member |
| 8867 | // of each set is shown, chosen by which store controls the fact. |
| 8868 | let conditional_pairs: &[&[&str]] = &[ |
| 8869 | &[ |
| 8870 | "permission_posture", |
| 8871 | "approval_policy", |
| 8872 | "managed_approval_policy", |
| 8873 | ], |
| 8874 | &["allow_shell", "managed_allow_shell"], |
| 8875 | &["base_url", "provider_url"], |
| 8876 | ]; |
| 8877 | for def in codewhale_config::schema_rows() { |
| 8878 | let present_in_pair = conditional_pairs |
| 8879 | .iter() |
| 8880 | .any(|pair| pair.contains(&def.key) && pair.iter().any(|key| built.contains(key))); |
| 8881 | // Experimental feature rows exist only when the flag is configured |
| 8882 | // or non-default (`experimental_feature_rows`). |
| 8883 | let configured_only = def.key.starts_with("features."); |
| 8884 | assert!( |
| 8885 | def.ui.is_none() || built.contains(def.key) || present_in_pair || configured_only, |
| 8886 | "{} is declared in schema_rows but has no ConfigRow", |
| 8887 | def.key |
| 8888 | ); |
| 8889 | } |
| 8890 | |
| 8891 | // No row should exist that is not declared in the schema. |
| 8892 | for key in &built { |
| 8893 | assert!( |
| 8894 | schema_keys.contains(key), |
| 8895 | "{key} has a ConfigRow but is not declared in schema_rows" |
| 8896 | ); |
| 8897 | } |
| 8898 | |
| 8899 | assert_eq!( |
| 8900 | codewhale_config::schema_tabs(), |
| 8901 | ConfigCategory::ALL |
| 8902 | .iter() |
| 8903 | .map(|category| category.id()) |
| 8904 | .collect::<Vec<_>>(), |
| 8905 | "rail order and schema tab order have drifted apart" |
| 8906 | ); |
| 8907 | |
| 8908 | let mut expected: Vec<String> = Vec::new(); |
| 8909 | for tab in codewhale_config::schema_tabs() { |
| 8910 | let mut group: Option<&str> = None; |
| 8911 | for def in codewhale_config::schema_rows() { |
| 8912 | let ui = def.ui.as_ref().expect("schema_rows filters on ui"); |
| 8913 | if ui.tab != tab || !built.contains(def.key) { |
| 8914 | continue; |
| 8915 | } |
| 8916 | if group != Some(ui.group) { |
| 8917 | group = Some(ui.group); |
| 8918 | expected.push(format!("{tab}/{}", ui.group)); |
| 8919 | } |
| 8920 | expected.push(format!("{tab}/{}/{}", ui.group, def.key)); |
| 8921 | } |
| 8922 | } |
| 8923 | |
| 8924 | let mut actual: Vec<String> = Vec::new(); |
| 8925 | for category in ConfigCategory::ALL { |
| 8926 | view.category = category; |
| 8927 | for item in view.visible_items() { |
| 8928 | match item { |
| 8929 | ConfigListItem::Section(section) => { |
| 8930 | actual.push(format!("{}/{}", category.id(), section.id())); |
| 8931 | } |
| 8932 | ConfigListItem::Row(idx) => { |
| 8933 | let row = &view.rows[idx]; |
| 8934 | actual.push(format!( |
| 8935 | "{}/{}/{}", |
| 8936 | category.id(), |
| 8937 | row.section().id(), |
| 8938 | row.key |
| 8939 | )); |
| 8940 | } |
| 8941 | } |
| 8942 | } |
| 8943 | } |
| 8944 | |
| 8945 | assert_eq!(actual, expected); |
| 8946 | } |
| 8947 | |
| 8948 | /// Every persisted row must land in a store. Typed action rows only open |
| 8949 | /// another surface; `settings.toml` rows round-trip through `Settings`. |
| 8950 | /// The remaining receipts and `config.toml` keys are spelled out so a new |
| 8951 | /// editable row cannot quietly discard the user's edit. |
| 8952 | #[test] |
| 8953 | fn every_settings_row_reaches_a_store() { |
| 8954 | let _guard = crate::test_support::lock_test_env(); |
| 8955 | // Non-action rows outside `settings.toml`: report a fact or persist |
| 8956 | // to config.toml through `set_config_value`. |
| 8957 | const NOT_SETTINGS_TOML: &[&str] = &[ |
| 8958 | "fleet.exec.max_spawn_depth", |
| 8959 | "goal_command", |
| 8960 | "workflow", |
| 8961 | "mcp_open", |
| 8962 | "mcp_reconnect", |
| 8963 | "mcp_diagnose", |
| 8964 | "plugins_open", |
| 8965 | "sandbox_details", |
| 8966 | "mcp_config_path", |
| 8967 | "approval_mode", |
| 8968 | "permission_posture", |
| 8969 | "approval_policy", |
| 8970 | "managed_approval_policy", |
| 8971 | "allow_shell", |
| 8972 | "managed_allow_shell", |
| 8973 | "telemetry", |
| 8974 | "context_window", |
| 8975 | "effective_context_window", |
| 8976 | "fast_model", |
| 8977 | "features.vision_model", |
| 8978 | "features.subagents", |
| 8979 | "features.web_search", |
| 8980 | "features.apply_patch", |
| 8981 | "features.mcp", |
| 8982 | "features.exec_policy", |
| 8983 | "base_url", |
| 8984 | "provider_url", |
| 8985 | "effective_context_window", |
| 8986 | "external_credentials.openai-codex", |
| 8987 | "external_credentials.xai", |
| 8988 | ]; |
| 8989 | |
| 8990 | for def in codewhale_config::schema_rows() { |
| 8991 | if def.ui.is_some_and(|ui| { |
| 8992 | ui.row == codewhale_config::settings_schema::SettingRowKind::Action |
| 8993 | }) { |
| 8994 | continue; |
| 8995 | } |
| 8996 | if let Some(setting) = |
| 8997 | codewhale_config::notifications::NotificationSetting::parse(def.key) |
| 8998 | { |
| 8999 | let samples = match setting { |
| 9000 | codewhale_config::notifications::NotificationSetting::SoundFile => { |
| 9001 | vec!["call with spaces.wav".to_string()] |
| 9002 | } |
| 9003 | codewhale_config::notifications::NotificationSetting::EventSoundEvents => { |
| 9004 | vec![r#"["input-needed", "model-notify"]"#.to_string()] |
| 9005 | } |
| 9006 | _ => def |
| 9007 | .values() |
| 9008 | .map(|values| values.into_iter().map(str::to_string).collect()) |
| 9009 | .unwrap_or_else(|| vec!["37".to_string()]), |
| 9010 | }; |
| 9011 | let temp = tempfile::tempdir().unwrap(); |
| 9012 | let path = temp.path().join("config.toml"); |
| 9013 | for sample in samples { |
| 9014 | let edit = |
| 9015 | crate::config::NotificationConfigUpdate::parse(setting, &sample).unwrap(); |
| 9016 | edit.persist(&path).unwrap(); |
| 9017 | let loaded = Config::load(Some(path.clone()), None) |
| 9018 | .unwrap() |
| 9019 | .notifications_config(); |
| 9020 | assert_eq!( |
| 9021 | loaded.display(setting), |
| 9022 | edit.display(), |
| 9023 | "{} must reach the TUI config store", |
| 9024 | def.key |
| 9025 | ); |
| 9026 | } |
| 9027 | continue; |
| 9028 | } |
| 9029 | // At least one value per row that is not the default, so a row |
| 9030 | // whose store silently drops writes cannot pass by looking like |
| 9031 | // an untouched `Settings`: bools and enums try every value, an |
| 9032 | // int tries the default plus one, free text tries a sentinel. |
| 9033 | // `config_choice_values` also covers the two registry-backed |
| 9034 | // strings (theme, locale), whose value set is the shipped list. |
| 9035 | let samples: Vec<String> = match config_choice_values(def.key) { |
| 9036 | Some(values) => values, |
| 9037 | // Validated free text: a value the store's own parser accepts. |
| 9038 | None if def.key == "background_color" => vec!["#1a1b26".to_string()], |
| 9039 | None if def.key == "default_model" => vec!["deepseek-v4-pro".to_string()], |
| 9040 | None if def.is_int() => { |
| 9041 | let default: i64 = def.default.parse().unwrap_or(0); |
| 9042 | vec![(default + 1).to_string()] |
| 9043 | } |
| 9044 | None if def.is_float() => { |
| 9045 | let default: f64 = def.default.parse().unwrap_or(50.0); |
| 9046 | vec![(default + 0.5).to_string()] |
| 9047 | } |
| 9048 | None => vec!["roundtrip-probe".to_string()], |
| 9049 | }; |
| 9050 | assert!( |
| 9051 | !samples.is_empty(), |
| 9052 | "{} yields no sample to round-trip", |
| 9053 | def.key |
| 9054 | ); |
| 9055 | let mut settings = Settings::default(); |
| 9056 | let accepted = samples |
| 9057 | .first() |
| 9058 | .is_some_and(|sample| settings.set(def.key, sample).is_ok()); |
| 9059 | if !accepted { |
| 9060 | assert!( |
| 9061 | NOT_SETTINGS_TOML.contains(&def.key), |
| 9062 | "{} shows a row that settings.toml will not take", |
| 9063 | def.key |
| 9064 | ); |
| 9065 | continue; |
| 9066 | } |
| 9067 | for sample in &samples { |
| 9068 | let mut settings = Settings::default(); |
| 9069 | settings |
| 9070 | .set(def.key, sample) |
| 9071 | .unwrap_or_else(|error| panic!("{} rejects {sample}: {error}", def.key)); |
| 9072 | let written = toml::to_string(&settings) |
| 9073 | .unwrap_or_else(|error| panic!("{} will not serialize: {error}", def.key)); |
| 9074 | let reloaded: Settings = toml::from_str(&written) |
| 9075 | .unwrap_or_else(|error| panic!("{} will not reload: {error}", def.key)); |
| 9076 | assert_eq!( |
| 9077 | toml::to_string(&reloaded).expect("reloaded settings serialize"), |
| 9078 | written, |
| 9079 | "{} does not survive a settings.toml round trip at {sample}", |
| 9080 | def.key |
| 9081 | ); |
| 9082 | } |
| 9083 | } |
| 9084 | } |
| 9085 | |
| 9086 | /// Every field `Settings` persists to settings.toml is declared in |
| 9087 | /// SETTINGS_SCHEMA — a row for editable values, a hidden def for picker |
| 9088 | /// memory and one-way flags. A persisted field without a declaration has |
| 9089 | /// no kind, no provenance layer, and no resolver home. |
| 9090 | #[test] |
| 9091 | fn every_persisted_settings_field_is_declared_in_the_schema() { |
| 9092 | use crate::settings::PinnedModel; |
| 9093 | |
| 9094 | // Options serialize as absent when None; force them present so the |
| 9095 | // table below names every key settings.toml can hold. |
| 9096 | let settings = Settings { |
| 9097 | background_color: Some("#1a1b26".to_string()), |
| 9098 | default_provider: Some("deepseek".to_string()), |
| 9099 | default_model: Some("deepseek-v4-pro".to_string()), |
| 9100 | reasoning_effort: Some("medium".to_string()), |
| 9101 | permission_posture: Some("ask".to_string()), |
| 9102 | sandbox_mode: Some("read-only".to_string()), |
| 9103 | provider_models: Some(std::collections::HashMap::from([( |
| 9104 | "deepseek".to_string(), |
| 9105 | "deepseek-v4-pro".to_string(), |
| 9106 | )])), |
| 9107 | enabled_models: Some(std::collections::HashMap::from([( |
| 9108 | "deepseek".to_string(), |
| 9109 | vec!["deepseek-v4-pro".to_string()], |
| 9110 | )])), |
| 9111 | pinned_models: vec![PinnedModel { |
| 9112 | provider: "deepseek".to_string(), |
| 9113 | model: "deepseek-v4-pro".to_string(), |
| 9114 | label: None, |
| 9115 | }], |
| 9116 | behavioral_tip_impressions: std::collections::BTreeMap::from([( |
| 9117 | "probe".to_string(), |
| 9118 | 1u8, |
| 9119 | )]), |
| 9120 | footer_hint_uses: std::collections::BTreeMap::from([("probe".to_string(), 1u8)]), |
| 9121 | ..Settings::default() |
| 9122 | }; |
| 9123 | let table = toml::Value::try_from(&settings) |
| 9124 | .expect("settings serialize") |
| 9125 | .as_table() |
| 9126 | .expect("settings are a table") |
| 9127 | .clone(); |
| 9128 | // The probe is only trustworthy if it actually names the keys whose |
| 9129 | // only declaration is hidden; a future `skip_serializing` would |
| 9130 | // silently drop a key from this table instead of failing below. |
| 9131 | for key in [ |
| 9132 | "tool_collapse_mode", |
| 9133 | "max_input_history", |
| 9134 | "default_provider", |
| 9135 | "sandbox_mode", |
| 9136 | "provider_models", |
| 9137 | "enabled_models", |
| 9138 | "pinned_models", |
| 9139 | "feature_intro_shown", |
| 9140 | "yolo_deprecation_shown", |
| 9141 | "work_surface_bottom_migrated", |
| 9142 | "behavioral_tip_impressions", |
| 9143 | "footer_hint_uses", |
| 9144 | ] { |
| 9145 | assert!( |
| 9146 | table.contains_key(key), |
| 9147 | "probe settings undercovers settings.toml: `{key}` did not serialize" |
| 9148 | ); |
| 9149 | } |
| 9150 | for key in table.keys() { |
| 9151 | assert!( |
| 9152 | codewhale_config::setting(key).is_some(), |
| 9153 | "settings.toml persists `{key}` with no SETTINGS_SCHEMA declaration" |
| 9154 | ); |
| 9155 | } |
| 9156 | } |
| 9157 | |
| 9158 | /// Every message key declared by the schema must resolve to a localized |
| 9159 | /// string in every shipped locale. `tr_key` returns the key itself when a |
| 9160 | /// pack is missing the entry, so this fails fast on a stale binding. |
| 9161 | #[test] |
| 9162 | fn settings_schema_message_keys_are_localized() { |
| 9163 | let mut keys: Vec<&'static str> = Vec::new(); |
| 9164 | for def in codewhale_config::SETTINGS_SCHEMA { |
| 9165 | if let Some(ui) = def.ui { |
| 9166 | if !ui.label.is_empty() { |
| 9167 | keys.push(ui.label); |
| 9168 | } |
| 9169 | if !ui.description.is_empty() { |
| 9170 | keys.push(ui.description); |
| 9171 | } |
| 9172 | } |
| 9173 | let options = match def.kind { |
| 9174 | codewhale_config::SettingKind::Bool(options) => options, |
| 9175 | codewhale_config::SettingKind::Enum(options) => options, |
| 9176 | codewhale_config::SettingKind::Int |
| 9177 | | codewhale_config::SettingKind::String |
| 9178 | | codewhale_config::SettingKind::Float => &[], |
| 9179 | }; |
| 9180 | for option in options { |
| 9181 | if !option.label.is_empty() { |
| 9182 | keys.push(option.label); |
| 9183 | } |
| 9184 | if !option.description.is_empty() { |
| 9185 | keys.push(option.description); |
| 9186 | } |
| 9187 | } |
| 9188 | } |
| 9189 | |
| 9190 | for locale in Locale::shipped() { |
| 9191 | for key in &keys { |
| 9192 | let resolved = tr_key(*locale, key); |
| 9193 | assert_ne!( |
| 9194 | resolved.as_ref(), |
| 9195 | *key, |
| 9196 | "{key} is not localized for {locale:?}" |
| 9197 | ); |
| 9198 | } |
| 9199 | } |
| 9200 | } |
| 9201 | |
| 9202 | #[test] |
| 9203 | fn config_view_exposes_configured_and_effective_context_window() { |
| 9204 | let _env = crate::test_support::lock_test_env(); |
| 9205 | let temp = tempfile::tempdir().expect("config fixture"); |
| 9206 | let config_path = temp.path().join("config.toml"); |
| 9207 | std::fs::write( |
| 9208 | &config_path, |
| 9209 | r#" |
| 9210 | provider = "moonshot" |
| 9211 | [providers.moonshot] |
| 9212 | model = "kimi-k3" |
| 9213 | context_window = 262144 |
| 9214 | "#, |
| 9215 | ) |
| 9216 | .expect("config"); |
| 9217 | let _config_path = |
| 9218 | crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 9219 | let config = |
| 9220 | Config::load(Some(config_path.clone()), None).expect("captured fixture config"); |
| 9221 | let options = TuiOptions { |
| 9222 | config_path: Some(config_path), |
| 9223 | ..crate::test_support::test_tui_options(temp.path()) |
| 9224 | }; |
| 9225 | let mut app = App::new(options, &config); |
| 9226 | let identity = config |
| 9227 | .active_provider_identity() |
| 9228 | .expect("captured fixture provider"); |
| 9229 | assert_eq!( |
| 9230 | config.context_window_for_provider_config(&identity), |
| 9231 | Some(262_144) |
| 9232 | ); |
| 9233 | app.set_provider_identity_record(identity); |
| 9234 | app.model = "kimi-k3".to_string(); |
| 9235 | app.active_route_limits = Some(codewhale_config::route::RouteLimits { |
| 9236 | context_tokens: Some(262_144), |
| 9237 | ..Default::default() |
| 9238 | }); |
| 9239 | app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured; |
| 9240 | |
| 9241 | let view = ConfigView::new_for_app(&app); |
| 9242 | let configured = view |
| 9243 | .rows |
| 9244 | .iter() |
| 9245 | .find(|row| row.key == "context_window") |
| 9246 | .expect("configured context row"); |
| 9247 | let effective = view |
| 9248 | .rows |
| 9249 | .iter() |
| 9250 | .find(|row| row.key == "effective_context_window") |
| 9251 | .expect("effective context row"); |
| 9252 | |
| 9253 | assert_eq!(configured.value, "262144"); |
| 9254 | assert_eq!(effective.value, "262144 tokens · configured"); |
| 9255 | |
| 9256 | app.auto_model = true; |
| 9257 | app.last_effective_provider = Some(app.api_provider); |
| 9258 | app.last_effective_provider_identity = Some("other-moonshot".to_string()); |
| 9259 | let automatic_view = ConfigView::new_for_app(&app); |
| 9260 | let configured = automatic_view |
| 9261 | .rows |
| 9262 | .iter() |
| 9263 | .find(|row| row.key == "context_window") |
| 9264 | .expect("automatic configured context row"); |
| 9265 | assert_eq!(configured.value, "(not set)"); |
| 9266 | app.pending_turn_route = Some((app.api_provider, "kimi-k3".to_string(), true)); |
| 9267 | let pending_view = ConfigView::new_for_app(&app); |
| 9268 | let configured = pending_view |
| 9269 | .rows |
| 9270 | .iter() |
| 9271 | .find(|row| row.key == "context_window") |
| 9272 | .expect("pending foreign configured context row"); |
| 9273 | assert_eq!(configured.value, "(not set)"); |
| 9274 | app.pending_turn_route = None; |
| 9275 | app.auto_model = false; |
| 9276 | |
| 9277 | // Same provider kind is insufficient when a different owned table is |
| 9278 | // selected. The saved row must not borrow another table's limit. |
| 9279 | let saved_path = PathBuf::from( |
| 9280 | std::env::var_os("DEEPSEEK_CONFIG_PATH").expect("isolated saved config path"), |
| 9281 | ); |
| 9282 | std::fs::write( |
| 9283 | &saved_path, |
| 9284 | r#" |
| 9285 | provider = "custom-current" |
| 9286 | [providers.custom-current] |
| 9287 | kind = "openai-compatible" |
| 9288 | base_url = "https://current.example.invalid/v1" |
| 9289 | model = "fixture-model" |
| 9290 | context_window = 131072 |
| 9291 | "#, |
| 9292 | ) |
| 9293 | .expect("saved custom provider"); |
| 9294 | let saved = Config::load(Some(saved_path.clone()), None).expect("saved custom config"); |
| 9295 | let saved_identity = saved |
| 9296 | .active_provider_identity() |
| 9297 | .expect("saved admitted custom identity"); |
| 9298 | assert_eq!(saved_identity.provider, crate::config::ProviderKind::Custom); |
| 9299 | assert_eq!(saved_identity.key.as_str(), "custom-current"); |
| 9300 | assert_eq!( |
| 9301 | saved.context_window_for_provider_config(&saved_identity), |
| 9302 | Some(131_072) |
| 9303 | ); |
| 9304 | let options = TuiOptions { |
| 9305 | config_path: Some(saved_path), |
| 9306 | ..crate::test_support::test_tui_options(temp.path()) |
| 9307 | }; |
| 9308 | let mut app = App::new(options, &saved); |
| 9309 | app.set_provider_identity_record(saved_identity.clone()); |
| 9310 | let saved_view = ConfigView::new_for_app(&app); |
| 9311 | let configured = saved_view |
| 9312 | .rows |
| 9313 | .iter() |
| 9314 | .find(|row| row.key == "context_window") |
| 9315 | .expect("saved custom configured context row"); |
| 9316 | assert_eq!(configured.value, "131072"); |
| 9317 | let foreign: Config = toml::from_str( |
| 9318 | r#" |
| 9319 | provider = "custom-other" |
| 9320 | [providers.custom-other] |
| 9321 | kind = "openai-compatible" |
| 9322 | base_url = "https://other.example.invalid/v1" |
| 9323 | model = "fixture-model" |
| 9324 | context_window = 131072 |
| 9325 | "#, |
| 9326 | ) |
| 9327 | .expect("foreign configured provider"); |
| 9328 | let foreign_identity = foreign |
| 9329 | .active_provider_identity() |
| 9330 | .expect("foreign admitted provider identity"); |
| 9331 | assert_eq!( |
| 9332 | foreign_identity.provider, |
| 9333 | crate::config::ProviderKind::Custom |
| 9334 | ); |
| 9335 | assert_eq!(foreign_identity.provider, app.api_provider); |
| 9336 | assert_eq!(foreign_identity.key.as_str(), "custom-other"); |
| 9337 | assert_ne!(foreign_identity.key, saved_identity.key); |
| 9338 | app.set_provider_identity_record(foreign_identity); |
| 9339 | let foreign_view = ConfigView::new_for_app(&app); |
| 9340 | let configured = foreign_view |
| 9341 | .rows |
| 9342 | .iter() |
| 9343 | .find(|row| row.key == "context_window") |
| 9344 | .expect("foreign configured context row"); |
| 9345 | assert_eq!(configured.value, "(not set)"); |
| 9346 | } |
| 9347 | |
| 9348 | #[test] |
| 9349 | fn config_view_displays_saved_codex_reasoning_effort_label() { |
| 9350 | let _guard = ConfigSettingsEnvGuard::new("reasoning_effort = \"max\"\n"); |
| 9351 | let mut app = create_test_app(); |
| 9352 | app.api_provider = crate::config::ProviderKind::OpenaiCodex; |
| 9353 | |
| 9354 | let view = ConfigView::new_for_app(&app); |
| 9355 | let row = view |
| 9356 | .rows |
| 9357 | .iter() |
| 9358 | .find(|row| row.key == "reasoning_effort") |
| 9359 | .expect("reasoning_effort row"); |
| 9360 | |
| 9361 | assert_eq!(row.value, "max"); |
| 9362 | } |
| 9363 | |
| 9364 | #[test] |
| 9365 | fn config_view_editing_localized_default_placeholders_starts_blank() { |
| 9366 | let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\n"); |
| 9367 | let app = create_test_app(); |
| 9368 | let mut view = ConfigView::new_for_app(&app); |
| 9369 | |
| 9370 | for (key, message_id) in [ |
| 9371 | ("reasoning_effort", MessageId::ConfigDefaultReasoning), |
| 9372 | ("background_color", MessageId::ConfigDefaultValue), |
| 9373 | ] { |
| 9374 | view.focus_key(key); |
| 9375 | view.start_edit(); |
| 9376 | |
| 9377 | let edit = view.editing.as_ref().expect("editing should start"); |
| 9378 | assert_eq!(edit.original_value, tr(Locale::ZhHans, message_id)); |
| 9379 | assert!( |
| 9380 | edit.buffer.is_empty(), |
| 9381 | "localized default placeholder should not become edit text for {key}" |
| 9382 | ); |
| 9383 | |
| 9384 | view.editing = None; |
| 9385 | } |
| 9386 | } |
| 9387 | |
| 9388 | #[test] |
| 9389 | fn config_view_filter_matches_group_and_rows() { |
| 9390 | let mut view = create_config_view(Locale::En); |
| 9391 | |
| 9392 | type_filter(&mut view, "workbar"); |
| 9393 | |
| 9394 | assert_eq!(view.filter, "workbar"); |
| 9395 | assert_eq!(visible_section_labels(&view), vec!["Workbar"]); |
| 9396 | assert_eq!( |
| 9397 | visible_row_keys(&view), |
| 9398 | vec![ |
| 9399 | "work_surface_placement", |
| 9400 | "work_surface_top_height", |
| 9401 | "work_surface_side_width", |
| 9402 | "rail_panel", |
| 9403 | ] |
| 9404 | ); |
| 9405 | assert_eq!(view.rows[view.selected].key, "work_surface_placement"); |
| 9406 | } |
| 9407 | |
| 9408 | #[test] |
| 9409 | fn localized_config_view_filter_matches_english_section_and_scope_labels() { |
| 9410 | let mut view = create_config_view(Locale::PtBr); |
| 9411 | |
| 9412 | type_filter(&mut view, "workbar saved"); |
| 9413 | |
| 9414 | assert_eq!(view.filter, "workbar saved"); |
| 9415 | assert_eq!(visible_section_labels(&view), vec!["Barra lateral"]); |
| 9416 | assert_eq!( |
| 9417 | visible_row_keys(&view), |
| 9418 | vec![ |
| 9419 | "work_surface_placement", |
| 9420 | "work_surface_top_height", |
| 9421 | "work_surface_side_width", |
| 9422 | "rail_panel", |
| 9423 | ] |
| 9424 | ); |
| 9425 | } |
| 9426 | |
| 9427 | #[test] |
| 9428 | fn config_view_filter_accepts_unicode_case() { |
| 9429 | let app = create_test_app(); |
| 9430 | let mut view = ConfigView::new_for_app(&app); |
| 9431 | |
| 9432 | type_filter(&mut view, "thinking"); |
| 9433 | assert_eq!( |
| 9434 | visible_row_keys(&view), |
| 9435 | vec![ |
| 9436 | // `reasoning_effort` joined this filter when the thinking |
| 9437 | // ladder gave it a config row; the schema files it under |
| 9438 | // Models, so it now sorts after the appearance rows. |
| 9439 | "show_thinking", |
| 9440 | "thinking_default_expanded", |
| 9441 | "thinking_preview_lines", |
| 9442 | "thinking_highlight", |
| 9443 | "reasoning_effort" |
| 9444 | ] |
| 9445 | ); |
| 9446 | |
| 9447 | view.clear_filter(); |
| 9448 | view.rows[0].value = "CAFÉ".to_string(); |
| 9449 | type_filter(&mut view, "café"); |
| 9450 | assert_eq!(visible_row_keys(&view), vec!["theme"]); |
| 9451 | } |
| 9452 | |
| 9453 | fn assert_config_search_owns_text(query: &str) { |
| 9454 | let mut view = create_config_view(Locale::En); |
| 9455 | // Start on an actionable boolean so a stolen Space would emit a |
| 9456 | // persisted update, and a stolen e would open an editor. |
| 9457 | view.focus_key("low_motion"); |
| 9458 | let values = view |
| 9459 | .rows |
| 9460 | .iter() |
| 9461 | .map(|row| row.value.clone()) |
| 9462 | .collect::<Vec<_>>(); |
| 9463 | let mut stack = ViewStack::new(); |
| 9464 | stack.push(view); |
| 9465 | for ch in query.chars() { |
| 9466 | assert!( |
| 9467 | stack |
| 9468 | .handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)) |
| 9469 | .is_empty(), |
| 9470 | "{query:?}" |
| 9471 | ); |
| 9472 | assert_eq!(stack.top_kind(), Some(ModalKind::Config), "{query:?}"); |
| 9473 | } |
| 9474 | let mut modal = stack.pop().unwrap(); |
| 9475 | let view = modal.as_any_mut().downcast_mut::<ConfigView>().unwrap(); |
| 9476 | assert_eq!(view.filter, query); |
| 9477 | assert!( |
| 9478 | view.editing.is_none(), |
| 9479 | "search text must not enter a settings editor" |
| 9480 | ); |
| 9481 | assert_eq!( |
| 9482 | view.rows |
| 9483 | .iter() |
| 9484 | .map(|row| row.value.clone()) |
| 9485 | .collect::<Vec<_>>(), |
| 9486 | values |
| 9487 | ); |
| 9488 | assert!(matches!( |
| 9489 | view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), |
| 9490 | ViewAction::None |
| 9491 | )); |
| 9492 | assert!(view.filter.is_empty()); |
| 9493 | assert!(matches!( |
| 9494 | view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), |
| 9495 | ViewAction::Close |
| 9496 | )); |
| 9497 | } |
| 9498 | |
| 9499 | #[test] |
| 9500 | fn config_search_owns_initial_q() { |
| 9501 | assert_config_search_owns_text("quiet"); |
| 9502 | assert_config_search_owns_text("Queue"); |
| 9503 | } |
| 9504 | |
| 9505 | #[test] |
| 9506 | fn config_search_owns_initial_e() { |
| 9507 | assert_config_search_owns_text("effort"); |
| 9508 | assert_config_search_owns_text("Effort"); |
| 9509 | } |
| 9510 | |
| 9511 | #[test] |
| 9512 | fn config_search_owns_initial_j() { |
| 9513 | assert_config_search_owns_text("json"); |
| 9514 | } |
| 9515 | |
| 9516 | #[test] |
| 9517 | fn config_search_owns_initial_k() { |
| 9518 | assert_config_search_owns_text("key"); |
| 9519 | } |
| 9520 | |
| 9521 | #[test] |
| 9522 | fn config_search_owns_initial_space() { |
| 9523 | assert_config_search_owns_text(" 队列é"); |
| 9524 | } |
| 9525 | |
| 9526 | #[test] |
| 9527 | fn config_view_filter_matches_friendly_labels_and_hints() { |
| 9528 | let mut view = create_config_view(Locale::En); |
| 9529 | |
| 9530 | type_filter(&mut view, "shell access"); |
| 9531 | assert_eq!(visible_row_keys(&view), vec!["allow_shell"]); |
| 9532 | |
| 9533 | view.clear_filter(); |
| 9534 | type_filter(&mut view, "reasoning level"); |
| 9535 | assert_eq!(visible_row_keys(&view), vec!["reasoning_effort"]); |
| 9536 | |
| 9537 | view.clear_filter(); |
| 9538 | type_filter(&mut view, "fan-out/fan-in"); |
| 9539 | assert_eq!(visible_row_keys(&view), vec!["workflow"]); |
| 9540 | } |
| 9541 | |
| 9542 | /// #5134 filed an issue to ask how to raise the context window, because |
| 9543 | /// the rows that answer it are keyed `context_window` and only findable by |
| 9544 | /// someone who already knows that name. The filter has to answer the words |
| 9545 | /// a user actually types. |
| 9546 | #[test] |
| 9547 | fn config_view_filter_finds_context_window_by_user_vocabulary() { |
| 9548 | let mut view = create_config_view(Locale::En); |
| 9549 | |
| 9550 | for phrase in ["context length", "context size", "max context length"] { |
| 9551 | view.clear_filter(); |
| 9552 | type_filter(&mut view, phrase); |
| 9553 | let keys = visible_row_keys(&view); |
| 9554 | assert!( |
| 9555 | keys.contains(&"context_window"), |
| 9556 | "`{phrase}` must surface the context_window row: {keys:?}" |
| 9557 | ); |
| 9558 | assert!( |
| 9559 | keys.contains(&"effective_context_window"), |
| 9560 | "`{phrase}` must surface the resolved window row: {keys:?}" |
| 9561 | ); |
| 9562 | } |
| 9563 | |
| 9564 | // The adjacent knob the same user reaches for next. |
| 9565 | view.clear_filter(); |
| 9566 | type_filter(&mut view, "compaction threshold"); |
| 9567 | let keys = visible_row_keys(&view); |
| 9568 | assert!( |
| 9569 | keys.contains(&"auto_compact_threshold_percent"), |
| 9570 | "`compaction threshold` must surface the auto-compaction trigger: {keys:?}" |
| 9571 | ); |
| 9572 | } |
| 9573 | |
| 9574 | #[test] |
| 9575 | fn config_view_renders_friendly_setting_labels() { |
| 9576 | let mut view = create_config_view(Locale::En); |
| 9577 | assert_ne!( |
| 9578 | config_label_for_key("show_thinking"), |
| 9579 | config_label_for_key("thinking_highlight"), |
| 9580 | "reasoning visibility and background controls need distinct labels" |
| 9581 | ); |
| 9582 | view.category = ConfigCategory::ModelsProviders; |
| 9583 | view.select_first_visible_row(); |
| 9584 | let area = Rect::new(0, 0, 100, 40); |
| 9585 | let mut buf = Buffer::empty(area); |
| 9586 | |
| 9587 | view.render(area, &mut buf); |
| 9588 | |
| 9589 | let dump = buffer_text(&buf, area); |
| 9590 | assert!( |
| 9591 | dump.contains("Active provider"), |
| 9592 | "missing provider label:\n{dump}" |
| 9593 | ); |
| 9594 | assert!( |
| 9595 | dump.contains("Models & providers"), |
| 9596 | "missing settings rail:\n{dump}" |
| 9597 | ); |
| 9598 | |
| 9599 | view.category = ConfigCategory::Trust; |
| 9600 | view.select_first_visible_row(); |
| 9601 | let mut permission_buf = Buffer::empty(area); |
| 9602 | view.render(area, &mut permission_buf); |
| 9603 | let permission_dump = buffer_text(&permission_buf, area); |
| 9604 | assert!( |
| 9605 | permission_dump.contains("Shell access"), |
| 9606 | "missing shell label:\n{permission_dump}" |
| 9607 | ); |
| 9608 | } |
| 9609 | |
| 9610 | #[test] |
| 9611 | fn localized_config_view_renders_at_narrow_width() { |
| 9612 | let mut app = create_test_app(); |
| 9613 | app.ui_locale = Locale::PtBr; |
| 9614 | let mut view = ConfigView::new_for_app(&app); |
| 9615 | view.category = ConfigCategory::ModelsProviders; |
| 9616 | view.select_first_visible_row(); |
| 9617 | let area = Rect::new(0, 0, 60, 18); |
| 9618 | let mut buf = Buffer::empty(area); |
| 9619 | |
| 9620 | view.render(area, &mut buf); |
| 9621 | |
| 9622 | let dump = buffer_text(&buf, area); |
| 9623 | assert!(dump.contains("Provedor"), "missing localized rows:\n{dump}"); |
| 9624 | assert!( |
| 9625 | !dump.contains("MISSING"), |
| 9626 | "missing-key marker leaked:\n{dump}" |
| 9627 | ); |
| 9628 | } |
| 9629 | |
| 9630 | #[test] |
| 9631 | fn config_view_selected_row_uses_muted_selection_highlight() { |
| 9632 | let mut view = create_config_view(Locale::En); |
| 9633 | view.selected = view |
| 9634 | .rows |
| 9635 | .iter() |
| 9636 | .position(|row| row.key == "theme") |
| 9637 | .expect("theme row"); |
| 9638 | view.category = ConfigCategory::Appearance; |
| 9639 | view.adjust_scroll(8); |
| 9640 | let area = Rect::new(0, 0, 100, 24); |
| 9641 | let mut buf = Buffer::empty(area); |
| 9642 | |
| 9643 | view.render(area, &mut buf); |
| 9644 | |
| 9645 | let y = view |
| 9646 | .last_row_hitboxes |
| 9647 | .borrow() |
| 9648 | .iter() |
| 9649 | .find_map(|(rect, idx)| (*idx == view.selected).then_some(rect.y)) |
| 9650 | .expect("selected config row should have a hitbox"); |
| 9651 | let highlighted_cells = (area.x..area.x.saturating_add(area.width)) |
| 9652 | .filter(|&x| { |
| 9653 | let cell = &buf[(x, y)]; |
| 9654 | !cell.symbol().trim().is_empty() |
| 9655 | && cell.bg == palette::SELECTION_BG |
| 9656 | && cell.fg == palette::SELECTION_TEXT |
| 9657 | }) |
| 9658 | .count(); |
| 9659 | |
| 9660 | assert!( |
| 9661 | highlighted_cells >= 4, |
| 9662 | "selected config row should render readable selection text" |
| 9663 | ); |
| 9664 | assert!( |
| 9665 | !(area.x..area.x.saturating_add(area.width)) |
| 9666 | .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION), |
| 9667 | "selected config row should not use the bright accent background" |
| 9668 | ); |
| 9669 | } |
| 9670 | |
| 9671 | #[test] |
| 9672 | fn config_view_keeps_scope_column_aligned_for_long_keys() { |
| 9673 | let mut view = create_config_view(Locale::ZhHans); |
| 9674 | type_filter(&mut view, "composer"); |
| 9675 | let area = Rect::new(0, 0, 100, 24); |
| 9676 | let mut buf = Buffer::empty(area); |
| 9677 | |
| 9678 | view.render(area, &mut buf); |
| 9679 | |
| 9680 | let dump = buffer_text(&buf, area); |
| 9681 | assert!( |
| 9682 | dump.contains("粘 贴 检 测"), |
| 9683 | "localized config labels should stay readable:\n{dump}" |
| 9684 | ); |
| 9685 | let scope_columns = (area.y..area.y.saturating_add(area.height)) |
| 9686 | .filter_map(|y| { |
| 9687 | // One dumped char per cell (wide glyphs dump as glyph + |
| 9688 | // continuation cell), so a char count is the cell column. |
| 9689 | // Every list row paints an affordance in the same column, so |
| 9690 | // the first affordance glyph is the shared alignment anchor. |
| 9691 | let line = buffer_row_text(&buf, area, y); |
| 9692 | if !line.contains("已 保 存") { |
| 9693 | return None; |
| 9694 | } |
| 9695 | line.find(['‹', '[', '✎']) |
| 9696 | .map(|byte| line[..byte].chars().count()) |
| 9697 | }) |
| 9698 | .collect::<Vec<_>>(); |
| 9699 | assert!( |
| 9700 | scope_columns.len() >= 2, |
| 9701 | "expected composer config rows with scopes:\n{dump}" |
| 9702 | ); |
| 9703 | assert!( |
| 9704 | scope_columns |
| 9705 | .iter() |
| 9706 | .all(|column| *column == scope_columns[0]), |
| 9707 | "scope column should stay aligned even for long keys ({scope_columns:?}):\n{dump}" |
| 9708 | ); |
| 9709 | } |
| 9710 | |
| 9711 | #[test] |
| 9712 | fn config_view_filter_no_match_does_not_edit_hidden_row() { |
| 9713 | let app = create_test_app(); |
| 9714 | let mut view = ConfigView::new_for_app(&app); |
| 9715 | |
| 9716 | type_filter(&mut view, "zzzz"); |
| 9717 | assert!(visible_row_keys(&view).is_empty()); |
| 9718 | |
| 9719 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9720 | assert!(matches!(action, ViewAction::None)); |
| 9721 | assert!(view.editing.is_none()); |
| 9722 | |
| 9723 | let clear = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 9724 | assert!(matches!(clear, ViewAction::None)); |
| 9725 | assert!(view.filter.is_empty()); |
| 9726 | assert!(!visible_row_keys(&view).is_empty()); |
| 9727 | } |
| 9728 | |
| 9729 | #[test] |
| 9730 | fn a_rejected_setting_reopens_the_editor_with_the_typed_value() { |
| 9731 | let app = create_test_app(); |
| 9732 | let mut view = ConfigView::new_for_app(&app); |
| 9733 | type_filter(&mut view, "mcp_config"); |
| 9734 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9735 | view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); |
| 9736 | type_filter(&mut view, "servers.json"); |
| 9737 | assert!(matches!( |
| 9738 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 9739 | ViewAction::Emit(ViewEvent::ConfigUpdated { .. }) |
| 9740 | )); |
| 9741 | assert!(view.editing.is_none()); |
| 9742 | |
| 9743 | let accepted = ConfigView::rebuild_preserving(&app, &view, "mcp_config_path"); |
| 9744 | assert!( |
| 9745 | accepted.editing.is_none(), |
| 9746 | "an accepted value closes the editor" |
| 9747 | ); |
| 9748 | |
| 9749 | let mut rejected = ConfigView::rebuild_preserving(&app, &view, "mcp_config_path"); |
| 9750 | rejected.restore_rejected_commit(&view); |
| 9751 | let edit = rejected.editing.as_ref().expect("a rejected value reopens"); |
| 9752 | assert_eq!(edit.key.as_str(), "mcp_config_path"); |
| 9753 | assert_eq!(edit.buffer.iter().collect::<String>(), "servers.json"); |
| 9754 | } |
| 9755 | |
| 9756 | #[test] |
| 9757 | fn a_pending_worker_row_reads_queued_not_running() { |
| 9758 | use crate::tools::subagent::AgentWorkerStatus; |
| 9759 | assert_eq!( |
| 9760 | super::lifecycle_worker_status(AgentLifecycle::Pending), |
| 9761 | Some(AgentWorkerStatus::Queued) |
| 9762 | ); |
| 9763 | assert_eq!( |
| 9764 | super::lifecycle_worker_status(AgentLifecycle::Running), |
| 9765 | None |
| 9766 | ); |
| 9767 | let (queued, ..) = super::format_agent_status( |
| 9768 | Locale::En, |
| 9769 | &SubAgentStatus::Running, |
| 9770 | Some(AgentWorkerStatus::Queued), |
| 9771 | ); |
| 9772 | let (running, ..) = super::format_agent_status(Locale::En, &SubAgentStatus::Running, None); |
| 9773 | assert_eq!(queued, tr(Locale::En, MessageId::AutomationRunStatusQueued)); |
| 9774 | assert_ne!(queued, running); |
| 9775 | } |
| 9776 | |
| 9777 | #[test] |
| 9778 | fn config_view_can_edit_filtered_row() { |
| 9779 | let app = create_test_app(); |
| 9780 | let mut view = ConfigView::new_for_app(&app); |
| 9781 | |
| 9782 | type_filter(&mut view, "mcp_config"); |
| 9783 | assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]); |
| 9784 | |
| 9785 | let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9786 | assert!(matches!(start, ViewAction::None)); |
| 9787 | assert!(view.editing.is_some()); |
| 9788 | |
| 9789 | let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); |
| 9790 | assert!(matches!(clear, ViewAction::None)); |
| 9791 | type_filter(&mut view, "servers.json"); |
| 9792 | |
| 9793 | let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9794 | match submit { |
| 9795 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9796 | key, |
| 9797 | value, |
| 9798 | persist, |
| 9799 | }) => { |
| 9800 | assert_eq!(key, "mcp_config_path"); |
| 9801 | assert_eq!(value, "servers.json"); |
| 9802 | assert!(persist); |
| 9803 | } |
| 9804 | other => panic!("expected config update emit, got {other:?}"), |
| 9805 | } |
| 9806 | } |
| 9807 | |
| 9808 | #[test] |
| 9809 | fn config_view_enter_and_ctrl_u_emit_config_updated() { |
| 9810 | let app = create_test_app(); |
| 9811 | let mut view = ConfigView::new_for_app(&app); |
| 9812 | view.focus_key("background_color"); |
| 9813 | |
| 9814 | let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9815 | assert!(matches!(start, ViewAction::None)); |
| 9816 | assert!(view.editing.is_some()); |
| 9817 | |
| 9818 | let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); |
| 9819 | assert!(matches!(clear, ViewAction::None)); |
| 9820 | let cleared = view |
| 9821 | .editing |
| 9822 | .as_ref() |
| 9823 | .expect("editing should remain active after Ctrl+U"); |
| 9824 | assert!(cleared.buffer.is_empty()); |
| 9825 | |
| 9826 | for ch in "55".chars() { |
| 9827 | let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 9828 | assert!(matches!(action, ViewAction::None)); |
| 9829 | } |
| 9830 | |
| 9831 | let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9832 | match submit { |
| 9833 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9834 | key, |
| 9835 | value, |
| 9836 | persist, |
| 9837 | }) => { |
| 9838 | assert_eq!(key, "background_color"); |
| 9839 | assert_eq!(value, "55"); |
| 9840 | assert!(persist); |
| 9841 | } |
| 9842 | other => panic!("expected config update emit, got {other:?}"), |
| 9843 | } |
| 9844 | assert!(view.editing.is_none()); |
| 9845 | } |
| 9846 | |
| 9847 | #[test] |
| 9848 | fn config_view_boolean_rows_toggle_without_text_editing() { |
| 9849 | let app = create_test_app(); |
| 9850 | let mut view = ConfigView::new_for_app(&app); |
| 9851 | view.focus_key("low_motion"); |
| 9852 | let expected = |
| 9853 | if canonical_config_choice("low_motion", &view.rows[view.selected].value) == "true" { |
| 9854 | "false" |
| 9855 | } else { |
| 9856 | "true" |
| 9857 | }; |
| 9858 | |
| 9859 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9860 | |
| 9861 | match action { |
| 9862 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9863 | key, |
| 9864 | value, |
| 9865 | persist, |
| 9866 | }) => { |
| 9867 | assert_eq!(key, "low_motion"); |
| 9868 | assert_eq!(value, expected); |
| 9869 | assert!(persist); |
| 9870 | } |
| 9871 | other => panic!("expected direct boolean update, got {other:?}"), |
| 9872 | } |
| 9873 | assert!(view.editing.is_none()); |
| 9874 | } |
| 9875 | |
| 9876 | #[test] |
| 9877 | fn config_view_enum_rows_use_a_bounded_choice_list() { |
| 9878 | let app = create_test_app(); |
| 9879 | let mut view = ConfigView::new_for_app(&app); |
| 9880 | view.focus_key("default_mode"); |
| 9881 | |
| 9882 | let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9883 | assert!(matches!(start, ViewAction::None)); |
| 9884 | let edit = view.editing.as_ref().expect("choice editor"); |
| 9885 | assert_eq!( |
| 9886 | edit.choices.as_deref(), |
| 9887 | Some( |
| 9888 | &[ |
| 9889 | "agent".to_string(), |
| 9890 | "plan".to_string(), |
| 9891 | "operate".to_string(), |
| 9892 | ][..] |
| 9893 | ) |
| 9894 | ); |
| 9895 | assert!( |
| 9896 | edit.choices |
| 9897 | .as_ref() |
| 9898 | .expect("startup choices") |
| 9899 | .iter() |
| 9900 | .all(|choice| choice != "yolo") |
| 9901 | ); |
| 9902 | |
| 9903 | let _ = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); |
| 9904 | let apply = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 9905 | match apply { |
| 9906 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 9907 | key, |
| 9908 | value, |
| 9909 | persist, |
| 9910 | }) => { |
| 9911 | assert_eq!(key, "default_mode"); |
| 9912 | assert_eq!(value, "operate"); |
| 9913 | assert!(persist); |
| 9914 | } |
| 9915 | other => panic!("expected startup choice update, got {other:?}"), |
| 9916 | } |
| 9917 | |
| 9918 | assert_eq!( |
| 9919 | canonical_config_choice("default_mode", "Operate"), |
| 9920 | "operate" |
| 9921 | ); |
| 9922 | assert_eq!( |
| 9923 | config_choice_label(Locale::En, "default_mode", "operate"), |
| 9924 | "Operate" |
| 9925 | ); |
| 9926 | assert!(!config_choice_detail(Locale::En, "default_mode", "operate").is_empty()); |
| 9927 | } |
| 9928 | |
| 9929 | #[test] |
| 9930 | fn locale_choices_cover_shipped_registry_and_mark_partial_packs() { |
| 9931 | let choices = config_choice_values("locale").expect("locale choices"); |
| 9932 | let expected = std::iter::once("auto".to_string()) |
| 9933 | .chain( |
| 9934 | Locale::shipped() |
| 9935 | .iter() |
| 9936 | .map(|locale| locale.tag().to_string()), |
| 9937 | ) |
| 9938 | .collect::<Vec<_>>(); |
| 9939 | assert_eq!( |
| 9940 | choices, expected, |
| 9941 | "native locale choices must match Locale::shipped()" |
| 9942 | ); |
| 9943 | |
| 9944 | let partial_badge = tr(Locale::En, MessageId::ConfigLocalePartialBadge); |
| 9945 | let partial_detail = tr(Locale::En, MessageId::ConfigLocalePartialDetail); |
| 9946 | for locale in Locale::shipped() { |
| 9947 | let canonical = canonical_config_choice("locale", locale.tag()); |
| 9948 | assert_eq!(canonical, locale.tag()); |
| 9949 | |
| 9950 | let label = config_choice_label(Locale::En, "locale", &canonical); |
| 9951 | assert_eq!( |
| 9952 | label.contains(partial_badge.as_ref()), |
| 9953 | locale.is_partial_pack(), |
| 9954 | "{} partial-pack badge drifted", |
| 9955 | locale.tag() |
| 9956 | ); |
| 9957 | |
| 9958 | let detail = config_choice_detail(Locale::En, "locale", &canonical); |
| 9959 | assert_eq!( |
| 9960 | !detail.is_empty(), |
| 9961 | locale.is_partial_pack(), |
| 9962 | "{} partial-pack detail drifted", |
| 9963 | locale.tag() |
| 9964 | ); |
| 9965 | if locale.is_partial_pack() { |
| 9966 | assert_eq!(detail, partial_detail); |
| 9967 | } |
| 9968 | } |
| 9969 | } |
| 9970 | |
| 9971 | #[test] |
| 9972 | fn locale_choice_editor_submits_newly_admitted_locales() { |
| 9973 | for tag in ["ko", "vi", "zh-Hant"] { |
| 9974 | let mut view = create_config_view(Locale::En); |
| 9975 | view.focus_key("locale"); |
| 9976 | view.start_edit(); |
| 9977 | let edit = view.editing.as_mut().expect("locale choice editor"); |
| 9978 | edit.selected_choice = edit |
| 9979 | .choices |
| 9980 | .as_ref() |
| 9981 | .and_then(|choices| choices.iter().position(|choice| choice == tag)) |
| 9982 | .unwrap_or_else(|| panic!("locale choices must include {tag}")); |
| 9983 | |
| 9984 | match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) { |
| 9985 | ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => { |
| 9986 | assert_eq!(key, "locale"); |
| 9987 | assert_eq!(value, tag); |
| 9988 | } |
| 9989 | other => panic!("selecting locale {tag} must submit ConfigUpdated, got {other:?}"), |
| 9990 | } |
| 9991 | } |
| 9992 | } |
| 9993 | |
| 9994 | #[test] |
| 9995 | fn complete_locale_shows_no_partial_badge_at_minimum_terminal_layout() { |
| 9996 | // zh-Hant reached full en.json parity in #5143 and no shipped pack is |
| 9997 | // partial anymore, so the picker must not render the partial badge. |
| 9998 | let mut view = create_config_view(Locale::En); |
| 9999 | view.focus_key("locale"); |
| 10000 | view.start_edit(); |
| 10001 | let edit = view.editing.as_mut().expect("locale choice editor"); |
| 10002 | edit.selected_choice = edit |
| 10003 | .choices |
| 10004 | .as_ref() |
| 10005 | .and_then(|choices| choices.iter().position(|choice| choice == "zh-Hant")) |
| 10006 | .expect("zh-Hant choice"); |
| 10007 | |
| 10008 | let area = Rect::new(0, 0, 40, 12); |
| 10009 | let mut buf = Buffer::empty(area); |
| 10010 | view.render(area, &mut buf); |
| 10011 | let dump = buffer_text(&buf, area); |
| 10012 | assert!( |
| 10013 | dump.contains("zh-Hant"), |
| 10014 | "zh-Hant choice must render at minimum layout: {dump:?}" |
| 10015 | ); |
| 10016 | assert!( |
| 10017 | !dump.contains("zh-Hant (partial)"), |
| 10018 | "zh-Hant is a complete pack and must not show the partial badge: {dump:?}" |
| 10019 | ); |
| 10020 | } |
| 10021 | |
| 10022 | #[test] |
| 10023 | fn settings_registry_types_every_config_row() { |
| 10024 | let app = create_test_app(); |
| 10025 | let view = ConfigView::new_for_app(&app); |
| 10026 | let registry = SettingsRegistry::new(&view); |
| 10027 | |
| 10028 | let kind_for = |key: &str| { |
| 10029 | let row = view |
| 10030 | .rows |
| 10031 | .iter() |
| 10032 | .find(|row| row.key == key) |
| 10033 | .unwrap_or_else(|| panic!("missing config row {key}")); |
| 10034 | registry.meta(row).kind |
| 10035 | }; |
| 10036 | |
| 10037 | assert_eq!(kind_for("provider"), SettingKind::Action); |
| 10038 | assert_eq!(kind_for("model"), SettingKind::Action); |
| 10039 | assert_eq!(kind_for("low_motion"), SettingKind::Boolean); |
| 10040 | assert_eq!(kind_for("default_mode"), SettingKind::Choice); |
| 10041 | assert_eq!(kind_for("thinking_preview_lines"), SettingKind::Integer); |
| 10042 | assert_eq!(kind_for("mcp_open"), SettingKind::Action); |
| 10043 | assert_eq!(kind_for("mcp_reconnect"), SettingKind::Action); |
| 10044 | assert_eq!(kind_for("mcp_diagnose"), SettingKind::Action); |
| 10045 | assert_eq!(kind_for("plugins_open"), SettingKind::Action); |
| 10046 | assert_eq!(kind_for("mcp_config_path"), SettingKind::Text); |
| 10047 | assert_eq!( |
| 10048 | kind_for("fleet.exec.max_spawn_depth"), |
| 10049 | SettingKind::ReadOnly |
| 10050 | ); |
| 10051 | |
| 10052 | for row in &view.rows { |
| 10053 | let meta = registry.meta(row); |
| 10054 | assert_eq!(meta.category, row.section()); |
| 10055 | assert_eq!( |
| 10056 | meta.kind == SettingKind::Choice || meta.kind == SettingKind::Boolean, |
| 10057 | meta.choices.is_some(), |
| 10058 | "choice metadata drifted for {}", |
| 10059 | row.key |
| 10060 | ); |
| 10061 | } |
| 10062 | } |
| 10063 | |
| 10064 | #[test] |
| 10065 | fn config_labels_are_consumed_from_complete_locale_packs() { |
| 10066 | for locale in Locale::shipped_complete() { |
| 10067 | assert_eq!( |
| 10068 | config_label_for_key_for_locale(*locale, "provider"), |
| 10069 | tr(*locale, MessageId::ConfigLabelProvider) |
| 10070 | ); |
| 10071 | assert_eq!( |
| 10072 | config_label_for_key_for_locale(*locale, "features.mcp"), |
| 10073 | tr(*locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", "Mcp") |
| 10074 | ); |
| 10075 | } |
| 10076 | assert_ne!( |
| 10077 | config_label_for_key_for_locale(Locale::Ja, "provider"), |
| 10078 | config_label_for_key_for_locale(Locale::En, "provider") |
| 10079 | ); |
| 10080 | } |
| 10081 | |
| 10082 | #[test] |
| 10083 | fn model_row_hint_names_the_model_picker() { |
| 10084 | let app = create_test_app(); |
| 10085 | let mut view = ConfigView::new_for_app(&app); |
| 10086 | view.focus_key("model"); |
| 10087 | |
| 10088 | let hint = view.activation_copy(&view.rows[view.selected]); |
| 10089 | assert!(hint.contains("Enter opens model picker"), "{hint}"); |
| 10090 | assert!(!hint.contains("Enter opens provider picker"), "{hint}"); |
| 10091 | assert!( |
| 10092 | hint.starts_with(&en(MessageId::ConfigActivateAgain)), |
| 10093 | "{hint}" |
| 10094 | ); |
| 10095 | } |
| 10096 | |
| 10097 | #[test] |
| 10098 | fn config_view_mouse_wheel_moves_rows_and_choice_selection() { |
| 10099 | let app = create_test_app(); |
| 10100 | let mut view = ConfigView::new_for_app(&app); |
| 10101 | let first_row = view.selected; |
| 10102 | |
| 10103 | let _ = view.handle_mouse(MouseEvent { |
| 10104 | kind: MouseEventKind::ScrollDown, |
| 10105 | column: 0, |
| 10106 | row: 0, |
| 10107 | modifiers: KeyModifiers::NONE, |
| 10108 | }); |
| 10109 | assert!( |
| 10110 | view.selected > first_row, |
| 10111 | "wheel should move the settings list" |
| 10112 | ); |
| 10113 | |
| 10114 | view.focus_key("default_mode"); |
| 10115 | view.start_edit(); |
| 10116 | view.editing |
| 10117 | .as_mut() |
| 10118 | .expect("choice editor") |
| 10119 | .selected_choice = 0; |
| 10120 | let _ = view.handle_mouse(MouseEvent { |
| 10121 | kind: MouseEventKind::ScrollDown, |
| 10122 | column: 0, |
| 10123 | row: 0, |
| 10124 | modifiers: KeyModifiers::NONE, |
| 10125 | }); |
| 10126 | assert_eq!( |
| 10127 | view.editing |
| 10128 | .as_ref() |
| 10129 | .expect("choice editor") |
| 10130 | .selected_choice, |
| 10131 | 1 |
| 10132 | ); |
| 10133 | } |
| 10134 | |
| 10135 | #[test] |
| 10136 | fn config_view_mouse_click_selects_row() { |
| 10137 | let app = create_test_app(); |
| 10138 | let mut view = ConfigView::new_for_app(&app); |
| 10139 | view.category = ConfigCategory::ModelsProviders; |
| 10140 | view.select_first_visible_row(); |
| 10141 | let area = Rect::new(0, 0, 100, 30); |
| 10142 | let mut buf = Buffer::empty(area); |
| 10143 | view.render(area, &mut buf); |
| 10144 | |
| 10145 | let hitboxes = view.last_row_hitboxes.borrow().clone(); |
| 10146 | let (_, row_idx) = hitboxes |
| 10147 | .iter() |
| 10148 | .find(|(_, idx)| view.rows.get(*idx).is_some_and(|row| row.key == "model")) |
| 10149 | .copied() |
| 10150 | .expect("model row should have a hitbox"); |
| 10151 | let y = hitboxes |
| 10152 | .iter() |
| 10153 | .find_map(|(rect, idx)| (*idx == row_idx).then_some(rect.y)) |
| 10154 | .expect("selected row should have a y coordinate"); |
| 10155 | |
| 10156 | let action = view.handle_mouse(MouseEvent { |
| 10157 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10158 | column: 20, |
| 10159 | row: y, |
| 10160 | modifiers: KeyModifiers::NONE, |
| 10161 | }); |
| 10162 | |
| 10163 | assert!(matches!(action, ViewAction::None)); |
| 10164 | assert_eq!(view.selected, row_idx); |
| 10165 | |
| 10166 | let second = view.handle_mouse(MouseEvent { |
| 10167 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10168 | column: 20, |
| 10169 | row: y, |
| 10170 | modifiers: KeyModifiers::NONE, |
| 10171 | }); |
| 10172 | match second { |
| 10173 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 10174 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 10175 | }) => assert_eq!(command, "/model"), |
| 10176 | other => panic!("second click should open the model picker, got {other:?}"), |
| 10177 | } |
| 10178 | assert!(view.editing.is_none()); |
| 10179 | } |
| 10180 | |
| 10181 | #[test] |
| 10182 | fn config_view_hover_tints_without_moving_selection() { |
| 10183 | let app = create_test_app(); |
| 10184 | let mut view = ConfigView::new_for_app(&app); |
| 10185 | let area = Rect::new(0, 0, 120, 32); |
| 10186 | let mut buf = Buffer::empty(area); |
| 10187 | view.render(area, &mut buf); |
| 10188 | let selected_before = view.selected; |
| 10189 | |
| 10190 | // Hover a non-selected row: the tint lands, the selection holds. |
| 10191 | let (rect, row_idx) = view |
| 10192 | .last_row_hitboxes |
| 10193 | .borrow() |
| 10194 | .iter() |
| 10195 | .copied() |
| 10196 | .find(|(_, idx)| *idx != selected_before) |
| 10197 | .expect("a non-selected row"); |
| 10198 | let action = view.handle_mouse(MouseEvent { |
| 10199 | kind: MouseEventKind::Moved, |
| 10200 | column: rect.x.saturating_add(1), |
| 10201 | row: rect.y, |
| 10202 | modifiers: KeyModifiers::NONE, |
| 10203 | }); |
| 10204 | assert!(matches!(action, ViewAction::None)); |
| 10205 | assert_eq!(view.hovered_row, Some(row_idx)); |
| 10206 | assert_eq!(view.selected, selected_before); |
| 10207 | |
| 10208 | // Repaint: the hovered row wears the shared hover band. |
| 10209 | let mut buf = Buffer::empty(area); |
| 10210 | view.render(area, &mut buf); |
| 10211 | assert_eq!( |
| 10212 | buf[(rect.x, rect.y)].bg, |
| 10213 | palette::SURFACE_ELEVATED, |
| 10214 | "hovered row must show the shared hover band" |
| 10215 | ); |
| 10216 | |
| 10217 | // Hover a strip chip: the rail tint lands, the tab holds. |
| 10218 | let (chip, _) = view |
| 10219 | .last_rail_hitboxes |
| 10220 | .borrow() |
| 10221 | .iter() |
| 10222 | .copied() |
| 10223 | .find(|(_, category)| *category == ConfigCategory::Advanced) |
| 10224 | .expect("Advanced chip"); |
| 10225 | let action = view.handle_mouse(MouseEvent { |
| 10226 | kind: MouseEventKind::Moved, |
| 10227 | column: chip.x.saturating_add(1), |
| 10228 | row: chip.y, |
| 10229 | modifiers: KeyModifiers::NONE, |
| 10230 | }); |
| 10231 | assert!(matches!(action, ViewAction::None)); |
| 10232 | assert_eq!(view.hovered_rail, Some(ConfigCategory::Advanced)); |
| 10233 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 10234 | |
| 10235 | // The search line is no target: hovering it clears every tint. |
| 10236 | let action = view.handle_mouse(MouseEvent { |
| 10237 | kind: MouseEventKind::Moved, |
| 10238 | column: 5, |
| 10239 | row: 1, |
| 10240 | modifiers: KeyModifiers::NONE, |
| 10241 | }); |
| 10242 | assert!(matches!(action, ViewAction::None)); |
| 10243 | assert_eq!(view.hovered_row, None); |
| 10244 | assert_eq!(view.hovered_rail, None); |
| 10245 | } |
| 10246 | |
| 10247 | #[test] |
| 10248 | fn config_view_rail_categories_are_clickable() { |
| 10249 | let app = create_test_app(); |
| 10250 | let mut view = ConfigView::new_for_app(&app); |
| 10251 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 10252 | // 120 columns: the vertical rail; 100 columns (96 inner): the strip. |
| 10253 | for width in [120u16, 100] { |
| 10254 | view.category = ConfigCategory::Appearance; |
| 10255 | view.select_first_visible_row(); |
| 10256 | let area = Rect::new(0, 0, width, 30); |
| 10257 | let mut buf = Buffer::empty(area); |
| 10258 | view.render(area, &mut buf); |
| 10259 | |
| 10260 | let hitboxes = view.last_rail_hitboxes.borrow().clone(); |
| 10261 | assert_eq!(hitboxes.len(), ConfigCategory::ALL.len(), "{width}"); |
| 10262 | let (rect, category) = hitboxes |
| 10263 | .iter() |
| 10264 | .copied() |
| 10265 | .find(|(_, category)| *category == ConfigCategory::Advanced) |
| 10266 | .expect("Advanced should have a hitbox"); |
| 10267 | |
| 10268 | let action = view.handle_mouse(MouseEvent { |
| 10269 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10270 | column: rect.x.saturating_add(1), |
| 10271 | row: rect.y, |
| 10272 | modifiers: KeyModifiers::NONE, |
| 10273 | }); |
| 10274 | assert!(matches!(action, ViewAction::None)); |
| 10275 | assert_eq!(category, ConfigCategory::Advanced); |
| 10276 | assert_eq!(view.category, ConfigCategory::Advanced, "{width}"); |
| 10277 | assert!( |
| 10278 | view.rows |
| 10279 | .get(view.selected) |
| 10280 | .is_some_and(|row| ConfigCategory::for_row(row) == ConfigCategory::Advanced) |
| 10281 | ); |
| 10282 | } |
| 10283 | } |
| 10284 | |
| 10285 | #[test] |
| 10286 | fn config_categories_cover_every_row_with_the_approved_seven() { |
| 10287 | let app = create_test_app(); |
| 10288 | let view = ConfigView::new_for_app(&app); |
| 10289 | let labels: Vec<Cow<'static, str>> = ConfigCategory::ALL |
| 10290 | .iter() |
| 10291 | .map(|category| category.label(Locale::En)) |
| 10292 | .collect(); |
| 10293 | assert_eq!( |
| 10294 | labels, |
| 10295 | [ |
| 10296 | "Appearance", |
| 10297 | "Models & providers", |
| 10298 | "Work", |
| 10299 | "Tools & MCP", |
| 10300 | "Trust", |
| 10301 | "Motion", |
| 10302 | "Advanced", |
| 10303 | ] |
| 10304 | ); |
| 10305 | let category_of = |key: &str| { |
| 10306 | let row = view |
| 10307 | .rows |
| 10308 | .iter() |
| 10309 | .find(|row| row.key == key) |
| 10310 | .unwrap_or_else(|| panic!("row {key}")); |
| 10311 | ConfigCategory::for_row(row) |
| 10312 | }; |
| 10313 | assert_eq!(category_of("theme"), ConfigCategory::Appearance); |
| 10314 | assert_eq!(category_of("provider"), ConfigCategory::ModelsProviders); |
| 10315 | assert_eq!(category_of("model"), ConfigCategory::ModelsProviders); |
| 10316 | assert_eq!( |
| 10317 | category_of("reasoning_effort"), |
| 10318 | ConfigCategory::ModelsProviders |
| 10319 | ); |
| 10320 | // Raw endpoint, credential receipt, and context diagnostic rows live |
| 10321 | // under Advanced so default categories read as product language. |
| 10322 | for key in ["base_url", "context_window", "effective_context_window"] { |
| 10323 | assert_eq!(category_of(key), ConfigCategory::Advanced, "{key}"); |
| 10324 | } |
| 10325 | assert!( |
| 10326 | view.rows |
| 10327 | .iter() |
| 10328 | .filter(|row| ConfigCategory::for_row(row) == ConfigCategory::ModelsProviders) |
| 10329 | .all(|row| !row.key.starts_with("external_credentials.")), |
| 10330 | "credential receipts must not surface in Models & providers" |
| 10331 | ); |
| 10332 | // Sub-agent depth moved out of the one-row Fleet tab into Models. |
| 10333 | assert_eq!( |
| 10334 | category_of("fleet.exec.max_spawn_depth"), |
| 10335 | ConfigCategory::ModelsProviders |
| 10336 | ); |
| 10337 | assert_eq!(category_of("composer_density"), ConfigCategory::Work); |
| 10338 | assert_eq!(category_of("work_surface_placement"), ConfigCategory::Work); |
| 10339 | assert_eq!(category_of("auto_compact"), ConfigCategory::Work); |
| 10340 | assert_eq!(category_of("mcp_open"), ConfigCategory::ToolsMcp); |
| 10341 | assert_eq!(category_of("approval_mode"), ConfigCategory::Trust); |
| 10342 | assert_eq!(category_of("allow_shell"), ConfigCategory::Trust); |
| 10343 | assert_eq!(category_of("telemetry"), ConfigCategory::Trust); |
| 10344 | assert_eq!(category_of("low_motion"), ConfigCategory::Motion); |
| 10345 | assert_eq!(category_of("fancy_animations"), ConfigCategory::Motion); |
| 10346 | for category in ConfigCategory::ALL { |
| 10347 | assert!( |
| 10348 | view.rows.iter().any(|row| category.contains(row)), |
| 10349 | "{} lists no rows", |
| 10350 | category.label(Locale::En) |
| 10351 | ); |
| 10352 | } |
| 10353 | for category in ConfigCategory::ALL { |
| 10354 | assert_eq!(category.next().prev(), category); |
| 10355 | } |
| 10356 | } |
| 10357 | |
| 10358 | /// English copy for an id, for asserting on rendered chrome. |
| 10359 | fn en(id: MessageId) -> String { |
| 10360 | tr(Locale::En, id).into_owned() |
| 10361 | } |
| 10362 | |
| 10363 | fn render_dump(view: &ConfigView, width: u16, height: u16) -> String { |
| 10364 | let area = Rect::new(0, 0, width, height); |
| 10365 | let mut buf = Buffer::empty(area); |
| 10366 | view.render(area, &mut buf); |
| 10367 | buffer_text(&buf, area) |
| 10368 | } |
| 10369 | |
| 10370 | #[test] |
| 10371 | fn config_shell_uses_strip_and_list_at_80x24_and_rail_list_detail_when_wide() { |
| 10372 | let app = create_test_app(); |
| 10373 | let view = ConfigView::new_for_app(&app); |
| 10374 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 10375 | |
| 10376 | let dump = render_dump(&view, 80, 24); |
| 10377 | assert!( |
| 10378 | dump.contains("Appearance"), |
| 10379 | "strip shows the active category:\n{dump}" |
| 10380 | ); |
| 10381 | assert!(dump.contains("Theme"), "list missing theme row:\n{dump}"); |
| 10382 | assert!( |
| 10383 | !dump.contains("▸ Appearance"), |
| 10384 | "no vertical rail at 80 columns:\n{dump}" |
| 10385 | ); |
| 10386 | assert!( |
| 10387 | !dump.contains(&format!("{:<10}", en(MessageId::ConfigFactSource))), |
| 10388 | "detail pane must shed at 80 columns:\n{dump}" |
| 10389 | ); |
| 10390 | assert!( |
| 10391 | dump.contains(&en(MessageId::ConfigActivateAgain)), |
| 10392 | "selected row spells out its activation:\n{dump}" |
| 10393 | ); |
| 10394 | assert!( |
| 10395 | dump.contains(&en(MessageId::ConfigFactCurrent)) |
| 10396 | && dump.contains(&en(MessageId::ConfigFactSaved)), |
| 10397 | "narrow status row folds the lanes:\n{dump}" |
| 10398 | ); |
| 10399 | { |
| 10400 | let rows = view.last_row_hitboxes.borrow(); |
| 10401 | assert!(!rows.is_empty(), "list rows must stay clickable at 80x24"); |
| 10402 | assert!( |
| 10403 | rows.iter() |
| 10404 | .all(|(rect, _)| rect.width > 20 && rect.height == 1), |
| 10405 | "row hitboxes are exact rects: {rows:?}" |
| 10406 | ); |
| 10407 | let strip = view.last_rail_hitboxes.borrow(); |
| 10408 | assert!(!strip.is_empty() && strip.len() <= 8, "{strip:?}"); |
| 10409 | assert!( |
| 10410 | strip.iter().all(|(rect, _)| rect.y < rows[0].0.y), |
| 10411 | "strip sits above the list: {strip:?}" |
| 10412 | ); |
| 10413 | } |
| 10414 | |
| 10415 | let dump = render_dump(&view, 120, 32); |
| 10416 | assert!( |
| 10417 | dump.contains("❯ Display"), |
| 10418 | "groups column lists the active tab's groups:\n{dump}" |
| 10419 | ); |
| 10420 | for label in [ |
| 10421 | &en(MessageId::ConfigFactCurrent), |
| 10422 | &en(MessageId::ConfigFactSaved), |
| 10423 | &en(MessageId::ConfigFactStartup), |
| 10424 | &en(MessageId::ConfigFactSource), |
| 10425 | &en(MessageId::ConfigFactScope), |
| 10426 | &en(MessageId::ConfigFactApply), |
| 10427 | &en(MessageId::ConfigFactKind), |
| 10428 | &en(MessageId::ConfigFactAvailable), |
| 10429 | ] { |
| 10430 | assert!( |
| 10431 | dump.contains(&format!("{label:<10}")), |
| 10432 | "detail pane missing {label:?}:\n{dump}" |
| 10433 | ); |
| 10434 | } |
| 10435 | assert!( |
| 10436 | dump.contains(&en(MessageId::ConfigSourceUserSettings)), |
| 10437 | "source names the store:\n{dump}" |
| 10438 | ); |
| 10439 | assert!( |
| 10440 | dump.contains(&en(MessageId::ConfigApplyOnSave)), |
| 10441 | "apply semantics:\n{dump}" |
| 10442 | ); |
| 10443 | assert!( |
| 10444 | dump.contains(&en(MessageId::ConfigKindChoice)), |
| 10445 | "editor kind painted for the theme row:\n{dump}" |
| 10446 | ); |
| 10447 | assert_eq!(view.last_rail_hitboxes.borrow().len(), 7); |
| 10448 | |
| 10449 | for (w, h) in [(0u16, 0u16), (20, 4), (44, 12), (60, 18), (300, 60)] { |
| 10450 | let _ = render_dump(&view, w, h); |
| 10451 | } |
| 10452 | } |
| 10453 | |
| 10454 | #[test] |
| 10455 | fn config_shell_blocker_sizes_keep_active_category_list_and_affordances() { |
| 10456 | let app = create_test_app(); |
| 10457 | let mut view = ConfigView::new_for_app(&app); |
| 10458 | for (w, h) in [(40u16, 12u16), (80, 24), (100, 30), (120, 32)] { |
| 10459 | view.category = ConfigCategory::Appearance; |
| 10460 | view.select_first_visible_row(); |
| 10461 | let area = Rect::new(0, 0, w, h); |
| 10462 | let mut buf = Buffer::empty(area); |
| 10463 | view.render(area, &mut buf); |
| 10464 | let dump = buffer_text(&buf, area); |
| 10465 | assert!(dump.contains("Appearance"), "{w}x{h}:\n{dump}"); |
| 10466 | assert!(dump.contains("Search:"), "{w}x{h} keeps search:\n{dump}"); |
| 10467 | let rows = view.last_row_hitboxes.borrow().clone(); |
| 10468 | assert!(!rows.is_empty(), "{w}x{h} lists rows:\n{dump}"); |
| 10469 | // Every listed row paints its affordance glyph inside its own |
| 10470 | // hitbox cells (toggle / choose / edit / open / read-only). |
| 10471 | for (rect, idx) in rows { |
| 10472 | let row = &view.rows[idx]; |
| 10473 | let kind = view.editor_kind(row); |
| 10474 | let line: String = (rect.x..rect.right()) |
| 10475 | .map(|x| buf[(x, rect.y)].symbol().to_string()) |
| 10476 | .collect(); |
| 10477 | let glyph = super::setting_affordance(kind, Some(true)); |
| 10478 | let glyph_off = super::setting_affordance(kind, Some(false)); |
| 10479 | assert!( |
| 10480 | line.contains(glyph) || line.contains(glyph_off), |
| 10481 | "{w}x{h} row {} lacks its {kind:?} affordance: {line:?}", |
| 10482 | row.key |
| 10483 | ); |
| 10484 | } |
| 10485 | } |
| 10486 | } |
| 10487 | |
| 10488 | #[test] |
| 10489 | fn config_shell_short_heights_keep_advanced_reachable_by_keys_and_pointer() { |
| 10490 | let app = create_test_app(); |
| 10491 | let mut view = ConfigView::new_for_app(&app); |
| 10492 | for (w, h) in [(40u16, 12u16), (44, 12), (60, 16)] { |
| 10493 | view.category = ConfigCategory::Appearance; |
| 10494 | view.select_first_visible_row(); |
| 10495 | for _ in 0..6 { |
| 10496 | let _ = view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); |
| 10497 | } |
| 10498 | assert_eq!(view.category, ConfigCategory::Advanced, "{w}x{h}"); |
| 10499 | let area = Rect::new(0, 0, w, h); |
| 10500 | let mut buf = Buffer::empty(area); |
| 10501 | view.render(area, &mut buf); |
| 10502 | let dump = buffer_text(&buf, area); |
| 10503 | assert!( |
| 10504 | dump.contains("Advanced"), |
| 10505 | "{w}x{h} active category:\n{dump}" |
| 10506 | ); |
| 10507 | let strip = view.last_rail_hitboxes.borrow().clone(); |
| 10508 | let (advanced, _) = strip |
| 10509 | .iter() |
| 10510 | .copied() |
| 10511 | .find(|(_, category)| *category == ConfigCategory::Advanced) |
| 10512 | .unwrap_or_else(|| panic!("{w}x{h} Advanced hitbox: {dump}")); |
| 10513 | let cells: String = (advanced.x..advanced.right()) |
| 10514 | .map(|x| buf[(x, advanced.y)].symbol().to_string()) |
| 10515 | .collect(); |
| 10516 | assert!( |
| 10517 | cells.contains("Advanced"), |
| 10518 | "{w}x{h} hitbox cells: {cells:?}" |
| 10519 | ); |
| 10520 | // Pointer parity: the neighbour chip or compact Previous control |
| 10521 | // moves the category exactly as ← does. |
| 10522 | let _ = view.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); |
| 10523 | let by_key = view.category; |
| 10524 | let _ = view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); |
| 10525 | let mut buf = Buffer::empty(area); |
| 10526 | view.render(area, &mut buf); |
| 10527 | let strip = view.last_rail_hitboxes.borrow().clone(); |
| 10528 | let motion = strip |
| 10529 | .iter() |
| 10530 | .find(|(_, category)| *category == by_key) |
| 10531 | .map(|(rect, _)| *rect) |
| 10532 | .or_else(|| { |
| 10533 | view.last_nav_controls |
| 10534 | .borrow() |
| 10535 | .iter() |
| 10536 | .find(|(_, step)| *step == super::NavStep::Previous) |
| 10537 | .map(|(rect, _)| *rect) |
| 10538 | }) |
| 10539 | .unwrap_or_else(|| panic!("{w}x{h} {by_key:?} navigation target")); |
| 10540 | let action = view.handle_mouse(MouseEvent { |
| 10541 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10542 | column: motion.x, |
| 10543 | row: motion.y, |
| 10544 | modifiers: KeyModifiers::NONE, |
| 10545 | }); |
| 10546 | assert!(matches!(action, ViewAction::None)); |
| 10547 | assert_eq!(view.category, by_key, "{w}x{h} pointer parity"); |
| 10548 | } |
| 10549 | } |
| 10550 | |
| 10551 | #[test] |
| 10552 | fn config_detail_never_synthesizes_current_from_saved() { |
| 10553 | let app = create_test_app(); |
| 10554 | let mut view = ConfigView::new_for_app(&app); |
| 10555 | let low_motion = view |
| 10556 | .rows |
| 10557 | .iter() |
| 10558 | .position(|row| row.key == "low_motion") |
| 10559 | .expect("low_motion row"); |
| 10560 | view.rows[low_motion].value = "false".to_string(); |
| 10561 | view.rows[low_motion].facts.effective = Some("true".to_string()); |
| 10562 | // Pin the authority: the host motion-override probe (a legacy console |
| 10563 | // host, NO_ANIMATIONS, an SSH session, …) would otherwise relabel this |
| 10564 | // row and make the test host-dependent. The subject here is the |
| 10565 | // saved/effective synthesis contract, not override detection. |
| 10566 | view.rows[low_motion].facts.authority = super::SettingAuthority::UserSettings; |
| 10567 | view.rows[low_motion].facts.authority_detail = None; |
| 10568 | view.category = ConfigCategory::Motion; |
| 10569 | view.selected = low_motion; |
| 10570 | |
| 10571 | let fact = view |
| 10572 | .setting_fact(&view.rows[low_motion]) |
| 10573 | .expect("setting fact"); |
| 10574 | assert_eq!(fact.effective.as_deref(), Some("On")); |
| 10575 | assert_eq!(fact.saved.as_deref(), Some("Off")); |
| 10576 | assert_eq!(fact.startup.as_deref(), Some("Off")); |
| 10577 | assert_eq!(fact.authority, super::SettingAuthority::UserSettings); |
| 10578 | assert_eq!(fact.apply, super::SettingApplySemantics::Immediate); |
| 10579 | |
| 10580 | let dump = render_dump(&view, 120, 32); |
| 10581 | let lane = |name: &str| { |
| 10582 | dump.lines() |
| 10583 | .find(|line| line.contains(&format!("{name:<10}"))) |
| 10584 | .unwrap_or_else(|| panic!("{name} lane:\n{dump}")) |
| 10585 | .to_string() |
| 10586 | }; |
| 10587 | assert!(lane("current").contains("On"), "{}", lane("current")); |
| 10588 | assert!(lane("saved").contains("Off"), "{}", lane("saved")); |
| 10589 | |
| 10590 | // A saved row with no App observation reports current as unobserved |
| 10591 | // instead of echoing the persisted value. |
| 10592 | let calm = view |
| 10593 | .rows |
| 10594 | .iter() |
| 10595 | .find(|row| row.key == "calm_mode") |
| 10596 | .expect("calm_mode row"); |
| 10597 | let fact = view.setting_fact(calm).expect("setting fact"); |
| 10598 | assert!(fact.effective.is_none() && fact.current.is_none()); |
| 10599 | assert_eq!( |
| 10600 | fact.saved.as_deref(), |
| 10601 | Some(config_choice_label(Locale::En, "calm_mode", &calm.value).as_str()) |
| 10602 | ); |
| 10603 | assert!( |
| 10604 | view.setting_detail_summary(calm) |
| 10605 | .contains(&en(MessageId::ConfigLaneUnobserved)) |
| 10606 | ); |
| 10607 | |
| 10608 | // Theme and locale report the value the app is actually running |
| 10609 | // with, from App, not the persisted string. |
| 10610 | let theme = view |
| 10611 | .rows |
| 10612 | .iter() |
| 10613 | .find(|row| row.key == "theme") |
| 10614 | .expect("theme row"); |
| 10615 | assert_eq!( |
| 10616 | theme.facts.effective.as_deref(), |
| 10617 | Some(app.theme_id.name()), |
| 10618 | "theme current lane comes from App" |
| 10619 | ); |
| 10620 | let locale = view |
| 10621 | .rows |
| 10622 | .iter() |
| 10623 | .find(|row| row.key == "locale") |
| 10624 | .expect("locale row"); |
| 10625 | assert_eq!(locale.facts.effective.as_deref(), Some(app.ui_locale.tag())); |
| 10626 | |
| 10627 | // Session-owned facts never claim a saved default they did not read. |
| 10628 | let provider = view |
| 10629 | .rows |
| 10630 | .iter() |
| 10631 | .find(|row| row.key == "provider") |
| 10632 | .expect("provider row"); |
| 10633 | let fact = view.setting_fact(provider).expect("setting fact"); |
| 10634 | assert_eq!(fact.authority, super::SettingAuthority::Session); |
| 10635 | assert_eq!(fact.effective, view.snapshot.provider.effective); |
| 10636 | assert!(fact.saved.is_none() && fact.startup.is_none()); |
| 10637 | } |
| 10638 | |
| 10639 | #[test] |
| 10640 | fn config_rows_carry_truthful_apply_semantics_and_kinds() { |
| 10641 | use super::{ConfigRowKind, SettingApplySemantics, SettingAuthority}; |
| 10642 | let app = create_test_app(); |
| 10643 | let view = ConfigView::new_for_app(&app); |
| 10644 | let row = |key: &str| { |
| 10645 | view.rows |
| 10646 | .iter() |
| 10647 | .find(|row| row.key == key) |
| 10648 | .unwrap_or_else(|| panic!("row {key}")) |
| 10649 | }; |
| 10650 | assert_eq!( |
| 10651 | row("default_mode").facts.apply, |
| 10652 | SettingApplySemantics::NextSession |
| 10653 | ); |
| 10654 | assert_eq!( |
| 10655 | row("mcp_config_path").facts.apply, |
| 10656 | SettingApplySemantics::ReloadRequired |
| 10657 | ); |
| 10658 | assert_eq!(row("theme").facts.apply, SettingApplySemantics::Immediate); |
| 10659 | for key in ["mcp_open", "mcp_reconnect", "mcp_diagnose", "plugins_open"] { |
| 10660 | let action = row(key); |
| 10661 | assert_eq!(action.facts.kind, ConfigRowKind::Action, "{key}"); |
| 10662 | assert!(view.setting_fact(action).is_none(), "{key} is not a fact"); |
| 10663 | assert!( |
| 10664 | view.setting_detail_summary(action) |
| 10665 | .contains(&en(MessageId::ConfigRowActionNote)), |
| 10666 | "{key}" |
| 10667 | ); |
| 10668 | } |
| 10669 | for key in ["effective_context_window", "base_url"] { |
| 10670 | let receipt = row(key); |
| 10671 | assert_eq!(receipt.facts.kind, ConfigRowKind::Diagnostic, "{key}"); |
| 10672 | assert!(view.setting_fact(receipt).is_none(), "{key}"); |
| 10673 | } |
| 10674 | assert_eq!( |
| 10675 | row("permission_posture").facts.authority, |
| 10676 | SettingAuthority::UserSettings |
| 10677 | ); |
| 10678 | assert_eq!( |
| 10679 | row("allow_shell").facts.authority, |
| 10680 | SettingAuthority::WorkspaceConfiguration |
| 10681 | ); |
| 10682 | assert_eq!( |
| 10683 | super::ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy).apply, |
| 10684 | SettingApplySemantics::ReadOnly |
| 10685 | ); |
| 10686 | assert_eq!( |
| 10687 | row("telemetry").facts.authority, |
| 10688 | SettingAuthority::WorkspaceConfiguration |
| 10689 | ); |
| 10690 | assert_eq!( |
| 10691 | super::setting_apply_label(Locale::En, SettingApplySemantics::ReloadRequired), |
| 10692 | en(MessageId::ConfigApplyReload) |
| 10693 | ); |
| 10694 | assert_eq!( |
| 10695 | super::setting_apply_label(Locale::En, SettingApplySemantics::UiNowEngineRestart), |
| 10696 | en(MessageId::ConfigApplyUiNowEngineRestart) |
| 10697 | ); |
| 10698 | assert_eq!( |
| 10699 | super::setting_apply_label(Locale::ZhHans, SettingApplySemantics::ReloadRequired), |
| 10700 | tr(Locale::ZhHans, MessageId::ConfigApplyReload) |
| 10701 | ); |
| 10702 | } |
| 10703 | |
| 10704 | #[test] |
| 10705 | fn config_list_clicks_outside_row_rects_never_select_or_activate() { |
| 10706 | let app = create_test_app(); |
| 10707 | let mut view = ConfigView::new_for_app(&app); |
| 10708 | let area = Rect::new(0, 0, 120, 32); |
| 10709 | let mut buf = Buffer::empty(area); |
| 10710 | view.render(area, &mut buf); |
| 10711 | let before = view.selected; |
| 10712 | let rows = view.last_row_hitboxes.borrow().clone(); |
| 10713 | let (first, _) = rows[0]; |
| 10714 | let list_bottom = rows.iter().map(|(rect, _)| rect.bottom()).max().unwrap(); |
| 10715 | let tabs = view.last_rail_hitboxes.borrow().clone(); |
| 10716 | let tabs_bottom = tabs.iter().map(|(rect, _)| rect.bottom()).max().unwrap(); |
| 10717 | assert!(tabs_bottom <= first.y, "tabs must not overlap the list"); |
| 10718 | |
| 10719 | let click = |view: &mut ConfigView, column: u16, row: u16| { |
| 10720 | view.handle_mouse(MouseEvent { |
| 10721 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10722 | column, |
| 10723 | row, |
| 10724 | modifiers: KeyModifiers::NONE, |
| 10725 | }) |
| 10726 | }; |
| 10727 | // Rail area on a row that is not a category, the divider between |
| 10728 | // rail and list, the detail pane, the status row, and the footer. |
| 10729 | let probes = [ |
| 10730 | (first.x.saturating_sub(4), list_bottom.saturating_sub(1)), |
| 10731 | (first.x.saturating_sub(1), first.y), |
| 10732 | (first.right().saturating_add(3), first.y), |
| 10733 | (first.x.saturating_add(2), area.bottom().saturating_sub(4)), |
| 10734 | (first.x.saturating_add(2), area.bottom().saturating_sub(2)), |
| 10735 | ]; |
| 10736 | for (column, row) in probes { |
| 10737 | // Twice: a second click is the activation gesture on a row. |
| 10738 | let _ = click(&mut view, column, row); |
| 10739 | let action = click(&mut view, column, row); |
| 10740 | assert!(matches!(action, ViewAction::None), "({column},{row})"); |
| 10741 | assert_eq!(view.selected, before, "({column},{row}) must not select"); |
| 10742 | assert!(view.editing.is_none(), "({column},{row}) must not activate"); |
| 10743 | } |
| 10744 | // Inside the rect: selects on the first click. |
| 10745 | let (rect, idx) = rows[rows.len() - 1]; |
| 10746 | let _ = click(&mut view, rect.x, rect.y); |
| 10747 | assert_eq!(view.selected, idx); |
| 10748 | } |
| 10749 | |
| 10750 | #[test] |
| 10751 | fn config_search_indexes_categories_and_category_click_clears_filter() { |
| 10752 | let app = create_test_app(); |
| 10753 | let mut view = ConfigView::new_for_app(&app); |
| 10754 | type_filter(&mut view, "fleet"); |
| 10755 | assert!( |
| 10756 | visible_row_keys(&view).contains(&"fleet.exec.max_spawn_depth"), |
| 10757 | "{:?}", |
| 10758 | visible_row_keys(&view) |
| 10759 | ); |
| 10760 | view.clear_filter(); |
| 10761 | type_filter(&mut view, "trust"); |
| 10762 | let keys = visible_row_keys(&view); |
| 10763 | assert!(keys.contains(&"approval_mode"), "{keys:?}"); |
| 10764 | assert!(keys.contains(&"telemetry"), "{keys:?}"); |
| 10765 | |
| 10766 | let area = Rect::new(0, 0, 80, 24); |
| 10767 | let mut buf = Buffer::empty(area); |
| 10768 | view.render(area, &mut buf); |
| 10769 | // The strip windows from the active (Appearance) chip at 80 columns; |
| 10770 | // Trust is inside that window. |
| 10771 | let (rect, category) = view |
| 10772 | .last_rail_hitboxes |
| 10773 | .borrow() |
| 10774 | .iter() |
| 10775 | .copied() |
| 10776 | .find(|(_, category)| *category == ConfigCategory::Trust) |
| 10777 | .expect("Trust chip visible while filtering"); |
| 10778 | let action = view.handle_mouse(MouseEvent { |
| 10779 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10780 | column: rect.x, |
| 10781 | row: rect.y, |
| 10782 | modifiers: KeyModifiers::NONE, |
| 10783 | }); |
| 10784 | assert!(matches!(action, ViewAction::None)); |
| 10785 | assert_eq!(category, ConfigCategory::Trust); |
| 10786 | assert!(view.filter.is_empty(), "category click clears the filter"); |
| 10787 | assert_eq!(view.category, ConfigCategory::Trust); |
| 10788 | let keys = visible_row_keys(&view); |
| 10789 | assert!(keys.contains(&"approval_mode"), "{keys:?}"); |
| 10790 | assert!(keys.contains(&"telemetry"), "{keys:?}"); |
| 10791 | assert!( |
| 10792 | keys.iter().all(|key| ConfigCategory::Trust |
| 10793 | .contains(view.rows.iter().find(|row| row.key == *key).unwrap())), |
| 10794 | "only Trust rows remain after the click: {keys:?}" |
| 10795 | ); |
| 10796 | } |
| 10797 | |
| 10798 | /// Interaction evidence at the blocker sizes: the real `ConfigView` |
| 10799 | /// driven by keys and pointer at 40x12, 80x24, 100x30, and 120x32. The |
| 10800 | /// rendered buffers are printed (`--nocapture`) as harness evidence — the |
| 10801 | /// real renderer into a ratatui `Buffer`, not a terminal capture. |
| 10802 | #[test] |
| 10803 | #[allow( |
| 10804 | clippy::print_stdout, |
| 10805 | reason = "prints the rendered buffers as interaction evidence under --nocapture" |
| 10806 | )] |
| 10807 | fn config_shell_interaction_evidence_at_blocker_sizes() { |
| 10808 | let app = create_test_app(); |
| 10809 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 10810 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 10811 | }; |
| 10812 | let click = |view: &mut ConfigView, column: u16, row: u16| { |
| 10813 | view.handle_mouse(MouseEvent { |
| 10814 | kind: MouseEventKind::Down(MouseButton::Left), |
| 10815 | column, |
| 10816 | row, |
| 10817 | modifiers: KeyModifiers::NONE, |
| 10818 | }) |
| 10819 | }; |
| 10820 | for (w, h) in [(40u16, 12u16), (80, 24), (100, 30), (120, 32)] { |
| 10821 | let mut view = ConfigView::new_for_app(&app); |
| 10822 | let area = Rect::new(0, 0, w, h); |
| 10823 | let snapshot = |view: &ConfigView, step: &str| { |
| 10824 | let mut buf = Buffer::empty(area); |
| 10825 | view.render(area, &mut buf); |
| 10826 | let dump = buffer_text(&buf, area); |
| 10827 | println!("== {w}x{h} · {step} ==\n{dump}"); |
| 10828 | dump |
| 10829 | }; |
| 10830 | |
| 10831 | // Opens on Appearance with the first Appearance row selected. |
| 10832 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 10833 | assert_eq!(view.rows[view.selected].key, "theme"); |
| 10834 | let dump = snapshot(&view, "open"); |
| 10835 | assert!(dump.contains("Appearance"), "{w}x{h}:\n{dump}"); |
| 10836 | assert!(dump.contains("Search:"), "{w}x{h}:\n{dump}"); |
| 10837 | |
| 10838 | // → lands on Models & providers (the one-row Fleet tab is gone; |
| 10839 | // sub-agent depth moved into the Model group). Focus it and check |
| 10840 | // the read-only config.toml posture. |
| 10841 | assert!(matches!(key(&mut view, KeyCode::Right), ViewAction::None)); |
| 10842 | assert_eq!(view.category, ConfigCategory::ModelsProviders); |
| 10843 | view.focus_key("fleet.exec.max_spawn_depth"); |
| 10844 | assert_eq!(view.rows[view.selected].key, "fleet.exec.max_spawn_depth"); |
| 10845 | let dump = snapshot(&view, "after → (Models & providers)"); |
| 10846 | assert!(dump.contains("Models & providers"), "{w}x{h}:\n{dump}"); |
| 10847 | assert!( |
| 10848 | dump.contains(super::setting_affordance(SettingKind::ReadOnly, None)), |
| 10849 | "{w}x{h} read-only affordance:\n{dump}" |
| 10850 | ); |
| 10851 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 10852 | assert!(view.editing.is_none(), "{w}x{h} read-only rows never edit"); |
| 10853 | |
| 10854 | // Tab ×4 → Motion; ↓ → fancy_animations; Enter toggles it and |
| 10855 | // emits the persisted update without opening an editor. |
| 10856 | for _ in 0..4 { |
| 10857 | assert!(matches!(key(&mut view, KeyCode::Tab), ViewAction::None)); |
| 10858 | } |
| 10859 | assert_eq!(view.category, ConfigCategory::Motion); |
| 10860 | assert_eq!(view.rows[view.selected].key, "low_motion"); |
| 10861 | assert!(matches!(key(&mut view, KeyCode::Down), ViewAction::None)); |
| 10862 | assert_eq!(view.rows[view.selected].key, "fancy_animations"); |
| 10863 | let dump = snapshot(&view, "Motion · ↓ to fancy_animations"); |
| 10864 | assert!( |
| 10865 | if w < 50 { |
| 10866 | dump.contains("Enter") |
| 10867 | } else { |
| 10868 | dump.contains(&en(MessageId::ConfigActivateAgain)) |
| 10869 | }, |
| 10870 | "{w}x{h} activation copy:\n{dump}" |
| 10871 | ); |
| 10872 | match key(&mut view, KeyCode::Enter) { |
| 10873 | ViewAction::Emit(ViewEvent::ConfigUpdated { key, persist, .. }) => { |
| 10874 | assert_eq!(key, "fancy_animations"); |
| 10875 | assert!(persist); |
| 10876 | } |
| 10877 | other => panic!("{w}x{h} Enter should toggle, got {other:?}"), |
| 10878 | } |
| 10879 | assert!(view.editing.is_none()); |
| 10880 | |
| 10881 | // Pointer parity: click a visible non-active chip or the compact |
| 10882 | // Previous control, then select and activate the first row. |
| 10883 | let mut buf = Buffer::empty(area); |
| 10884 | view.render(area, &mut buf); |
| 10885 | let (chip, target) = view |
| 10886 | .last_rail_hitboxes |
| 10887 | .borrow() |
| 10888 | .iter() |
| 10889 | .copied() |
| 10890 | .find(|(_, category)| *category != ConfigCategory::Motion) |
| 10891 | .or_else(|| { |
| 10892 | view.last_nav_controls |
| 10893 | .borrow() |
| 10894 | .iter() |
| 10895 | .find(|(_, step)| *step == super::NavStep::Previous) |
| 10896 | .map(|(rect, _)| (*rect, ConfigCategory::Trust)) |
| 10897 | }) |
| 10898 | .expect("another category is reachable through a painted target"); |
| 10899 | assert!(matches!(click(&mut view, chip.x, chip.y), ViewAction::None)); |
| 10900 | assert_eq!(view.category, target, "{w}x{h} chip click"); |
| 10901 | let mut buf = Buffer::empty(area); |
| 10902 | view.render(area, &mut buf); |
| 10903 | let (row_rect, row_idx) = view.last_row_hitboxes.borrow()[0]; |
| 10904 | assert!(matches!( |
| 10905 | click(&mut view, row_rect.x + 1, row_rect.y), |
| 10906 | ViewAction::None |
| 10907 | )); |
| 10908 | assert_eq!(view.selected, row_idx, "{w}x{h} first click selects"); |
| 10909 | let dump = snapshot( |
| 10910 | &view, |
| 10911 | &format!("pointer · {} chip, row selected", target.label(Locale::En)), |
| 10912 | ); |
| 10913 | assert!(dump.contains("❯"), "{w}x{h} selected row marker:\n{dump}"); |
| 10914 | let second = click(&mut view, row_rect.x + 1, row_rect.y); |
| 10915 | let row = &view.rows[row_idx]; |
| 10916 | match (row.editable, row.facts.command) { |
| 10917 | (false, _) => { |
| 10918 | assert!(matches!(second, ViewAction::None)); |
| 10919 | assert!(view.editing.is_none()); |
| 10920 | } |
| 10921 | (true, Some((command, _))) => match second { |
| 10922 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 10923 | action: CommandPaletteAction::ExecuteCommand { command: emitted }, |
| 10924 | }) => assert_eq!(emitted, command), |
| 10925 | ViewAction::Emit(ViewEvent::ExecutePanelCommand { |
| 10926 | command: emitted, |
| 10927 | pager_title: Some(_), |
| 10928 | }) => assert_eq!(emitted, command), |
| 10929 | other => panic!("{w}x{h} second click should open {command}: {other:?}"), |
| 10930 | }, |
| 10931 | (true, None) => assert!( |
| 10932 | view.editing.is_some() || matches!(second, ViewAction::Emit(_)), |
| 10933 | "{w}x{h} second click must edit or emit: {second:?}" |
| 10934 | ), |
| 10935 | } |
| 10936 | if view.editing.is_some() { |
| 10937 | let _ = snapshot(&view, "editor after second click"); |
| 10938 | assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::None)); |
| 10939 | assert!(view.editing.is_none()); |
| 10940 | } |
| 10941 | |
| 10942 | // Search: typing filters across categories; Esc clears; Esc again |
| 10943 | // closes the view. |
| 10944 | for ch in "telemetry".chars() { |
| 10945 | assert!(matches!( |
| 10946 | key(&mut view, KeyCode::Char(ch)), |
| 10947 | ViewAction::None |
| 10948 | )); |
| 10949 | } |
| 10950 | assert_eq!(visible_row_keys(&view), vec!["telemetry"]); |
| 10951 | let dump = snapshot(&view, "search \"telemetry\""); |
| 10952 | assert!(dump.contains("telemetry"), "{w}x{h}:\n{dump}"); |
| 10953 | assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::None)); |
| 10954 | assert!(view.filter.is_empty()); |
| 10955 | assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::Close)); |
| 10956 | } |
| 10957 | } |
| 10958 | |
| 10959 | /// P1.1: a store that fails to load never becomes a default labelled |
| 10960 | /// saved/startup. The row is unavailable, read-only, and its lanes carry |
| 10961 | /// the load error; App-observed lanes (low_motion) still render. |
| 10962 | #[test] |
| 10963 | fn config_rows_report_unreadable_stores_instead_of_defaults() { |
| 10964 | let _lock = crate::test_support::lock_test_env(); |
| 10965 | let tmp = TempDir::new().expect("tempdir"); |
| 10966 | let config_path = tmp.path().join(".deepseek").join("config.toml"); |
| 10967 | std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); |
| 10968 | std::fs::write( |
| 10969 | config_path.parent().unwrap().join("settings.toml"), |
| 10970 | "theme = [broken\n", |
| 10971 | ) |
| 10972 | .unwrap(); |
| 10973 | std::fs::write(&config_path, "approval_policy = [broken\n").unwrap(); |
| 10974 | let _guard = crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 10975 | let mut app = create_test_app(); |
| 10976 | app.config_path = Some(config_path.clone()); |
| 10977 | let mut view = ConfigView::new_for_app(&app); |
| 10978 | |
| 10979 | let theme = view.rows.iter().find(|row| row.key == "theme").unwrap(); |
| 10980 | assert!( |
| 10981 | !theme.editable, |
| 10982 | "an unreadable store is never written through" |
| 10983 | ); |
| 10984 | assert_eq!(theme.value, en(MessageId::ConfigUnavailable)); |
| 10985 | let error = theme |
| 10986 | .facts |
| 10987 | .store_error |
| 10988 | .clone() |
| 10989 | .expect("settings load error is preserved"); |
| 10990 | assert!(!error.is_empty()); |
| 10991 | let fact = view.setting_fact(theme).expect("setting fact"); |
| 10992 | assert!(fact.saved.is_none() && fact.startup.is_none(), "{fact:?}"); |
| 10993 | assert_eq!( |
| 10994 | fact.effective.as_deref(), |
| 10995 | Some(app.theme_id.name()), |
| 10996 | "the live theme is still known from App" |
| 10997 | ); |
| 10998 | let summary = view.setting_detail_summary(theme); |
| 10999 | assert!( |
| 11000 | summary.contains(&en(MessageId::ConfigLaneUnavailable).replace("{error}", &error)), |
| 11001 | "{summary}" |
| 11002 | ); |
| 11003 | |
| 11004 | let telemetry = view.rows.iter().find(|row| row.key == "telemetry").unwrap(); |
| 11005 | assert!(!telemetry.editable); |
| 11006 | assert!( |
| 11007 | telemetry.facts.store_error.is_some(), |
| 11008 | "config.toml error is preserved" |
| 11009 | ); |
| 11010 | let low_motion = view |
| 11011 | .rows |
| 11012 | .iter() |
| 11013 | .find(|row| row.key == "low_motion") |
| 11014 | .unwrap(); |
| 11015 | let fact = view.setting_fact(low_motion).expect("setting fact"); |
| 11016 | assert!( |
| 11017 | fact.saved.is_none(), |
| 11018 | "no persisted lane from a broken store" |
| 11019 | ); |
| 11020 | assert!( |
| 11021 | fact.effective.is_some(), |
| 11022 | "App still supplies the live value" |
| 11023 | ); |
| 11024 | |
| 11025 | // Rendered: the detail pane names the failure, never a default. |
| 11026 | view.focus_key("theme"); |
| 11027 | let lines = view.setting_detail_lines(&view.rows[view.selected], 400); |
| 11028 | let expected = en(MessageId::ConfigLaneUnavailable).replace("{error}", &error); |
| 11029 | // The lane is one line (the error is folded) and may be ellipsized |
| 11030 | // past the pane width, so its head is the stable part. |
| 11031 | assert!( |
| 11032 | !error.contains('\n'), |
| 11033 | "store error is folded onto one line: {error:?}" |
| 11034 | ); |
| 11035 | let head: String = expected.chars().take(60).collect(); |
| 11036 | assert!( |
| 11037 | lines.iter().any(|line| line.to_string().contains(&head)), |
| 11038 | "saved lane carries the load error: {expected}" |
| 11039 | ); |
| 11040 | let dump = render_dump(&view, 120, 32); |
| 11041 | let head: String = en(MessageId::ConfigUnavailable).chars().take(7).collect(); |
| 11042 | assert!( |
| 11043 | dump.contains(&head), |
| 11044 | "unavailable value painted in the list:\n{dump}" |
| 11045 | ); |
| 11046 | let unavailable_prefix = en(MessageId::ConfigLaneUnavailable) |
| 11047 | .split("{error}") |
| 11048 | .next() |
| 11049 | .unwrap() |
| 11050 | .to_string(); |
| 11051 | assert!( |
| 11052 | dump.contains(unavailable_prefix.trim_end()), |
| 11053 | "unavailable lane painted:\n{dump}" |
| 11054 | ); |
| 11055 | // Session rows are untouched by store failures. |
| 11056 | let provider = view.rows.iter().find(|row| row.key == "provider").unwrap(); |
| 11057 | assert!(provider.editable && provider.facts.store_error.is_none()); |
| 11058 | } |
| 11059 | |
| 11060 | /// An environment/terminal override wins the effective decision but does |
| 11061 | /// not repair a broken store: the overridden motion row must still report |
| 11062 | /// the settings.toml load failure instead of synthesizing a saved lane. |
| 11063 | /// (Windows CI caught this through the legacy-console probe; store truth |
| 11064 | /// is keyed on the row's store, not its current authority.) |
| 11065 | #[test] |
| 11066 | fn overridden_motion_row_still_reports_a_broken_settings_store() { |
| 11067 | let _lock = crate::test_support::lock_test_env(); |
| 11068 | let tmp = TempDir::new().expect("tempdir"); |
| 11069 | let config_dir = tmp.path().join(".deepseek"); |
| 11070 | std::fs::create_dir_all(&config_dir).unwrap(); |
| 11071 | std::fs::write(config_dir.join("settings.toml"), "theme = [broken\n").unwrap(); |
| 11072 | let config_path = config_dir.join("config.toml"); |
| 11073 | std::fs::write(&config_path, "approval_policy = [broken\n").unwrap(); |
| 11074 | let _guard = crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); |
| 11075 | let _override = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1"); |
| 11076 | let app = create_test_app(); |
| 11077 | let view = ConfigView::new_for_app(&app); |
| 11078 | |
| 11079 | let row = view |
| 11080 | .rows |
| 11081 | .iter() |
| 11082 | .find(|row| row.key == "low_motion") |
| 11083 | .expect("low_motion row"); |
| 11084 | assert_eq!(row.facts.authority, super::SettingAuthority::Environment); |
| 11085 | assert!( |
| 11086 | row.facts.store_error.is_some(), |
| 11087 | "the override wins the effective decision; the broken store is still reported" |
| 11088 | ); |
| 11089 | let fact = view.setting_fact(row).expect("setting fact"); |
| 11090 | assert!(fact.saved.is_none() && fact.startup.is_none(), "{fact:?}"); |
| 11091 | assert!( |
| 11092 | fact.effective.is_some(), |
| 11093 | "App still supplies the live value" |
| 11094 | ); |
| 11095 | } |
| 11096 | |
| 11097 | /// P1.2: when a runtime overlay forces low motion, the row's source is |
| 11098 | /// that override, not `settings.toml`. |
| 11099 | #[test] |
| 11100 | fn motion_rows_name_the_winning_environment_override() { |
| 11101 | let _lock = crate::test_support::lock_test_env(); |
| 11102 | let _no_animations = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1"); |
| 11103 | let app = create_test_app(); |
| 11104 | let mut view = ConfigView::new_for_app(&app); |
| 11105 | for key in ["low_motion", "fancy_animations"] { |
| 11106 | let row = view.rows.iter().find(|row| row.key == key).unwrap(); |
| 11107 | assert_eq!( |
| 11108 | row.facts.authority, |
| 11109 | super::SettingAuthority::Environment, |
| 11110 | "{key}" |
| 11111 | ); |
| 11112 | assert_eq!(row.facts.authority_detail, Some("NO_ANIMATIONS"), "{key}"); |
| 11113 | } |
| 11114 | let theme = view.rows.iter().find(|row| row.key == "theme").unwrap(); |
| 11115 | assert_eq!(theme.facts.authority, super::SettingAuthority::UserSettings); |
| 11116 | view.focus_key("low_motion"); |
| 11117 | let expected = en(MessageId::ConfigSourceEnvironment).replace("{name}", "NO_ANIMATIONS"); |
| 11118 | // The detail pane is 39 columns wide at 120x32 and ellipsizes long |
| 11119 | // values, so the unbounded detail lines carry the full label and the |
| 11120 | // rendered pane carries its visible head. |
| 11121 | let lines = view.setting_detail_lines(&view.rows[view.selected], 200); |
| 11122 | assert!( |
| 11123 | lines |
| 11124 | .iter() |
| 11125 | .any(|line| line.to_string().contains(&expected)), |
| 11126 | "detail names the override: {expected}" |
| 11127 | ); |
| 11128 | let dump = render_dump(&view, 120, 32); |
| 11129 | let head: String = expected.chars().take(20).collect(); |
| 11130 | assert!(dump.contains(&head), "source names the override:\n{dump}"); |
| 11131 | } |
| 11132 | |
| 11133 | /// Slice C: Edit Theme live preview — highlighting a theme row emits a |
| 11134 | /// session-only `ConfigUpdated` (the surface repaints immediately) while |
| 11135 | /// only Enter/Apply persists; Esc reverts to the opening value. |
| 11136 | #[test] |
| 11137 | fn edit_theme_highlight_previews_without_persisting_and_esc_reverts() { |
| 11138 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 11139 | let app = create_test_app(); |
| 11140 | let mut view = ConfigView::new_for_app(&app); |
| 11141 | view.focus_key("theme"); |
| 11142 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 11143 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 11144 | }; |
| 11145 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 11146 | assert!( |
| 11147 | view.editing |
| 11148 | .as_ref() |
| 11149 | .is_some_and(|edit| edit.key == "theme"), |
| 11150 | "Enter must open the theme editor" |
| 11151 | ); |
| 11152 | |
| 11153 | // ↓ highlights shoreline: preview (persist:false), editor stays open. |
| 11154 | match key(&mut view, KeyCode::Down) { |
| 11155 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 11156 | key, |
| 11157 | value, |
| 11158 | persist, |
| 11159 | }) => { |
| 11160 | assert_eq!(key, "theme"); |
| 11161 | assert_eq!(value, "shoreline"); |
| 11162 | assert!(!persist, "highlighting must not persist"); |
| 11163 | } |
| 11164 | other => panic!("highlight must preview, got {other:?}"), |
| 11165 | } |
| 11166 | assert!(view.editing.is_some(), "preview keeps the editor open"); |
| 11167 | |
| 11168 | // Esc reverts the live surface to the opening value, session-only. |
| 11169 | match key(&mut view, KeyCode::Esc) { |
| 11170 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 11171 | key, |
| 11172 | value, |
| 11173 | persist, |
| 11174 | }) => { |
| 11175 | assert_eq!(key, "theme"); |
| 11176 | assert_eq!(value, "terminal"); |
| 11177 | assert!(!persist, "revert must not persist"); |
| 11178 | } |
| 11179 | other => panic!("esc must revert the preview, got {other:?}"), |
| 11180 | } |
| 11181 | assert!(view.editing.is_none()); |
| 11182 | } |
| 11183 | |
| 11184 | /// Slice C: Enter/Apply in Edit Theme persists the highlighted theme. |
| 11185 | #[test] |
| 11186 | fn edit_theme_enter_persists_the_highlighted_theme() { |
| 11187 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 11188 | let app = create_test_app(); |
| 11189 | let mut view = ConfigView::new_for_app(&app); |
| 11190 | view.focus_key("theme"); |
| 11191 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 11192 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 11193 | }; |
| 11194 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 11195 | let _ = key(&mut view, KeyCode::Down); |
| 11196 | match key(&mut view, KeyCode::Enter) { |
| 11197 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 11198 | key, |
| 11199 | value, |
| 11200 | persist, |
| 11201 | }) => { |
| 11202 | assert_eq!(key, "theme"); |
| 11203 | assert_eq!(value, "shoreline"); |
| 11204 | assert!(persist, "Apply must persist"); |
| 11205 | } |
| 11206 | other => panic!("enter must persist the highlight, got {other:?}"), |
| 11207 | } |
| 11208 | assert!(view.editing.is_none()); |
| 11209 | } |
| 11210 | |
| 11211 | /// Slice C: Esc without moving the highlight previews nothing and |
| 11212 | /// reverts nothing. |
| 11213 | #[test] |
| 11214 | fn edit_theme_esc_without_preview_is_silent() { |
| 11215 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 11216 | let app = create_test_app(); |
| 11217 | let mut view = ConfigView::new_for_app(&app); |
| 11218 | view.focus_key("theme"); |
| 11219 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 11220 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 11221 | }; |
| 11222 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 11223 | assert!( |
| 11224 | matches!(key(&mut view, KeyCode::Esc), ViewAction::None), |
| 11225 | "no preview happened, so there is nothing to revert" |
| 11226 | ); |
| 11227 | } |
| 11228 | |
| 11229 | /// Slice C: live preview is theme-only — other choice editors keep |
| 11230 | /// their silent highlight behavior. |
| 11231 | #[test] |
| 11232 | fn edit_choice_highlight_previews_only_the_theme_key() { |
| 11233 | let _guard = ConfigSettingsEnvGuard::new(""); |
| 11234 | let app = create_test_app(); |
| 11235 | let mut view = ConfigView::new_for_app(&app); |
| 11236 | view.focus_key("default_mode"); |
| 11237 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 11238 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 11239 | }; |
| 11240 | assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None)); |
| 11241 | assert!( |
| 11242 | view.editing |
| 11243 | .as_ref() |
| 11244 | .is_some_and(|edit| edit.key == "default_mode"), |
| 11245 | "Enter must open the default_mode editor" |
| 11246 | ); |
| 11247 | assert!( |
| 11248 | matches!(key(&mut view, KeyCode::Down), ViewAction::None), |
| 11249 | "non-theme highlight must stay silent" |
| 11250 | ); |
| 11251 | assert!( |
| 11252 | matches!(key(&mut view, KeyCode::Esc), ViewAction::None), |
| 11253 | "no preview means no revert" |
| 11254 | ); |
| 11255 | } |
| 11256 | |
| 11257 | /// Slice C (global hover rule): hovering an Edit Theme choice row |
| 11258 | /// highlights it and live-previews; hovering the same row again is |
| 11259 | /// silent. |
| 11260 | #[test] |
| 11261 | fn edit_theme_hover_highlights_and_previews() { |
| 11262 | let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n"); |
| 11263 | let app = create_test_app(); |
| 11264 | let mut view = ConfigView::new_for_app(&app); |
| 11265 | view.focus_key("theme"); |
| 11266 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11267 | let area = Rect::new(0, 0, 120, 32); |
| 11268 | let mut buf = Buffer::empty(area); |
| 11269 | view.render(area, &mut buf); |
| 11270 | let hover = |view: &mut ConfigView, column: u16, row: u16| { |
| 11271 | view.handle_mouse(MouseEvent { |
| 11272 | kind: MouseEventKind::Moved, |
| 11273 | column, |
| 11274 | row, |
| 11275 | modifiers: KeyModifiers::NONE, |
| 11276 | }) |
| 11277 | }; |
| 11278 | // Choice index 2 is shoreline (system, terminal, shoreline, …). |
| 11279 | let (rect, _) = view |
| 11280 | .last_choice_hitboxes |
| 11281 | .borrow() |
| 11282 | .iter() |
| 11283 | .copied() |
| 11284 | .find(|(_, idx)| *idx == 2) |
| 11285 | .expect("rendered shoreline hitbox"); |
| 11286 | match hover(&mut view, rect.x, rect.y) { |
| 11287 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 11288 | key, |
| 11289 | value, |
| 11290 | persist, |
| 11291 | }) => { |
| 11292 | assert_eq!(key, "theme"); |
| 11293 | assert_eq!(value, "shoreline"); |
| 11294 | assert!(!persist, "hover preview must not persist"); |
| 11295 | } |
| 11296 | other => panic!("hover must preview, got {other:?}"), |
| 11297 | } |
| 11298 | assert!( |
| 11299 | matches!(hover(&mut view, rect.x, rect.y), ViewAction::None), |
| 11300 | "hovering the highlighted row is silent" |
| 11301 | ); |
| 11302 | assert!( |
| 11303 | matches!(hover(&mut view, 0, 0), ViewAction::None), |
| 11304 | "hovering outside every row is silent" |
| 11305 | ); |
| 11306 | } |
| 11307 | |
| 11308 | /// P1.3: at 40 columns every category is reachable with the pointer alone |
| 11309 | /// — visible chips are clicked directly, hidden ones through the › and ‹ |
| 11310 | /// overflow markers, which are themselves hitboxes. |
| 11311 | #[test] |
| 11312 | fn config_strip_categories_are_reachable_by_pointer_alone_at_40_columns() { |
| 11313 | let app = create_test_app(); |
| 11314 | let mut view = ConfigView::new_for_app(&app); |
| 11315 | let area = Rect::new(0, 0, 40, 12); |
| 11316 | let click = |view: &mut ConfigView, rect: Rect| { |
| 11317 | view.handle_mouse(MouseEvent { |
| 11318 | kind: MouseEventKind::Down(MouseButton::Left), |
| 11319 | column: rect.x, |
| 11320 | row: rect.y, |
| 11321 | modifiers: KeyModifiers::NONE, |
| 11322 | }) |
| 11323 | }; |
| 11324 | let mut reached = vec![view.category]; |
| 11325 | for target in ConfigCategory::ALL { |
| 11326 | // Walk to `target` using only painted hitboxes. |
| 11327 | for _ in 0..16 { |
| 11328 | if view.category == target { |
| 11329 | break; |
| 11330 | } |
| 11331 | let mut buf = Buffer::empty(area); |
| 11332 | view.render(area, &mut buf); |
| 11333 | let chip = view |
| 11334 | .last_rail_hitboxes |
| 11335 | .borrow() |
| 11336 | .iter() |
| 11337 | .find(|(_, category)| *category == target) |
| 11338 | .map(|(rect, _)| *rect); |
| 11339 | let step = |step: super::NavStep| { |
| 11340 | view.last_nav_controls |
| 11341 | .borrow() |
| 11342 | .iter() |
| 11343 | .find(|(_, s)| *s == step) |
| 11344 | .map(|(rect, _)| *rect) |
| 11345 | }; |
| 11346 | let rect = chip |
| 11347 | .or_else(|| step(super::NavStep::Next)) |
| 11348 | .or_else(|| step(super::NavStep::Previous)) |
| 11349 | .expect("a chip or an overflow marker is always clickable"); |
| 11350 | // The marker cells are painted, not empty. |
| 11351 | let cells: String = (rect.x..rect.right()) |
| 11352 | .map(|x| buf[(x, rect.y)].symbol().to_string()) |
| 11353 | .collect(); |
| 11354 | assert!(!cells.trim().is_empty(), "{target:?}: {cells:?}"); |
| 11355 | assert!(matches!(click(&mut view, rect), ViewAction::None)); |
| 11356 | } |
| 11357 | assert_eq!(view.category, target, "pointer-only path to {target:?}"); |
| 11358 | reached.push(target); |
| 11359 | } |
| 11360 | for category in ConfigCategory::ALL { |
| 11361 | assert!(reached.contains(&category)); |
| 11362 | } |
| 11363 | // And back to the front by pointer only: the Appearance chip once it |
| 11364 | // scrolls into view, ‹ until then. |
| 11365 | for _ in 0..16 { |
| 11366 | if view.category == ConfigCategory::Appearance { |
| 11367 | break; |
| 11368 | } |
| 11369 | let mut buf = Buffer::empty(area); |
| 11370 | view.render(area, &mut buf); |
| 11371 | let chip = view |
| 11372 | .last_rail_hitboxes |
| 11373 | .borrow() |
| 11374 | .iter() |
| 11375 | .find(|(_, category)| *category == ConfigCategory::Appearance) |
| 11376 | .map(|(rect, _)| *rect); |
| 11377 | let rect = chip |
| 11378 | .or_else(|| { |
| 11379 | view.last_nav_controls |
| 11380 | .borrow() |
| 11381 | .iter() |
| 11382 | .find(|(_, s)| *s == super::NavStep::Previous) |
| 11383 | .map(|(rect, _)| *rect) |
| 11384 | }) |
| 11385 | .expect("the Appearance chip or ‹ is painted"); |
| 11386 | let _ = click(&mut view, rect); |
| 11387 | } |
| 11388 | assert_eq!(view.category, ConfigCategory::Appearance); |
| 11389 | } |
| 11390 | |
| 11391 | /// P1.4: choice and text editors expose clickable Apply / Cancel controls |
| 11392 | /// and exact choice hitboxes at 40x12 and 80x24. |
| 11393 | #[test] |
| 11394 | fn config_editors_apply_and_cancel_by_pointer_with_exact_hitboxes() { |
| 11395 | let app = create_test_app(); |
| 11396 | let click = |view: &mut ConfigView, column: u16, row: u16| { |
| 11397 | view.handle_mouse(MouseEvent { |
| 11398 | kind: MouseEventKind::Down(MouseButton::Left), |
| 11399 | column, |
| 11400 | row, |
| 11401 | modifiers: KeyModifiers::NONE, |
| 11402 | }) |
| 11403 | }; |
| 11404 | for (w, h) in [(40u16, 12u16), (80, 24)] { |
| 11405 | let area = Rect::new(0, 0, w, h); |
| 11406 | // Choice editor. |
| 11407 | let mut view = ConfigView::new_for_app(&app); |
| 11408 | view.focus_key("default_mode"); |
| 11409 | assert!(matches!( |
| 11410 | view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 11411 | ViewAction::None |
| 11412 | )); |
| 11413 | assert!(view.editing.is_some()); |
| 11414 | let mut buf = Buffer::empty(area); |
| 11415 | view.render(area, &mut buf); |
| 11416 | let dump = buffer_text(&buf, area); |
| 11417 | let controls = view.last_editor_controls.borrow().clone(); |
| 11418 | let apply = controls |
| 11419 | .iter() |
| 11420 | .find(|(_, c)| *c == super::EditorControl::Apply) |
| 11421 | .map(|(r, _)| *r) |
| 11422 | .unwrap_or_else(|| panic!("{w}x{h} Apply control:\n{dump}")); |
| 11423 | let cancel = controls |
| 11424 | .iter() |
| 11425 | .find(|(_, c)| *c == super::EditorControl::Cancel) |
| 11426 | .map(|(r, _)| *r) |
| 11427 | .unwrap_or_else(|| panic!("{w}x{h} Cancel control:\n{dump}")); |
| 11428 | for (rect, id) in [ |
| 11429 | (apply, MessageId::ConfigEditorApply), |
| 11430 | (cancel, MessageId::ConfigEditorCancel), |
| 11431 | ] { |
| 11432 | let cells: String = (rect.x..rect.right()) |
| 11433 | .map(|x| buf[(x, rect.y)].symbol().to_string()) |
| 11434 | .collect(); |
| 11435 | assert!(cells.contains(&en(id)), "{w}x{h} {cells:?}"); |
| 11436 | assert!(rect.bottom() <= area.bottom()); |
| 11437 | } |
| 11438 | assert!( |
| 11439 | dump.contains(&en(MessageId::ConfigEditChooseLabel)), |
| 11440 | "{w}x{h}:\n{dump}" |
| 11441 | ); |
| 11442 | let choices = view.last_choice_hitboxes.borrow().clone(); |
| 11443 | let (operate_rect, operate_idx) = choices |
| 11444 | .iter() |
| 11445 | .copied() |
| 11446 | .find(|(_, idx)| *idx == 2) |
| 11447 | .unwrap_or_else(|| panic!("{w}x{h} third choice painted:\n{dump}")); |
| 11448 | // Clicking a choice selects it; clicking beside the controls does |
| 11449 | // nothing; clicking Apply emits exactly that choice. |
| 11450 | assert!(matches!( |
| 11451 | click(&mut view, operate_rect.x + 2, operate_rect.y), |
| 11452 | ViewAction::None |
| 11453 | )); |
| 11454 | assert_eq!(view.editing.as_ref().unwrap().selected_choice, operate_idx); |
| 11455 | let beside = apply.right().saturating_add(1); |
| 11456 | if beside < cancel.x { |
| 11457 | assert!(matches!( |
| 11458 | click(&mut view, beside, apply.y), |
| 11459 | ViewAction::None |
| 11460 | )); |
| 11461 | assert!(view.editing.is_some(), "{w}x{h} gap click is inert"); |
| 11462 | } |
| 11463 | match click(&mut view, apply.x + 1, apply.y) { |
| 11464 | ViewAction::Emit(ViewEvent::ConfigUpdated { |
| 11465 | key, |
| 11466 | value, |
| 11467 | persist, |
| 11468 | }) => { |
| 11469 | assert_eq!(key, "default_mode"); |
| 11470 | assert_eq!(value, "operate"); |
| 11471 | assert!(persist); |
| 11472 | } |
| 11473 | other => panic!("{w}x{h} Apply click should emit: {other:?}"), |
| 11474 | } |
| 11475 | assert!(view.editing.is_none()); |
| 11476 | |
| 11477 | // Cancel by pointer. |
| 11478 | view.focus_key("default_mode"); |
| 11479 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11480 | let mut buf = Buffer::empty(area); |
| 11481 | view.render(area, &mut buf); |
| 11482 | let cancel = view |
| 11483 | .last_editor_controls |
| 11484 | .borrow() |
| 11485 | .iter() |
| 11486 | .find(|(_, c)| *c == super::EditorControl::Cancel) |
| 11487 | .map(|(r, _)| *r) |
| 11488 | .unwrap(); |
| 11489 | assert!(matches!( |
| 11490 | click(&mut view, cancel.x, cancel.y), |
| 11491 | ViewAction::None |
| 11492 | )); |
| 11493 | assert!(view.editing.is_none(), "{w}x{h} Cancel leaves the editor"); |
| 11494 | assert_eq!( |
| 11495 | view.status.as_deref(), |
| 11496 | Some(en(MessageId::ConfigEditCancelled).as_str()) |
| 11497 | ); |
| 11498 | |
| 11499 | // Text editor: type, then Apply by pointer. |
| 11500 | view.focus_key("thinking_preview_lines"); |
| 11501 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11502 | for ch in "77".chars() { |
| 11503 | let _ = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 11504 | } |
| 11505 | let mut buf = Buffer::empty(area); |
| 11506 | view.render(area, &mut buf); |
| 11507 | let dump = buffer_text(&buf, area); |
| 11508 | let apply = view |
| 11509 | .last_editor_controls |
| 11510 | .borrow() |
| 11511 | .iter() |
| 11512 | .find(|(_, c)| *c == super::EditorControl::Apply) |
| 11513 | .map(|(r, _)| *r) |
| 11514 | .unwrap_or_else(|| panic!("{w}x{h} text editor Apply:\n{dump}")); |
| 11515 | assert!( |
| 11516 | dump.contains(&en(MessageId::ConfigEditNewLabel).trim_end().to_string()), |
| 11517 | "{w}x{h} value line stays visible above the controls:\n{dump}" |
| 11518 | ); |
| 11519 | match click(&mut view, apply.x, apply.y) { |
| 11520 | ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => { |
| 11521 | assert_eq!(key, "thinking_preview_lines"); |
| 11522 | assert_eq!(value, "77"); |
| 11523 | } |
| 11524 | other => panic!("{w}x{h} text Apply should emit: {other:?}"), |
| 11525 | } |
| 11526 | } |
| 11527 | } |
| 11528 | |
| 11529 | /// P1.5: second-click activation is disarmed by every keyboard step, so no |
| 11530 | /// single click after navigation can mutate anything. |
| 11531 | #[test] |
| 11532 | fn config_second_click_arming_resets_on_every_keyboard_step() { |
| 11533 | let app = create_test_app(); |
| 11534 | let mut view = ConfigView::new_for_app(&app); |
| 11535 | view.category = ConfigCategory::Motion; |
| 11536 | view.select_first_visible_row(); |
| 11537 | let area = Rect::new(0, 0, 80, 24); |
| 11538 | let click_row = |view: &mut ConfigView, key: &str| -> ViewAction { |
| 11539 | let mut buf = Buffer::empty(area); |
| 11540 | view.render(area, &mut buf); |
| 11541 | let rect = view |
| 11542 | .last_row_hitboxes |
| 11543 | .borrow() |
| 11544 | .iter() |
| 11545 | .find(|(_, idx)| view.rows[*idx].key == key) |
| 11546 | .map(|(rect, _)| *rect) |
| 11547 | .unwrap_or_else(|| panic!("{key} row painted")); |
| 11548 | view.handle_mouse(MouseEvent { |
| 11549 | kind: MouseEventKind::Down(MouseButton::Left), |
| 11550 | column: rect.x + 1, |
| 11551 | row: rect.y, |
| 11552 | modifiers: KeyModifiers::NONE, |
| 11553 | }) |
| 11554 | }; |
| 11555 | let key = |view: &mut ConfigView, code: KeyCode| { |
| 11556 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 11557 | }; |
| 11558 | let sequences: Vec<Vec<KeyCode>> = vec![ |
| 11559 | vec![KeyCode::Down, KeyCode::Up], |
| 11560 | vec![KeyCode::Right, KeyCode::Left], |
| 11561 | vec![KeyCode::Tab, KeyCode::BackTab], |
| 11562 | vec![KeyCode::Char('x'), KeyCode::Backspace], |
| 11563 | vec![KeyCode::PageDown, KeyCode::PageUp], |
| 11564 | ]; |
| 11565 | for sequence in sequences { |
| 11566 | // The previous iteration's checking click left the row armed; a |
| 11567 | // keyboard step (like any real navigation) disarms it first. |
| 11568 | let _ = key(&mut view, KeyCode::Up); |
| 11569 | assert!(matches!( |
| 11570 | click_row(&mut view, "low_motion"), |
| 11571 | ViewAction::None |
| 11572 | )); |
| 11573 | for code in &sequence { |
| 11574 | let _ = key(&mut view, *code); |
| 11575 | } |
| 11576 | assert_eq!(view.category, ConfigCategory::Motion, "{sequence:?}"); |
| 11577 | let action = click_row(&mut view, "low_motion"); |
| 11578 | assert!( |
| 11579 | matches!(action, ViewAction::None), |
| 11580 | "{sequence:?} then one click must not mutate: {action:?}" |
| 11581 | ); |
| 11582 | assert!(view.editing.is_none(), "{sequence:?}"); |
| 11583 | assert_eq!(view.rows[view.selected].key, "low_motion"); |
| 11584 | } |
| 11585 | // Control: two consecutive clicks with nothing in between activate |
| 11586 | // (the previous check click is disarmed by a keyboard step first). |
| 11587 | let _ = key(&mut view, KeyCode::Up); |
| 11588 | assert!(matches!( |
| 11589 | click_row(&mut view, "low_motion"), |
| 11590 | ViewAction::None |
| 11591 | )); |
| 11592 | assert!(matches!( |
| 11593 | click_row(&mut view, "low_motion"), |
| 11594 | ViewAction::Emit(ViewEvent::ConfigUpdated { .. }) |
| 11595 | )); |
| 11596 | // A rebuilt focus (the host re-renders after applying) is disarmed |
| 11597 | // even though the emitting click left the row armed. |
| 11598 | view.focus_key("low_motion"); |
| 11599 | assert!(matches!( |
| 11600 | click_row(&mut view, "low_motion"), |
| 11601 | ViewAction::None |
| 11602 | )); |
| 11603 | } |
| 11604 | |
| 11605 | /// P1.6: the reachable editor surface renders from the packs — search |
| 11606 | /// label, choose label, choice labels and details, footer, controls, and |
| 11607 | /// hints — with no English fallbacks in zh-Hans. |
| 11608 | #[test] |
| 11609 | fn config_editor_surface_is_localized() { |
| 11610 | let mut app = create_test_app(); |
| 11611 | app.ui_locale = Locale::ZhHans; |
| 11612 | let mut view = ConfigView::new_for_app(&app); |
| 11613 | let zh = |id: MessageId| tr(Locale::ZhHans, id).into_owned(); |
| 11614 | let spaced = |text: &str| -> String { |
| 11615 | text.chars() |
| 11616 | .map(|ch| { |
| 11617 | if UnicodeWidthStr::width(ch.to_string().as_str()) > 1 { |
| 11618 | format!("{ch} ") |
| 11619 | } else { |
| 11620 | ch.to_string() |
| 11621 | } |
| 11622 | }) |
| 11623 | .collect() |
| 11624 | }; |
| 11625 | let dump = render_dump(&view, 80, 24); |
| 11626 | assert!( |
| 11627 | dump.contains(spaced(&zh(MessageId::ConfigSearchLabel)).trim()), |
| 11628 | "search label:\n{dump}" |
| 11629 | ); |
| 11630 | view.focus_key("default_mode"); |
| 11631 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11632 | let dump = render_dump(&view, 80, 24); |
| 11633 | for id in [ |
| 11634 | MessageId::ConfigEditChooseLabel, |
| 11635 | MessageId::ConfigChoiceModeAct, |
| 11636 | MessageId::ConfigChoiceModePlan, |
| 11637 | MessageId::ConfigChoiceModeOperate, |
| 11638 | MessageId::ConfigChoiceDetailModeAgent, |
| 11639 | MessageId::ConfigEditorApply, |
| 11640 | MessageId::ConfigEditorCancel, |
| 11641 | ] { |
| 11642 | let text = zh(id); |
| 11643 | assert!( |
| 11644 | dump.contains(spaced(&text).trim_end()), |
| 11645 | "localized {id:?} = {text}:\n{dump}" |
| 11646 | ); |
| 11647 | } |
| 11648 | assert!( |
| 11649 | dump.contains(spaced(&zh(MessageId::ConfigChoiceFooter)).trim()) |
| 11650 | || dump.contains(spaced(&zh(MessageId::ConfigChoiceFooterCompact)).trim()), |
| 11651 | "localized choice footer:\n{dump}" |
| 11652 | ); |
| 11653 | for english in ["Choose:", "Full Access", "Apply", "Cancel", "Search:"] { |
| 11654 | assert!(!dump.contains(english), "English leaked: {english}\n{dump}"); |
| 11655 | } |
| 11656 | let _ = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 11657 | view.focus_key("low_motion"); |
| 11658 | let dump = render_dump(&view, 120, 32); |
| 11659 | let hint = zh(MessageId::ConfigHintLowMotion); |
| 11660 | assert!( |
| 11661 | dump.contains(spaced(&hint).trim_end()), |
| 11662 | "localized hint in the detail pane:\n{dump}" |
| 11663 | ); |
| 11664 | } |
| 11665 | |
| 11666 | #[test] |
| 11667 | fn config_shell_renders_localized_chrome_without_missing_markers() { |
| 11668 | let mut app = create_test_app(); |
| 11669 | app.ui_locale = Locale::ZhHans; |
| 11670 | let view = ConfigView::new_for_app(&app); |
| 11671 | for (w, h) in [(80u16, 24u16), (120, 32)] { |
| 11672 | let dump = render_dump(&view, w, h); |
| 11673 | assert!(!dump.contains("MISSING"), "{w}x{h}:\n{dump}"); |
| 11674 | let section = tr(Locale::ZhHans, MessageId::ConfigSectionDisplay); |
| 11675 | let spaced: String = section.chars().map(|ch| format!("{ch} ")).collect(); |
| 11676 | assert!( |
| 11677 | dump.contains(spaced.trim_end()), |
| 11678 | "{w}x{h} localized section label {section}:\n{dump}" |
| 11679 | ); |
| 11680 | // The new shell chrome renders from the packs too: the active |
| 11681 | // category chip/rail row and, when wide, the detail fact labels. |
| 11682 | let category = tr(Locale::ZhHans, MessageId::ConfigCategoryAppearance); |
| 11683 | let spaced: String = category.chars().map(|ch| format!("{ch} ")).collect(); |
| 11684 | assert!( |
| 11685 | dump.contains(spaced.trim_end()), |
| 11686 | "{w}x{h} localized category {category}:\n{dump}" |
| 11687 | ); |
| 11688 | if w >= 100 { |
| 11689 | for id in [MessageId::ConfigFactCurrent, MessageId::ConfigFactApply] { |
| 11690 | let label = tr(Locale::ZhHans, id); |
| 11691 | let spaced: String = label.chars().map(|ch| format!("{ch} ")).collect(); |
| 11692 | assert!( |
| 11693 | dump.contains(spaced.trim_end()), |
| 11694 | "{w}x{h} localized fact label {label}:\n{dump}" |
| 11695 | ); |
| 11696 | } |
| 11697 | let unobserved = tr(Locale::ZhHans, MessageId::ConfigLaneUnobserved); |
| 11698 | let spaced: String = unobserved.chars().map(|ch| format!("{ch} ")).collect(); |
| 11699 | assert!( |
| 11700 | dump.contains(spaced.trim_end()), |
| 11701 | "{w}x{h} localized unobserved lane:\n{dump}" |
| 11702 | ); |
| 11703 | } |
| 11704 | let scope = tr(Locale::ZhHans, MessageId::ConfigScopeSaved); |
| 11705 | let spaced: String = scope.chars().map(|ch| format!("{ch} ")).collect(); |
| 11706 | assert!( |
| 11707 | dump.contains(spaced.trim_end()) || dump.contains(scope.as_ref()), |
| 11708 | "{w}x{h} localized scope badge {scope}:\n{dump}" |
| 11709 | ); |
| 11710 | } |
| 11711 | } |
| 11712 | |
| 11713 | #[test] |
| 11714 | fn config_view_mcp_action_rows_run_existing_commands() { |
| 11715 | let app = create_test_app(); |
| 11716 | let mut view = ConfigView::new_for_app(&app); |
| 11717 | for (key, command) in [ |
| 11718 | ("mcp_open", "/mcp"), |
| 11719 | ("mcp_reconnect", "/mcp reload"), |
| 11720 | ("mcp_diagnose", "/mcp validate"), |
| 11721 | ("plugins_open", "/plugin"), |
| 11722 | ] { |
| 11723 | view.focus_key(key); |
| 11724 | let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11725 | match action { |
| 11726 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 11727 | action: CommandPaletteAction::ExecuteCommand { command: emitted }, |
| 11728 | }) => { |
| 11729 | assert_eq!(emitted, command, "{key}"); |
| 11730 | assert!(!emitted.contains("/mcp auth"), "{key}"); |
| 11731 | } |
| 11732 | other => panic!("{key} should run {command}, got {other:?}"), |
| 11733 | } |
| 11734 | } |
| 11735 | } |
| 11736 | |
| 11737 | #[test] |
| 11738 | fn config_view_bottom_hint_semantically_truncates_at_narrow_width() { |
| 11739 | // The dense bottom status line must truncate on a word boundary with an |
| 11740 | // ellipsis instead of leaving a mid-word fragment clipped by the |
| 11741 | // terminal (#3987). |
| 11742 | let mut app = create_test_app(); |
| 11743 | app.ui_locale = Locale::En; |
| 11744 | let mut view = ConfigView::new_for_app(&app); |
| 11745 | view.status = Some( |
| 11746 | "CFGSTATUS persisted the configuration override to disk successfully \ |
| 11747 | without clipping the trailing MARKEREND status text" |
| 11748 | .to_string(), |
| 11749 | ); |
| 11750 | |
| 11751 | let area = Rect::new(0, 0, 100, 40); |
| 11752 | let mut buf = Buffer::empty(area); |
| 11753 | view.render(area, &mut buf); |
| 11754 | |
| 11755 | let rows: Vec<String> = (0..area.height) |
| 11756 | .map(|y| { |
| 11757 | (0..area.width) |
| 11758 | .map(|x| buf[(x, y)].symbol()) |
| 11759 | .collect::<String>() |
| 11760 | }) |
| 11761 | .collect(); |
| 11762 | |
| 11763 | // No rendered row may overflow the available columns. |
| 11764 | for (idx, row) in rows.iter().enumerate() { |
| 11765 | assert!( |
| 11766 | crate::tui::ui_text::text_display_width(row) <= usize::from(area.width), |
| 11767 | "line {idx} overflows: {row:?}" |
| 11768 | ); |
| 11769 | } |
| 11770 | |
| 11771 | let status_line = rows |
| 11772 | .iter() |
| 11773 | .find(|row| row.contains("CFGSTATUS")) |
| 11774 | .expect("bottom status hint should be rendered"); |
| 11775 | assert!( |
| 11776 | status_line.contains('…'), |
| 11777 | "status should be truncated with an ellipsis: {status_line:?}" |
| 11778 | ); |
| 11779 | assert!( |
| 11780 | !status_line.contains("MARKEREND"), |
| 11781 | "truncated status must drop trailing text: {status_line:?}" |
| 11782 | ); |
| 11783 | } |
| 11784 | |
| 11785 | #[test] |
| 11786 | fn config_view_typing_replaces_on_first_char() { |
| 11787 | let app = create_test_app(); |
| 11788 | let mut view = ConfigView::new_for_app(&app); |
| 11789 | view.focus_key("background_color"); |
| 11790 | |
| 11791 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11792 | let edit = view.editing.as_ref().expect("editing should be active"); |
| 11793 | assert!(edit.select_all, "editor should start with select-all"); |
| 11794 | |
| 11795 | let _ = view.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); |
| 11796 | let edit = view.editing.as_ref().expect("editing should remain active"); |
| 11797 | assert_eq!(edit.buffer.iter().collect::<String>(), "x"); |
| 11798 | } |
| 11799 | |
| 11800 | #[test] |
| 11801 | fn config_view_escape_cancels_editing() { |
| 11802 | let mut app = create_test_app(); |
| 11803 | app.ui_locale = Locale::En; |
| 11804 | let mut view = ConfigView::new_for_app(&app); |
| 11805 | view.focus_key("thinking_preview_lines"); |
| 11806 | let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); |
| 11807 | assert!(view.editing.is_some()); |
| 11808 | |
| 11809 | let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 11810 | assert!(matches!(cancel, ViewAction::None)); |
| 11811 | assert!(view.editing.is_none()); |
| 11812 | assert_eq!( |
| 11813 | view.status.as_deref(), |
| 11814 | Some(&*tr(Locale::En, MessageId::ConfigEditCancelled)) |
| 11815 | ); |
| 11816 | } |
| 11817 | |
| 11818 | /// A modal that doesn't override `handle_paste` must report |
| 11819 | /// "not consumed" so the host can fall through to the composer. |
| 11820 | /// Regression: views/mod.rs previously inverted the boolean, swallowing |
| 11821 | /// every Cmd-V while any modal was on top. |
| 11822 | #[test] |
| 11823 | fn default_modal_does_not_consume_paste() { |
| 11824 | let mut stack = ViewStack::new(); |
| 11825 | stack.push(HelpView::new_for_locale(codewhale_localization::Locale::En)); |
| 11826 | assert!(!stack.handle_paste("hello")); |
| 11827 | assert_eq!(stack.top_kind(), Some(ModalKind::Help)); |
| 11828 | } |
| 11829 | |
| 11830 | struct BareModal; |
| 11831 | |
| 11832 | impl ModalView for BareModal { |
| 11833 | fn kind(&self) -> ModalKind { |
| 11834 | ModalKind::ContextMenu |
| 11835 | } |
| 11836 | |
| 11837 | fn handle_key(&mut self, _key: KeyEvent) -> ViewAction { |
| 11838 | ViewAction::None |
| 11839 | } |
| 11840 | |
| 11841 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 11842 | let x = area.x + area.width / 2; |
| 11843 | let y = area.y + area.height / 2; |
| 11844 | buf[(x, y)] |
| 11845 | .set_symbol("M") |
| 11846 | .set_style(Style::default().fg(Color::White).bg(Color::Red)); |
| 11847 | } |
| 11848 | |
| 11849 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 11850 | self |
| 11851 | } |
| 11852 | } |
| 11853 | |
| 11854 | #[test] |
| 11855 | fn view_stack_paints_opaque_backdrop_before_modal() { |
| 11856 | let area = Rect::new(0, 0, 24, 8); |
| 11857 | let modal_x = area.x + area.width / 2; |
| 11858 | let modal_y = area.y + area.height / 2; |
| 11859 | let mut buf = Buffer::empty(area); |
| 11860 | for y in area.top()..area.bottom() { |
| 11861 | for x in area.left()..area.right() { |
| 11862 | buf[(x, y)] |
| 11863 | .set_symbol("X") |
| 11864 | .set_style(Style::default().fg(Color::Red).bg(Color::Blue)); |
| 11865 | } |
| 11866 | } |
| 11867 | |
| 11868 | let mut stack = ViewStack::new(); |
| 11869 | stack.push(BareModal); |
| 11870 | stack.render(area, &mut buf); |
| 11871 | |
| 11872 | assert_eq!(buf[(modal_x, modal_y)].symbol(), "M"); |
| 11873 | for y in area.top()..area.bottom() { |
| 11874 | for x in area.left()..area.right() { |
| 11875 | if x == modal_x && y == modal_y { |
| 11876 | continue; |
| 11877 | } |
| 11878 | let cell = &buf[(x, y)]; |
| 11879 | assert_eq!( |
| 11880 | cell.symbol(), |
| 11881 | " ", |
| 11882 | "stale glyph at ({x},{y}) must be cleared" |
| 11883 | ); |
| 11884 | assert_eq!( |
| 11885 | cell.bg, |
| 11886 | palette::WHALE_BG, |
| 11887 | "backdrop at ({x},{y}) must be opaque" |
| 11888 | ); |
| 11889 | } |
| 11890 | } |
| 11891 | } |
| 11892 | |
| 11893 | #[test] |
| 11894 | fn view_stack_masks_links_behind_opaque_modals() { |
| 11895 | let area = Rect::new(0, 0, 24, 8); |
| 11896 | crate::tui::osc8::set_frame_links(vec![crate::tui::osc8::LinkRegion { |
| 11897 | row: 3, |
| 11898 | col_start: 2, |
| 11899 | col_end: 18, |
| 11900 | target: "https://example.invalid/under-modal".to_string(), |
| 11901 | }]); |
| 11902 | let mut stack = ViewStack::new(); |
| 11903 | stack.push(BareModal); |
| 11904 | stack.render(area, &mut Buffer::empty(area)); |
| 11905 | assert!(crate::tui::osc8::take_frame_links().is_empty()); |
| 11906 | } |
| 11907 | |
| 11908 | fn buffer_text(buf: &Buffer, area: Rect) -> String { |
| 11909 | let mut out = String::new(); |
| 11910 | for y in area.top()..area.bottom() { |
| 11911 | for x in area.left()..area.right() { |
| 11912 | out.push_str(buf[(x, y)].symbol()); |
| 11913 | } |
| 11914 | out.push('\n'); |
| 11915 | } |
| 11916 | out |
| 11917 | } |
| 11918 | |
| 11919 | fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String { |
| 11920 | (area.left()..area.right()) |
| 11921 | .map(|x| buf[(x, y)].symbol()) |
| 11922 | .collect() |
| 11923 | } |
| 11924 | |
| 11925 | /// 40x12 regression: the compact tier must surrender secondary chrome |
| 11926 | /// (in-body title, column captions, separator) before it surrenders the |
| 11927 | /// settings rows, and the wrapped footer height must come out of the |
| 11928 | /// table budget instead of silently clipping rows. |
| 11929 | #[test] |
| 11930 | fn config_compact_theme_category_and_footer_remain_legible() { |
| 11931 | let _guard = ConfigSettingsEnvGuard::new("theme = \"shoreline\"\n"); |
| 11932 | let mut view = create_config_view(Locale::En); |
| 11933 | view.focus_key("theme"); |
| 11934 | let area = Rect::new(0, 0, 40, 12); |
| 11935 | let mut buf = Buffer::empty(area); |
| 11936 | view.render(area, &mut buf); |
| 11937 | let dump = buffer_text(&buf, area); |
| 11938 | let theme_rect = view |
| 11939 | .last_row_hitboxes |
| 11940 | .borrow() |
| 11941 | .iter() |
| 11942 | .find(|(_, idx)| view.rows[*idx].key == "theme") |
| 11943 | .unwrap() |
| 11944 | .0; |
| 11945 | assert!( |
| 11946 | buffer_row_text(&buf, area, theme_rect.y).contains("shoreline"), |
| 11947 | "{dump}" |
| 11948 | ); |
| 11949 | assert!(dump.contains("Appearance 1/7"), "{dump}"); |
| 11950 | let footer = dump |
| 11951 | .lines() |
| 11952 | .find(|line| line.contains("Enter") && line.contains("Esc")) |
| 11953 | .expect("all compact action hints share one line"); |
| 11954 | assert!(footer.contains("Tab")); |
| 11955 | for category in ConfigCategory::ALL { |
| 11956 | view.category = category; |
| 11957 | view.select_first_visible_row(); |
| 11958 | view.render(area, &mut buf); |
| 11959 | let dump = buffer_text(&buf, area); |
| 11960 | assert!( |
| 11961 | dump.contains(category.label(Locale::En).as_ref()), |
| 11962 | "{category:?}: {dump}" |
| 11963 | ); |
| 11964 | assert_eq!(view.last_nav_controls.borrow().len(), 2); |
| 11965 | } |
| 11966 | } |
| 11967 | |
| 11968 | #[test] |
| 11969 | fn config_sandbox_search_opens_observed_status_without_editing_policy() { |
| 11970 | let mut view = create_config_view(Locale::En); |
| 11971 | for query in [ |
| 11972 | "sandbox", |
| 11973 | "filesystem", |
| 11974 | "unenforced", |
| 11975 | "bubblewrap", |
| 11976 | "doctor", |
| 11977 | ] { |
| 11978 | view.restore_filter(query.to_string()); |
| 11979 | let matches = view.matching_row_indices(); |
| 11980 | let index = *matches |
| 11981 | .iter() |
| 11982 | .find(|&&idx| view.rows[idx].key == "sandbox_details") |
| 11983 | .expect("sandbox explanation discoverable"); |
| 11984 | view.selected = index; |
| 11985 | let row = &view.rows[index]; |
| 11986 | assert_eq!(row.facts.kind, ConfigRowKind::Action); |
| 11987 | assert_eq!(row.facts.store, SettingStore::None); |
| 11988 | assert!( |
| 11989 | matches!(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), |
| 11990 | ViewAction::Emit(ViewEvent::ExecutePanelCommand { command, pager_title: Some(_) }) if command == "/status") |
| 11991 | ); |
| 11992 | assert!(view.editing.is_none()); |
| 11993 | } |
| 11994 | assert!(!view.rows.iter().any(|row| row.key == "sandbox_mode")); |
| 11995 | } |
| 11996 | |
| 11997 | #[test] |
| 11998 | fn config_view_compact_heights_always_show_a_selectable_setting() { |
| 11999 | let mut view = create_config_view(Locale::En); |
| 12000 | for (width, height, label) in [(40u16, 12u16, "40x12"), (60, 16, "60x16")] { |
| 12001 | let area = Rect::new(0, 0, width, height); |
| 12002 | let mut buf = Buffer::empty(area); |
| 12003 | |
| 12004 | view.render(area, &mut buf); |
| 12005 | |
| 12006 | let dump = buffer_text(&buf, area); |
| 12007 | let (selected_y, selected_idx) = { |
| 12008 | let hitboxes = view.last_row_hitboxes.borrow(); |
| 12009 | assert!( |
| 12010 | !hitboxes.is_empty(), |
| 12011 | "{label} should register selectable setting hitboxes:\n{dump}" |
| 12012 | ); |
| 12013 | hitboxes |
| 12014 | .iter() |
| 12015 | .find(|(_, idx)| *idx == view.selected) |
| 12016 | .copied() |
| 12017 | .unwrap_or_else(|| { |
| 12018 | panic!("{label} selected setting should be rendered:\n{dump}") |
| 12019 | }) |
| 12020 | }; |
| 12021 | let row = buffer_row_text(&buf, area, selected_y.y); |
| 12022 | let row_label = config_label_for_key(&view.rows[selected_idx].key); |
| 12023 | let prefix: String = row_label.chars().take(8).collect(); |
| 12024 | assert!( |
| 12025 | row.contains(&prefix), |
| 12026 | "{label} hitbox row should contain the selected setting ({row_label:?}); got {row:?}" |
| 12027 | ); |
| 12028 | assert!( |
| 12029 | dump.contains("Search:"), |
| 12030 | "{label} should keep the search affordance:\n{dump}" |
| 12031 | ); |
| 12032 | } |
| 12033 | |
| 12034 | // The selection anchor must hold while navigating across sections at |
| 12035 | // the smallest supported size. |
| 12036 | let area = Rect::new(0, 0, 40, 12); |
| 12037 | for step in 0..12 { |
| 12038 | view.move_selection(1); |
| 12039 | let mut buf = Buffer::empty(area); |
| 12040 | view.render(area, &mut buf); |
| 12041 | let rendered = view |
| 12042 | .last_row_hitboxes |
| 12043 | .borrow() |
| 12044 | .iter() |
| 12045 | .any(|(_, idx)| *idx == view.selected); |
| 12046 | assert!( |
| 12047 | rendered, |
| 12048 | "selected setting fell out of the 40x12 window after {} moves", |
| 12049 | step + 1 |
| 12050 | ); |
| 12051 | } |
| 12052 | } |
| 12053 | |
| 12054 | /// 40x12 regression: the edit surface must keep the editable value line |
| 12055 | /// (and its hint) above the wrapped footer. |
| 12056 | #[test] |
| 12057 | fn config_view_compact_edit_surface_keeps_value_line_visible() { |
| 12058 | let mut view = create_config_view(Locale::En); |
| 12059 | view.focus_key("approval_mode"); |
| 12060 | view.start_edit(); |
| 12061 | assert!(view.editing.is_some(), "approval_mode should be editable"); |
| 12062 | assert_eq!( |
| 12063 | view.editing |
| 12064 | .as_ref() |
| 12065 | .and_then(|edit| edit.choices.as_ref()) |
| 12066 | .expect("session permission choices"), |
| 12067 | &vec![ |
| 12068 | "ask".to_string(), |
| 12069 | "auto-review".to_string(), |
| 12070 | "full-access".to_string(), |
| 12071 | ] |
| 12072 | ); |
| 12073 | let area = Rect::new(0, 0, 40, 12); |
| 12074 | let mut buf = Buffer::empty(area); |
| 12075 | |
| 12076 | view.render(area, &mut buf); |
| 12077 | |
| 12078 | let dump = buffer_text(&buf, area); |
| 12079 | assert!( |
| 12080 | dump.contains("Choose:") && dump.contains("Full Access"), |
| 12081 | "the choice list must stay visible at 40x12:\n{dump}" |
| 12082 | ); |
| 12083 | } |
| 12084 | } |
| 12085 |