返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / views / mod.rs
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(&section)
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
12085 lines RUST