返回 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::{ApiProvider, ApprovalPolicyControl, Config};
15 use crate::features::{FEATURES, Stage};
16 use crate::settings::Settings;
17 use crate::tools::UserInputResponse;
18 use crate::tools::subagent::{
19 FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus, localized_whale_display_names,
20 };
21 use crate::tui::app::App;
22 use crate::tui::approval::{ElevationOption, ReviewDecision};
23 use crate::tui::focus_texture::FocusTextureMode;
24 use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
25 use crate::tui::menu_style;
26 use crate::tui::tideline::{SettingApplySemantics, SettingAuthority, SettingFact, UiSnapshot};
27 use crate::tui::widgets::agent_card::AgentLifecycle;
28 use codewhale_localization::{
29 Locale, MessageId, configured_locale_is_partial_pack, normalize_configured_locale, tr, tr_key,
30 };
31 use codewhale_palette as palette;
32
33 pub mod automations;
34 pub mod extensions;
35 pub mod fleet_detail;
36 pub mod fleet_list;
37 pub mod fleet_roster;
38 pub mod fleet_setup;
39 pub mod mode_picker;
40 pub mod route_save_prompt;
41 pub mod skills_manager;
42 pub mod status_picker;
43 pub mod workflows_manager;
44
45 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46 pub enum ModalKind {
47 PetHabitat,
48 Approval,
49 Elevation,
50 UserInput,
51 CommandPalette,
52 Help,
53 SubAgents,
54 Pager,
55 LiveTranscript,
56 SessionPicker,
57 Config,
58 ModelPicker,
59 ProviderPicker,
60 ModePicker,
61 FleetRoster,
62 FleetSetup,
63 FleetList,
64 FleetDetail,
65 HotbarSetup,
66 SetupWizard,
67 FilePicker,
68 StatusPicker,
69 FeedbackPicker,
70 ThemePicker,
71 ContextMenu,
72 ContextInspector,
73 SkillsManager,
74 /// Unified, read-only extensions inventory. Mutations delegate to the
75 /// existing Hooks / Plugins / Skills / MCP command controllers.
76 Extensions,
77 /// Native git worktree manager (list / create / switch / compare).
78 WorktreeManager,
79 /// Live workflow **run** dashboard (`/workflows`): active and retained
80 /// runs from the journal, with host-side cancel.
81 WorkflowsManager,
82 /// The scheduled-automation room (`/automation`): every automation the
83 /// person owns, with pause / resume / run / cancel / delete.
84 Automations,
85 /// "Resume this session?" over the launch card. Resuming replaces the
86 /// whole session context, so it asks first.
87 LaunchResumeConfirm,
88 }
89
90 /// Clear and paint a modal popup with an opaque surface.
91 ///
92 /// Older modals often called `Clear` only, which left reset-background blank
93 /// cells that could read as translucent on terminals with a non-default app
94 /// background. This helper makes the popup area explicit and keeps the small
95 /// shadow from inheriting stale transcript glyphs.
96 pub(crate) fn render_modal_surface(area: Rect, popup_area: Rect, buf: &mut Buffer) {
97 let shadow_x = popup_area.x.saturating_add(1);
98 let shadow_y = popup_area.y.saturating_add(1);
99 let shadow_right = area.x.saturating_add(area.width);
100 let shadow_bottom = area.y.saturating_add(area.height);
101 let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x));
102 let shadow_height = popup_area
103 .height
104 .min(shadow_bottom.saturating_sub(shadow_y));
105
106 if shadow_width > 0 && shadow_height > 0 {
107 Block::default()
108 .style(Style::default().bg(palette::SURFACE_ELEVATED))
109 .render(
110 Rect {
111 x: shadow_x,
112 y: shadow_y,
113 width: shadow_width,
114 height: shadow_height,
115 },
116 buf,
117 );
118 }
119
120 Clear.render(popup_area, buf);
121 Block::default()
122 .style(Style::default().bg(palette::WHALE_BG))
123 .render(popup_area, buf);
124 }
125
126 /// Paint a full-screen underwater instrument surface and return its body.
127 ///
128 /// Secondary rooms use one title hairline and one bottom action rail instead
129 /// of a centered generic card. A one-cell outer margin is retained when the
130 /// terminal can afford it; compact panes use every cell.
131 pub(crate) fn render_underwater_surface(
132 area: Rect,
133 buf: &mut Buffer,
134 title: impl Into<String>,
135 ) -> Rect {
136 let margin_x = u16::from(area.width >= 44);
137 let margin_y = u16::from(area.height >= 24);
138 let surface = Rect {
139 x: area.x.saturating_add(margin_x),
140 y: area.y.saturating_add(margin_y),
141 width: area.width.saturating_sub(margin_x.saturating_mul(2)),
142 height: area.height.saturating_sub(margin_y.saturating_mul(2)),
143 };
144 Clear.render(area, buf);
145 Block::default()
146 .style(Style::default().bg(palette::WHALE_BG))
147 .render(area, buf);
148 // Ratatui clips long block titles at the border edge without signalling
149 // that anything is missing. Reserve the corner cells and semantic-ellipsis
150 // the title so compact terminals still read as intentional instruments.
151 let title_width = usize::from(surface.width.saturating_sub(4));
152 let title = crate::tui::ui_text::semantic_truncate(&title.into(), title_width);
153 let block = Block::default()
154 .title(Line::from(Span::styled(
155 format!(" {title} "),
156 Style::default()
157 .fg(palette::WHALE_ACTION)
158 .add_modifier(Modifier::BOLD),
159 )))
160 .borders(Borders::TOP | Borders::BOTTOM)
161 .border_style(Style::default().fg(palette::BORDER_COLOR))
162 .style(Style::default().bg(palette::WHALE_BG))
163 .padding(Padding::new(1, 1, u16::from(area.height >= 24), 0));
164 let inner = block.inner(surface);
165 block.render(surface, buf);
166 inner
167 }
168
169 /// Paint a scrollbar on the exact right edge of the panel it controls and
170 /// return the content rect with that rail reserved. Nothing is drawn when all
171 /// rows fit, so narrow surfaces do not spend a column on a fictional control.
172 pub(crate) fn render_panel_scroll_rail(
173 area: Rect,
174 buf: &mut Buffer,
175 total_rows: usize,
176 offset: usize,
177 visible_rows: usize,
178 focused: bool,
179 ) -> Rect {
180 if area.width < 2 || area.height == 0 || total_rows <= visible_rows.max(1) {
181 return area;
182 }
183 let rail_x = area.right().saturating_sub(1);
184 let rail_height = usize::from(area.height);
185 let visible = visible_rows.max(1).min(total_rows);
186 let thumb_height = ((rail_height * visible).div_ceil(total_rows)).clamp(1, rail_height);
187 let max_offset = total_rows.saturating_sub(visible);
188 let travel = rail_height.saturating_sub(thumb_height);
189 let thumb_top = travel
190 .saturating_mul(offset.min(max_offset))
191 .checked_div(max_offset)
192 .unwrap_or(0);
193 let thumb_color = if focused {
194 palette::TEXT_MUTED
195 } else {
196 palette::TEXT_DIM
197 };
198 for local_y in 0..area.height {
199 let y = area.y.saturating_add(local_y);
200 let local = usize::from(local_y);
201 let is_thumb = local >= thumb_top && local < thumb_top + thumb_height;
202 buf[(rail_x, y)]
203 .set_symbol(if is_thumb { "█" } else { "│" })
204 .set_style(Style::default().fg(if is_thumb {
205 thumb_color
206 } else {
207 palette::BORDER_COLOR
208 }));
209 }
210 Rect {
211 width: area.width.saturating_sub(1),
212 ..area
213 }
214 }
215
216 fn render_modal_backdrop(area: Rect, buf: &mut Buffer) {
217 for y in area.top()..area.bottom() {
218 for x in area.left()..area.right() {
219 buf[(x, y)]
220 .set_symbol(" ")
221 .set_style(Style::default().bg(palette::WHALE_BG));
222 }
223 }
224 }
225
226 /// Compute a centered, responsive popup rect for a modal.
227 ///
228 /// The size starts from `preferred_*`, but is clamped so it never exceeds the
229 /// frame (leaving a small breathing-room margin when there is space) and never
230 /// drops below `min_*` unless the frame itself is smaller. Centering the result
231 /// inside `area` replaces the repeated, error-prone
232 /// `N.min(area.width.saturating_sub(..))` arithmetic scattered across modals so
233 /// every overlay sizes itself the same way at 80x24, 100x30, 120x32, 160x40,
234 /// and beyond. See #3732.
235 pub(crate) fn centered_modal_area(
236 area: Rect,
237 preferred_width: u16,
238 preferred_height: u16,
239 min_width: u16,
240 min_height: u16,
241 ) -> Rect {
242 // Keep a 2-cell margin on each axis when the frame can spare it so the
243 // backdrop stays visible around the card; otherwise fill the frame.
244 let avail_width = area.width.saturating_sub(2).max(1);
245 let avail_height = area.height.saturating_sub(2).max(1);
246 let width = preferred_width.clamp(min_width.min(avail_width), avail_width);
247 let height = preferred_height.clamp(min_height.min(avail_height), avail_height);
248 Rect {
249 x: area.x + area.width.saturating_sub(width) / 2,
250 y: area.y + area.height.saturating_sub(height) / 2,
251 width,
252 height,
253 }
254 }
255
256 /// A single key/label hint shown in a modal's action footer.
257 ///
258 /// Footers built from `ActionHint`s are laid out by [`action_footer_lines`],
259 /// which wraps to additional rows instead of letting an action run off the
260 /// right edge of the modal — the core overflow bug behind #3732. Use this for
261 /// action/navigation hints; truncate only identifiers/paths/hashes elsewhere.
262 pub(crate) struct ActionHint {
263 key: Cow<'static, str>,
264 label: Cow<'static, str>,
265 }
266
267 impl ActionHint {
268 pub(crate) fn new(
269 key: impl Into<Cow<'static, str>>,
270 label: impl Into<Cow<'static, str>>,
271 ) -> Self {
272 Self {
273 key: key.into(),
274 label: label.into(),
275 }
276 }
277
278 /// Display columns this hint occupies: ` key ` (key padded by a space on
279 /// each side) followed by the label.
280 fn width(&self) -> usize {
281 UnicodeWidthStr::width(self.key.as_ref()) + 2 + UnicodeWidthStr::width(self.label.as_ref())
282 }
283
284 fn spans(&self) -> [Span<'static>; 2] {
285 [
286 Span::styled(
287 format!(" {} ", self.key),
288 Style::default()
289 .fg(palette::WHALE_ACTION)
290 .add_modifier(Modifier::BOLD),
291 ),
292 Span::styled(
293 self.label.clone().into_owned(),
294 Style::default().fg(palette::TEXT_MUTED),
295 ),
296 ]
297 }
298 }
299
300 /// Lay out action hints into one or more lines that each fit within `width`.
301 ///
302 /// Hints are packed greedily; when the next hint would overflow the current row
303 /// the layout starts a new row rather than truncating. No action is ever
304 /// dropped or clipped (a single hint wider than `width` is emitted alone, which
305 /// only happens at degenerate widths below the modal minimums). This is the
306 /// shared replacement for the single-line `title_bottom` footers that silently
307 /// pushed actions off-screen.
308 pub(crate) fn action_footer_lines(hints: &[ActionHint], width: u16) -> Vec<Line<'static>> {
309 let width = usize::from(width);
310 if hints.is_empty() || width == 0 {
311 return Vec::new();
312 }
313 const GAP: usize = 1;
314 let mut lines: Vec<Line<'static>> = Vec::new();
315 let mut current: Vec<Span<'static>> = Vec::new();
316 let mut current_width = 0usize;
317 for hint in hints {
318 let hint_width = hint.width();
319 let needed = if current.is_empty() {
320 hint_width
321 } else {
322 current_width + GAP + hint_width
323 };
324 if !current.is_empty() && needed > width {
325 lines.push(Line::from(std::mem::take(&mut current)));
326 current_width = 0;
327 }
328 if !current.is_empty() {
329 current.push(Span::raw(" ".repeat(GAP)));
330 current_width += GAP;
331 }
332 current.extend(hint.spans());
333 current_width += hint_width;
334 }
335 if !current.is_empty() {
336 lines.push(Line::from(current));
337 }
338 lines
339 }
340
341 /// Reserve `lines` worth of rows at the bottom of `inner`, paint them, and
342 /// return the content area that remains above. Shared by the action-hint and
343 /// free-text modal footers.
344 fn place_footer_lines(
345 inner: Rect,
346 buf: &mut Buffer,
347 lines: Vec<Line<'static>>,
348 quiet_gutter: bool,
349 ) -> Rect {
350 if lines.is_empty() || inner.height == 0 {
351 return inner;
352 }
353 let footer_height = u16::try_from(lines.len())
354 .unwrap_or(u16::MAX)
355 .min(inner.height);
356 // Opted-in overlays keep one quiet row between scrollable body copy and
357 // the action rail. Degenerate heights keep every row for content.
358 let gutter_height = u16::from(quiet_gutter && inner.height >= footer_height.saturating_add(4));
359 let footer_area = Rect {
360 x: inner.x,
361 y: inner.y + inner.height - footer_height,
362 width: inner.width,
363 height: footer_height,
364 };
365 Paragraph::new(lines).render(footer_area, buf);
366 Rect {
367 x: inner.x,
368 y: inner.y,
369 width: inner.width,
370 height: inner
371 .height
372 .saturating_sub(footer_height.saturating_add(gutter_height)),
373 }
374 }
375
376 /// Render a wrapping action footer anchored to the bottom of `inner` and
377 /// return the content area that remains above it.
378 ///
379 /// Modals call this after painting their block so the footer reserves exactly
380 /// as many rows as it needs (bounded by the available height) and the body
381 /// fills the rest. Centralizing it keeps every modal's action row visible and
382 /// reachable at narrow widths.
383 pub(crate) fn render_modal_footer(inner: Rect, buf: &mut Buffer, hints: &[ActionHint]) -> Rect {
384 let lines = action_footer_lines(hints, inner.width);
385 place_footer_lines(inner, buf, lines, false)
386 }
387
388 /// Render a modal action footer with one quiet body-to-footer row when the
389 /// caller's responsive layout has explicitly budgeted for it.
390 pub(crate) fn render_modal_footer_with_gutter(
391 inner: Rect,
392 buf: &mut Buffer,
393 hints: &[ActionHint],
394 ) -> Rect {
395 let lines = action_footer_lines(hints, inner.width);
396 place_footer_lines(inner, buf, lines, true)
397 }
398
399 /// Word-wrap a free-form footer string into styled lines that each fit `width`.
400 ///
401 /// For footers that are pre-composed prose/sentences (e.g. localized config
402 /// hints) rather than discrete key/label hints. Wrapping on whitespace keeps
403 /// every word visible instead of clipping the tail at the modal edge.
404 pub(crate) fn wrapped_footer_lines(text: &str, width: u16, style: Style) -> Vec<Line<'static>> {
405 let width = usize::from(width);
406 if text.trim().is_empty() || width == 0 {
407 return Vec::new();
408 }
409 let mut lines: Vec<Line<'static>> = Vec::new();
410 let mut current = String::new();
411 let mut current_width = 0usize;
412 for word in text.split_whitespace() {
413 let word_width = UnicodeWidthStr::width(word);
414 let needed = if current.is_empty() {
415 word_width
416 } else {
417 current_width + 1 + word_width
418 };
419 if !current.is_empty() && needed > width {
420 lines.push(Line::from(Span::styled(
421 std::mem::take(&mut current),
422 style,
423 )));
424 current_width = 0;
425 }
426 if !current.is_empty() {
427 current.push(' ');
428 current_width += 1;
429 }
430 current.push_str(word);
431 current_width += word_width;
432 }
433 if !current.is_empty() {
434 lines.push(Line::from(Span::styled(current, style)));
435 }
436 lines
437 }
438
439 /// Render a wrapping free-text footer anchored to the bottom of `inner` and
440 /// return the content area above it. The prose counterpart to
441 /// [`render_modal_footer`].
442 pub(crate) fn render_modal_text_footer(
443 inner: Rect,
444 buf: &mut Buffer,
445 text: &str,
446 style: Style,
447 ) -> Rect {
448 let lines = wrapped_footer_lines(text, inner.width, style);
449 // Free-text status footers are already separated semantically from their
450 // table body and can carry the last visible receipt themselves. Do not
451 // spend another row here; action-rail layouts can opt into that gutter.
452 place_footer_lines(inner, buf, lines, false)
453 }
454
455 /// Shared list/detail geometry for modal managers and pickers.
456 ///
457 /// Wide modals get a stable left list and a right detail pane. Narrow modals
458 /// stack the list over the detail so neither side becomes unreadably thin.
459 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
460 pub(crate) struct ListDetailLayout {
461 pub(crate) list: Rect,
462 pub(crate) detail: Rect,
463 pub(crate) stacked: bool,
464 }
465
466 impl ListDetailLayout {
467 #[must_use]
468 pub(crate) fn split(area: Rect, min_detail_width: u16) -> Self {
469 if area.width == 0 || area.height == 0 {
470 return Self {
471 list: area,
472 detail: area,
473 stacked: true,
474 };
475 }
476
477 let gap = 1;
478 let min_list_width = 30.min(area.width);
479 let can_split = area.width >= 96
480 && area
481 .width
482 .saturating_sub(gap)
483 .saturating_sub(min_list_width)
484 >= min_detail_width;
485 if can_split {
486 let max_list_width = area.width.saturating_sub(gap + min_detail_width);
487 let preferred = area.width.saturating_mul(42) / 100;
488 let list_width = preferred.clamp(min_list_width, max_list_width.min(52));
489 let detail_width = area.width.saturating_sub(list_width + gap);
490 return Self {
491 list: Rect {
492 x: area.x,
493 y: area.y,
494 width: list_width,
495 height: area.height,
496 },
497 detail: Rect {
498 x: area.x + list_width + gap,
499 y: area.y,
500 width: detail_width,
501 height: area.height,
502 },
503 stacked: false,
504 };
505 }
506
507 let gap = if area.height >= 8 { 1 } else { 0 };
508 let min_detail_height = 4.min(area.height);
509 let max_list_height = area.height.saturating_sub(gap + min_detail_height);
510 let preferred = area.height.saturating_mul(3) / 5;
511 let list_height = preferred.clamp(1, max_list_height.max(1));
512 let detail_height = area.height.saturating_sub(list_height + gap);
513 Self {
514 list: Rect {
515 x: area.x,
516 y: area.y,
517 width: area.width,
518 height: list_height,
519 },
520 detail: Rect {
521 x: area.x,
522 y: area.y + list_height + gap,
523 width: area.width,
524 height: detail_height,
525 },
526 stacked: true,
527 }
528 }
529 }
530
531 /// Plain empty-state copy for modal list/detail bodies.
532 #[derive(Debug, Clone, PartialEq, Eq)]
533 pub(crate) struct EmptyState {
534 title: Cow<'static, str>,
535 body: Cow<'static, str>,
536 primary_action: Option<(Cow<'static, str>, Cow<'static, str>)>,
537 secondary_action: Option<(Cow<'static, str>, Cow<'static, str>)>,
538 }
539
540 impl EmptyState {
541 pub(crate) fn new(
542 title: impl Into<Cow<'static, str>>,
543 body: impl Into<Cow<'static, str>>,
544 ) -> Self {
545 Self {
546 title: title.into(),
547 body: body.into(),
548 primary_action: None,
549 secondary_action: None,
550 }
551 }
552
553 #[must_use]
554 pub(crate) fn primary_action(
555 mut self,
556 key: impl Into<Cow<'static, str>>,
557 label: impl Into<Cow<'static, str>>,
558 ) -> Self {
559 self.primary_action = Some((key.into(), label.into()));
560 self
561 }
562
563 #[must_use]
564 pub(crate) fn secondary_action(
565 mut self,
566 key: impl Into<Cow<'static, str>>,
567 label: impl Into<Cow<'static, str>>,
568 ) -> Self {
569 self.secondary_action = Some((key.into(), label.into()));
570 self
571 }
572
573 pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) {
574 let mut lines = vec![
575 Line::from(Span::styled(
576 self.title.clone().into_owned(),
577 Style::default()
578 .fg(palette::TEXT_PRIMARY)
579 .add_modifier(Modifier::BOLD),
580 )),
581 Line::from(""),
582 Line::from(Span::styled(
583 self.body.clone().into_owned(),
584 Style::default().fg(palette::TEXT_MUTED),
585 )),
586 ];
587 if self.primary_action.is_some() || self.secondary_action.is_some() {
588 lines.push(Line::from(""));
589 }
590 for (key, label) in [self.primary_action.as_ref(), self.secondary_action.as_ref()]
591 .into_iter()
592 .flatten()
593 {
594 let hint = ActionHint::new(key.clone(), label.clone());
595 lines.push(Line::from(hint.spans().to_vec()));
596 }
597 Paragraph::new(lines)
598 .style(Style::default().fg(palette::TEXT_PRIMARY))
599 .wrap(Wrap { trim: true })
600 .render(area, buf);
601 }
602 }
603
604 #[derive(Debug, Clone)]
605 pub enum CommandPaletteAction {
606 ExecuteCommand { command: String },
607 InsertText { text: String },
608 OpenTextPager { title: String, content: String },
609 }
610
611 #[derive(Debug, Clone, PartialEq, Eq)]
612 pub enum ContextMenuAction {
613 CopySelection,
614 OpenSelection,
615 ClearSelection,
616 CopyCell {
617 cell_index: usize,
618 },
619 OpenDetails {
620 cell_index: usize,
621 },
622 Paste,
623 OpenCommandPalette,
624 OpenContextInspector,
625 OpenHelp,
626 /// Open the selected file:line in the user's editor.
627 OpenFileAtLine {
628 cell_index: usize,
629 },
630 /// Hide a transcript cell. Adds the cell's index to `collapsed_cells`.
631 HideCell {
632 cell_index: usize,
633 },
634 /// Show a previously hidden cell (when right-clicking near it).
635 ShowCell {
636 cell_index: usize,
637 },
638 /// Show all currently hidden cells.
639 ShowAllHidden,
640 /// Execute a slash command associated with a contextual UI row.
641 ExecuteCommand {
642 command: String,
643 },
644 /// Copy a pre-resolved text payload (e.g. a sidebar row's full text)
645 /// to the clipboard.
646 CopyText {
647 text: String,
648 },
649 /// Pin/unpin the host terminal window (normal window ↔ always-on-top
650 /// mini window). Windows only; no-op elsewhere.
651 ToggleWindowPin,
652 }
653
654 #[derive(Debug, Clone)]
655 pub enum ViewEvent {
656 CommandPaletteSelected {
657 action: CommandPaletteAction,
658 },
659 OpenTextPager {
660 title: String,
661 content: String,
662 },
663 ApprovalDecision {
664 tool_id: String,
665 tool_name: String,
666 decision: ReviewDecision,
667 timed_out: bool,
668 /// Exact-argument fingerprint, used to scope *denials* (#1617).
669 approval_key: String,
670 /// Lossy / arity-aware fingerprint, used to scope *approvals*.
671 approval_grouping_key: String,
672 /// Permission rules to append when the decision approves.
673 persistent_rules: Vec<codewhale_config::ToolAskRule>,
674 },
675 ElevationDecision {
676 tool_id: String,
677 tool_name: String,
678 option: ElevationOption,
679 },
680 UserInputSubmitted {
681 tool_id: String,
682 response: UserInputResponse,
683 },
684 UserInputCancelled {
685 tool_id: String,
686 },
687 ConfigUpdated {
688 key: String,
689 value: String,
690 persist: bool,
691 },
692 /// The canonical `/theme` picker's selection. Preview, rollback, and
693 /// persist travel in one event so the theme never changes by half.
694 ThemeSelectionUpdated {
695 theme: String,
696 persist: bool,
697 },
698 SubAgentsRefresh,
699 SidebarAgentCancel {
700 agent_id: String,
701 },
702 /// An agent row activation (Work strip, sidebar dossier, `/agents`) or
703 /// Alt+V from Agent Details, Enter/click on any agent row, and Enter in the
704 /// `/agents` register all request the agent's transcript — since v0.9.7's
705 /// "one agent, one destination" inversion that is the in-place focus.
706 OpenAgentTranscript {
707 agent_id: String,
708 },
709 /// Agent Details was popped with Esc/q/Left. The Work surface uses this
710 /// to release only its detail-open owner while retaining selection.
711 AgentDetailsClosed {
712 agent_id: String,
713 },
714 /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a
715 /// candidate. The handler should insert `@<path>` at the composer's cursor
716 /// position.
717 FilePickerSelected {
718 path: String,
719 },
720 SessionSelected {
721 session_id: String,
722 },
723 SessionRenamed {
724 metadata: Box<crate::session_manager::SessionMetadata>,
725 },
726 /// A session's archive flag was flipped (#2934 / #4397).
727 ///
728 /// Distinct from `SessionRenamed` so the receipt can say what actually
729 /// happened; reusing rename would report "Renamed session …" for an
730 /// archive, which is exactly the kind of small lie that erodes trust in
731 /// every other receipt.
732 SessionArchived {
733 metadata: Box<crate::session_manager::SessionMetadata>,
734 },
735 SessionDeleted {
736 session_id: String,
737 title: String,
738 },
739 /// Emitted by the `/model` picker on Enter or Shift+D. Carries both the
740 /// chosen model id and reasoning effort tier so the UI handler can update
741 /// App state and forward `Op::SetModel` to the running engine.
742 /// `save_as_startup_default` is true only for the explicit Shift+D action;
743 /// ordinary Enter remains a session-local route change. `previous_*`
744 /// fields let the handler skip work when nothing changed and craft a clear
745 /// status message.
746 ModelPickerApplied {
747 model: String,
748 provider: Option<crate::config::ApiProvider>,
749 /// Exact named custom route key when the selected provider enum is
750 /// `Custom`; built-in routes leave this unset.
751 provider_id: Option<String>,
752 effort: crate::reasoning_preference::ReasoningEffort,
753 previous_model: String,
754 previous_effort: crate::reasoning_preference::ReasoningEffort,
755 save_as_startup_default: bool,
756 },
757 /// Emitted by the `/model` picker on Esc so the next open can restore
758 /// the browsing context — view mode and highlighted row (#4109 / #4115).
759 ModelPickerDismissed {
760 /// True when the dismissed view browses beyond configured providers
761 /// (Catalog / Recent / Coding / Cheap / Long context).
762 catalog_view: bool,
763 /// Named view key (`configured`, `catalog`, `recent`, `coding`,
764 /// `cheap`, `long_context`) for reopen restore (#4115).
765 view: String,
766 selected_row_id: Option<String>,
767 },
768 /// Enter on a locked (unauthenticated) model: explain why selection is
769 /// blocked and open the provider auth/setup path when possible.
770 /// Re-resolve readiness + rebuild catalog rows for the open model picker.
771 ModelPickerRefresh,
772 ModelPickerTogglePin {
773 provider: crate::config::ApiProvider,
774 /// Exact named route for `Custom`; built-in providers leave this unset.
775 provider_id: Option<String>,
776 model: String,
777 },
778 ModelPickerMovePin {
779 provider: crate::config::ApiProvider,
780 /// Exact named route for `Custom`; built-in providers leave this unset.
781 provider_id: Option<String>,
782 model: String,
783 delta: isize,
784 },
785 /// `⇧F` in the picker: add the row's exact route to the team (the
786 /// selected saved team), or remove it when it is already there (design §10 F1).
787 ModelPickerToggleFleet {
788 provider: crate::config::ApiProvider,
789 /// Exact named route for `Custom`; built-in providers leave this unset.
790 provider_id: Option<String>,
791 model: String,
792 },
793 /// Enter on a Fleet editor row: open the standard `/model` picker for
794 /// that row (the editor stays underneath) instead of the editor's own
795 /// inline route list.
796 FleetProfileRoutePickRequested {
797 editor_id: uuid::Uuid,
798 },
799 FleetProfileRoutePicked {
800 editor_id: uuid::Uuid,
801 provider: crate::config::ApiProvider,
802 provider_id: Option<String>,
803 model: String,
804 reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
805 },
806 FleetProfileRouteCommitRequested {
807 editor_id: uuid::Uuid,
808 },
809 FleetAssignmentPickerDismissed {
810 editor_id: uuid::Uuid,
811 },
812 FleetRosterOpenCoordinatorRequested,
813 FleetDetailRoutePickRequested {
814 target: crate::tui::views::fleet_detail::FleetRouteTarget,
815 editor_id: uuid::Uuid,
816 },
817 /// The `/model` picker, opened for a Fleet editor row, resolved a route.
818 /// Carries the row's absolute route — never a diff against the session —
819 /// and the host applies and saves it on the editor still on the stack.
820 /// The picker's `auto` row means "inherit the session route".
821 FleetRoutePicked {
822 target: crate::tui::views::fleet_detail::FleetRouteTarget,
823 editor_id: uuid::Uuid,
824 provider: crate::config::ApiProvider,
825 /// Exact named route for `Custom`; built-in providers leave this unset.
826 provider_id: Option<String>,
827 model: String,
828 reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
829 },
830 ModelPickerNeedsAuth {
831 provider: crate::config::ApiProvider,
832 model: String,
833 reason: String,
834 },
835 /// Transient status toast from a modal (e.g. locked-model explanation).
836 StatusMessage {
837 message: String,
838 },
839 /// The Tideline topbar's route segment requested the normal `/provider`
840 /// surface. It carries no catalog, readiness, or selected-route payload:
841 /// those facts remain owned by the provider picker and its apply path.
842 TopbarRoutePickerRequested,
843 /// The info line's model field requested the normal `/model` surface.
844 /// Same rule as the route segment: an entry point carrying no catalog.
845 TopbarModelPickerRequested,
846 /// Emitted by the `/provider` picker on Esc so the next open can restore
847 /// the browsing context — view mode and highlighted row.
848 ProviderPickerDismissed {
849 catalog_view: bool,
850 selected_provider_id: Option<String>,
851 },
852 /// Emitted by the `/provider` picker when the user selects a provider
853 /// that already has credentials — the handler should perform the same
854 /// switch as `AppAction::SwitchProvider`.
855 ProviderPickerApplied {
856 provider: crate::config::ApiProvider,
857 provider_id: Option<String>,
858 },
859 /// Emitted by the `/provider` picker after the user types an API key
860 /// inline for a provider that lacked one. The handler validates the key
861 /// live; on success it reopens the guided flow at the model-pick stage
862 /// without persisting yet (#3875).
863 ProviderPickerApiKeySubmitted {
864 provider: crate::config::ApiProvider,
865 provider_id: Option<String>,
866 api_key: String,
867 /// Endpoint chosen in the wizard's billing-route stage, applied to the
868 /// verification config only — nothing is written until confirm (#4526).
869 base_url: Option<String>,
870 },
871 /// Emitted by the `/provider` guided setup confirm stage after the user
872 /// accepted provider + model. The handler persists the key (and model)
873 /// via the comment-preserving config path, then performs the switch.
874 ProviderPickerSetupConfirmed {
875 provider: crate::config::ApiProvider,
876 provider_id: Option<String>,
877 api_key: String,
878 model: String,
879 context_window: Option<u32>,
880 /// Endpoint the key was verified against, persisted to the provider's
881 /// own `base_url` before the key is saved (#4526).
882 base_url: Option<String>,
883 },
884 /// Emitted by the `/provider` picker after the custom provider form is
885 /// completed. The handler persists a named OpenAI-compatible provider
886 /// table and switches to it without storing raw secrets.
887 ProviderPickerCustomProviderSubmitted {
888 provider_id: String,
889 base_url: String,
890 model: Option<String>,
891 api_key_env: Option<String>,
892 },
893 /// Emitted by provider/setup UI when xAI device-code OAuth is requested.
894 ProviderPickerXaiOAuthRequested,
895 /// Emitted by provider/setup UI when native ChatGPT PKCE sign-in is requested.
896 ProviderPickerChatgptOAuthRequested,
897 /// Emitted only after the picker showed owner, exact path, and the full
898 /// read-only side-effect contract and the user explicitly confirmed it.
899 ProviderPickerExternalConsentConfirmed {
900 provider: crate::config::ApiProvider,
901 consent_provider: codewhale_config::ProviderKind,
902 source: codewhale_config::ExternalCredentialSource,
903 path: std::path::PathBuf,
904 },
905 /// One-step revocation from a provider row that currently has consent.
906 ProviderPickerExternalConsentRevoked {
907 provider: crate::config::ApiProvider,
908 },
909 /// Emitted by the `/provider` picker (the `M` action) to jump straight to
910 /// the `/model` picker pre-filtered to the highlighted provider (#3083).
911 ProviderPickerOpenModels {
912 provider: crate::config::ApiProvider,
913 provider_id: Option<String>,
914 },
915 /// Emitted by `/provider` `T`: probe `/models` and refresh readiness
916 /// without treating a 2xx as model-ready (#5350).
917 ProviderPickerTestConnection {
918 provider: crate::config::ApiProvider,
919 provider_id: Option<String>,
920 /// Restore Catalog vs Configured after the probe. Must not force
921 /// the all-providers catalog if the user was on configured-only.
922 catalog_view: bool,
923 },
924 /// Emitted by the `/mode` picker when the user chooses a mode.
925 ModeSelected {
926 mode: codewhale_config::AppMode,
927 },
928 /// Emitted by the `/statusline` picker every time the user toggles an
929 /// item (live preview) and once more on Enter (final). The handler
930 /// updates `app.status_items` immediately and persists on `final_save`
931 /// so the footer animates without a write per keystroke.
932 StatusItemsUpdated {
933 items: Vec<crate::config::StatusItem>,
934 final_save: bool,
935 },
936 /// Emitted by the `/hotbar` setup wizard when the user saves the draft
937 /// bindings. The host updates live config state; disk persistence is
938 /// handled by the follow-up persistence slice.
939 HotbarSetupSaved {
940 bindings: Vec<codewhale_config::HotbarBindingToml>,
941 },
942 /// Emitted by the constitution-first setup shell when a staged setup-state
943 /// record should be committed atomically to `$CODEWHALE_HOME/setup_state.json`.
944 SetupStateCommitRequested {
945 state: codewhale_config::SetupState,
946 message: String,
947 },
948 /// Emitted by the constitution-first setup shell when accepting a guided
949 /// structured user-global constitution. The host commits the constitution
950 /// and matching setup-state record together.
951 SetupConstitutionCommitRequested {
952 constitution: codewhale_config::UserConstitution,
953 state: codewhale_config::SetupState,
954 message: String,
955 },
956 /// Emitted by the setup Constitution card (`A`, provider route ready) to
957 /// ask the user's first configured model to draft the constitution from
958 /// the guided answers plus an optional bounded own-words note. The host
959 /// performs the one-shot call, pushes the sanitized/bounded draft back into the wizard, and opens the
960 /// ratification preview; on any failure it reports why and leaves the
961 /// deterministic guided draft standing. Nothing is persisted by this
962 /// event — saving still goes through the ratify keypress and
963 /// [`SetupConstitutionCommitRequested`](Self::SetupConstitutionCommitRequested).
964 SetupConstitutionModelDraftRequested {
965 draft: crate::tui::setup::GuidedConstitutionDraft,
966 freeform_note: Option<String>,
967 locale: codewhale_localization::Locale,
968 },
969 /// Emitted by the fleet setup Review step (`m`) to ask the configured
970 /// model to draft the agent profile the wizard describes. The host
971 /// performs the one-shot call, pushes the sanitized/bounded draft back
972 /// into the wizard, and opens the rendered-TOML preview; on failure it
973 /// reports why and the manual authoring flow stands. Nothing is
974 /// persisted by this event.
975 FleetProfileModelDraftRequested {
976 role: String,
977 /// Target model for the worker: a concrete model id, or "inherit".
978 model: String,
979 /// Canonical provider id for a concrete cross-provider route pick, or
980 /// `None` for `inherit` (#4093). Carried so the model-drafted profile
981 /// keeps the picked provider instead of collapsing to an ambiguous,
982 /// provider-scoped profile — the exact bug #4093 fixes.
983 provider: Option<String>,
984 /// Canonical reasoning tier selected by the wizard, or `None` for
985 /// inherit (#4137). Carried with the async draft for the same reason
986 /// as `provider`: the ratified profile must preserve the operator's
987 /// explicit choice, not whatever the model echoed.
988 reasoning_effort: Option<String>,
989 locale: codewhale_localization::Locale,
990 },
991 /// Emitted by the `/fleet` roster view (`s` / Enter) to edit a member.
992 /// The host routes a selected v2 Fleet to its exact editor and uses the
993 /// legacy profile wizard only when no named Fleet is selected.
994 FleetRosterOpenSetupRequested {
995 /// Exact Fleet member id; roles are not unique and therefore cannot
996 /// identify which row the operator selected.
997 member_id: String,
998 },
999 /// Open the live workers tab from the unified Fleet surface.
1000 FleetRosterOpenWorkersRequested,
1001
1002 /// The roster asks the host to open the secondary named-Fleet switcher
1003 /// (`/fleet fleets`; `/fleet fleets` remains compatible). Editing stays on
1004 /// setup; this is pick/select only.
1005 FleetRosterOpenFleetsRequested,
1006
1007 /// The Fleet list view asks the host to open a saved Fleet's detail view.
1008 FleetListOpenDetailRequested {
1009 name: String,
1010 scope: crate::fleet::store::FleetScope,
1011 },
1012 /// A Fleet store mutation happened (select/save/delete/rename/copy).
1013 /// The message is the exact receipt; the host refreshes roster state.
1014 FleetStoreChanged {
1015 message: String,
1016 },
1017 /// Emitted by the fleet setup Review step after the user previewed a
1018 /// model-drafted profile and pressed the explicit ratify key. The host
1019 /// renders TOML deterministically from the validated draft and persists it
1020 /// atomically in the explicitly selected project or personal scope.
1021 FleetProfileDraftCommitRequested {
1022 draft: Box<crate::fleet::profile::FleetProfileDraft>,
1023 scope: crate::fleet::profile::FleetProfileScope,
1024 },
1025 /// Emitted by the Fleet setup Model step when the user selects a route that
1026 /// has structurally valid external-consent credentials but is not the
1027 /// active session provider. The host performs a route-scoped validation
1028 /// (minting the read capability only for this exact provider/source/path)
1029 /// and records the result in the session health snapshot so the same row
1030 /// becomes selectable on the next render. The parent session provider and
1031 /// model are never changed.
1032 FleetSetupExternalConsentActivationRequested {
1033 provider_id: String,
1034 model: String,
1035 },
1036 /// Emitted by the setup Runtime Posture card after the user has previewed
1037 /// and confirmed an explicit preset/config diff.
1038 SetupRuntimePresetApplyRequested {
1039 preset: crate::tui::setup::SetupRuntimePreset,
1040 state: codewhale_config::SetupState,
1041 message: String,
1042 },
1043 /// Emitted by the setup Provider/Model readiness card to hand off to the
1044 /// existing provider manager instead of duplicating provider auth UI.
1045 SetupOpenProviderRequested,
1046 /// Emitted by the setup Provider/Model readiness card to hand off to the
1047 /// existing provider-qualified model route picker.
1048 SetupOpenModelRequested,
1049 /// Emitted by the setup Operate/Fleet readiness card to hand off to the
1050 /// existing Fleet setup wizard without writing Fleet config itself.
1051 SetupOpenFleetRequested,
1052 /// Emitted by the setup Hotbar card to hand off to the existing Hotbar
1053 /// setup wizard without rewriting bindings itself.
1054 SetupOpenHotbarRequested,
1055 /// Emitted by the setup Runtime Posture card to hand off to the existing
1056 /// work-mode picker.
1057 SetupOpenModeRequested,
1058 /// Emitted by the setup Runtime Posture card to hand off to the existing
1059 /// config view for approval/sandbox/network details.
1060 SetupOpenConfigRequested,
1061 /// Emitted by the progressive setup guide to start the same account-owned
1062 /// web remote-control flow as `/rc`. Setup never duplicates enrollment.
1063 SetupOpenRemoteControlRequested,
1064 /// Emitted by the `/hotbar` setup wizard when the user chooses "Disable
1065 /// Hotbar". The host persists `hotbar = []` and hides the panel.
1066 HotbarDisableRequested,
1067 /// Emitted by the live-transcript overlay while in backtrack preview
1068 /// mode (#133) when the user steps the highlighted user message with
1069 /// Left or Right. The handler advances `app.backtrack`, refreshes the
1070 /// overlay's `selected_idx`, and pins scroll near the new highlight.
1071 BacktrackStep {
1072 direction: crate::tui::backtrack::Direction,
1073 },
1074 /// Emitted by the live-transcript overlay when the user presses Enter
1075 /// in backtrack preview mode (#133). The handler calls
1076 /// `app.backtrack.confirm()`, trims `app.history`/`api_messages` to
1077 /// the selected user message, populates the composer with the
1078 /// dropped user text, and closes the overlay.
1079 BacktrackConfirm,
1080 /// Emitted by the live-transcript overlay when the user presses Esc
1081 /// in backtrack preview mode (#133). The handler resets
1082 /// `app.backtrack` and closes the overlay without trimming.
1083 BacktrackCancel,
1084 ContextMenuSelected {
1085 action: ContextMenuAction,
1086 },
1087 /// Emitted by the pager (`c` / `y`) to copy its body to the system
1088 /// clipboard. The host handler writes via `app.clipboard` and surfaces a
1089 /// status message — modal views cannot reach `app` directly. `label` is
1090 /// the noun shown in the success / failure status (e.g. "Pager content").
1091 CopyToClipboard {
1092 text: String,
1093 label: String,
1094 },
1095 /// Emitted by the skills manager when the user confirms an install /
1096 /// import / update / remove / trust action. The host runs the mutation
1097 /// controller and rebuilds the open manager view.
1098 SkillMutationRequested {
1099 request: crate::skills::mutation::SkillMutationRequest,
1100 },
1101 /// Toggle owned-only vs compatible audit scan inside the skills manager.
1102 SkillsManagerToggleCompatible,
1103 /// The launch card's resume confirmation was accepted. The host resumes
1104 /// the named session through the same path the card's own Enter uses.
1105 LaunchResumeConfirmed {
1106 session_id: String,
1107 },
1108 /// A slash command an Extensions row activated in place: the panel stays
1109 /// open, the host runs the command through the normal command path, then
1110 /// hands the panel a fresh snapshot so every row re-reads live state.
1111 /// When `pager_title` is set, the command's text output renders in a
1112 /// pager stacked on the panel rather than landing in the transcript.
1113 ExecutePanelCommand {
1114 command: String,
1115 pager_title: Option<String>,
1116 },
1117 /// The open Extensions panel's bounded poll: the host rebuilds the read
1118 /// model only when the MCP snapshot generation or the initializing flag
1119 /// moved past what the panel's snapshot last saw.
1120 RefreshExtensions {
1121 mcp_generation: u64,
1122 mcp_initializing: bool,
1123 },
1124 }
1125
1126 #[derive(Debug, Clone)]
1127 pub enum ViewAction {
1128 None,
1129 Close,
1130 Emit(ViewEvent),
1131 EmitAndClose(ViewEvent),
1132 }
1133
1134 pub trait ModalView: std::any::Any {
1135 fn kind(&self) -> ModalKind;
1136 fn handle_key(&mut self, key: KeyEvent) -> ViewAction;
1137 /// Returns `true` if the modal consumed the paste; `false` to let the
1138 /// host route the text elsewhere (e.g. drop it because a modal is open,
1139 /// or insert it into the composer when no modal wants it). The default
1140 /// is `false` so modals that don't care about paste don't silently
1141 /// swallow Cmd-V.
1142 fn handle_paste(&mut self, _text: &str) -> bool {
1143 false
1144 }
1145
1146 fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction {
1147 ViewAction::None
1148 }
1149 fn render(&self, area: Rect, buf: &mut Buffer);
1150 /// The region this modal actually paints within the full frame `area`.
1151 ///
1152 /// Defaults to the whole frame, which is the legacy full-screen overlay
1153 /// behaviour every picker/menu still relies on. Inline modals (the
1154 /// approval prompt) override this to return a bottom-anchored band so the
1155 /// backdrop only dims their strip and the transcript above stays visible.
1156 /// The returned rect MUST match the region the modal renders into, or the
1157 /// dim and the painted content will disagree.
1158 fn occupied_region(&self, area: Rect) -> Rect {
1159 area
1160 }
1161 fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool {
1162 false
1163 }
1164 fn tick(&mut self) -> ViewAction {
1165 ViewAction::None
1166 }
1167 /// Erased downcast hook for views that need a typed reference back from
1168 /// the boxed trait object (e.g. the live transcript overlay needs `&mut`
1169 /// access from outside the trait so it can refresh its snapshot of the
1170 /// app's transcript state right before render).
1171 fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
1172
1173 /// The approval tool id this view decides, when this view is an approval
1174 /// card. Enables identity-aware dismissal: a remote decision must close
1175 /// its own card, not whichever approval happens to be on top.
1176 fn approval_request_id(&self) -> Option<&str> {
1177 None
1178 }
1179 }
1180
1181 #[derive(Default)]
1182 pub struct ViewStack {
1183 views: Vec<Box<dyn ModalView>>,
1184 /// Focus-context texture prototype mode (#4823). `Off` by default, which
1185 /// keeps the render output byte-identical to the pre-prototype path.
1186 focus_texture: FocusTextureMode,
1187 /// Theme snapshot for the texture pass, set alongside the mode each
1188 /// frame. `None` (e.g. tests that never opt in) disables the texture.
1189 focus_texture_theme: Option<codewhale_palette::UiTheme>,
1190 }
1191
1192 impl ViewStack {
1193 pub fn new() -> Self {
1194 Self {
1195 views: Vec::new(),
1196 focus_texture: FocusTextureMode::Off,
1197 focus_texture_theme: None,
1198 }
1199 }
1200
1201 /// Set the focus-context texture mode and theme for subsequent renders
1202 /// (#4823 prototype). Called once per frame from the UI render path with
1203 /// the parsed setting; a plain enum/theme copy, no allocation.
1204 pub fn set_focus_texture(&mut self, mode: FocusTextureMode, theme: codewhale_palette::UiTheme) {
1205 self.focus_texture = mode;
1206 self.focus_texture_theme = Some(theme);
1207 }
1208
1209 pub fn is_empty(&self) -> bool {
1210 self.views.is_empty()
1211 }
1212
1213 pub fn top_kind(&self) -> Option<ModalKind> {
1214 self.views.last().map(|view| view.kind())
1215 }
1216
1217 /// Whether the top view is the approval card deciding exactly `gate`.
1218 /// Identity-aware: a web-mirror dismissal closes its own card, never an
1219 /// unrelated approval that happens to be on top.
1220 pub fn top_matches_approval_gate(&self, gate: &str) -> bool {
1221 self.views.last().is_some_and(|view| {
1222 crate::remote_control::view_is_approval_for_gate(view.as_ref(), gate)
1223 })
1224 }
1225
1226 pub fn contains_kind(&self, kind: ModalKind) -> bool {
1227 self.views.iter().any(|view| view.kind() == kind)
1228 }
1229
1230 /// Close the named view and any child modal opened above it. This keeps a
1231 /// shell-global toggle from stacking a duplicate parent behind its picker.
1232 pub fn pop_through_kind(&mut self, kind: ModalKind) -> bool {
1233 while let Some(view) = self.pop() {
1234 if view.kind() == kind {
1235 return true;
1236 }
1237 }
1238 false
1239 }
1240
1241 pub fn top_occupied_region(&self, area: Rect) -> Option<Rect> {
1242 self.views.last().map(|view| view.occupied_region(area))
1243 }
1244
1245 pub fn push<V: ModalView + 'static>(&mut self, view: V) {
1246 let kind = view.kind();
1247 self.views.push(Box::new(view));
1248 tracing::debug!(target: "codewhale_tui::view_stack", action = "push", kind = ?kind, depth = self.views.len(), "view pushed");
1249 }
1250
1251 /// Push an already-boxed view back onto the stack. Used by call sites
1252 /// that pop a view, mutate it externally, and need to restore it without
1253 /// the generic `push` re-boxing dance.
1254 pub fn push_boxed(&mut self, view: Box<dyn ModalView>) {
1255 let kind = view.kind();
1256 self.views.push(view);
1257 tracing::debug!(target: "codewhale_tui::view_stack", action = "push_boxed", kind = ?kind, depth = self.views.len(), "view pushed");
1258 }
1259
1260 pub fn pop(&mut self) -> Option<Box<dyn ModalView>> {
1261 let popped = self.views.pop();
1262 if let Some(view) = popped.as_ref() {
1263 tracing::debug!(target: "codewhale_tui::view_stack", action = "pop", kind = ?view.kind(), depth = self.views.len(), "view popped");
1264 }
1265 popped
1266 }
1267
1268 pub fn render(&self, area: Rect, buf: &mut Buffer) {
1269 // Focus-context texture prototype (#4823): runs over the already
1270 // rendered background BEFORE any backdrop or view paint, so the
1271 // focused modal is painted afterwards at full strength and the
1272 // texture can never overwrite it. `Off` (the default) leaves the
1273 // buffer untouched, keeping output byte-identical to the
1274 // pre-prototype path.
1275 if self.focus_texture != FocusTextureMode::Off
1276 && let (Some(focus), Some(theme)) =
1277 (self.top_occupied_region(area), self.focus_texture_theme)
1278 {
1279 crate::tui::focus_texture::apply_focus_texture(
1280 area,
1281 buf,
1282 focus,
1283 &theme,
1284 self.focus_texture,
1285 crate::tui::color_compat::ascii_safe_enabled(),
1286 );
1287 }
1288 // Dim each view's own occupied region rather than the whole frame, so
1289 // an inline modal (the approval prompt) leaves the transcript above it
1290 // visible instead of blacking out the screen. Full-screen modals keep
1291 // the default `occupied_region` of the entire frame, so their backdrop
1292 // is unchanged.
1293 for view in &self.views {
1294 let region = view.occupied_region(area);
1295 crate::tui::osc8::overlay_frame_links(region, Vec::new());
1296 render_modal_backdrop(region, buf);
1297 view.render(area, buf);
1298 }
1299 }
1300
1301 pub fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool {
1302 let mut updated = false;
1303 for view in &mut self.views {
1304 updated |= view.update_subagents(agents);
1305 }
1306 updated
1307 }
1308
1309 pub fn handle_key(&mut self, key: KeyEvent) -> Vec<ViewEvent> {
1310 let action = self
1311 .views
1312 .last_mut()
1313 .map(|view| view.handle_key(key))
1314 .unwrap_or(ViewAction::None);
1315 self.apply_action(action)
1316 }
1317
1318 pub fn handle_paste(&mut self, text: &str) -> bool {
1319 self.views
1320 .last_mut()
1321 .map(|view| view.handle_paste(text))
1322 .unwrap_or(false)
1323 }
1324
1325 pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec<ViewEvent> {
1326 let action = self
1327 .views
1328 .last_mut()
1329 .map(|view| view.handle_mouse(mouse))
1330 .unwrap_or(ViewAction::None);
1331 self.apply_action(action)
1332 }
1333
1334 pub fn tick(&mut self) -> Vec<ViewEvent> {
1335 let action = self
1336 .views
1337 .last_mut()
1338 .map(|view| view.tick())
1339 .unwrap_or(ViewAction::None);
1340 self.apply_action(action)
1341 }
1342
1343 fn apply_action(&mut self, action: ViewAction) -> Vec<ViewEvent> {
1344 let mut events = Vec::new();
1345 match action {
1346 ViewAction::None => {}
1347 ViewAction::Close => {
1348 if let Some(view) = self.views.pop() {
1349 tracing::debug!(target: "codewhale_tui::view_stack", action = "close", kind = ?view.kind(), depth = self.views.len(), "view closed via action");
1350 }
1351 }
1352 ViewAction::Emit(event) => {
1353 events.push(event);
1354 }
1355 ViewAction::EmitAndClose(event) => {
1356 events.push(event);
1357 if let Some(view) = self.views.pop() {
1358 tracing::debug!(target: "codewhale_tui::view_stack", action = "emit_and_close", kind = ?view.kind(), depth = self.views.len(), "view closed via action");
1359 }
1360 }
1361 }
1362 events
1363 }
1364
1365 /// Whether the Extensions panel is the top view.
1366 pub fn extensions_is_top(&self) -> bool {
1367 self.views
1368 .last()
1369 .is_some_and(|view| view.kind() == ModalKind::Extensions)
1370 }
1371
1372 /// Hand a freshly-built read model to the open Extensions panel, when it
1373 /// is on top. A pager or another modal stacked above it means the user is
1374 /// looking at something else — the rebuild is skipped and the next poll
1375 /// retries.
1376 pub fn refresh_extensions(&mut self, snapshot: extensions::ExtensionsSnapshot) {
1377 if let Some(view) = self.views.last_mut()
1378 && let Some(panel) = view
1379 .as_any_mut()
1380 .downcast_mut::<extensions::ExtensionsView>()
1381 {
1382 panel.refresh_snapshot(snapshot);
1383 }
1384 }
1385 }
1386
1387 impl fmt::Debug for ViewStack {
1388 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1389 f.debug_struct("ViewStack")
1390 .field("len", &self.views.len())
1391 .field("top", &self.top_kind())
1392 .finish()
1393 }
1394 }
1395
1396 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1397 enum ConfigScope {
1398 Session,
1399 Saved,
1400 }
1401
1402 impl ConfigScope {
1403 fn label(self, locale: Locale) -> Cow<'static, str> {
1404 tr(
1405 locale,
1406 match self {
1407 ConfigScope::Session => MessageId::ConfigScopeSession,
1408 ConfigScope::Saved => MessageId::ConfigScopeSaved,
1409 },
1410 )
1411 }
1412
1413 fn persist(self) -> bool {
1414 matches!(self, ConfigScope::Saved)
1415 }
1416 }
1417
1418 #[derive(Debug, Clone)]
1419 struct ConfigRow {
1420 key: String,
1421 value: String,
1422 editable: bool,
1423 scope: ConfigScope,
1424 /// Typed facts decided when the row is built from `App`, `Settings`, and
1425 /// `Config`; the shell never re-derives them from the key at render time.
1426 facts: ConfigRowFacts,
1427 }
1428
1429 impl ConfigRow {
1430 fn edit_value(&self) -> &str {
1431 if self.key.starts_with("notifications.") {
1432 self.facts.effective.as_deref().unwrap_or(&self.value)
1433 } else {
1434 &self.value
1435 }
1436 }
1437
1438 /// The schema declaration behind this row. `None` means the key is not
1439 /// declared, and the row is dropped before the view is built.
1440 fn schema(&self) -> Option<&'static codewhale_config::SettingDef> {
1441 codewhale_config::setting(&self.key)
1442 }
1443
1444 /// The row's `ui` block. `None` means "declared, but not shown".
1445 fn ui(&self) -> Option<&'static codewhale_config::SettingUi> {
1446 self.schema().and_then(|def| def.ui.as_ref())
1447 }
1448
1449 /// Section heading, from the schema's group id.
1450 fn section(&self) -> ConfigSection {
1451 self.ui()
1452 .and_then(|ui| ConfigSection::from_id(ui.group))
1453 .unwrap_or(ConfigSection::Experimental)
1454 }
1455 }
1456
1457 /// What a row *is*: a persisted or session setting fact, an action that opens
1458 /// another surface, or a read-only receipt observed from the running app.
1459 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1460 enum ConfigRowKind {
1461 Setting,
1462 Action,
1463 Diagnostic,
1464 }
1465
1466 /// Which [`UiSnapshot`] fact a row projects, when it projects one.
1467 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1468 enum SnapshotLane {
1469 Provider,
1470 Model,
1471 }
1472
1473 /// Which durable store a saved row persists to — independent of which
1474 /// authority currently wins the *effective* decision. An environment or
1475 /// terminal override (NO_ANIMATIONS, a legacy console host, …) relabels the
1476 /// row's authority without repairing or breaking its store, so store-error
1477 /// marking must key on this field, never on `authority`.
1478 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1479 enum SettingStore {
1480 /// `settings.toml` (user settings).
1481 UserSettings,
1482 /// `config.toml` (workspace configuration).
1483 WorkspaceConfig,
1484 /// Session-owned, diagnostic, or otherwise not persisted from this surface.
1485 None,
1486 }
1487
1488 impl SettingStore {
1489 /// The store an authority implies when a row is *built* under it. Only
1490 /// meaningful at construction: an override authority applied later must
1491 /// keep the row's original store.
1492 fn for_authority(authority: SettingAuthority) -> Self {
1493 match authority {
1494 SettingAuthority::UserSettings => SettingStore::UserSettings,
1495 SettingAuthority::WorkspaceConfiguration => SettingStore::WorkspaceConfig,
1496 _ => SettingStore::None,
1497 }
1498 }
1499 }
1500
1501 #[derive(Debug, Clone, PartialEq, Eq)]
1502 struct ConfigRowFacts {
1503 kind: ConfigRowKind,
1504 authority: SettingAuthority,
1505 /// The store this row's saved/startup lanes come from (#5730 Windows CI:
1506 /// an override-authority row still has a store that can fail to load).
1507 store: SettingStore,
1508 apply: SettingApplySemantics,
1509 /// Value observed in force from an explicit `App` field. `None` means
1510 /// unobserved; it is never inferred from the persisted value.
1511 effective: Option<String>,
1512 /// Snapshot lane supplying the live fact for this row.
1513 snapshot: Option<SnapshotLane>,
1514 /// Slash command that activation runs, with the localized verb for it.
1515 command: Option<(&'static str, MessageId)>,
1516 /// The concrete environment/terminal token when `authority` is an
1517 /// override (`NO_ANIMATIONS`, `TERM_PROGRAM=vscode`, …).
1518 authority_detail: Option<&'static str>,
1519 /// The load error of the row's store when it could not be read; the saved
1520 /// and startup lanes are then unavailable, never a default in disguise.
1521 store_error: Option<String>,
1522 }
1523
1524 impl ConfigRowFacts {
1525 /// A `settings.toml` value edited here and applied on save.
1526 fn saved_setting() -> Self {
1527 Self {
1528 kind: ConfigRowKind::Setting,
1529 authority: SettingAuthority::UserSettings,
1530 store: SettingStore::UserSettings,
1531 apply: SettingApplySemantics::Immediate,
1532 effective: None,
1533 snapshot: None,
1534 command: None,
1535 authority_detail: None,
1536 store_error: None,
1537 }
1538 }
1539
1540 /// The effective value is forced by an environment or terminal override.
1541 fn overridden(self, environment: bool, detail: &'static str) -> Self {
1542 Self {
1543 authority: if environment {
1544 SettingAuthority::Environment
1545 } else {
1546 SettingAuthority::Terminal
1547 },
1548 authority_detail: Some(detail),
1549 ..self
1550 }
1551 }
1552
1553 /// The row's store failed to load: no saved or startup value is known.
1554 /// The error is folded onto one line so it reads in a single lane.
1555 fn unavailable(self, error: &str) -> Self {
1556 Self {
1557 store_error: Some(error.split_whitespace().collect::<Vec<_>>().join(" ")),
1558 ..self
1559 }
1560 }
1561
1562 /// A value the live session owns; its row value *is* the observed value.
1563 fn session_setting() -> Self {
1564 Self {
1565 authority: SettingAuthority::Session,
1566 store: SettingStore::None,
1567 apply: SettingApplySemantics::EffectiveNow,
1568 ..Self::saved_setting()
1569 }
1570 }
1571
1572 /// A persisted setting shown but not editable from this surface.
1573 fn read_only_setting(authority: SettingAuthority) -> Self {
1574 Self {
1575 store: SettingStore::for_authority(authority),
1576 authority,
1577 apply: SettingApplySemantics::ReadOnly,
1578 ..Self::saved_setting()
1579 }
1580 }
1581
1582 /// A receipt observed from the running app, never a persisted fact.
1583 fn diagnostic(authority: SettingAuthority) -> Self {
1584 Self {
1585 kind: ConfigRowKind::Diagnostic,
1586 store: SettingStore::None,
1587 authority,
1588 apply: SettingApplySemantics::ReadOnly,
1589 ..Self::saved_setting()
1590 }
1591 }
1592
1593 /// A row whose activation opens another surface; not a persisted fact.
1594 fn action(command: &'static str, verb: MessageId) -> Self {
1595 Self {
1596 kind: ConfigRowKind::Action,
1597 authority: SettingAuthority::Session,
1598 store: SettingStore::None,
1599 apply: SettingApplySemantics::EffectiveNow,
1600 command: Some((command, verb)),
1601 ..Self::saved_setting()
1602 }
1603 }
1604
1605 fn authority(self, authority: SettingAuthority) -> Self {
1606 // Callers that relabel a row's authority through this builder are
1607 // describing the row's store (e.g. a config.toml row), so the store
1608 // follows. An *override* never goes through this builder — it wins
1609 // the effective decision while the store stays what it was.
1610 let store = SettingStore::for_authority(authority);
1611 Self {
1612 authority,
1613 store,
1614 ..self
1615 }
1616 }
1617
1618 fn apply(self, apply: SettingApplySemantics) -> Self {
1619 Self { apply, ..self }
1620 }
1621
1622 fn effective(self, value: impl Into<String>) -> Self {
1623 Self {
1624 effective: Some(value.into()),
1625 ..self
1626 }
1627 }
1628
1629 fn snapshot(self, lane: SnapshotLane) -> Self {
1630 Self {
1631 snapshot: Some(lane),
1632 ..self
1633 }
1634 }
1635
1636 /// A setting fact whose activation opens a picker instead of an editor.
1637 fn opens(self, command: &'static str, verb: MessageId) -> Self {
1638 Self {
1639 command: Some((command, verb)),
1640 ..self
1641 }
1642 }
1643 }
1644
1645 /// Editor behavior for one Settings entry. This is intentionally independent
1646 /// from where the value is stored: category/scope describe ownership, while
1647 /// kind determines the interaction and validation surface.
1648 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1649 enum SettingKind {
1650 Boolean,
1651 Choice,
1652 Integer,
1653 Text,
1654 Action,
1655 ReadOnly,
1656 }
1657
1658 #[derive(Debug, Clone)]
1659 struct SettingMeta {
1660 kind: SettingKind,
1661 category: ConfigSection,
1662 choices: Option<Vec<String>>,
1663 }
1664
1665 #[derive(Debug, Clone)]
1666 struct SettingsRegistry {
1667 provider: ApiProvider,
1668 base_url: String,
1669 model: String,
1670 auto_model: bool,
1671 }
1672
1673 impl SettingsRegistry {
1674 fn new(view: &ConfigView) -> Self {
1675 Self {
1676 provider: view.api_provider,
1677 base_url: view.route_base_url.clone(),
1678 model: view.route_model.clone(),
1679 auto_model: view.auto_model,
1680 }
1681 }
1682
1683 fn reasoning_effort_choices(&self) -> Vec<String> {
1684 let mut values = vec!["default".to_string()];
1685 for effort in crate::tui::model_picker::picker_efforts_for_route(
1686 self.provider,
1687 &self.base_url,
1688 &self.model,
1689 self.auto_model,
1690 ) {
1691 let label = if self.provider == ApiProvider::OpenaiCodex {
1692 effort.display_label_for_provider(self.provider)
1693 } else {
1694 effort.as_setting()
1695 };
1696 if !values.iter().any(|value| value == label) {
1697 values.push(label.to_string());
1698 }
1699 }
1700 values
1701 }
1702
1703 fn meta(&self, row: &ConfigRow) -> SettingMeta {
1704 let choices = if row.key == "reasoning_effort" {
1705 Some(self.reasoning_effort_choices())
1706 } else {
1707 config_choice_values(&row.key)
1708 };
1709 let kind = if !row.editable {
1710 SettingKind::ReadOnly
1711 } else if row.facts.command.is_some() {
1712 SettingKind::Action
1713 } else if config_boolean_key(&row.key) {
1714 SettingKind::Boolean
1715 } else if choices.is_some() {
1716 SettingKind::Choice
1717 } else if config_integer_key(&row.key) {
1718 SettingKind::Integer
1719 } else {
1720 SettingKind::Text
1721 };
1722 SettingMeta {
1723 kind,
1724 category: row.section(),
1725 choices,
1726 }
1727 }
1728 }
1729
1730 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1731 enum ConfigSection {
1732 Provider,
1733 Model,
1734 Permissions,
1735 Network,
1736 Display,
1737 Composer,
1738 Sidebar,
1739 History,
1740 Mcp,
1741 Fleet,
1742 /// Workflow orchestration (`/workflow`). Kept out of Fleet: a Fleet is
1743 /// *who*, a Workflow is *what order* the work follows over it.
1744 Workflow,
1745 /// Session-scoped drivers such as `/goal`.
1746 Session,
1747 /// Explicitly legacy compatibility settings that are not a live choice —
1748 /// e.g. the DeepSeek-only `default_model` fallback (#4751).
1749 Legacy,
1750 Experimental,
1751 }
1752
1753 /// The seven Tideline settings categories in rail order
1754 /// (`docs/design/tideline-redesign.html`, "Settings categories").
1755 ///
1756 /// A category is a projection over the existing [`ConfigRow`] store: rows keep
1757 /// their fine-grained [`ConfigSection`]; the category follows the section
1758 /// unless the row's typed facts file it elsewhere (motion keys, telemetry,
1759 /// low-level receipts).
1760 ///
1761 /// This enum is the single taxonomy: the `ConfigView` rail/strip and the
1762 /// Tideline settings stage scaffold both iterate [`ConfigCategory::ALL`].
1763 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1764 pub(crate) enum ConfigCategory {
1765 Appearance,
1766 ModelsProviders,
1767 Work,
1768 ToolsMcp,
1769 Trust,
1770 Motion,
1771 Advanced,
1772 }
1773
1774 impl ConfigCategory {
1775 /// The schema tab id this category renders.
1776 fn id(self) -> &'static str {
1777 match self {
1778 ConfigCategory::Appearance => codewhale_config::settings_schema::TAB_APPEARANCE,
1779 ConfigCategory::ModelsProviders => codewhale_config::settings_schema::TAB_MODELS,
1780 ConfigCategory::Work => codewhale_config::settings_schema::TAB_WORK,
1781 ConfigCategory::ToolsMcp => codewhale_config::settings_schema::TAB_TOOLS,
1782 ConfigCategory::Trust => codewhale_config::settings_schema::TAB_TRUST,
1783 ConfigCategory::Motion => codewhale_config::settings_schema::TAB_MOTION,
1784 ConfigCategory::Advanced => codewhale_config::settings_schema::TAB_ADVANCED,
1785 }
1786 }
1787
1788 fn from_id(id: &str) -> Option<Self> {
1789 Self::ALL.into_iter().find(|category| category.id() == id)
1790 }
1791
1792 const ALL: [ConfigCategory; 7] = [
1793 ConfigCategory::Appearance,
1794 ConfigCategory::ModelsProviders,
1795 ConfigCategory::Work,
1796 ConfigCategory::ToolsMcp,
1797 ConfigCategory::Trust,
1798 ConfigCategory::Motion,
1799 ConfigCategory::Advanced,
1800 ];
1801
1802 fn label(self, locale: Locale) -> Cow<'static, str> {
1803 tr(
1804 locale,
1805 match self {
1806 ConfigCategory::Appearance => MessageId::ConfigCategoryAppearance,
1807 ConfigCategory::ModelsProviders => MessageId::ConfigCategoryModelsProviders,
1808 ConfigCategory::Work => MessageId::ConfigCategoryWork,
1809 ConfigCategory::ToolsMcp => MessageId::ConfigCategoryToolsMcp,
1810 ConfigCategory::Trust => MessageId::ConfigCategoryTrust,
1811 ConfigCategory::Motion => MessageId::ConfigCategoryMotion,
1812 ConfigCategory::Advanced => MessageId::ConfigCategoryAdvanced,
1813 },
1814 )
1815 }
1816
1817 /// The rail category the schema files this row under.
1818 fn for_row(row: &ConfigRow) -> Self {
1819 row.ui()
1820 .and_then(|ui| Self::from_id(ui.tab))
1821 .unwrap_or(ConfigCategory::Advanced)
1822 }
1823
1824 fn contains(self, row: &ConfigRow) -> bool {
1825 Self::for_row(row) == self
1826 }
1827
1828 fn position(self) -> usize {
1829 Self::ALL
1830 .iter()
1831 .position(|category| *category == self)
1832 .unwrap_or(0)
1833 }
1834
1835 fn next(self) -> Self {
1836 Self::ALL[(self.position() + 1) % Self::ALL.len()]
1837 }
1838
1839 fn prev(self) -> Self {
1840 Self::ALL[(self.position() + Self::ALL.len() - 1) % Self::ALL.len()]
1841 }
1842 }
1843
1844 impl ConfigSection {
1845 const ALL: [ConfigSection; 14] = [
1846 ConfigSection::Provider,
1847 ConfigSection::Model,
1848 ConfigSection::Permissions,
1849 ConfigSection::Network,
1850 ConfigSection::Display,
1851 ConfigSection::Composer,
1852 ConfigSection::Sidebar,
1853 ConfigSection::History,
1854 ConfigSection::Mcp,
1855 ConfigSection::Fleet,
1856 ConfigSection::Workflow,
1857 ConfigSection::Session,
1858 ConfigSection::Legacy,
1859 ConfigSection::Experimental,
1860 ];
1861
1862 /// The schema group id this section heads.
1863 fn id(self) -> &'static str {
1864 match self {
1865 ConfigSection::Provider => "provider",
1866 ConfigSection::Model => "model",
1867 ConfigSection::Permissions => "permissions",
1868 ConfigSection::Network => "network",
1869 ConfigSection::Display => "display",
1870 ConfigSection::Composer => "composer",
1871 ConfigSection::Sidebar => "workbar",
1872 ConfigSection::History => "history",
1873 ConfigSection::Mcp => "mcp",
1874 ConfigSection::Fleet => "fleet",
1875 ConfigSection::Workflow => "workflow",
1876 ConfigSection::Session => "session",
1877 ConfigSection::Legacy => "legacy",
1878 ConfigSection::Experimental => "experimental",
1879 }
1880 }
1881
1882 fn from_id(id: &str) -> Option<Self> {
1883 Self::ALL.into_iter().find(|section| section.id() == id)
1884 }
1885
1886 fn label(self, locale: Locale) -> Cow<'static, str> {
1887 tr(
1888 locale,
1889 match self {
1890 ConfigSection::Provider => MessageId::ConfigSectionProvider,
1891 ConfigSection::Model => MessageId::ConfigSectionModel,
1892 ConfigSection::Permissions => MessageId::ConfigSectionPermissions,
1893 ConfigSection::Network => MessageId::ConfigSectionNetwork,
1894 ConfigSection::Display => MessageId::ConfigSectionDisplay,
1895 ConfigSection::Composer => MessageId::ConfigSectionComposer,
1896 ConfigSection::Sidebar => MessageId::ConfigSectionSidebar,
1897 ConfigSection::History => MessageId::ConfigSectionHistory,
1898 ConfigSection::Mcp => MessageId::ConfigSectionMcp,
1899 ConfigSection::Fleet => MessageId::ConfigSectionFleet,
1900 ConfigSection::Workflow => MessageId::ConfigSectionWorkflow,
1901 ConfigSection::Session => MessageId::ConfigSectionSession,
1902 ConfigSection::Legacy => MessageId::ConfigSectionLegacy,
1903 ConfigSection::Experimental => MessageId::ConfigSectionExperimental,
1904 },
1905 )
1906 }
1907 }
1908
1909 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1910 enum ConfigListItem {
1911 Section(ConfigSection),
1912 Row(usize),
1913 }
1914
1915 /// Clickable editor controls; keyboard Enter/Esc do the same thing.
1916 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1917 enum EditorControl {
1918 Apply,
1919 Cancel,
1920 }
1921
1922 /// Clickable overflow markers of the category strip.
1923 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1924 pub(crate) enum NavStep {
1925 Previous,
1926 Next,
1927 }
1928
1929 #[derive(Debug, Clone)]
1930 struct ConfigEdit {
1931 key: String,
1932 original_value: String,
1933 buffer: Vec<char>,
1934 cursor: usize,
1935 select_all: bool,
1936 scope: ConfigScope,
1937 choices: Option<Vec<String>>,
1938 selected_choice: usize,
1939 }
1940
1941 pub struct ConfigView {
1942 rows: Vec<ConfigRow>,
1943 selected: usize,
1944 scroll: usize,
1945 editing: Option<ConfigEdit>,
1946 filter: String,
1947 status: Option<String>,
1948 locale: Locale,
1949 last_visible_rows: Cell<usize>,
1950 /// Selection-anchored scroll actually used by the last render; keeps the
1951 /// panel scroll rail truthful when the stored scroll predates a resize.
1952 last_render_scroll: Cell<usize>,
1953 /// Exact painted cells of each list row; a click selects a row only when
1954 /// it lands inside one of these rects.
1955 last_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
1956 /// Exact painted cells of each visible editor choice.
1957 last_choice_hitboxes: RefCell<Vec<(Rect, usize)>>,
1958 /// Exact painted cells of the editor's Apply / Cancel controls.
1959 last_editor_controls: RefCell<Vec<(Rect, EditorControl)>>,
1960 /// Exact painted cells of each category in the rail or strip.
1961 last_rail_hitboxes: RefCell<Vec<(Rect, ConfigCategory)>>,
1962 /// Exact painted cells of the strip's ‹ / › overflow markers.
1963 last_nav_controls: RefCell<Vec<(Rect, NavStep)>>,
1964 last_mouse_selected: Option<usize>,
1965 /// Pointer hover state, repainted from the shared hover style. Hover
1966 /// never moves the keyboard selection; it only tints what the pointer
1967 /// is over so every clickable element answers visibly.
1968 hovered_row: Option<usize>,
1969 hovered_rail: Option<ConfigCategory>,
1970 hovered_nav: Option<NavStep>,
1971 hovered_editor: Option<EditorControl>,
1972 hovered_choice: Option<usize>,
1973 api_provider: ApiProvider,
1974 route_base_url: String,
1975 route_model: String,
1976 auto_model: bool,
1977 /// Selected rail category of the Tideline settings shell.
1978 category: ConfigCategory,
1979 /// Read-only session projection for the provider/model facts; the detail
1980 /// pane shows its lanes verbatim instead of guessing a saved default.
1981 snapshot: UiSnapshot,
1982 }
1983
1984 const CONFIG_MIN_KEY_COLUMN_WIDTH: usize = 19;
1985 const CONFIG_VALUE_COLUMN_WIDTH: usize = 44;
1986 const CONFIG_MIN_VALUE_COLUMN_WIDTH: usize = 10;
1987 const CONFIG_SCOPE_COLUMN_WIDTH: usize = 7;
1988 const CONFIG_ROW_PREFIX_WIDTH: usize = 2;
1989 /// The two two-column gaps painted between key, value, and affordance.
1990 const CONFIG_COLUMN_GAPS_WIDTH: usize = 4;
1991 /// Affordance glyph column (`[x]`, `‹ ›`, `✎`, `›`, `⊘`) plus its gap.
1992 const CONFIG_AFFORDANCE_COLUMN_WIDTH: usize = 5;
1993 /// List width below which the scope badge sheds in favour of the value.
1994 const CONFIG_SCOPE_BADGE_MIN_WIDTH: usize = 60;
1995
1996 impl ConfigView {
1997 pub fn new_for_app(app: &App) -> Self {
1998 // A store that fails to load yields no saved facts. The defaults below
1999 // only shape the row list; every row backed by a failed store is
2000 // marked unavailable before the view is returned.
2001 let (settings, settings_error) = match Settings::load_persisted() {
2002 Ok(settings) => {
2003 let error = settings.load_error.clone();
2004 (settings, error)
2005 }
2006 Err(error) => (Settings::default(), Some(error.to_string())),
2007 };
2008 let (config, config_error) =
2009 match Config::load(app.config_path.clone(), app.config_profile.as_deref()) {
2010 Ok(config) => (config, None),
2011 Err(error) => (Config::default(), Some(error.to_string())),
2012 };
2013 let motion_override = crate::settings::detect_low_motion_override();
2014 let motion_facts = |facts: ConfigRowFacts| match motion_override {
2015 Some(source) => facts.overridden(source.is_environment(), source.label()),
2016 None => facts,
2017 };
2018 let permission_control = config.approval_policy_control(
2019 app.config_path.as_deref(),
2020 app.config_profile.as_deref(),
2021 &app.workspace,
2022 );
2023 let saved_permission_row = match permission_control {
2024 ApprovalPolicyControl::Unset => ConfigRow {
2025 key: "permission_posture".to_string(),
2026 value: settings
2027 .permission_posture
2028 .as_deref()
2029 .unwrap_or("ask")
2030 .to_string(),
2031 editable: true,
2032 scope: ConfigScope::Saved,
2033 facts: ConfigRowFacts::saved_setting(),
2034 },
2035 ApprovalPolicyControl::RootConfig => ConfigRow {
2036 key: "approval_policy".to_string(),
2037 value: config
2038 .approval_policy
2039 .as_deref()
2040 .unwrap_or("ask")
2041 .to_string(),
2042 editable: permission_control.editable_root(),
2043 scope: ConfigScope::Saved,
2044 facts: ConfigRowFacts::saved_setting()
2045 .authority(SettingAuthority::WorkspaceConfiguration),
2046 },
2047 source => ConfigRow {
2048 key: "managed_approval_policy".to_string(),
2049 value: format!(
2050 "{} · {}",
2051 app.approval_mode.permission_chip_label(),
2052 source.label()
2053 ),
2054 editable: false,
2055 scope: ConfigScope::Saved,
2056 facts: ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy),
2057 },
2058 };
2059 let approval_session_editable = matches!(permission_control, ApprovalPolicyControl::Unset);
2060 let shell_control = config.allow_shell_control(
2061 app.config_path.as_deref(),
2062 app.config_profile.as_deref(),
2063 &app.workspace,
2064 );
2065 let shell_row = if shell_control.editable_root() {
2066 ConfigRow {
2067 key: "allow_shell".to_string(),
2068 value: app.allow_shell.to_string(),
2069 editable: true,
2070 scope: ConfigScope::Saved,
2071 facts: ConfigRowFacts::saved_setting()
2072 .authority(SettingAuthority::WorkspaceConfiguration),
2073 }
2074 } else {
2075 ConfigRow {
2076 key: "managed_allow_shell".to_string(),
2077 value: format!("{} · {}", app.allow_shell, shell_control.label()),
2078 editable: false,
2079 scope: ConfigScope::Saved,
2080 facts: ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy),
2081 }
2082 };
2083 let (active_route_provider, _) = app.effective_route_display();
2084 let (active_provider_identity, active_route_model) = app.effective_route_identity_display();
2085 let mut rows = vec![
2086 ConfigRow {
2087 key: "provider".to_string(),
2088 value: active_provider_identity.clone(),
2089 editable: true,
2090 scope: ConfigScope::Session,
2091 facts: ConfigRowFacts::session_setting()
2092 .snapshot(SnapshotLane::Provider)
2093 .opens("/provider", MessageId::ConfigActionOpenProvider),
2094 },
2095 ConfigRow {
2096 key: config_base_url_row_key(active_route_provider).to_string(),
2097 value: config_base_url_row_value(app),
2098 // An endpoint is a route receipt, not a loose global knob.
2099 // `/provider` owns changing the credential, model, and endpoint
2100 // together; this row must not pretend that editing a live
2101 // receipt can mutate an already-running client.
2102 editable: false,
2103 scope: ConfigScope::Session,
2104 facts: ConfigRowFacts::diagnostic(SettingAuthority::Session),
2105 },
2106 ConfigRow {
2107 key: "context_window".to_string(),
2108 value: config
2109 .context_window_for_provider_config(active_route_provider)
2110 .map_or_else(|| "(not set)".to_string(), |tokens| tokens.to_string()),
2111 editable: false,
2112 scope: ConfigScope::Saved,
2113 facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration),
2114 },
2115 ConfigRow {
2116 key: "effective_context_window".to_string(),
2117 value: format!(
2118 "{} tokens · {}",
2119 crate::route_budget::route_context_window_tokens(
2120 app.api_provider,
2121 app.effective_model_for_budget(),
2122 app.active_route_limits,
2123 ),
2124 app.active_context_window_source.display_label()
2125 ),
2126 editable: false,
2127 scope: ConfigScope::Session,
2128 facts: ConfigRowFacts::diagnostic(SettingAuthority::Session),
2129 },
2130 ConfigRow {
2131 key: "model".to_string(),
2132 // `·` keeps the row unambiguous when a provider display name
2133 // itself contains `/` (e.g. `Zhipu AI / Z.ai`).
2134 value: format!("{active_provider_identity} · {active_route_model}"),
2135 editable: true,
2136 scope: ConfigScope::Session,
2137 facts: ConfigRowFacts::session_setting()
2138 .snapshot(SnapshotLane::Model)
2139 .opens("/model", MessageId::ConfigActionOpenModel),
2140 },
2141 // DeepSeek-only legacy fallback: hide on non-DeepSeek providers so
2142 // it is not misread as an active setting (#4717). Keep the field
2143 // and routing behavior; surface the row only for DeepSeek routes
2144 // (or when an explicit value is set and the operator needs to see it).
2145 // Built below after provider check so non-DeepSeek menus stay clean.
2146 ConfigRow {
2147 key: "reasoning_effort".to_string(),
2148 value: settings.reasoning_effort.as_deref().map_or_else(
2149 || tr(app.ui_locale, MessageId::ConfigDefaultReasoning).to_string(),
2150 |value| {
2151 crate::reasoning_preference::ReasoningEffort::from_setting_for_provider(
2152 value,
2153 app.api_provider,
2154 )
2155 .as_setting_for_provider(app.api_provider)
2156 .to_string()
2157 },
2158 ),
2159 editable: true,
2160 scope: ConfigScope::Saved,
2161 facts: ConfigRowFacts::saved_setting(),
2162 },
2163 ConfigRow {
2164 key: "approval_mode".to_string(),
2165 value: app.approval_mode.permission_chip_label().to_string(),
2166 editable: approval_session_editable,
2167 scope: ConfigScope::Session,
2168 facts: ConfigRowFacts::session_setting(),
2169 },
2170 saved_permission_row,
2171 ConfigRow {
2172 key: "default_mode".to_string(),
2173 value: settings.default_mode.clone(),
2174 editable: true,
2175 scope: ConfigScope::Saved,
2176 // The startup mode is read once when a session begins.
2177 facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession),
2178 },
2179 shell_row,
2180 ConfigRow {
2181 key: "telemetry".to_string(),
2182 value: crate::telemetry_notice::saved_preference_enabled(&config).to_string(),
2183 editable: true,
2184 scope: ConfigScope::Saved,
2185 // Telemetry is a trust decision, not a network knob.
2186 facts: ConfigRowFacts::saved_setting()
2187 .authority(SettingAuthority::WorkspaceConfiguration),
2188 },
2189 ConfigRow {
2190 key: "stream_chunk_timeout_secs".to_string(),
2191 value: app.stream_chunk_timeout_secs.to_string(),
2192 editable: true,
2193 scope: ConfigScope::Session,
2194 facts: ConfigRowFacts::session_setting(),
2195 },
2196 ConfigRow {
2197 key: "theme".to_string(),
2198 value: settings.theme.clone(),
2199 editable: true,
2200 scope: ConfigScope::Saved,
2201 // The live theme is the one the app is painting with.
2202 facts: ConfigRowFacts::saved_setting().effective(app.theme_id.name()),
2203 },
2204 ConfigRow {
2205 key: "locale".to_string(),
2206 value: settings.locale.clone(),
2207 editable: true,
2208 scope: ConfigScope::Saved,
2209 facts: ConfigRowFacts::saved_setting().effective(app.ui_locale.tag()),
2210 },
2211 ConfigRow {
2212 key: "background_color".to_string(),
2213 value: settings.background_color.clone().unwrap_or_else(|| {
2214 tr(app.ui_locale, MessageId::ConfigDefaultValue).to_string()
2215 }),
2216 editable: true,
2217 scope: ConfigScope::Saved,
2218 facts: ConfigRowFacts::saved_setting(),
2219 },
2220 ConfigRow {
2221 key: "focus_texture".to_string(),
2222 value: settings.focus_texture.clone(),
2223 editable: true,
2224 scope: ConfigScope::Saved,
2225 facts: ConfigRowFacts::saved_setting(),
2226 },
2227 ConfigRow {
2228 key: "calm_mode".to_string(),
2229 value: settings.calm_mode.to_string(),
2230 editable: true,
2231 scope: ConfigScope::Saved,
2232 facts: ConfigRowFacts::saved_setting(),
2233 },
2234 ConfigRow {
2235 key: "low_motion".to_string(),
2236 value: settings.low_motion.to_string(),
2237 editable: true,
2238 scope: ConfigScope::Saved,
2239 // `low_motion` always wins over fancy animations; both are
2240 // Motion, and their live values come from `App`, not disk.
2241 facts: motion_facts(
2242 ConfigRowFacts::saved_setting().effective(app.low_motion.to_string()),
2243 ),
2244 },
2245 ConfigRow {
2246 key: "fancy_animations".to_string(),
2247 value: settings.fancy_animations.to_string(),
2248 editable: true,
2249 scope: ConfigScope::Saved,
2250 facts: motion_facts(
2251 ConfigRowFacts::saved_setting().effective(app.fancy_animations.to_string()),
2252 ),
2253 },
2254 // `launch_screen` is a retired setting: accepted on load, dropped
2255 // on save — no config row (main's retirement wins over the
2256 // branch's stale row).
2257 ConfigRow {
2258 key: "show_thinking".to_string(),
2259 value: settings.show_thinking.to_string(),
2260 editable: true,
2261 scope: ConfigScope::Saved,
2262 facts: ConfigRowFacts::saved_setting(),
2263 },
2264 ConfigRow {
2265 key: "thinking_default_expanded".to_string(),
2266 value: settings.thinking_default_expanded.to_string(),
2267 editable: true,
2268 scope: ConfigScope::Saved,
2269 facts: ConfigRowFacts::saved_setting(),
2270 },
2271 ConfigRow {
2272 key: "thinking_preview_lines".to_string(),
2273 value: settings.thinking_preview_lines.to_string(),
2274 editable: true,
2275 scope: ConfigScope::Saved,
2276 facts: ConfigRowFacts::saved_setting(),
2277 },
2278 ConfigRow {
2279 key: "thinking_highlight".to_string(),
2280 value: settings.thinking_highlight.to_string(),
2281 editable: true,
2282 scope: ConfigScope::Saved,
2283 facts: ConfigRowFacts::saved_setting(),
2284 },
2285 ConfigRow {
2286 key: "help_expand_groups".to_string(),
2287 value: settings.help_expand_groups.to_string(),
2288 editable: true,
2289 scope: ConfigScope::Saved,
2290 facts: ConfigRowFacts::saved_setting(),
2291 },
2292 ConfigRow {
2293 key: "contextual_tips".to_string(),
2294 value: settings.contextual_tips.to_string(),
2295 editable: true,
2296 scope: ConfigScope::Saved,
2297 facts: ConfigRowFacts::saved_setting()
2298 .effective(app.behavioral_tips.enabled().to_string()),
2299 },
2300 ConfigRow {
2301 key: "pin_last_prompt".to_string(),
2302 value: settings.pin_last_prompt.to_string(),
2303 editable: true,
2304 scope: ConfigScope::Saved,
2305 facts: ConfigRowFacts::saved_setting(),
2306 },
2307 ConfigRow {
2308 key: "show_tool_details".to_string(),
2309 value: settings.show_tool_details.to_string(),
2310 editable: true,
2311 scope: ConfigScope::Saved,
2312 facts: ConfigRowFacts::saved_setting(),
2313 },
2314 ConfigRow {
2315 key: "inline_diffs".to_string(),
2316 value: settings.inline_diffs.clone(),
2317 editable: true,
2318 scope: ConfigScope::Saved,
2319 facts: ConfigRowFacts::saved_setting(),
2320 },
2321 ConfigRow {
2322 key: "status_indicator".to_string(),
2323 value: settings.status_indicator.clone(),
2324 editable: true,
2325 scope: ConfigScope::Saved,
2326 facts: ConfigRowFacts::saved_setting(),
2327 },
2328 ConfigRow {
2329 key: "synchronized_output".to_string(),
2330 value: settings.synchronized_output.clone(),
2331 editable: true,
2332 scope: ConfigScope::Saved,
2333 facts: ConfigRowFacts::saved_setting(),
2334 },
2335 ConfigRow {
2336 key: "cost_currency".to_string(),
2337 value: settings.cost_currency.clone(),
2338 editable: true,
2339 scope: ConfigScope::Saved,
2340 facts: ConfigRowFacts::saved_setting().effective(cost_currency_config_value(app)),
2341 },
2342 ConfigRow {
2343 key: "transcript_spacing".to_string(),
2344 value: settings.transcript_spacing.clone(),
2345 editable: true,
2346 scope: ConfigScope::Saved,
2347 facts: ConfigRowFacts::saved_setting(),
2348 },
2349 ConfigRow {
2350 key: "tool_collapse".to_string(),
2351 value: settings.tool_collapse_mode.clone(),
2352 editable: true,
2353 scope: ConfigScope::Saved,
2354 facts: ConfigRowFacts::saved_setting(),
2355 },
2356 ConfigRow {
2357 key: "composer_density".to_string(),
2358 value: settings.composer_density.clone(),
2359 editable: true,
2360 scope: ConfigScope::Saved,
2361 facts: ConfigRowFacts::saved_setting(),
2362 },
2363 ConfigRow {
2364 key: "composer_border".to_string(),
2365 value: settings.composer_border.to_string(),
2366 editable: true,
2367 scope: ConfigScope::Saved,
2368 facts: ConfigRowFacts::saved_setting(),
2369 },
2370 ConfigRow {
2371 key: "composer_multiline_mode".to_string(),
2372 value: settings.composer_multiline_mode.to_string(),
2373 editable: true,
2374 scope: ConfigScope::Saved,
2375 facts: ConfigRowFacts::saved_setting(),
2376 },
2377 ConfigRow {
2378 key: "composer_vim_mode".to_string(),
2379 value: settings.composer_vim_mode.clone(),
2380 editable: true,
2381 scope: ConfigScope::Saved,
2382 facts: ConfigRowFacts::saved_setting(),
2383 },
2384 ConfigRow {
2385 key: "bracketed_paste".to_string(),
2386 value: settings.bracketed_paste.to_string(),
2387 editable: true,
2388 scope: ConfigScope::Saved,
2389 facts: ConfigRowFacts::saved_setting(),
2390 },
2391 ConfigRow {
2392 key: "paste_burst_detection".to_string(),
2393 value: settings.paste_burst_detection.to_string(),
2394 editable: true,
2395 scope: ConfigScope::Saved,
2396 facts: ConfigRowFacts::saved_setting(),
2397 },
2398 ConfigRow {
2399 key: "mention_menu_limit".to_string(),
2400 value: settings.mention_menu_limit.to_string(),
2401 editable: true,
2402 scope: ConfigScope::Saved,
2403 facts: ConfigRowFacts::saved_setting(),
2404 },
2405 ConfigRow {
2406 key: "mention_menu_behavior".to_string(),
2407 value: settings.mention_menu_behavior.clone(),
2408 editable: true,
2409 scope: ConfigScope::Saved,
2410 facts: ConfigRowFacts::saved_setting(),
2411 },
2412 ConfigRow {
2413 key: "mention_walk_depth".to_string(),
2414 value: settings.mention_walk_depth.to_string(),
2415 editable: true,
2416 scope: ConfigScope::Saved,
2417 facts: ConfigRowFacts::saved_setting(),
2418 },
2419 ConfigRow {
2420 key: "workspace_follow_symlinks".to_string(),
2421 value: settings.workspace_follow_symlinks.to_string(),
2422 editable: true,
2423 scope: ConfigScope::Saved,
2424 // Mention menus follow it now; engine tools read it at startup.
2425 facts: ConfigRowFacts::saved_setting()
2426 .apply(SettingApplySemantics::UiNowEngineRestart),
2427 },
2428 ConfigRow {
2429 key: "work_surface_placement".to_string(),
2430 value: settings.work_surface_placement.clone(),
2431 editable: true,
2432 scope: ConfigScope::Saved,
2433 facts: ConfigRowFacts::saved_setting(),
2434 },
2435 ConfigRow {
2436 key: "work_surface_top_height".to_string(),
2437 value: settings.work_surface_top_height.to_string(),
2438 editable: true,
2439 scope: ConfigScope::Saved,
2440 facts: ConfigRowFacts::saved_setting(),
2441 },
2442 ConfigRow {
2443 key: "work_surface_side_width".to_string(),
2444 value: settings.work_surface_side_width.to_string(),
2445 editable: true,
2446 scope: ConfigScope::Saved,
2447 facts: ConfigRowFacts::saved_setting(),
2448 },
2449 ConfigRow {
2450 key: "rail_panel".to_string(),
2451 value: settings.rail_panel.clone(),
2452 editable: true,
2453 scope: ConfigScope::Saved,
2454 facts: ConfigRowFacts::saved_setting(),
2455 },
2456 ConfigRow {
2457 key: "context_panel".to_string(),
2458 value: settings.context_panel.to_string(),
2459 editable: true,
2460 scope: ConfigScope::Saved,
2461 facts: ConfigRowFacts::saved_setting(),
2462 },
2463 ConfigRow {
2464 key: "sessions_rail".to_string(),
2465 value: settings.sessions_rail.to_string(),
2466 editable: true,
2467 scope: ConfigScope::Saved,
2468 facts: ConfigRowFacts::saved_setting(),
2469 },
2470 // Read at startup by `main`, not held on `App`, so the row reflects
2471 // the persisted value rather than a live field (#2934).
2472 ConfigRow {
2473 key: "session_auto_resume".to_string(),
2474 value: settings.session_auto_resume.to_string(),
2475 editable: true,
2476 scope: ConfigScope::Saved,
2477 facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession),
2478 },
2479 ConfigRow {
2480 key: "auto_compact".to_string(),
2481 value: settings.auto_compact.to_string(),
2482 editable: true,
2483 scope: ConfigScope::Saved,
2484 facts: ConfigRowFacts::saved_setting(),
2485 },
2486 ConfigRow {
2487 key: "auto_compact_threshold_percent".to_string(),
2488 value: format!("{:.0}", settings.auto_compact_threshold_percent),
2489 editable: true,
2490 scope: ConfigScope::Saved,
2491 facts: ConfigRowFacts::saved_setting(),
2492 },
2493 ConfigRow {
2494 key: "max_history".to_string(),
2495 value: settings.max_input_history.to_string(),
2496 editable: true,
2497 scope: ConfigScope::Saved,
2498 facts: ConfigRowFacts::saved_setting(),
2499 },
2500 ConfigRow {
2501 key: "mcp_open".to_string(),
2502 value: "/mcp".to_string(),
2503 editable: true,
2504 scope: ConfigScope::Session,
2505 facts: ConfigRowFacts::action("/mcp", MessageId::ConfigActionOpenMcp),
2506 },
2507 ConfigRow {
2508 key: "mcp_reconnect".to_string(),
2509 value: "/mcp reload".to_string(),
2510 editable: true,
2511 scope: ConfigScope::Session,
2512 facts: ConfigRowFacts::action("/mcp reload", MessageId::ConfigActionMcpReconnect),
2513 },
2514 ConfigRow {
2515 key: "mcp_diagnose".to_string(),
2516 value: "/mcp validate".to_string(),
2517 editable: true,
2518 scope: ConfigScope::Session,
2519 facts: ConfigRowFacts::action("/mcp validate", MessageId::ConfigActionMcpDiagnose),
2520 },
2521 ConfigRow {
2522 key: "plugins_open".to_string(),
2523 value: "/plugin".to_string(),
2524 editable: true,
2525 scope: ConfigScope::Session,
2526 facts: ConfigRowFacts::action("/plugin", MessageId::ConfigActionOpenPlugins),
2527 },
2528 ConfigRow {
2529 key: "mcp_config_path".to_string(),
2530 value: app.mcp_config_path.display().to_string(),
2531 editable: true,
2532 scope: ConfigScope::Saved,
2533 // The live path changes on save; running servers keep their
2534 // old config until `/mcp reload`.
2535 facts: ConfigRowFacts::saved_setting()
2536 .authority(SettingAuthority::WorkspaceConfiguration)
2537 .apply(SettingApplySemantics::ReloadRequired),
2538 },
2539 ConfigRow {
2540 key: "fleet.exec.max_spawn_depth".to_string(),
2541 value: config
2542 .fleet
2543 .as_ref()
2544 .map(|fleet| fleet.exec.max_spawn_depth)
2545 .unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth)
2546 .to_string(),
2547 editable: false,
2548 scope: ConfigScope::Saved,
2549 facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration),
2550 },
2551 ];
2552 // The DeepSeek-only legacy fallback stays a persisted runtime key but
2553 // has no settings row: it is not a live choice on any provider, and
2554 // a leftover value is cleared with `/set default_model` instead of
2555 // a Legacy table section.
2556 let external_status_rows = [ApiProvider::OpenaiCodex, ApiProvider::Xai]
2557 .into_iter()
2558 .filter_map(|provider| {
2559 config
2560 .external_credential_consent_status(provider)
2561 .map(|status| {
2562 let state = if status.route_state == "active" {
2563 tr(app.ui_locale, MessageId::CtxInspActive)
2564 } else {
2565 tr(app.ui_locale, MessageId::ProviderExternalDormant)
2566 };
2567 let scope = tr(app.ui_locale, MessageId::ProviderExternalDetailScope)
2568 .replace("{access}", status.access.as_str())
2569 .replace("{provider}", &status.provider)
2570 .replace("{source}", status.source.as_str())
2571 .replace("{version}", &status.consent_version.to_string())
2572 .replace("{state}", &state);
2573 let owner_path = tr(app.ui_locale, MessageId::ProviderExternalOwnerPath)
2574 .replace("{owner}", status.owner)
2575 .replace("{path}", &codewhale_config::quote_os_path(&status.path));
2576 let pinned_warning = status.ambient_path_changed.then(|| {
2577 tr(app.ui_locale, MessageId::ProviderExternalPinnedPathWarning)
2578 .replace("{owner}", status.owner)
2579 .replace("{path}", &codewhale_config::quote_os_path(&status.path))
2580 });
2581 let semantics = match status.access {
2582 codewhale_config::ExternalCredentialAccess::Disabled => {
2583 tr(app.ui_locale, MessageId::ProviderExternalDisabledDetail)
2584 }
2585 codewhale_config::ExternalCredentialAccess::ReadOnly => {
2586 tr(app.ui_locale, MessageId::ProviderExternalReadOnlySemantics)
2587 }
2588 codewhale_config::ExternalCredentialAccess::Managed => {
2589 tr(app.ui_locale, MessageId::ProviderExternalManagedDetail)
2590 }
2591 };
2592 let semantics_revoke =
2593 tr(app.ui_locale, MessageId::ProviderExternalSemanticsRevoke)
2594 .replace("{semantics}", &semantics)
2595 .replace("{revoke}", &status.revoke_command);
2596 ConfigRow {
2597 key: format!("external_credentials.{}", provider.as_str()),
2598 value: match pinned_warning {
2599 Some(warning) => format!(
2600 "{scope} · {owner_path} · {warning} · {semantics_revoke}"
2601 ),
2602 None => format!("{scope} · {owner_path} · {semantics_revoke}"),
2603 },
2604 editable: false,
2605 scope: ConfigScope::Saved,
2606 facts: ConfigRowFacts::diagnostic(
2607 SettingAuthority::WorkspaceConfiguration,
2608 ),
2609 }
2610 })
2611 });
2612 rows.splice(2..2, external_status_rows);
2613 // An explanation route, never an editable sandbox policy. The existing
2614 // status report owns the observed platform/backend enforcement facts.
2615 rows.push(ConfigRow {
2616 key: "sandbox_details".into(),
2617 value: "/status".into(),
2618 editable: true,
2619 scope: ConfigScope::Session,
2620 facts: ConfigRowFacts::action("/status", MessageId::AutomationActionInspect),
2621 });
2622 rows.extend(experimental_config_rows(&config));
2623 rows.extend(
2624 codewhale_config::notifications::NotificationSetting::ALL
2625 .into_iter()
2626 .map(|setting| ConfigRow {
2627 key: format!("notifications.{}", setting.key()),
2628 value: config.notifications_config().display(setting),
2629 editable: true,
2630 scope: ConfigScope::Saved,
2631 facts: ConfigRowFacts::saved_setting()
2632 .authority(SettingAuthority::WorkspaceConfiguration)
2633 .effective(app.notification_settings.display(setting)),
2634 }),
2635 );
2636
2637 // The schema decides what is shown and in what order. A row whose key
2638 // carries no `ui` block is declared but not browsable (it stays
2639 // settable through `/set`); a row the schema does not declare at all
2640 // cannot be placed, labelled, or edited, so it is not painted either.
2641 rows.retain(|row| row.ui().is_some());
2642 rows.sort_by_key(|row| codewhale_config::setting_index(&row.key).unwrap_or(usize::MAX));
2643
2644 // A row whose store failed to load carries the error instead of a
2645 // default: its value reads unavailable, it is not editable (writing
2646 // through an unreadable store could clobber it), and its saved and
2647 // startup lanes are reported as unavailable. Keyed on the row's
2648 // *store*, not its authority: an environment/terminal override wins
2649 // the effective decision without making the store any less broken
2650 // (caught by Windows CI, where the legacy-console probe relabels the
2651 // motion rows' authority and a broken store then went unreported).
2652 let unavailable = tr(app.ui_locale, MessageId::ConfigUnavailable).into_owned();
2653 for row in &mut rows {
2654 let error = match row.facts.store {
2655 SettingStore::UserSettings => settings_error.as_deref(),
2656 SettingStore::WorkspaceConfig => config_error.as_deref(),
2657 SettingStore::None => None,
2658 };
2659 if let Some(error) = error
2660 && row.facts.kind == ConfigRowKind::Setting
2661 {
2662 row.facts = row.facts.clone().unavailable(error);
2663 row.editable = false;
2664 row.value = unavailable.clone();
2665 }
2666 }
2667
2668 let mut view = Self {
2669 rows,
2670 selected: 0,
2671 scroll: 0,
2672 editing: None,
2673 filter: String::new(),
2674 status: None,
2675 locale: app.ui_locale,
2676 last_visible_rows: Cell::new(0),
2677 last_render_scroll: Cell::new(0),
2678 last_row_hitboxes: RefCell::new(Vec::new()),
2679 last_choice_hitboxes: RefCell::new(Vec::new()),
2680 last_editor_controls: RefCell::new(Vec::new()),
2681 last_rail_hitboxes: RefCell::new(Vec::new()),
2682 last_nav_controls: RefCell::new(Vec::new()),
2683 last_mouse_selected: None,
2684 hovered_row: None,
2685 hovered_rail: None,
2686 hovered_nav: None,
2687 hovered_editor: None,
2688 hovered_choice: None,
2689 api_provider: app.api_provider,
2690 route_base_url: app.active_route_base_url.clone(),
2691 route_model: app.model.clone(),
2692 auto_model: app.auto_model,
2693 // Settings opens on Appearance (design: first rail category).
2694 category: ConfigCategory::Appearance,
2695 snapshot: UiSnapshot::from_app(app),
2696 };
2697 view.select_first_visible_row();
2698 view
2699 }
2700
2701 fn tr(&self, id: MessageId) -> Cow<'static, str> {
2702 tr(self.locale, id)
2703 }
2704
2705 /// Keep the user's place when the host rebuilds this view after applying
2706 /// a setting to the live app.
2707 pub(crate) fn focus_key(&mut self, key: &str) {
2708 if let Some(index) = self.rows.iter().position(|row| row.key == key) {
2709 self.category = ConfigCategory::for_row(&self.rows[index]);
2710 self.selected = index;
2711 self.last_mouse_selected = None;
2712 self.clear_hover();
2713 self.adjust_scroll(self.visible_rows_cached());
2714 }
2715 }
2716
2717 /// Snapshot the active search so live config updates can rebuild the
2718 /// modal without making the user's filtered result set jump away.
2719 pub(crate) fn filter_query(&self) -> &str {
2720 &self.filter
2721 }
2722
2723 /// The key whose inline editor is open, if any. Exposes the transient
2724 /// editing state to the host-level tests in `tui::ui::tests` that drive
2725 /// the real `refresh_config_view_if_open` path (#theme-nav-exit).
2726 ///
2727 /// Test-only: production code reads `editing` directly, and a non-test lib
2728 /// build would flag this as dead code under `-D warnings`.
2729 #[cfg(test)]
2730 pub(crate) fn editing_key(&self) -> Option<&str> {
2731 self.editing.as_ref().map(|edit| edit.key.as_str())
2732 }
2733
2734 /// The highlighted choice index inside the open editor, if any.
2735 ///
2736 /// Test-only; see [`Self::editing_key`].
2737 #[cfg(test)]
2738 pub(crate) fn editing_selected_choice(&self) -> Option<usize> {
2739 self.editing.as_ref().map(|edit| edit.selected_choice)
2740 }
2741
2742 /// Rebuild after the host applied a setting (`refresh_config_view_if_open`)
2743 /// while keeping the open editor alive.
2744 ///
2745 /// The theme editor live-previews on every highlight, and each preview is a
2746 /// `ConfigUpdated` that lands here again. A bare `new_for_app` dropped
2747 /// `editing`, so the first arrow key closed the editor, the next one fell
2748 /// through to the non-editing key map (where Left/Right switch category),
2749 /// and `selected_choice` snapped back to 0. The user saw the highlight leap
2750 /// away from the row they were on — the theme never moved (#theme-nav-exit).
2751 ///
2752 /// The rows themselves must come from the refreshed snapshot: a persisted
2753 /// commit changes the value on disk and the row has to show it. Only the
2754 /// transient editing state is carried over.
2755 pub(crate) fn rebuild_preserving(app: &App, previous: &Self, focus_key: &str) -> Self {
2756 let mut view = Self::new_for_app(app);
2757 view.restore_filter(previous.filter_query().to_string());
2758 view.focus_key(focus_key);
2759 let carried = match &previous.editing {
2760 // A row that vanished from the refreshed snapshot (filter, scope or
2761 // availability changed underneath) has no editor to belong to.
2762 Some(edit) => view
2763 .rows
2764 .iter()
2765 .position(|row| row.key == edit.key)
2766 .map(|index| (index, edit.clone())),
2767 None => None,
2768 };
2769 match carried {
2770 Some((index, mut edit)) => {
2771 // The highlight is the user's cursor, not a disk fact: keep it
2772 // inside the refreshed choice list instead of resetting it.
2773 let choices_len = edit.choices.as_ref().map_or(0, Vec::len);
2774 if edit.selected_choice >= choices_len {
2775 edit.selected_choice = choices_len.saturating_sub(1);
2776 }
2777 view.selected = index;
2778 view.editing = Some(edit);
2779 }
2780 None => view.editing = None,
2781 }
2782 view
2783 }
2784
2785 pub(crate) fn restore_filter(&mut self, filter: String) {
2786 self.update_filter(|current| *current = filter);
2787 }
2788
2789 fn visible_rows_cached(&self) -> usize {
2790 let cached = self.last_visible_rows.get();
2791 if cached == 0 { 8 } else { cached }
2792 }
2793
2794 /// The lowercased search terms for the current filter, computed once per
2795 /// interaction instead of once per row per pass (#6213 T6).
2796 fn filter_terms(&self) -> Vec<String> {
2797 self.filter
2798 .trim()
2799 .to_lowercase()
2800 .split_whitespace()
2801 .map(str::to_string)
2802 .collect()
2803 }
2804
2805 fn row_matches_filter(&self, row: &ConfigRow, terms: &[String]) -> bool {
2806 if terms.is_empty() {
2807 return true;
2808 }
2809
2810 let meta = SettingsRegistry::new(self).meta(row);
2811 let section = meta.category.label(self.locale).to_lowercase();
2812 let section_en = meta.category.label(Locale::En).to_lowercase();
2813 let category = ConfigCategory::for_row(row);
2814 let category_label = category.label(self.locale).to_lowercase();
2815 let category_en = category.label(Locale::En).to_lowercase();
2816 let label = config_label_for_key_for_locale(self.locale, &row.key).to_lowercase();
2817 let key = row.key.to_lowercase();
2818 let raw_value = row.value.to_lowercase();
2819 let value = self.row_display_value(row).to_lowercase();
2820 let scope = row.scope.label(self.locale).to_lowercase();
2821 let scope_en = row.scope.label(Locale::En).to_lowercase();
2822 let hint = config_hint_for_key(self.locale, &row.key).to_lowercase();
2823
2824 let explanation_terms = if row.key == "sandbox_details" {
2825 "sandbox filesystem unenforced isolation bubblewrap bwrap doctor"
2826 } else {
2827 ""
2828 };
2829 terms.iter().all(|term| {
2830 explanation_terms.contains(term)
2831 || section.contains(term)
2832 || section_en.contains(term)
2833 || category_label.contains(term)
2834 || category_en.contains(term)
2835 || label.contains(term)
2836 || key.contains(term)
2837 || raw_value.contains(term)
2838 || value.contains(term)
2839 || scope.contains(term)
2840 || scope_en.contains(term)
2841 || hint.contains(term)
2842 })
2843 }
2844
2845 fn matching_row_indices(&self) -> Vec<usize> {
2846 let filtering = !self.filter.is_empty();
2847 let terms = self.filter_terms();
2848 self.rows
2849 .iter()
2850 .enumerate()
2851 .filter_map(|(idx, row)| {
2852 (self.row_matches_filter(row, &terms) && (filtering || self.category.contains(row)))
2853 .then_some(idx)
2854 })
2855 .collect()
2856 }
2857
2858 fn visible_items(&self) -> Vec<ConfigListItem> {
2859 let mut items = Vec::new();
2860 let mut current_section = None;
2861 let filtering = !self.filter.is_empty();
2862
2863 let terms = self.filter_terms();
2864 for (idx, row) in self.rows.iter().enumerate() {
2865 if !self.row_matches_filter(row, &terms) {
2866 continue;
2867 }
2868 // The rail category filters rows unless the user is searching.
2869 if !filtering && !self.category.contains(row) {
2870 continue;
2871 }
2872
2873 if current_section != Some(row.section()) {
2874 current_section = Some(row.section());
2875 items.push(ConfigListItem::Section(row.section()));
2876 }
2877 items.push(ConfigListItem::Row(idx));
2878 }
2879
2880 items
2881 }
2882
2883 fn select_first_visible_row(&mut self) {
2884 if let Some(idx) = self
2885 .visible_items()
2886 .into_iter()
2887 .find_map(|item| match item {
2888 ConfigListItem::Row(i) => Some(i),
2889 ConfigListItem::Section(_) => None,
2890 })
2891 {
2892 self.selected = idx;
2893 self.scroll = 0;
2894 }
2895 self.last_mouse_selected = None;
2896 self.clear_hover();
2897 }
2898
2899 fn key_column_width(&self) -> usize {
2900 self.rows
2901 .iter()
2902 .map(|row| {
2903 let label = config_label_for_key_for_locale(self.locale, &row.key);
2904 UnicodeWidthStr::width(label.as_str())
2905 })
2906 .max()
2907 .unwrap_or(CONFIG_MIN_KEY_COLUMN_WIDTH)
2908 .max(CONFIG_MIN_KEY_COLUMN_WIDTH)
2909 }
2910
2911 fn table_column_widths(&self, content_width: usize) -> (usize, usize, usize) {
2912 // The affordance glyph is the interaction; the scope badge is
2913 // secondary and sheds first so narrow lists keep a readable value.
2914 let scope_width = if content_width >= CONFIG_SCOPE_BADGE_MIN_WIDTH {
2915 CONFIG_SCOPE_COLUMN_WIDTH
2916 } else {
2917 0
2918 };
2919 let fixed_width = CONFIG_ROW_PREFIX_WIDTH
2920 + CONFIG_COLUMN_GAPS_WIDTH
2921 + CONFIG_AFFORDANCE_COLUMN_WIDTH
2922 + scope_width;
2923 let key_value_width = content_width.saturating_sub(fixed_width);
2924 let desired_key_width = self.key_column_width();
2925
2926 if key_value_width == 0 {
2927 return (0, 0, scope_width);
2928 }
2929
2930 let minimum_key_width = CONFIG_MIN_KEY_COLUMN_WIDTH.min(key_value_width);
2931 let key_width = desired_key_width
2932 .min(key_value_width.saturating_sub(CONFIG_MIN_VALUE_COLUMN_WIDTH))
2933 .max(minimum_key_width);
2934 let value_width = key_value_width
2935 .saturating_sub(key_width)
2936 .min(CONFIG_VALUE_COLUMN_WIDTH);
2937
2938 (key_width, value_width, scope_width)
2939 }
2940
2941 fn selected_row_index(&self) -> Option<usize> {
2942 let selected = self.selected;
2943 self.matching_row_indices()
2944 .into_iter()
2945 .any(|idx| idx == selected)
2946 .then_some(selected)
2947 }
2948
2949 fn selected_display_position(&self, items: &[ConfigListItem]) -> Option<usize> {
2950 items
2951 .iter()
2952 .position(|item| matches!(item, ConfigListItem::Row(idx) if *idx == self.selected))
2953 }
2954
2955 fn sync_selection_to_filter(&mut self) {
2956 let matches = self.matching_row_indices();
2957 if matches.is_empty() {
2958 self.selected = 0;
2959 self.scroll = 0;
2960 return;
2961 }
2962
2963 if !matches.contains(&self.selected) {
2964 self.selected = matches[0];
2965 }
2966 }
2967
2968 /// Clear every hover tint: selection moves, filters, and scrolls can all
2969 /// shift painted rows out from under a stationary pointer.
2970 fn clear_hover(&mut self) {
2971 self.hovered_row = None;
2972 self.hovered_rail = None;
2973 self.hovered_nav = None;
2974 self.hovered_editor = None;
2975 self.hovered_choice = None;
2976 }
2977
2978 /// Hover pass: tint whatever the pointer is over using the shared hover
2979 /// style. Hover never moves the keyboard selection and never activates.
2980 fn track_hover(&mut self, mouse: MouseEvent) {
2981 let position = Position::new(mouse.column, mouse.row);
2982 if self.editing.is_some() {
2983 self.hovered_choice = self
2984 .last_choice_hitboxes
2985 .borrow()
2986 .iter()
2987 .find_map(|(rect, choice)| rect.contains(position).then_some(*choice));
2988 self.hovered_editor = self
2989 .last_editor_controls
2990 .borrow()
2991 .iter()
2992 .find_map(|(rect, control)| rect.contains(position).then_some(*control));
2993 self.hovered_row = None;
2994 self.hovered_rail = None;
2995 self.hovered_nav = None;
2996 return;
2997 }
2998 self.hovered_row = self
2999 .last_row_hitboxes
3000 .borrow()
3001 .iter()
3002 .find_map(|(rect, row_idx)| rect.contains(position).then_some(*row_idx));
3003 self.hovered_rail = self
3004 .last_rail_hitboxes
3005 .borrow()
3006 .iter()
3007 .find_map(|(rect, category)| rect.contains(position).then_some(*category));
3008 self.hovered_nav = self
3009 .last_nav_controls
3010 .borrow()
3011 .iter()
3012 .find_map(|(rect, step)| rect.contains(position).then_some(*step));
3013 self.hovered_editor = None;
3014 self.hovered_choice = None;
3015 }
3016
3017 fn update_filter(&mut self, update: impl FnOnce(&mut String)) {
3018 update(&mut self.filter);
3019 self.status = None;
3020 self.last_mouse_selected = None;
3021 self.clear_hover();
3022 self.sync_selection_to_filter();
3023 self.adjust_scroll(self.visible_rows_cached());
3024 }
3025
3026 fn adjust_scroll(&mut self, visible_rows: usize) {
3027 self.sync_selection_to_filter();
3028
3029 let items = self.visible_items();
3030 if items.is_empty() {
3031 self.scroll = 0;
3032 return;
3033 }
3034
3035 let visible_rows = visible_rows.max(1);
3036 let max_scroll = items.len().saturating_sub(visible_rows);
3037 self.scroll = self.scroll.min(max_scroll);
3038
3039 let Some(selected_pos) = self.selected_display_position(&items) else {
3040 self.scroll = 0;
3041 return;
3042 };
3043
3044 if selected_pos < self.scroll {
3045 self.scroll = selected_pos;
3046 }
3047
3048 if selected_pos >= self.scroll + visible_rows {
3049 self.scroll = selected_pos.saturating_sub(visible_rows.saturating_sub(1));
3050 }
3051 }
3052
3053 fn move_selection(&mut self, delta: isize) {
3054 let matches = self.matching_row_indices();
3055 if matches.is_empty() {
3056 return;
3057 }
3058
3059 let current = matches
3060 .iter()
3061 .position(|idx| *idx == self.selected)
3062 .unwrap_or(0);
3063 let next = crate::tui::list_nav::wrap_index(current, matches.len(), delta);
3064
3065 self.selected = matches[next];
3066 self.clear_hover();
3067 let visible_rows = self.visible_rows_cached();
3068 self.adjust_scroll(visible_rows);
3069 }
3070
3071 fn toggle_selected_boolean(&self) -> Option<ViewAction> {
3072 let row = self.rows.get(self.selected_row_index()?)?;
3073 if SettingsRegistry::new(self).meta(row).kind != SettingKind::Boolean {
3074 return None;
3075 }
3076 let value = if canonical_config_choice(&row.key, row.edit_value()) == "true" {
3077 "false"
3078 } else {
3079 "true"
3080 };
3081 Some(ViewAction::Emit(ViewEvent::ConfigUpdated {
3082 key: row.key.clone(),
3083 value: value.to_string(),
3084 persist: row.scope.persist(),
3085 }))
3086 }
3087
3088 fn open_selected_catalog_picker(&self) -> Option<ViewAction> {
3089 let row = self.rows.get(self.selected_row_index()?)?;
3090 if !row.editable {
3091 return None;
3092 }
3093 let (command, _) = row.facts.command?;
3094 if row.key == "sandbox_details" {
3095 return Some(ViewAction::Emit(ViewEvent::ExecutePanelCommand {
3096 command: command.to_string(),
3097 pager_title: Some(config_label_for_key_for_locale(self.locale, &row.key)),
3098 }));
3099 }
3100 Some(ViewAction::Emit(ViewEvent::CommandPaletteSelected {
3101 action: CommandPaletteAction::ExecuteCommand {
3102 command: command.to_string(),
3103 },
3104 }))
3105 }
3106
3107 fn move_choice(&mut self, delta: isize) {
3108 let Some(edit) = self.editing.as_mut() else {
3109 return;
3110 };
3111 let Some(choices) = edit.choices.as_ref() else {
3112 return;
3113 };
3114 let max = choices.len().saturating_sub(1);
3115 edit.selected_choice = if delta.is_negative() {
3116 edit.selected_choice.saturating_sub(delta.unsigned_abs())
3117 } else {
3118 (edit.selected_choice + delta as usize).min(max)
3119 };
3120 self.hovered_choice = None;
3121 }
3122
3123 /// Live-preview the edited choice when the edited key is the theme:
3124 /// highlighting a theme row applies it session-only (`persist:false`)
3125 /// so the surface behind the editor repaints immediately, while only
3126 /// Enter/Apply persists. Other keys preview nothing.
3127 fn preview_edited_choice(&self) -> ViewAction {
3128 let Some(edit) = self.editing.as_ref() else {
3129 return ViewAction::None;
3130 };
3131 if edit.key != "theme" {
3132 return ViewAction::None;
3133 }
3134 let Some(value) = edit
3135 .choices
3136 .as_ref()
3137 .and_then(|choices| choices.get(edit.selected_choice).cloned())
3138 else {
3139 return ViewAction::None;
3140 };
3141 ViewAction::Emit(ViewEvent::ConfigUpdated {
3142 key: edit.key.clone(),
3143 value,
3144 persist: false,
3145 })
3146 }
3147
3148 /// Leave the editor without applying (Esc or the Cancel control). When
3149 /// the theme highlight moved, the live surface already previews the
3150 /// highlighted theme, so Esc reverts it to the exact value the editor
3151 /// opened with (session-only, mirroring the `/theme` picker rollback).
3152 fn cancel_edit(&mut self) -> ViewAction {
3153 let revert = self
3154 .editing
3155 .as_ref()
3156 .filter(|edit| edit.key == "theme")
3157 .and_then(|edit| {
3158 let highlighted = edit.choices.as_ref()?.get(edit.selected_choice)?;
3159 (canonical_config_choice(&edit.key, highlighted)
3160 != canonical_config_choice(&edit.key, &edit.original_value))
3161 .then(|| ViewEvent::ConfigUpdated {
3162 key: edit.key.clone(),
3163 value: edit.original_value.clone(),
3164 persist: false,
3165 })
3166 });
3167 self.editing = None;
3168 self.status = Some(self.tr(MessageId::ConfigEditCancelled).to_string());
3169 self.last_mouse_selected = None;
3170 self.clear_hover();
3171 revert.map_or(ViewAction::None, ViewAction::Emit)
3172 }
3173
3174 /// Hover-follow for the editor's choice rows (the global hover rule):
3175 /// the pointer highlights the hovered row, painted with the shared
3176 /// selected-row style. On the theme editor it also live-previews,
3177 /// exactly like ↑/↓.
3178 fn hover_edited_choice(&mut self, mouse: MouseEvent) -> ViewAction {
3179 let position = Position::new(mouse.column, mouse.row);
3180 let hovered = self
3181 .last_choice_hitboxes
3182 .borrow()
3183 .iter()
3184 .find_map(|(rect, choice)| rect.contains(position).then_some(*choice));
3185 let Some(hovered) = hovered else {
3186 return ViewAction::None;
3187 };
3188 let changed = match self.editing.as_mut() {
3189 Some(edit) if edit.selected_choice != hovered => {
3190 edit.selected_choice = hovered;
3191 true
3192 }
3193 _ => false,
3194 };
3195 if changed {
3196 self.preview_edited_choice()
3197 } else {
3198 ViewAction::None
3199 }
3200 }
3201
3202 /// Apply the editor's value (Enter or the Apply control): the selected
3203 /// choice, or the trimmed text buffer.
3204 fn commit_edit(&mut self) -> ViewAction {
3205 let Some(edit) = self.editing.take() else {
3206 return ViewAction::None;
3207 };
3208 self.last_mouse_selected = None;
3209 self.clear_hover();
3210 let value = match edit.choices.as_ref() {
3211 Some(choices) => match choices.get(edit.selected_choice).cloned() {
3212 Some(value) => value,
3213 None => return ViewAction::None,
3214 },
3215 None => edit.buffer.iter().collect::<String>().trim().to_string(),
3216 };
3217 ViewAction::Emit(ViewEvent::ConfigUpdated {
3218 key: edit.key,
3219 value,
3220 persist: edit.scope.persist(),
3221 })
3222 }
3223
3224 fn handle_choice_key(&mut self, key: KeyEvent) -> ViewAction {
3225 match key.code {
3226 KeyCode::Esc => self.cancel_edit(),
3227 KeyCode::Enter => self.commit_edit(),
3228 KeyCode::Up | KeyCode::Left | KeyCode::Char('k') => {
3229 self.move_choice(-1);
3230 self.preview_edited_choice()
3231 }
3232 KeyCode::Down | KeyCode::Right | KeyCode::Char('j') => {
3233 self.move_choice(1);
3234 self.preview_edited_choice()
3235 }
3236 KeyCode::PageUp => {
3237 self.move_choice(-5);
3238 self.preview_edited_choice()
3239 }
3240 KeyCode::PageDown => {
3241 self.move_choice(5);
3242 self.preview_edited_choice()
3243 }
3244 KeyCode::Home => {
3245 if let Some(edit) = self.editing.as_mut() {
3246 edit.selected_choice = 0;
3247 }
3248 self.preview_edited_choice()
3249 }
3250 KeyCode::End => {
3251 if let Some(edit) = self.editing.as_mut()
3252 && let Some(choices) = edit.choices.as_ref()
3253 {
3254 edit.selected_choice = choices.len().saturating_sub(1);
3255 }
3256 self.preview_edited_choice()
3257 }
3258 KeyCode::Char(digit @ '1'..='9') => {
3259 if let Some(edit) = self.editing.as_mut()
3260 && let Some(choices) = edit.choices.as_ref()
3261 {
3262 let index = digit as usize - '1' as usize;
3263 if index < choices.len() {
3264 edit.selected_choice = index;
3265 }
3266 }
3267 self.preview_edited_choice()
3268 }
3269 KeyCode::Char(' ') => {
3270 self.move_choice(1);
3271 self.preview_edited_choice()
3272 }
3273 _ => ViewAction::None,
3274 }
3275 }
3276
3277 fn handle_editing_key(&mut self, key: KeyEvent) -> ViewAction {
3278 if self
3279 .editing
3280 .as_ref()
3281 .is_some_and(|edit| edit.choices.is_some())
3282 {
3283 return self.handle_choice_key(key);
3284 }
3285 match key.code {
3286 KeyCode::Esc => self.cancel_edit(),
3287 KeyCode::Enter => self.commit_edit(),
3288 KeyCode::Backspace => {
3289 if let Some(edit) = self.editing.as_mut() {
3290 if edit.select_all {
3291 edit.buffer.clear();
3292 edit.cursor = 0;
3293 edit.select_all = false;
3294 } else if edit.cursor > 0 {
3295 edit.cursor = edit.cursor.saturating_sub(1);
3296 edit.buffer.remove(edit.cursor);
3297 }
3298 }
3299 ViewAction::None
3300 }
3301 KeyCode::Delete => {
3302 if let Some(edit) = self.editing.as_mut() {
3303 if edit.select_all {
3304 edit.buffer.clear();
3305 edit.cursor = 0;
3306 edit.select_all = false;
3307 } else if edit.cursor < edit.buffer.len() {
3308 edit.buffer.remove(edit.cursor);
3309 }
3310 }
3311 ViewAction::None
3312 }
3313 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3314 if let Some(edit) = self.editing.as_mut() {
3315 edit.buffer.clear();
3316 edit.cursor = 0;
3317 edit.select_all = false;
3318 }
3319 ViewAction::None
3320 }
3321 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3322 if let Some(edit) = self.editing.as_mut() {
3323 edit.cursor = edit.buffer.len();
3324 edit.select_all = true;
3325 }
3326 ViewAction::None
3327 }
3328 KeyCode::Left => {
3329 if let Some(edit) = self.editing.as_mut() {
3330 if edit.select_all {
3331 edit.cursor = 0;
3332 edit.select_all = false;
3333 } else {
3334 edit.cursor = edit.cursor.saturating_sub(1);
3335 }
3336 }
3337 ViewAction::None
3338 }
3339 KeyCode::Right => {
3340 if let Some(edit) = self.editing.as_mut() {
3341 if edit.select_all {
3342 edit.cursor = edit.buffer.len();
3343 edit.select_all = false;
3344 } else {
3345 edit.cursor = (edit.cursor + 1).min(edit.buffer.len());
3346 }
3347 }
3348 ViewAction::None
3349 }
3350 KeyCode::Home => {
3351 if let Some(edit) = self.editing.as_mut() {
3352 edit.cursor = 0;
3353 edit.select_all = false;
3354 }
3355 ViewAction::None
3356 }
3357 KeyCode::End => {
3358 if let Some(edit) = self.editing.as_mut() {
3359 edit.cursor = edit.buffer.len();
3360 edit.select_all = false;
3361 }
3362 ViewAction::None
3363 }
3364 KeyCode::Char(ch)
3365 if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() =>
3366 {
3367 if let Some(edit) = self.editing.as_mut() {
3368 if edit.select_all {
3369 edit.buffer.clear();
3370 edit.cursor = 0;
3371 edit.select_all = false;
3372 }
3373 edit.buffer.insert(edit.cursor, ch);
3374 edit.cursor += 1;
3375 }
3376 ViewAction::None
3377 }
3378 _ => ViewAction::None,
3379 }
3380 }
3381
3382 fn start_edit(&mut self) {
3383 let Some(row_idx) = self.selected_row_index() else {
3384 return;
3385 };
3386 let Some(row) = self.rows.get(row_idx) else {
3387 return;
3388 };
3389 let key = row.key.clone();
3390 let original_value = row.edit_value().to_string();
3391 let initial_value = match config_default_placeholder_message(&key) {
3392 Some(message_id)
3393 if original_value == tr(self.locale, message_id)
3394 || original_value == tr(Locale::En, message_id) =>
3395 {
3396 String::new()
3397 }
3398 _ => original_value.clone(),
3399 };
3400
3401 let meta = SettingsRegistry::new(self).meta(row);
3402 let choices = meta.choices;
3403 let selected_choice = choices
3404 .as_ref()
3405 .and_then(|choices| {
3406 let current = canonical_config_choice(&key, &initial_value);
3407 choices
3408 .iter()
3409 .position(|choice| canonical_config_choice(&key, choice) == current)
3410 })
3411 .unwrap_or(0);
3412 let buffer: Vec<char> = initial_value.chars().collect();
3413 self.last_mouse_selected = None;
3414 self.editing = Some(ConfigEdit {
3415 key,
3416 original_value,
3417 cursor: buffer.len(),
3418 buffer,
3419 select_all: true,
3420 scope: row.scope,
3421 choices,
3422 selected_choice,
3423 });
3424 self.status = None;
3425 }
3426
3427 fn clear_filter(&mut self) {
3428 if self.filter.is_empty() {
3429 return;
3430 }
3431
3432 self.update_filter(|filter| filter.clear());
3433 }
3434
3435 fn row_display_value(&self, row: &ConfigRow) -> String {
3436 if row.key.starts_with("notifications.") {
3437 return config_choice_label(self.locale, &row.key, row.edit_value());
3438 }
3439 // The effective lane is only ever an explicit `App` observation carried
3440 // on the row's typed facts; a persisted value never stands in for it.
3441 let effective = row.facts.effective.as_deref();
3442 if row.key == "cost_currency"
3443 && row.scope == ConfigScope::Saved
3444 && let Some(effective_currency) = effective
3445 {
3446 let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value);
3447 let effective_cost_currency =
3448 crate::pricing::CostCurrency::from_setting(effective_currency);
3449 if saved_cost_currency != effective_cost_currency {
3450 return format!(
3451 "{}{}",
3452 row.value,
3453 self.tr(MessageId::ConfigRowEffective)
3454 .replace("{currency}", effective_currency)
3455 );
3456 }
3457 }
3458
3459 let runtime_value = effective.and_then(|value| value.parse::<bool>().ok());
3460 if let Some(runtime_value) = runtime_value
3461 && row.value.parse::<bool>().ok() != Some(runtime_value)
3462 {
3463 let saved = config_choice_label(
3464 self.locale,
3465 &row.key,
3466 &canonical_config_choice(&row.key, &row.value),
3467 );
3468 let effective = config_choice_label(self.locale, &row.key, &runtime_value.to_string());
3469 return format!(
3470 "{}{}",
3471 saved,
3472 self.tr(MessageId::ConfigRowEffective)
3473 .replace("{currency}", &effective)
3474 );
3475 }
3476
3477 // Preserve the exact saved currency alias in the table (for example
3478 // `rmb`) while the chooser highlights its canonical `cny` option.
3479 if row.key == "cost_currency" {
3480 return row.value.clone();
3481 }
3482
3483 if SettingsRegistry::new(self).meta(row).choices.is_some() {
3484 if config_default_placeholder_message(&row.key).is_some_and(|message_id| {
3485 row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id)
3486 }) {
3487 return self.tr(MessageId::ConfigValueProviderDefault).into_owned();
3488 }
3489 let canonical = canonical_config_choice(&row.key, &row.value);
3490 return config_choice_label(self.locale, &row.key, &canonical);
3491 }
3492
3493 row.value.clone()
3494 }
3495 }
3496
3497 fn config_base_url_row_key(provider: ApiProvider) -> &'static str {
3498 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
3499 "base_url"
3500 } else {
3501 "provider_url"
3502 }
3503 }
3504
3505 fn config_base_url_row_value(app: &App) -> String {
3506 app.active_route_base_url.clone()
3507 }
3508
3509 fn cost_currency_config_value(app: &App) -> String {
3510 match app.cost_currency {
3511 crate::pricing::CostCurrency::Usd => "usd",
3512 crate::pricing::CostCurrency::Cny => "cny",
3513 }
3514 .to_string()
3515 }
3516
3517 fn experimental_config_rows(config: &Config) -> Vec<ConfigRow> {
3518 let features = config.features();
3519 let configured = config.features.as_ref().map(|table| &table.entries);
3520 let mut rows = Vec::new();
3521
3522 for spec in FEATURES
3523 .iter()
3524 .filter(|spec| matches!(spec.stage, Stage::Experimental | Stage::Beta))
3525 {
3526 let effective = features.enabled(spec.id);
3527 let configured_value = configured
3528 .and_then(|entries| entries.get(spec.key))
3529 .copied();
3530 rows.push(ConfigRow {
3531 key: format!("features.{}", spec.key),
3532 value: experimental_feature_value(
3533 effective,
3534 spec.default_enabled,
3535 configured_value.is_some(),
3536 ),
3537 editable: false,
3538 scope: ConfigScope::Saved,
3539 facts: ConfigRowFacts::read_only_setting(SettingAuthority::WorkspaceConfiguration),
3540 });
3541 }
3542
3543 rows.push(ConfigRow {
3544 key: "goal_command".to_string(),
3545 value:
3546 "/goal sets session objectives with optional token budgets; state shows in Work context"
3547 .to_string(),
3548 editable: false,
3549 scope: ConfigScope::Saved,
3550 facts: ConfigRowFacts::diagnostic(SettingAuthority::Session),
3551 });
3552 rows.push(ConfigRow {
3553 // Workflow orchestration is its own section, not a Fleet concern.
3554 key: "workflow".to_string(),
3555 value:
3556 "/workflow runs scripted fan-out/fan-in operations with run cards and cancel support"
3557 .to_string(),
3558 editable: false,
3559 scope: ConfigScope::Saved,
3560 facts: ConfigRowFacts::diagnostic(SettingAuthority::Session),
3561 });
3562
3563 rows
3564 }
3565
3566 fn experimental_feature_value(effective: bool, default_enabled: bool, configured: bool) -> String {
3567 let state = if effective { "enabled" } else { "disabled" };
3568 let default_state = if default_enabled {
3569 "enabled"
3570 } else {
3571 "disabled"
3572 };
3573 if configured {
3574 format!("{state} (configured; default {default_state})")
3575 } else {
3576 format!("{state} (default {default_state})")
3577 }
3578 }
3579
3580 /// Localized label for a setting key.
3581 ///
3582 /// The schema names the string; the locale pack owns the text. A setting
3583 /// declared without a label message humanizes its key, and a `features.*` key
3584 /// wears the localized feature prefix.
3585 fn config_label_for_key_for_locale(locale: Locale, key: &str) -> String {
3586 let declared = codewhale_config::setting(key)
3587 .and_then(|def| def.ui.as_ref())
3588 .map(|ui| ui.label)
3589 .unwrap_or("");
3590 if !declared.is_empty() {
3591 return tr_key(locale, declared).to_string();
3592 }
3593 let humanized = humanize_config_key(key.strip_prefix("features.").unwrap_or(key));
3594 if key.starts_with("features.") {
3595 tr(locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", &humanized)
3596 } else {
3597 humanized
3598 }
3599 }
3600
3601 #[cfg(test)]
3602 fn config_label_for_key(key: &str) -> String {
3603 config_label_for_key_for_locale(Locale::En, key)
3604 }
3605
3606 fn humanize_config_key(key: &str) -> String {
3607 key.split(['.', '_', '-'])
3608 .filter(|part| !part.is_empty())
3609 .map(|part| {
3610 let mut chars = part.chars();
3611 let Some(first) = chars.next() else {
3612 return String::new();
3613 };
3614 let mut word = first.to_uppercase().collect::<String>();
3615 word.push_str(chars.as_str());
3616 word
3617 })
3618 .collect::<Vec<_>>()
3619 .join(" ")
3620 }
3621
3622 /// Localized description for a setting key.
3623 ///
3624 /// Theme and locale describe themselves with their shipped value lists (value
3625 /// lists, not prose, so they cannot go stale); every other sentence is the
3626 /// message the schema names.
3627 fn config_hint_for_key(locale: Locale, key: &str) -> Cow<'static, str> {
3628 match key {
3629 "theme" => {
3630 static THEME_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
3631 return Cow::Borrowed(THEME_HINT.get_or_init(|| {
3632 codewhale_palette::SELECTABLE_THEMES
3633 .iter()
3634 .map(|id| id.name())
3635 .collect::<Vec<_>>()
3636 .join(" | ")
3637 }));
3638 }
3639 "locale" => {
3640 static LOCALE_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
3641 return Cow::Borrowed(
3642 LOCALE_HINT.get_or_init(|| codewhale_localization::configured_locale_values(" | ")),
3643 );
3644 }
3645 _ => {}
3646 }
3647 let declared = codewhale_config::setting(key)
3648 .and_then(|def| def.ui.as_ref())
3649 .map(|ui| ui.description)
3650 .unwrap_or("");
3651 if declared.is_empty() {
3652 return Cow::Borrowed("");
3653 }
3654 tr_key(locale, declared)
3655 }
3656
3657 fn config_default_placeholder_message(key: &str) -> Option<MessageId> {
3658 match key {
3659 "default_model" | "background_color" => Some(MessageId::ConfigDefaultValue),
3660 "reasoning_effort" => Some(MessageId::ConfigDefaultReasoning),
3661 _ => None,
3662 }
3663 }
3664
3665 fn config_boolean_key(key: &str) -> bool {
3666 codewhale_config::setting(key).is_some_and(|def| def.is_bool())
3667 }
3668
3669 fn config_integer_key(key: &str) -> bool {
3670 codewhale_config::setting(key).is_some_and(|def| def.is_int())
3671 }
3672
3673 /// Selectable values for a key.
3674 ///
3675 /// Two settings take their values from a live registry instead of the schema:
3676 /// the shipped palettes and the shipped locale packs. `reasoning_effort` is a
3677 /// third, and `SettingsRegistry::reasoning_effort_choices` owns it because the
3678 /// answer depends on the active route, not just the provider. Everything else
3679 /// is the declared value set.
3680 fn config_choice_values(key: &str) -> Option<Vec<String>> {
3681 match key {
3682 "theme" => {
3683 return Some(
3684 codewhale_palette::SELECTABLE_THEMES
3685 .iter()
3686 .map(|id| id.name().to_string())
3687 .collect(),
3688 );
3689 }
3690 "locale" => {
3691 let mut values = vec!["auto".to_string()];
3692 values.extend(
3693 Locale::shipped()
3694 .iter()
3695 .map(|locale| locale.tag().to_string()),
3696 );
3697 return Some(values);
3698 }
3699 "reasoning_effort" => {
3700 // Settings-canonical vocabulary; the live settings screen uses
3701 // `reasoning_effort_choices()` which narrows this by route/provider.
3702 return Some(vec![
3703 "default".to_string(),
3704 "off".to_string(),
3705 "low".to_string(),
3706 "medium".to_string(),
3707 "high".to_string(),
3708 "xhigh".to_string(),
3709 "auto".to_string(),
3710 "ultra".to_string(),
3711 "max".to_string(),
3712 ]);
3713 }
3714 _ => {}
3715 }
3716 codewhale_config::setting(key)
3717 .and_then(|def| def.values())
3718 .map(|values| values.into_iter().map(str::to_string).collect())
3719 }
3720
3721 fn canonical_config_choice(key: &str, value: &str) -> String {
3722 let normalized = value.trim().to_ascii_lowercase().replace([' ', '_'], "-");
3723 match key {
3724 key if config_boolean_key(key) => match normalized.as_str() {
3725 "true" | "on" | "yes" | "1" | "enabled" => "true".to_string(),
3726 _ => "false".to_string(),
3727 },
3728 "approval_mode" | "permission_posture" => match normalized.as_str() {
3729 "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(),
3730 "auto" | "auto-review" => "auto-review".to_string(),
3731 "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(),
3732 _ => normalized,
3733 },
3734 "approval_policy" => match normalized.as_str() {
3735 "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(),
3736 "auto" | "auto-review" => "auto-review".to_string(),
3737 "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(),
3738 "never" | "deny" => "never".to_string(),
3739 _ => normalized,
3740 },
3741 "reasoning_effort" => {
3742 if matches!(normalized.as_str(), "" | "(default)" | "config-default") {
3743 "default".to_string()
3744 } else if normalized == "max" && value.trim().eq_ignore_ascii_case("xhigh") {
3745 "xhigh".to_string()
3746 } else {
3747 normalized
3748 }
3749 }
3750 "cost_currency" => match normalized.as_str() {
3751 "rmb" | "yuan" | "cny" => "cny".to_string(),
3752 _ => "usd".to_string(),
3753 },
3754 "default_mode" => match normalized.as_str() {
3755 "plan" => "plan".to_string(),
3756 "operate" | "operation" | "ops" => "operate".to_string(),
3757 _ => "agent".to_string(),
3758 },
3759 "locale" => normalize_configured_locale(value)
3760 .unwrap_or(value)
3761 .to_string(),
3762 _ => normalized,
3763 }
3764 }
3765
3766 /// Localized label for one value of a setting.
3767 ///
3768 /// The schema declares per-value labels; a boolean with none uses the shared
3769 /// on/off pair, and any other undeclared value shows itself.
3770 fn config_choice_label(locale: Locale, key: &str, value: &str) -> String {
3771 // The runtime "default" choice for reasoning_effort is the unset sentinel;
3772 // keep its localized placeholder label instead of showing the raw word.
3773 if key == "reasoning_effort" && value == "default" {
3774 return tr(locale, MessageId::ConfigDefaultReasoning).into_owned();
3775 }
3776 let declared = codewhale_config::setting(key);
3777 let message = declared
3778 .and_then(|def| def.option(value))
3779 .map(|option| option.label)
3780 .filter(|label| !label.is_empty());
3781 let label = match message {
3782 Some(message) => tr_key(locale, message).into_owned(),
3783 None if declared.is_some_and(|def| def.is_bool()) => match value {
3784 "true" => tr(locale, MessageId::ConfigValueOn).into_owned(),
3785 "false" => tr(locale, MessageId::ConfigValueOff).into_owned(),
3786 other => other.to_string(),
3787 },
3788 None => value.to_string(),
3789 };
3790
3791 if key == "locale" && configured_locale_is_partial_pack(value) {
3792 format!(
3793 "{label} ({})",
3794 tr(locale, MessageId::ConfigLocalePartialBadge)
3795 )
3796 } else {
3797 label
3798 }
3799 }
3800
3801 /// Localized one-line detail for one value of a setting.
3802 fn config_choice_detail(locale: Locale, key: &str, value: &str) -> Cow<'static, str> {
3803 if key == "locale" && configured_locale_is_partial_pack(value) {
3804 return tr(locale, MessageId::ConfigLocalePartialDetail);
3805 }
3806 let declared = codewhale_config::setting(key)
3807 .and_then(|def| def.option(value))
3808 .map(|option| option.description)
3809 .filter(|description| !description.is_empty());
3810 match declared {
3811 Some(message) => tr_key(locale, message),
3812 None => Cow::Borrowed(""),
3813 }
3814 }
3815
3816 fn render_config_editor_value_line(
3817 edit: &ConfigEdit,
3818 locale: Locale,
3819 ) -> ratatui::text::Line<'static> {
3820 use ratatui::{
3821 style::Style,
3822 text::{Line, Span},
3823 };
3824
3825 let mut spans = Vec::new();
3826 spans.push(Span::styled(
3827 tr(locale, MessageId::ConfigEditNewLabel),
3828 Style::default().fg(palette::TEXT_MUTED),
3829 ));
3830
3831 let cursor_style = Style::default()
3832 .fg(palette::WHALE_BG)
3833 .bg(palette::WHALE_ACTION)
3834 .bold();
3835 let selected_style = Style::default()
3836 .fg(palette::SELECTION_TEXT)
3837 .bg(palette::SELECTION_BG);
3838
3839 if edit.select_all && !edit.buffer.is_empty() {
3840 let text = edit.buffer.iter().collect::<String>();
3841 spans.push(Span::styled(text, selected_style));
3842 spans.push(Span::styled(" ", cursor_style));
3843 return Line::from(spans);
3844 }
3845
3846 let before = edit.buffer.iter().take(edit.cursor).collect::<String>();
3847 spans.push(Span::raw(before));
3848 if edit.cursor < edit.buffer.len() {
3849 let ch = edit.buffer[edit.cursor];
3850 spans.push(Span::styled(ch.to_string(), cursor_style));
3851 let after = edit
3852 .buffer
3853 .iter()
3854 .skip(edit.cursor.saturating_add(1))
3855 .collect::<String>();
3856 spans.push(Span::raw(after));
3857 } else {
3858 spans.push(Span::styled(" ", cursor_style));
3859 }
3860
3861 Line::from(spans)
3862 }
3863
3864 impl ModalView for ConfigView {
3865 fn kind(&self) -> ModalKind {
3866 ModalKind::Config
3867 }
3868
3869 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
3870 self
3871 }
3872
3873 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
3874 // Any key is a state change: a following single click must select,
3875 // never activate, whatever the pointer touched before.
3876 self.last_mouse_selected = None;
3877 if self.editing.is_some() {
3878 return self.handle_editing_key(key);
3879 }
3880 // A status line ("Edit cancelled", …) is transient: navigation gives
3881 // the row back to its activation copy.
3882 self.status = None;
3883
3884 match key.code {
3885 KeyCode::Esc => {
3886 if self.filter.is_empty() {
3887 ViewAction::Close
3888 } else {
3889 self.clear_filter();
3890 ViewAction::None
3891 }
3892 }
3893 KeyCode::Tab | KeyCode::Right
3894 if !key.modifiers.contains(KeyModifiers::SHIFT) && self.filter.is_empty() =>
3895 {
3896 self.category = self.category.next();
3897 self.select_first_visible_row();
3898 ViewAction::None
3899 }
3900 KeyCode::BackTab | KeyCode::Tab | KeyCode::Left if self.filter.is_empty() => {
3901 self.category = self.category.prev();
3902 self.select_first_visible_row();
3903 ViewAction::None
3904 }
3905 KeyCode::Up => {
3906 self.move_selection(-1);
3907 ViewAction::None
3908 }
3909 KeyCode::Down => {
3910 self.move_selection(1);
3911 ViewAction::None
3912 }
3913 KeyCode::PageUp => {
3914 self.move_selection(-5);
3915 ViewAction::None
3916 }
3917 KeyCode::PageDown => {
3918 self.move_selection(5);
3919 ViewAction::None
3920 }
3921 KeyCode::Backspace => {
3922 if !self.filter.is_empty() {
3923 self.update_filter(|filter| {
3924 filter.pop();
3925 });
3926 }
3927 ViewAction::None
3928 }
3929 // Ctrl+H is the legacy ASCII backspace many terminals emit.
3930 KeyCode::Char('h')
3931 if key.modifiers.contains(KeyModifiers::CONTROL)
3932 && !key.modifiers.contains(KeyModifiers::ALT) =>
3933 {
3934 if !self.filter.is_empty() {
3935 self.update_filter(|filter| {
3936 filter.pop();
3937 });
3938 }
3939 ViewAction::None
3940 }
3941 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3942 self.clear_filter();
3943 ViewAction::None
3944 }
3945 KeyCode::Enter => {
3946 if self
3947 .selected_row_index()
3948 .and_then(|idx| self.rows.get(idx))
3949 .is_some_and(|row| row.editable)
3950 {
3951 if let Some(action) = self.open_selected_catalog_picker() {
3952 return action;
3953 }
3954 if let Some(action) = self.toggle_selected_boolean() {
3955 return action;
3956 }
3957 self.start_edit();
3958 }
3959 ViewAction::None
3960 }
3961 KeyCode::Char(ch)
3962 if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() =>
3963 {
3964 self.update_filter(|filter| filter.push(ch));
3965 ViewAction::None
3966 }
3967 _ => ViewAction::None,
3968 }
3969 }
3970
3971 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
3972 if matches!(mouse.kind, MouseEventKind::Moved) {
3973 let has_choices = self
3974 .editing
3975 .as_ref()
3976 .is_some_and(|edit| edit.choices.is_some());
3977 if has_choices {
3978 return self.hover_edited_choice(mouse);
3979 }
3980 self.track_hover(mouse);
3981 return ViewAction::None;
3982 }
3983 if self.editing.is_some() {
3984 let has_choices = self
3985 .editing
3986 .as_ref()
3987 .is_some_and(|edit| edit.choices.is_some());
3988 match mouse.kind {
3989 MouseEventKind::ScrollUp if has_choices => {
3990 self.move_choice(-1);
3991 return self.preview_edited_choice();
3992 }
3993 MouseEventKind::ScrollDown if has_choices => {
3994 self.move_choice(1);
3995 return self.preview_edited_choice();
3996 }
3997 MouseEventKind::Down(MouseButton::Left) => {
3998 let position = Position::new(mouse.column, mouse.row);
3999 let control = self
4000 .last_editor_controls
4001 .borrow()
4002 .iter()
4003 .find_map(|(rect, control)| rect.contains(position).then_some(*control));
4004 match control {
4005 Some(EditorControl::Apply) => return self.commit_edit(),
4006 Some(EditorControl::Cancel) => return self.cancel_edit(),
4007 None => {}
4008 }
4009 let choice = self
4010 .last_choice_hitboxes
4011 .borrow()
4012 .iter()
4013 .find_map(|(rect, choice)| rect.contains(position).then_some(*choice));
4014 let picked = match (choice, self.editing.as_mut()) {
4015 (Some(choice), Some(edit)) => {
4016 edit.selected_choice = choice;
4017 true
4018 }
4019 _ => false,
4020 };
4021 if picked {
4022 return self.preview_edited_choice();
4023 }
4024 }
4025 _ => {}
4026 }
4027 return ViewAction::None;
4028 }
4029 match mouse.kind {
4030 MouseEventKind::ScrollUp => {
4031 self.move_selection(-3);
4032 self.last_mouse_selected = None;
4033 self.clear_hover();
4034 return ViewAction::None;
4035 }
4036 MouseEventKind::ScrollDown => {
4037 self.move_selection(3);
4038 self.last_mouse_selected = None;
4039 self.clear_hover();
4040 return ViewAction::None;
4041 }
4042 _ => {}
4043 }
4044 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
4045 return ViewAction::None;
4046 }
4047
4048 let position = Position::new(mouse.column, mouse.row);
4049 let clicked_category = self
4050 .last_rail_hitboxes
4051 .borrow()
4052 .iter()
4053 .find_map(|(rect, category)| rect.contains(position).then_some(*category));
4054 let stepped = self
4055 .last_nav_controls
4056 .borrow()
4057 .iter()
4058 .find_map(|(rect, step)| rect.contains(position).then_some(*step))
4059 .map(|step| match step {
4060 NavStep::Previous => self.category.prev(),
4061 NavStep::Next => self.category.next(),
4062 });
4063 if let Some(category) = clicked_category.or(stepped) {
4064 // A category click (or an overflow marker) is an explicit
4065 // navigation: it leaves search so the list shows exactly that
4066 // category, never a stale filtered mix.
4067 self.clear_filter();
4068 self.status = None;
4069 if self.category != category {
4070 self.category = category;
4071 self.select_first_visible_row();
4072 }
4073 self.last_mouse_selected = None;
4074 return ViewAction::None;
4075 }
4076
4077 // Only the painted cells of a list row select it; the rail, dividers,
4078 // detail pane, status row, and footer never do.
4079 let selected = self
4080 .last_row_hitboxes
4081 .borrow()
4082 .iter()
4083 .find_map(|(rect, row_idx)| rect.contains(position).then_some(*row_idx));
4084 if let Some(row_idx) = selected {
4085 let activate = self.last_mouse_selected == Some(row_idx) && self.selected == row_idx;
4086 self.selected = row_idx;
4087 self.status = None;
4088 self.adjust_scroll(self.visible_rows_cached());
4089 self.last_mouse_selected = Some(row_idx);
4090 if activate && self.rows.get(row_idx).is_some_and(|row| row.editable) {
4091 if let Some(action) = self.open_selected_catalog_picker() {
4092 return action;
4093 }
4094 if let Some(action) = self.toggle_selected_boolean() {
4095 return action;
4096 }
4097 self.start_edit();
4098 }
4099 }
4100 ViewAction::None
4101 }
4102
4103 fn render(&self, area: Rect, buf: &mut Buffer) {
4104 use ratatui::{
4105 style::Style,
4106 text::{Line, Span},
4107 widgets::{Paragraph, Widget},
4108 };
4109
4110 let inner =
4111 render_underwater_surface(area, buf, self.tr(MessageId::ConfigModalTitle).to_string());
4112 let (lines, footer) = if let Some(edit) = self.editing.as_ref() {
4113 *self.last_choice_hitboxes.borrow_mut() = Vec::new();
4114 *self.last_editor_controls.borrow_mut() = Vec::new();
4115 *self.last_rail_hitboxes.borrow_mut() = Vec::new();
4116 *self.last_nav_controls.borrow_mut() = Vec::new();
4117 let footer_text = if edit.choices.is_some() {
4118 if inner.width < 56 || inner.height <= 8 {
4119 self.tr(MessageId::ConfigChoiceFooterCompact).to_string()
4120 } else {
4121 self.tr(MessageId::ConfigChoiceFooter).to_string()
4122 }
4123 } else {
4124 self.tr(MessageId::ConfigEditFooter).to_string()
4125 };
4126 let reserved_footer_lines =
4127 wrapped_footer_lines(&footer_text, inner.width, Style::default()).len();
4128 // The clickable Apply / Cancel controls always own the last body
4129 // row above the footer.
4130 const CONTROL_ROWS: usize = 1;
4131 // Spacer rows are secondary chrome: give them up before the
4132 // editable value line falls below the wrapped footer on compact
4133 // terminals (#40x12).
4134 let body_rows =
4135 usize::from(inner.height).saturating_sub(reserved_footer_lines + CONTROL_ROWS);
4136 // The expanded header costs six rows before the options. Reserve
4137 // at least three choices plus their detail before adding spacers;
4138 // a slightly taller compact shell must not show fewer options.
4139 let spacious = body_rows >= if edit.choices.is_some() { 10 } else { 8 };
4140 let mut lines: Vec<Line> = Vec::new();
4141 let edit_label = config_label_for_key_for_locale(self.locale, &edit.key);
4142 let edit_title = if edit_label == edit.key {
4143 format!("{}{}", self.tr(MessageId::ConfigEditTitlePrefix), edit.key)
4144 } else {
4145 format!(
4146 "{}{} [{}]",
4147 self.tr(MessageId::ConfigEditTitlePrefix),
4148 edit_label,
4149 edit.key
4150 )
4151 };
4152 lines.push(Line::from(vec![Span::styled(
4153 edit_title,
4154 Style::default().fg(palette::WHALE_ACTION).bold(),
4155 )]));
4156 if spacious {
4157 lines.push(Line::from(""));
4158 }
4159 let muted = Style::default().fg(palette::TEXT_MUTED);
4160 let scope_spans = vec![
4161 Span::styled(self.tr(MessageId::ConfigEditScopeLabel), muted),
4162 Span::raw(edit.scope.label(self.locale)),
4163 ];
4164 let current_spans = vec![
4165 Span::styled(self.tr(MessageId::ConfigEditCurrentLabel), muted),
4166 Span::raw(truncate_view_text(&edit.original_value, 60)),
4167 ];
4168 if spacious {
4169 lines.push(Line::from(scope_spans));
4170 lines.push(Line::from(current_spans));
4171 lines.push(Line::from(""));
4172 } else {
4173 // Compact: scope and current share one row so the choices
4174 // and the controls both stay visible at 40x12.
4175 let mut merged = scope_spans;
4176 merged.push(Span::styled(" · ", muted));
4177 merged.extend(current_spans);
4178 lines.push(Line::from(merged));
4179 }
4180 if let Some(choices) = edit.choices.as_ref() {
4181 lines.push(Line::from(Span::styled(
4182 self.tr(MessageId::ConfigEditChooseLabel),
4183 Style::default().fg(palette::TEXT_MUTED),
4184 )));
4185
4186 // Large catalogs (providers and themes) remain bounded by the
4187 // terminal. Keep the active option centered and mouse-hitbox
4188 // only the slice that is actually visible.
4189 let selected_detail = choices
4190 .get(edit.selected_choice)
4191 .map(|choice| config_choice_detail(self.locale, &edit.key, choice))
4192 .unwrap_or_default();
4193 let available_rows = usize::from(inner.height)
4194 .saturating_sub(reserved_footer_lines + CONTROL_ROWS + lines.len());
4195 // At the minimum supported height, the choices themselves are
4196 // the primary object. Shed the explanatory detail before any
4197 // option; larger surfaces keep one row for that detail.
4198 let detail_rows = usize::from(!selected_detail.is_empty() && available_rows > 3);
4199 let option_budget = available_rows.saturating_sub(detail_rows).max(1);
4200 let visible_options = option_budget.min(choices.len());
4201 let max_start = choices.len().saturating_sub(visible_options);
4202 let start = edit
4203 .selected_choice
4204 .saturating_sub(visible_options / 2)
4205 .min(max_start);
4206 let end = (start + visible_options).min(choices.len());
4207 let mut hitboxes = Vec::new();
4208
4209 for (choice_idx, choice) in choices.iter().enumerate().take(end).skip(start) {
4210 let selected = choice_idx == edit.selected_choice;
4211 let marker = crate::tui::glyphs::selection_marker(selected);
4212 let label = config_choice_label(self.locale, &edit.key, choice);
4213 let line_y = inner.y.saturating_add(lines.len() as u16);
4214 hitboxes.push((
4215 Rect {
4216 x: inner.x,
4217 y: line_y,
4218 width: inner.width,
4219 height: 1,
4220 },
4221 choice_idx,
4222 ));
4223 let mut line = Line::from(format!(
4224 " {marker} {:>2}. {}",
4225 choice_idx + 1,
4226 truncate_view_text(&label, usize::from(inner.width).saturating_sub(8))
4227 ));
4228 line.style = if selected {
4229 menu_style::selected_row_style()
4230 } else if self.hovered_choice == Some(choice_idx) {
4231 Style::default()
4232 .fg(palette::TEXT_PRIMARY)
4233 .patch(crate::tui::menu_style::hovered_row_style())
4234 } else {
4235 Style::default().fg(palette::TEXT_PRIMARY)
4236 };
4237 lines.push(line);
4238 }
4239 *self.last_choice_hitboxes.borrow_mut() = hitboxes;
4240
4241 if !selected_detail.is_empty()
4242 && lines.len() + reserved_footer_lines + CONTROL_ROWS
4243 < usize::from(inner.height)
4244 {
4245 lines.push(Line::from(Span::styled(
4246 crate::tui::ui_text::semantic_truncate(
4247 selected_detail.as_ref(),
4248 usize::from(inner.width),
4249 ),
4250 Style::default().fg(palette::TEXT_MUTED),
4251 )));
4252 }
4253 } else {
4254 lines.push(render_config_editor_value_line(edit, self.locale));
4255 if spacious {
4256 lines.push(Line::from(""));
4257 }
4258 let hint = config_hint_for_key(self.locale, &edit.key);
4259 if !hint.is_empty() {
4260 lines.push(Line::from(vec![
4261 Span::styled(
4262 self.tr(MessageId::ConfigEditHintLabel),
4263 Style::default().fg(palette::TEXT_MUTED),
4264 ),
4265 Span::raw(hint),
4266 ]));
4267 }
4268 }
4269 (lines, footer_text)
4270 } else {
4271 self.render_settings_shell(inner, buf);
4272 return;
4273 };
4274
4275 // Footer wraps inside the body so its hints can never run off the modal
4276 // edge (#3732); the editor renders into the area above it, and the
4277 // Apply / Cancel controls own the last body row.
4278 let content = render_modal_text_footer(
4279 inner,
4280 buf,
4281 &footer,
4282 Style::default().fg(palette::TEXT_MUTED),
4283 );
4284 let body = Rect {
4285 height: content.height.saturating_sub(1),
4286 ..content
4287 };
4288 Paragraph::new(lines)
4289 .style(Style::default().fg(palette::TEXT_PRIMARY))
4290 .scroll((0, 0))
4291 .render(body, buf);
4292 if content.height > 0 {
4293 self.render_editor_controls(
4294 Rect {
4295 y: content.bottom().saturating_sub(1),
4296 height: 1,
4297 ..content
4298 },
4299 buf,
4300 );
4301 }
4302 }
4303 }
4304
4305 impl ConfigView {
4306 /// Paint `[ Apply ] [ Cancel ]` and record their exact hitboxes.
4307 fn render_editor_controls(&self, row: Rect, buf: &mut Buffer) {
4308 use crate::tui::ui_text::text_display_width;
4309
4310 let mut controls = Vec::new();
4311 let mut x = row.x;
4312 for (control, id, style) in [
4313 (
4314 EditorControl::Apply,
4315 MessageId::ConfigEditorApply,
4316 // The filled Apply control answers hover with an underline:
4317 // a bg tint would erase its button fill.
4318 if self.hovered_editor == Some(EditorControl::Apply) {
4319 menu_style::selected_row_style().add_modifier(Modifier::UNDERLINED)
4320 } else {
4321 menu_style::selected_row_style()
4322 },
4323 ),
4324 (
4325 EditorControl::Cancel,
4326 MessageId::ConfigEditorCancel,
4327 if self.hovered_editor == Some(EditorControl::Cancel) {
4328 Style::default()
4329 .fg(palette::TEXT_PRIMARY)
4330 .add_modifier(Modifier::BOLD)
4331 .patch(crate::tui::menu_style::hovered_row_style())
4332 } else {
4333 Style::default()
4334 .fg(palette::TEXT_PRIMARY)
4335 .add_modifier(Modifier::BOLD)
4336 },
4337 ),
4338 ] {
4339 let label = format!("[ {} ]", self.tr(id));
4340 let width = u16::try_from(text_display_width(&label)).unwrap_or(u16::MAX);
4341 let limit = row.right().saturating_sub(x);
4342 if limit == 0 {
4343 break;
4344 }
4345 buf.set_stringn(x, row.y, &label, usize::from(limit), style);
4346 controls.push((
4347 Rect {
4348 x,
4349 y: row.y,
4350 width: width.min(limit),
4351 height: 1,
4352 },
4353 control,
4354 ));
4355 x = x.saturating_add(width).saturating_add(2);
4356 }
4357 *self.last_editor_controls.borrow_mut() = controls;
4358 }
4359 }
4360
4361 // ---------------------------------------------------------------------------
4362 // Tideline settings shell: category rail │ setting + action list │ detail at
4363 // ≥100 columns; a horizontally windowed category strip over a full-width list
4364 // below that. Every fact painted here comes from a row's typed
4365 // `ConfigRowFacts`; nothing is re-derived from the key at render time.
4366
4367 /// Inner width at which the detail pane is painted beside the list and the
4368 /// groups column appears. Below it the panel is tabs over one full-width
4369 /// list, and the group headings inside the list carry the grouping.
4370 const CONFIG_SHELL_DETAIL_MIN_WIDTH: u16 = 100;
4371 /// Groups column width (the active tab's `ui.group` names).
4372 const CONFIG_SHELL_GROUPS_WIDTH: u16 = 18;
4373
4374 /// Pane geometry for one render of the settings shell.
4375 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4376 struct ConfigShellPanes {
4377 groups: Option<Rect>,
4378 list: Rect,
4379 detail: Option<Rect>,
4380 }
4381
4382 fn config_shell_panes(body: Rect, use_rail: bool) -> ConfigShellPanes {
4383 let rail_width = if use_rail {
4384 CONFIG_SHELL_GROUPS_WIDTH
4385 } else {
4386 0
4387 };
4388 let detail_width = if body.width >= CONFIG_SHELL_DETAIL_MIN_WIDTH {
4389 // A third of the body, floored so the list keeps a legible value
4390 // column at the 100-column blocker size.
4391 (body.width.saturating_mul(34) / 100).clamp(28, 44)
4392 } else {
4393 0
4394 };
4395 let rail_gap = u16::from(rail_width > 0);
4396 let detail_gap = u16::from(detail_width > 0);
4397 let list_width = body
4398 .width
4399 .saturating_sub(rail_width + rail_gap + detail_width + detail_gap)
4400 .max(1);
4401 let groups = (rail_width > 0).then_some(Rect {
4402 width: rail_width,
4403 ..body
4404 });
4405 let list = Rect {
4406 x: body.x.saturating_add(rail_width + rail_gap),
4407 width: list_width,
4408 ..body
4409 };
4410 let detail = (detail_width > 0).then_some(Rect {
4411 x: list.right().saturating_add(detail_gap),
4412 width: detail_width,
4413 ..body
4414 });
4415 ConfigShellPanes {
4416 groups,
4417 list,
4418 detail,
4419 }
4420 }
4421
4422 fn setting_authority_label(
4423 locale: Locale,
4424 authority: SettingAuthority,
4425 detail: Option<&str>,
4426 ) -> Cow<'static, str> {
4427 let name = detail.unwrap_or_default();
4428 match authority {
4429 SettingAuthority::Environment => {
4430 Cow::Owned(tr(locale, MessageId::ConfigSourceEnvironment).replace("{name}", name))
4431 }
4432 SettingAuthority::Terminal => {
4433 Cow::Owned(tr(locale, MessageId::ConfigSourceTerminal).replace("{name}", name))
4434 }
4435 SettingAuthority::Session => tr(locale, MessageId::ConfigSourceSession),
4436 SettingAuthority::UserSettings => tr(locale, MessageId::ConfigSourceUserSettings),
4437 SettingAuthority::WorkspaceConfiguration => tr(locale, MessageId::ConfigSourceConfig),
4438 SettingAuthority::ManagedPolicy => tr(locale, MessageId::ConfigSourceManaged),
4439 }
4440 }
4441
4442 fn setting_apply_label(locale: Locale, apply: SettingApplySemantics) -> Cow<'static, str> {
4443 tr(
4444 locale,
4445 match apply {
4446 SettingApplySemantics::EffectiveNow => MessageId::ConfigApplyEffectiveNow,
4447 SettingApplySemantics::Immediate => MessageId::ConfigApplyOnSave,
4448 SettingApplySemantics::NextSession => MessageId::ConfigApplyNextSession,
4449 SettingApplySemantics::RestartRequired => MessageId::ConfigApplyRestart,
4450 SettingApplySemantics::ReadOnly => MessageId::ConfigApplyReadOnly,
4451 SettingApplySemantics::ReloadRequired => MessageId::ConfigApplyReload,
4452 SettingApplySemantics::UiNowEngineRestart => MessageId::ConfigApplyUiNowEngineRestart,
4453 },
4454 )
4455 }
4456
4457 fn setting_kind_label(locale: Locale, kind: SettingKind) -> Cow<'static, str> {
4458 tr(
4459 locale,
4460 match kind {
4461 SettingKind::Boolean => MessageId::ConfigKindToggle,
4462 SettingKind::Choice => MessageId::ConfigKindChoice,
4463 SettingKind::Integer => MessageId::ConfigKindNumber,
4464 SettingKind::Text => MessageId::ConfigKindText,
4465 SettingKind::Action => MessageId::ConfigKindAction,
4466 SettingKind::ReadOnly => MessageId::ConfigKindReadOnly,
4467 },
4468 )
4469 }
4470
4471 /// Locale-neutral affordance glyph painted beside every list row so the
4472 /// interaction (toggle / choose / edit / open / none) is visible before the
4473 /// row is selected.
4474 fn setting_affordance(kind: SettingKind, on: Option<bool>) -> &'static str {
4475 match kind {
4476 SettingKind::Boolean => {
4477 if on == Some(true) {
4478 "[x]"
4479 } else {
4480 "[ ]"
4481 }
4482 }
4483 SettingKind::Choice => "‹ ›",
4484 SettingKind::Integer | SettingKind::Text => "✎",
4485 SettingKind::Action => "›",
4486 SettingKind::ReadOnly => "⊘",
4487 }
4488 }
4489
4490 /// Styles a category navigator paints with; `ConfigView` and the Tideline
4491 /// stage scaffold each supply their own palette.
4492 #[derive(Debug, Clone, Copy)]
4493 pub(crate) struct CategoryNavStyle {
4494 pub selected: Style,
4495 pub normal: Style,
4496 pub marker: Style,
4497 pub ascii_safe: bool,
4498 }
4499
4500 /// Window of chips `[start, end)` that fits `width` columns while always
4501 /// containing `selected`. Chips are separated by one column and two columns
4502 /// are reserved on each side that hides chips, for the overflow markers.
4503 fn category_strip_window(widths: &[usize], selected: usize, width: usize) -> (usize, usize) {
4504 let count = widths.len();
4505 let mut start = 0;
4506 loop {
4507 let mut used = if start > 0 { 2 } else { 0 };
4508 let mut end = start;
4509 while end < count {
4510 let separator = usize::from(end > start);
4511 let tail = if end + 1 < count { 2 } else { 0 };
4512 if end > start && used + separator + widths[end] + tail > width {
4513 break;
4514 }
4515 used += separator + widths[end];
4516 end += 1;
4517 }
4518 let end = end.max((start + 1).min(count));
4519 if selected < end || start + 1 >= count {
4520 return (start, end);
4521 }
4522 start += 1;
4523 }
4524 }
4525
4526 /// Painted cells of a category strip: the visible chips and the ‹ / ›
4527 /// overflow markers, which are themselves pointer targets so every category
4528 /// is reachable by clicking alone at any width.
4529 #[derive(Debug, Clone, Default, PartialEq, Eq)]
4530 pub(crate) struct CategoryStripHitboxes {
4531 pub chips: Vec<(Rect, ConfigCategory)>,
4532 pub previous: Option<Rect>,
4533 pub next: Option<Rect>,
4534 }
4535
4536 /// Paint the horizontally windowed category strip (the narrow-width
4537 /// navigator from the design's `.settings-nav` rule) and return the painted
4538 /// rect of every visible category and overflow marker.
4539 ///
4540 /// `hovered` tints the chip under the pointer (and `hovered_nav` the overflow
4541 /// marker) with the shared hover style so every strip target answers
4542 /// visibly; hover never moves `selected`.
4543 pub(crate) fn render_settings_category_strip(
4544 area: Rect,
4545 buf: &mut Buffer,
4546 selected: ConfigCategory,
4547 locale: Locale,
4548 style: CategoryNavStyle,
4549 hovered: Option<ConfigCategory>,
4550 hovered_nav: Option<NavStep>,
4551 ) -> CategoryStripHitboxes {
4552 use crate::tui::ui_text::{text_display_width, truncate_line_to_width};
4553
4554 let mut hitboxes = CategoryStripHitboxes::default();
4555 if area.width < 4 || area.height == 0 {
4556 return hitboxes;
4557 }
4558 // At phone-width terminals, show one complete category and its position.
4559 // Both arrows retain the same wraparound navigation and measured targets.
4560 if area.width < 50 {
4561 let previous = Rect::new(area.x, area.y, 2, 1);
4562 let next = Rect::new(area.right().saturating_sub(2), area.y, 2, 1);
4563 let label_area = Rect::new(area.x + 2, area.y, area.width.saturating_sub(4), 1);
4564 let label = format!(
4565 "{} {}/{}",
4566 selected.label(locale),
4567 selected.position() + 1,
4568 ConfigCategory::ALL.len()
4569 );
4570 for (rect, glyph, step) in [
4571 (
4572 previous,
4573 if style.ascii_safe { "< " } else { "‹ " },
4574 NavStep::Previous,
4575 ),
4576 (
4577 next,
4578 if style.ascii_safe { " >" } else { " ›" },
4579 NavStep::Next,
4580 ),
4581 ] {
4582 let marker_style = if hovered_nav == Some(step) {
4583 style.marker.patch(menu_style::hovered_row_style())
4584 } else {
4585 style.marker
4586 };
4587 buf.set_stringn(rect.x, rect.y, glyph, 2, marker_style);
4588 }
4589 buf.set_stringn(
4590 label_area.x,
4591 label_area.y,
4592 truncate_line_to_width(&label, usize::from(label_area.width)),
4593 usize::from(label_area.width),
4594 style.selected,
4595 );
4596 hitboxes.chips.push((label_area, selected));
4597 hitboxes.previous = Some(previous);
4598 hitboxes.next = Some(next);
4599 return hitboxes;
4600 }
4601 let labels: Vec<String> = ConfigCategory::ALL
4602 .iter()
4603 .map(|category| category.label(locale).into_owned())
4604 .collect();
4605 let widths: Vec<usize> = labels
4606 .iter()
4607 .map(|label| text_display_width(label) + 2)
4608 .collect();
4609 let (start, end) = category_strip_window(&widths, selected.position(), usize::from(area.width));
4610 let (prev, next) = if style.ascii_safe {
4611 ("< ", " >")
4612 } else {
4613 ("‹ ", " ›")
4614 };
4615 let y = area.y;
4616 let right = area.right();
4617 let mut x = area.x;
4618 if start > 0 {
4619 let prev_style = if hovered_nav == Some(NavStep::Previous) {
4620 style
4621 .marker
4622 .patch(crate::tui::menu_style::hovered_row_style())
4623 } else {
4624 style.marker
4625 };
4626 buf.set_stringn(x, y, prev, 2, prev_style);
4627 hitboxes.previous = Some(Rect {
4628 x,
4629 y,
4630 width: 2,
4631 height: 1,
4632 });
4633 x = x.saturating_add(2);
4634 }
4635 let tail_reserve: u16 = if end < labels.len() { 2 } else { 0 };
4636 for (index, label) in labels.iter().enumerate().take(end).skip(start) {
4637 let limit = right.saturating_sub(tail_reserve).saturating_sub(x);
4638 if limit == 0 {
4639 break;
4640 }
4641 let chip = format!(" {label} ");
4642 let painted = truncate_line_to_width(&chip, usize::from(limit));
4643 let painted_width = u16::try_from(text_display_width(&painted)).unwrap_or(limit);
4644 if painted_width == 0 {
4645 break;
4646 }
4647 let category = ConfigCategory::ALL[index];
4648 let chip_style = if category == selected {
4649 style.selected
4650 } else if hovered == Some(category) {
4651 style
4652 .normal
4653 .patch(crate::tui::menu_style::hovered_row_style())
4654 } else {
4655 style.normal
4656 };
4657 buf.set_stringn(x, y, &painted, usize::from(limit), chip_style);
4658 hitboxes.chips.push((
4659 Rect {
4660 x,
4661 y,
4662 width: painted_width,
4663 height: 1,
4664 },
4665 category,
4666 ));
4667 x = x.saturating_add(painted_width).saturating_add(1);
4668 }
4669 if end < labels.len() {
4670 let marker_x = right.saturating_sub(2);
4671 let next_style = if hovered_nav == Some(NavStep::Next) {
4672 style
4673 .marker
4674 .patch(crate::tui::menu_style::hovered_row_style())
4675 } else {
4676 style.marker
4677 };
4678 buf.set_stringn(marker_x, y, next, 2, next_style);
4679 hitboxes.next = Some(Rect {
4680 x: marker_x,
4681 y,
4682 width: 2,
4683 height: 1,
4684 });
4685 }
4686 hitboxes
4687 }
4688
4689 impl ConfigView {
4690 /// Display label of the exact persisted value, without any effective
4691 /// suffix: the `saved` lane of the detail pane.
4692 fn saved_display_value(&self, row: &ConfigRow) -> String {
4693 // Preserve the exact saved currency alias (for example `rmb`).
4694 if row.key == "cost_currency" {
4695 return row.value.clone();
4696 }
4697 if SettingsRegistry::new(self).meta(row).choices.is_some() {
4698 if config_default_placeholder_message(&row.key).is_some_and(|message_id| {
4699 row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id)
4700 }) {
4701 return self.tr(MessageId::ConfigValueProviderDefault).into_owned();
4702 }
4703 let canonical = canonical_config_choice(&row.key, &row.value);
4704 return config_choice_label(self.locale, &row.key, &canonical);
4705 }
4706 row.value.clone()
4707 }
4708
4709 /// Editor kind from the existing registry (its boolean/choice/integer
4710 /// tables plus the row's typed activation command).
4711 fn editor_kind(&self, row: &ConfigRow) -> SettingKind {
4712 SettingsRegistry::new(self).meta(row).kind
4713 }
4714
4715 /// Project a setting row onto the shared Tideline fact. Action and
4716 /// diagnostic rows are not persisted facts and project to `None`.
4717 ///
4718 /// A lane is filled only from an explicit observation: the session
4719 /// snapshot, the live session value, the persisted value (saved and
4720 /// startup), or the `App` field carried on the row as `effective`.
4721 /// Nothing is inferred across lanes.
4722 fn setting_fact(&self, row: &ConfigRow) -> Option<SettingFact<String>> {
4723 if row.facts.kind != ConfigRowKind::Setting {
4724 return None;
4725 }
4726 let mut fact = match row.facts.snapshot {
4727 Some(SnapshotLane::Provider) => self.snapshot.provider.clone(),
4728 Some(SnapshotLane::Model) => self.snapshot.model.clone(),
4729 None => match row.scope {
4730 ConfigScope::Session => SettingFact::active_session(self.saved_display_value(row)),
4731 ConfigScope::Saved => {
4732 // An unreadable store yields no saved or startup value.
4733 let saved = row
4734 .facts
4735 .store_error
4736 .is_none()
4737 .then(|| self.saved_display_value(row));
4738 let effective = row.facts.effective.as_deref().map(|value| {
4739 config_choice_label(
4740 self.locale,
4741 &row.key,
4742 &canonical_config_choice(&row.key, value),
4743 )
4744 });
4745 SettingFact {
4746 current: effective.clone(),
4747 effective,
4748 startup: saved.clone(),
4749 saved,
4750 authority: row.facts.authority,
4751 apply: row.facts.apply,
4752 }
4753 }
4754 },
4755 };
4756 fact.authority = row.facts.authority;
4757 fact.apply = row.facts.apply;
4758 Some(fact)
4759 }
4760
4761 /// Verb for activating the selected row (`Enter opens…`, `Space toggles`).
4762 fn setting_action_label(&self, row: &ConfigRow) -> Cow<'static, str> {
4763 if let Some((_, verb)) = row.facts.command {
4764 return self.tr(verb);
4765 }
4766 self.tr(match self.editor_kind(row) {
4767 SettingKind::Boolean => MessageId::ConfigActionToggle,
4768 SettingKind::Choice => MessageId::ConfigActionChoose,
4769 SettingKind::Integer | SettingKind::Text => MessageId::ConfigActionEdit,
4770 SettingKind::Action | SettingKind::ReadOnly => MessageId::ConfigActionReadOnly,
4771 })
4772 }
4773
4774 /// What activating the selected row does, spelled out: second click or
4775 /// Enter is the activation model, so the row says so.
4776 fn activation_copy(&self, row: &ConfigRow) -> String {
4777 if row.editable {
4778 format!(
4779 "{} {}",
4780 self.tr(MessageId::ConfigActivateAgain),
4781 self.setting_action_label(row)
4782 )
4783 } else {
4784 self.tr(MessageId::ConfigActionReadOnly).into_owned()
4785 }
4786 }
4787
4788 fn lane_or_unobserved(&self, lane: Option<&String>) -> String {
4789 lane.cloned()
4790 .unwrap_or_else(|| self.tr(MessageId::ConfigLaneUnobserved).into_owned())
4791 }
4792
4793 /// A persisted lane: the value, or the store's load error, or unobserved.
4794 fn store_lane(&self, lane: Option<&String>, store_error: Option<&str>) -> String {
4795 match (lane, store_error) {
4796 (Some(value), _) => value.clone(),
4797 (None, Some(error)) => self
4798 .tr(MessageId::ConfigLaneUnavailable)
4799 .replace("{error}", error),
4800 (None, None) => self.tr(MessageId::ConfigLaneUnobserved).into_owned(),
4801 }
4802 }
4803
4804 /// The detail pane: label and key, then the typed facts for the row's
4805 /// kind, then the description and the activation copy.
4806 fn setting_detail_lines(&self, row: &ConfigRow, width: usize) -> Vec<Line<'static>> {
4807 use crate::tui::ui_text::semantic_truncate;
4808
4809 let label = config_label_for_key_for_locale(self.locale, &row.key);
4810 let kind = self.editor_kind(row);
4811 let muted = Style::default().fg(palette::TEXT_MUTED);
4812 let primary = Style::default().fg(palette::TEXT_PRIMARY);
4813 let value_width = width.saturating_sub(10);
4814 let fact_line = |name: MessageId, value: &str| {
4815 Line::from(vec![
4816 Span::styled(format!("{:<10}", self.tr(name)), muted),
4817 Span::styled(semantic_truncate(value, value_width), primary),
4818 ])
4819 };
4820 let mut lines = vec![
4821 Line::from(Span::styled(
4822 semantic_truncate(&label, width),
4823 Style::default().fg(palette::WHALE_ACTION).bold(),
4824 )),
4825 Line::from(Span::styled(
4826 semantic_truncate(&row.key, width),
4827 Style::default().fg(palette::TEXT_DIM),
4828 )),
4829 Line::from(""),
4830 ];
4831 let source =
4832 setting_authority_label(self.locale, row.facts.authority, row.facts.authority_detail);
4833 let kind_label = setting_kind_label(self.locale, kind);
4834 match row.facts.kind {
4835 ConfigRowKind::Setting => {
4836 let fact = self
4837 .setting_fact(row)
4838 .unwrap_or_else(|| SettingFact::active_session(self.saved_display_value(row)));
4839 let store_error = row.facts.store_error.as_deref();
4840 lines.push(fact_line(
4841 MessageId::ConfigFactCurrent,
4842 &self.lane_or_unobserved(fact.effective.as_ref().or(fact.current.as_ref())),
4843 ));
4844 lines.push(fact_line(
4845 MessageId::ConfigFactSaved,
4846 &self.store_lane(fact.saved.as_ref(), store_error),
4847 ));
4848 lines.push(fact_line(
4849 MessageId::ConfigFactStartup,
4850 &self.store_lane(fact.startup.as_ref(), store_error),
4851 ));
4852 lines.push(fact_line(MessageId::ConfigFactSource, &source));
4853 lines.push(fact_line(
4854 MessageId::ConfigFactScope,
4855 row.scope.label(self.locale).as_ref(),
4856 ));
4857 lines.push(fact_line(
4858 MessageId::ConfigFactApply,
4859 &setting_apply_label(self.locale, fact.apply),
4860 ));
4861 lines.push(fact_line(MessageId::ConfigFactKind, &kind_label));
4862 // No existing source reports availability; say so rather
4863 // than implying the setting is known to be usable.
4864 lines.push(fact_line(
4865 MessageId::ConfigFactAvailable,
4866 &self.tr(MessageId::ConfigLaneUnobserved),
4867 ));
4868 }
4869 ConfigRowKind::Action => {
4870 lines.push(Line::from(Span::styled(
4871 self.tr(MessageId::ConfigRowActionNote).into_owned(),
4872 muted,
4873 )));
4874 if let Some((command, _)) = row.facts.command {
4875 lines.push(fact_line(MessageId::ConfigFactOpens, command));
4876 }
4877 lines.push(fact_line(MessageId::ConfigFactSource, &source));
4878 lines.push(fact_line(MessageId::ConfigFactKind, &kind_label));
4879 }
4880 ConfigRowKind::Diagnostic => {
4881 lines.push(Line::from(Span::styled(
4882 self.tr(MessageId::ConfigRowDiagnosticNote).into_owned(),
4883 muted,
4884 )));
4885 lines.push(fact_line(MessageId::ConfigFactObserved, &row.value));
4886 lines.push(fact_line(MessageId::ConfigFactSource, &source));
4887 lines.push(fact_line(MessageId::ConfigFactKind, &kind_label));
4888 }
4889 }
4890 lines.push(Line::from(""));
4891 let hint = config_hint_for_key(self.locale, &row.key);
4892 let description = if hint.is_empty() {
4893 self.tr(MessageId::ConfigDescriptionDefault)
4894 } else {
4895 hint
4896 };
4897 lines.push(Line::from(Span::styled(description.into_owned(), muted)));
4898 lines.push(Line::from(Span::styled(
4899 self.activation_copy(row),
4900 Style::default().fg(palette::TEXT_HINT),
4901 )));
4902 lines
4903 }
4904
4905 /// One-row fold of the detail for surfaces too narrow for the pane: the
4906 /// activation copy first, then the lanes that still fit.
4907 fn setting_detail_summary(&self, row: &ConfigRow) -> String {
4908 let activation = self.activation_copy(row);
4909 let label = config_label_for_key_for_locale(self.locale, &row.key);
4910 match row.facts.kind {
4911 ConfigRowKind::Setting => {
4912 let fact = self
4913 .setting_fact(row)
4914 .unwrap_or_else(|| SettingFact::active_session(self.saved_display_value(row)));
4915 format!(
4916 "{activation} · {label}: {} {} · {} {} · {}",
4917 self.tr(MessageId::ConfigFactCurrent),
4918 self.lane_or_unobserved(fact.effective.as_ref().or(fact.current.as_ref())),
4919 self.tr(MessageId::ConfigFactSaved),
4920 self.store_lane(fact.saved.as_ref(), row.facts.store_error.as_deref()),
4921 setting_apply_label(self.locale, fact.apply)
4922 )
4923 }
4924 ConfigRowKind::Action => {
4925 format!("{activation} · {}", self.tr(MessageId::ConfigRowActionNote))
4926 }
4927 ConfigRowKind::Diagnostic => {
4928 format!(
4929 "{label}: {} · {}",
4930 row.value,
4931 self.tr(MessageId::ConfigRowDiagnosticNote)
4932 )
4933 }
4934 }
4935 }
4936
4937 fn render_pane_divider(area: Rect, buf: &mut Buffer, x: u16) {
4938 if x < area.x || x >= area.right() {
4939 return;
4940 }
4941 for y in area.top()..area.bottom() {
4942 buf[(x, y)]
4943 .set_symbol("│")
4944 .set_style(Style::default().fg(palette::BORDER_COLOR));
4945 }
4946 }
4947
4948 fn render_setting_detail(&self, area: Rect, buf: &mut Buffer) {
4949 let Some(row) = self.selected_row_index().and_then(|idx| self.rows.get(idx)) else {
4950 Paragraph::new(Line::from(Span::styled(
4951 self.tr(MessageId::ConfigNoSettings).into_owned(),
4952 Style::default().fg(palette::TEXT_MUTED),
4953 )))
4954 .wrap(Wrap { trim: false })
4955 .render(area, buf);
4956 return;
4957 };
4958 Paragraph::new(self.setting_detail_lines(row, usize::from(area.width)))
4959 .wrap(Wrap { trim: false })
4960 .render(area, buf);
4961 }
4962
4963 /// Paint the three-pane shell into the surface body.
4964 /// The active tab's groups, in schema order, with the group holding the
4965 /// selected row lit. It is the same projection the centre list paints as
4966 /// headings — at ≥100 columns it also gets a column of its own.
4967 fn render_group_column(&self, area: Rect, buf: &mut Buffer) {
4968 let selected_group = self
4969 .selected_row_index()
4970 .and_then(|idx| self.rows.get(idx))
4971 .map(|row| row.section());
4972 let mut lines: Vec<Line> = Vec::new();
4973 for group in self.visible_groups() {
4974 let selected = Some(group) == selected_group;
4975 let style = if selected {
4976 Style::default()
4977 .fg(palette::WHALE_ACTION)
4978 .add_modifier(Modifier::BOLD)
4979 } else {
4980 Style::default().fg(palette::TEXT_MUTED)
4981 };
4982 lines.push(Line::from(vec![
4983 Span::styled(
4984 if selected { "❯ " } else { " " },
4985 Style::default().fg(palette::WHALE_ACTION),
4986 ),
4987 Span::styled(
4988 fit_config_column(
4989 &group.label(self.locale),
4990 usize::from(area.width).saturating_sub(2),
4991 ),
4992 style,
4993 ),
4994 ]));
4995 }
4996 Paragraph::new(lines).render(area, buf);
4997 }
4998
4999 /// The groups of the active tab that actually have rows, in schema order.
5000 fn visible_groups(&self) -> Vec<ConfigSection> {
5001 let mut groups: Vec<ConfigSection> = Vec::new();
5002 for item in self.visible_items() {
5003 if let ConfigListItem::Section(section) = item
5004 && !groups.contains(&section)
5005 {
5006 groups.push(section);
5007 }
5008 }
5009 groups
5010 }
5011
5012 /// A live preview of the footer the current values paint, drawn by the
5013 /// real footer renderer rather than a mock of it: the theme is the one
5014 /// the theme row selects, the chips are the mode and permission rows, and
5015 /// the depth line is this session's own context reading.
5016 fn render_footer_preview(&self, area: Rect, buf: &mut Buffer) {
5017 let label = self.tr(MessageId::ConfigPreviewLabel);
5018 let label_width = u16::try_from(UnicodeWidthStr::width(label.as_ref())).unwrap_or(0);
5019 if area.width <= label_width {
5020 return;
5021 }
5022 Paragraph::new(Line::from(Span::styled(
5023 label.into_owned(),
5024 Style::default().fg(palette::TEXT_HINT),
5025 )))
5026 .render(Rect { height: 1, ..area }, buf);
5027
5028 let value_of = |key: &str| {
5029 self.rows
5030 .iter()
5031 .find(|row| row.key == key)
5032 .map(|row| canonical_config_choice(key, &row.value))
5033 };
5034 let theme = value_of("theme")
5035 .and_then(|name| {
5036 palette::SELECTABLE_THEMES
5037 .iter()
5038 .find(|id| id.name() == name)
5039 .copied()
5040 })
5041 .map_or(palette::UI_THEME, palette::ThemeId::ui_theme);
5042 let context_percent = self.snapshot.context_budget.as_ref().map_or(0, |budget| {
5043 u8::try_from(budget.percent_basis_points / 100).unwrap_or(100)
5044 });
5045 let mode = value_of("default_mode").unwrap_or_else(|| "agent".to_string());
5046 let mode_ink = match mode.as_str() {
5047 "plan" => palette::ChromeInk::PolicyPlan,
5048 "operate" => palette::ChromeInk::PolicyOperate,
5049 _ => palette::ChromeInk::PolicyAct,
5050 };
5051 let permission = value_of("approval_mode")
5052 .or_else(|| value_of("permission_posture"))
5053 .unwrap_or_else(|| "ask".to_string());
5054 let permission_ink = match permission.as_str() {
5055 "auto-review" => palette::ChromeInk::PermissionAutoReview,
5056 "full-access" => palette::ChromeInk::PermissionFullAccess,
5057 _ => palette::ChromeInk::PermissionAsk,
5058 };
5059 let footer = crate::tui::phase_strip::TidelineFooter::new(
5060 &theme,
5061 (permission.as_str(), permission_ink),
5062 )
5063 .mode_chip(Some((mode.as_str(), mode_ink)))
5064 .context_percent(context_percent);
5065 crate::tui::phase_strip::render_tideline_footer(
5066 Rect {
5067 x: area.x.saturating_add(label_width),
5068 width: area.width - label_width,
5069 height: 1,
5070 ..area
5071 },
5072 buf,
5073 &footer,
5074 );
5075 }
5076
5077 fn render_settings_shell(&self, inner: Rect, buf: &mut Buffer) {
5078 *self.last_choice_hitboxes.borrow_mut() = Vec::new();
5079 *self.last_editor_controls.borrow_mut() = Vec::new();
5080 *self.last_nav_controls.borrow_mut() = Vec::new();
5081 let items = self.visible_items();
5082 let match_count = self.matching_row_indices().len();
5083
5084 let compact = inner.width < 50;
5085 // Keys and symbols stay legible in one row; the search field above
5086 // already explains typing. Shed verbose copy before editable content.
5087 let ascii_safe = crate::tui::color_compat::ascii_safe_enabled();
5088 let compact_hints = if ascii_safe {
5089 [
5090 ActionHint::new("Tab", "<>"),
5091 ActionHint::new("Up/Dn", ""),
5092 ActionHint::new("Enter", ""),
5093 ActionHint::new("Esc", ""),
5094 ]
5095 } else {
5096 [
5097 ActionHint::new("Tab", "⇆"),
5098 ActionHint::new("↑↓", ""),
5099 ActionHint::new("Enter", "↵"),
5100 ActionHint::new("Esc", "×"),
5101 ]
5102 };
5103 // Reserve the action footer by its actual wrapped height so no list
5104 // row silently falls off the bottom on compact terminals.
5105 let footer_height = |id: MessageId| -> usize {
5106 wrapped_footer_lines(&self.tr(id), inner.width, Style::default()).len()
5107 };
5108 let footer_lines = if compact {
5109 1
5110 } else if !self.filter.is_empty() {
5111 footer_height(MessageId::ConfigFooterFiltered)
5112 } else {
5113 footer_height(MessageId::ConfigFooterScrollable)
5114 .max(footer_height(MessageId::ConfigFooterDefault))
5115 }
5116 .max(1);
5117 let content_height = usize::from(inner.height).saturating_sub(footer_lines);
5118
5119 // Header: the tab row over the search line. The tabs are always the
5120 // top row — a settings panel that hides which tab you are on is the
5121 // thing this layout exists to fix.
5122 const HEADER_LINES: usize = 2;
5123 // ≥100 columns spends 18 cells on the groups column and a detail
5124 // pane; below that the tab row and the in-list group headings carry
5125 // the same structure at 80 columns.
5126 let show_detail = inner.width >= CONFIG_SHELL_DETAIL_MIN_WIDTH;
5127 // Bottom band: the selected row's sentence, then a preview of the
5128 // footer these settings paint. The preview sheds first on short
5129 // terminals; the sentence holds while two list lines remain.
5130 let preview_lines = usize::from(content_height >= HEADER_LINES + 10);
5131 let sentence_lines = if compact {
5132 usize::from(content_height >= HEADER_LINES + 3)
5133 } else if content_height.saturating_sub(HEADER_LINES + preview_lines) >= 3 {
5134 // Without a detail pane the band also carries the lanes that pane
5135 // would have shown, on a second line so neither is truncated away.
5136 if show_detail {
5137 1
5138 } else if content_height >= HEADER_LINES + 9 {
5139 3
5140 } else {
5141 2
5142 }
5143 } else {
5144 usize::from(self.status.is_some())
5145 };
5146 let bottom_lines = sentence_lines + preview_lines;
5147 let body_height = content_height
5148 .saturating_sub(HEADER_LINES + bottom_lines)
5149 .max(1);
5150 self.last_visible_rows.set(body_height);
5151 let use_rail = show_detail && body_height >= 3;
5152
5153 let clamp_height = |y: u16, wanted: usize| -> u16 {
5154 u16::try_from(wanted)
5155 .unwrap_or(u16::MAX)
5156 .min(inner.bottom().saturating_sub(y))
5157 };
5158 let header = Rect {
5159 height: clamp_height(inner.y, HEADER_LINES),
5160 ..inner
5161 };
5162 let body = Rect {
5163 y: header.bottom(),
5164 height: clamp_height(header.bottom(), body_height),
5165 ..inner
5166 };
5167 let bottom = Rect {
5168 y: body.bottom(),
5169 height: clamp_height(body.bottom(), bottom_lines),
5170 ..inner
5171 };
5172 let panes = config_shell_panes(body, use_rail);
5173
5174 // Selection-anchored scroll: the row being manipulated always renders.
5175 let list_line_budget = usize::from(body.height).max(1);
5176 // A section caption costs itself plus a blank spacer, except at the
5177 // top of the window where no spacer is painted.
5178 let item_line_cost = |item: &ConfigListItem, first: bool| match item {
5179 ConfigListItem::Section(_) if first => 1usize,
5180 ConfigListItem::Section(_) => 2usize,
5181 ConfigListItem::Row(_) => 1usize,
5182 };
5183 let visible_end = |start: usize| {
5184 let mut used = 0usize;
5185 let mut end = start;
5186 while end < items.len() {
5187 let cost = item_line_cost(&items[end], end == start);
5188 if end > start && used.saturating_add(cost) > list_line_budget {
5189 break;
5190 }
5191 used = used.saturating_add(cost);
5192 end += 1;
5193 }
5194 end
5195 };
5196 let mut start = self.scroll.min(items.len().saturating_sub(1));
5197 if let Some(selected_pos) = self.selected_display_position(&items) {
5198 start = start.min(selected_pos);
5199 while selected_pos >= visible_end(start) && start < selected_pos {
5200 start += 1;
5201 }
5202 }
5203 let end = visible_end(start);
5204 let scrollable = start > 0 || end < items.len();
5205 self.last_render_scroll.set(start);
5206
5207 // Header.
5208 let search_value = if self.filter.is_empty() {
5209 self.tr(MessageId::ConfigSearchPlaceholder).to_string()
5210 } else {
5211 self.filter.clone()
5212 };
5213 let search_line = Line::from(vec![
5214 Span::styled(
5215 self.tr(MessageId::ConfigSearchLabel),
5216 Style::default().fg(palette::TEXT_MUTED),
5217 ),
5218 Span::raw(search_value),
5219 Span::styled(
5220 format!(" ({match_count}/{})", self.rows.len()),
5221 Style::default().fg(palette::TEXT_MUTED),
5222 ),
5223 ]);
5224 *self.last_rail_hitboxes.borrow_mut() = Vec::new();
5225 if header.height > 0 {
5226 let nav_row = Rect {
5227 height: 1,
5228 ..header
5229 };
5230 {
5231 let strip_style = CategoryNavStyle {
5232 selected: menu_style::selected_row_style(),
5233 normal: Style::default().fg(palette::TEXT_MUTED),
5234 marker: Style::default().fg(palette::TEXT_HINT),
5235 ascii_safe,
5236 };
5237 let strip = render_settings_category_strip(
5238 nav_row,
5239 buf,
5240 self.category,
5241 self.locale,
5242 strip_style,
5243 self.hovered_rail,
5244 self.hovered_nav,
5245 );
5246 *self.last_rail_hitboxes.borrow_mut() = strip.chips;
5247 *self.last_nav_controls.borrow_mut() = strip
5248 .previous
5249 .map(|rect| (rect, NavStep::Previous))
5250 .into_iter()
5251 .chain(strip.next.map(|rect| (rect, NavStep::Next)))
5252 .collect();
5253 }
5254 }
5255 if header.height > 1 {
5256 Paragraph::new(search_line).render(
5257 Rect {
5258 y: header.y.saturating_add(1),
5259 height: 1,
5260 ..header
5261 },
5262 buf,
5263 );
5264 }
5265
5266 // Groups column: the active tab's `ui.group` names, the one holding
5267 // the selected row lit.
5268 if let Some(column) = panes.groups {
5269 self.render_group_column(column, buf);
5270 Self::render_pane_divider(body, buf, column.right());
5271 }
5272
5273 // List.
5274 let list =
5275 render_panel_scroll_rail(panes.list, buf, items.len(), start, list_line_budget, true);
5276 let (key_column_width, value_column_width, scope_column_width) =
5277 self.table_column_widths(usize::from(list.width));
5278 let mut lines: Vec<Line> = Vec::new();
5279 let mut row_hitboxes = Vec::new();
5280 for item in &items[start..end] {
5281 match item {
5282 ConfigListItem::Section(section) => {
5283 if !lines.is_empty() {
5284 lines.push(Line::from(""));
5285 }
5286 lines.push(Line::from(Span::styled(
5287 format!(" {}", section.label(self.locale)),
5288 Style::default()
5289 .fg(palette::TEXT_HINT)
5290 .bold()
5291 .add_modifier(Modifier::UNDERLINED),
5292 )));
5293 }
5294 ConfigListItem::Row(idx) => {
5295 let Some(row) = self.rows.get(*idx) else {
5296 continue;
5297 };
5298 let line_y = list.y.saturating_add(lines.len() as u16);
5299 if line_y >= list.bottom() {
5300 break;
5301 }
5302 row_hitboxes.push((
5303 Rect {
5304 x: list.x,
5305 y: line_y,
5306 width: list.width,
5307 height: 1,
5308 },
5309 *idx,
5310 ));
5311 let selected = *idx == self.selected;
5312 // Hover tints but never steals the keyboard selection.
5313 let hovered = !selected && self.hovered_row == Some(*idx);
5314 let style = if selected {
5315 menu_style::selected_row_style()
5316 } else if row.editable {
5317 Style::default().fg(palette::TEXT_PRIMARY)
5318 } else {
5319 // Read-only rows look distinct from editable ones.
5320 Style::default()
5321 .fg(palette::TEXT_MUTED)
5322 .add_modifier(Modifier::DIM)
5323 };
5324 let label = config_label_for_key_for_locale(self.locale, &row.key);
5325 let (key_width, value_width) = if compact {
5326 let available = usize::from(list.width).saturating_sub(
5327 CONFIG_ROW_PREFIX_WIDTH
5328 + CONFIG_COLUMN_GAPS_WIDTH
5329 + CONFIG_AFFORDANCE_COLUMN_WIDTH,
5330 );
5331 let key_width = UnicodeWidthStr::width(label.as_str()).min(available / 2);
5332 (key_width, available.saturating_sub(key_width))
5333 } else {
5334 (key_column_width, value_column_width)
5335 };
5336 let key = fit_config_column(&label, key_width);
5337 let value = fit_config_column(&self.row_display_value(row), value_width);
5338 let kind = self.editor_kind(row);
5339 let on = (kind == SettingKind::Boolean)
5340 .then(|| canonical_config_choice(&row.key, row.edit_value()) == "true");
5341 let affordance = setting_affordance(kind, on);
5342 // Action and diagnostic rows are not persisted facts, so
5343 // they carry no scope badge.
5344 let badge = match row.facts.kind {
5345 ConfigRowKind::Setting if scope_column_width > 0 => {
5346 row.scope.label(self.locale)
5347 }
5348 _ => Cow::Borrowed(""),
5349 };
5350 let rail = if selected { "❯" } else { " " };
5351 let mut line = Line::from(vec![
5352 Span::styled(
5353 rail,
5354 if selected {
5355 style
5356 } else {
5357 Style::default().fg(palette::TEXT_DIM)
5358 },
5359 ),
5360 Span::styled(format!("{key} {value} "), style),
5361 Span::styled(
5362 format!("{affordance:<3} "),
5363 if selected {
5364 style
5365 } else if row.editable {
5366 Style::default().fg(palette::WHALE_ACTION)
5367 } else {
5368 Style::default()
5369 .fg(palette::TEXT_DIM)
5370 .add_modifier(Modifier::DIM)
5371 },
5372 ),
5373 Span::styled(
5374 badge.into_owned(),
5375 if selected {
5376 style
5377 } else {
5378 Style::default()
5379 .fg(palette::TEXT_HINT)
5380 .add_modifier(Modifier::DIM)
5381 },
5382 ),
5383 ]);
5384 if selected {
5385 line.style = menu_style::selected_row_bg_style();
5386 } else if hovered {
5387 line.style = menu_style::hovered_row_style();
5388 }
5389 lines.push(line);
5390 }
5391 }
5392 }
5393 *self.last_row_hitboxes.borrow_mut() = row_hitboxes;
5394 if items.is_empty() {
5395 let message = if self.filter.is_empty() {
5396 self.tr(MessageId::ConfigNoSettings).to_string()
5397 } else {
5398 format!(
5399 "{}\"{}\".",
5400 self.tr(MessageId::ConfigNoMatchesPrefix),
5401 self.filter
5402 )
5403 };
5404 lines.push(Line::from(Span::styled(
5405 message,
5406 Style::default().fg(palette::TEXT_MUTED),
5407 )));
5408 }
5409 Paragraph::new(lines)
5410 .style(Style::default().fg(palette::TEXT_PRIMARY))
5411 .render(list, buf);
5412
5413 // Detail.
5414 if let Some(detail) = panes.detail {
5415 Self::render_pane_divider(body, buf, detail.x.saturating_sub(1));
5416 self.render_setting_detail(detail, buf);
5417 }
5418
5419 // Status row: an explicit status wins; otherwise the selected row's
5420 // activation copy, with the detail facts folded in when no pane
5421 // shows them.
5422 let sentence_height = u16::try_from(sentence_lines)
5423 .unwrap_or(0)
5424 .min(bottom.height);
5425 if sentence_height > 0 {
5426 let selected_row = self.selected_row_index().and_then(|idx| self.rows.get(idx));
5427 // The band says what the selected setting *is*: the schema's
5428 // sentence, in one plain line. A live status or an active filter
5429 // is more urgent and takes the row while it lasts.
5430 let bottom_text = if let Some(status) = self.status.as_ref() {
5431 status.clone()
5432 } else if let Some(row) = selected_row.filter(|_| compact) {
5433 format!(
5434 "{}: {}",
5435 config_label_for_key_for_locale(self.locale, &row.key),
5436 self.row_display_value(row)
5437 )
5438 } else if !self.filter.is_empty() {
5439 format!(
5440 "{}: {match_count}",
5441 self.tr(MessageId::ConfigFilteredSettings)
5442 )
5443 } else if let Some(row) = selected_row {
5444 let sentence = config_hint_for_key(self.locale, &row.key);
5445 if sentence.is_empty() {
5446 self.activation_copy(row)
5447 } else {
5448 sentence.into_owned()
5449 }
5450 } else {
5451 String::new()
5452 };
5453 let band = vec![Line::from(Span::styled(
5454 crate::tui::ui_text::semantic_truncate(&bottom_text, usize::from(inner.width)),
5455 Style::default().fg(palette::TEXT_MUTED),
5456 ))];
5457 // With a detail pane the lanes are already on screen; without one
5458 // the band's second line carries what that pane would have shown.
5459 Paragraph::new(band).render(
5460 Rect {
5461 height: 1,
5462 ..bottom
5463 },
5464 buf,
5465 );
5466 if sentence_height > 1
5467 && self.status.is_none()
5468 && self.filter.is_empty()
5469 && let Some(row) = selected_row
5470 {
5471 let folded = Rect {
5472 y: bottom.y.saturating_add(1),
5473 height: sentence_height - 1,
5474 ..bottom
5475 };
5476 Paragraph::new(Line::from(Span::styled(
5477 crate::tui::ui_text::semantic_truncate(
5478 &self.setting_detail_summary(row),
5479 usize::from(inner.width).saturating_mul(usize::from(folded.height)),
5480 ),
5481 Style::default().fg(palette::TEXT_HINT),
5482 )))
5483 .wrap(Wrap { trim: true })
5484 .render(folded, buf);
5485 }
5486 }
5487 if bottom.height > sentence_height {
5488 self.render_footer_preview(
5489 Rect {
5490 y: bottom.y.saturating_add(sentence_height),
5491 height: bottom.height - sentence_height,
5492 ..bottom
5493 },
5494 buf,
5495 );
5496 }
5497
5498 let footer = if !self.filter.is_empty() {
5499 self.tr(MessageId::ConfigFooterFiltered)
5500 } else if scrollable {
5501 self.tr(MessageId::ConfigFooterScrollable)
5502 } else {
5503 self.tr(MessageId::ConfigFooterDefault)
5504 };
5505 if compact {
5506 render_modal_footer(inner, buf, &compact_hints);
5507 } else {
5508 render_modal_text_footer(
5509 inner,
5510 buf,
5511 &footer,
5512 Style::default().fg(palette::TEXT_MUTED),
5513 );
5514 }
5515 }
5516 }
5517
5518 pub mod help;
5519
5520 pub use help::HelpView;
5521
5522 pub struct SubAgentsView {
5523 agents: Vec<SubAgentResult>,
5524 scroll: usize,
5525 /// Index into the render-ordered agent list (`ordered` on `grouped`).
5526 /// Enter/click open the selected agent's transcript — the same primary
5527 /// destination every other agent surface resolves to (v0.9.7).
5528 selected: usize,
5529 /// Rendered agent blocks from the last frame: `(first_line, line_count,
5530 /// agent_id)` in render order. Interior-mutable because `render` takes
5531 /// `&self`; consumed by click resolution and selection scroll-follow.
5532 row_lines: std::cell::RefCell<Vec<(usize, usize, String)>>,
5533 /// Body area of the last render, for mapping click rows onto lines.
5534 body_area: std::cell::Cell<Rect>,
5535 /// Effective (clamped) scroll of the last render.
5536 last_render_scroll: std::cell::Cell<usize>,
5537 /// Visible body height of the last render.
5538 last_visible_lines: std::cell::Cell<usize>,
5539 /// Motion policy at open: the Whale Teams working wake animates only
5540 /// under `MotionMode::Full` (Reduced/Still hold the poster frame).
5541 motion: crate::tui::motion::mode::MotionMode,
5542 /// UI locale for the whale state words.
5543 locale: Locale,
5544 /// Wall clock anchor for the working-wake frame.
5545 opened_at: std::time::Instant,
5546 /// True when the Fleet roster is parked directly underneath on the view
5547 /// stack (#5954), i.e. this view was reached with `Tab`/`w` from the
5548 /// roster. `Esc` still pops exactly one view — the flag only decides
5549 /// whether the footer promises `back` or `close`, and lets `F` return to
5550 /// the parked roster instead of stacking a second one. Direct entry
5551 /// (`/fleet workers`, the Work dock) leaves it false, so `Esc` closes.
5552 back_to_fleet_roster: bool,
5553 }
5554
5555 /// Build the agent rows shown by `/subagents`.
5556 ///
5557 /// The engine manager is the durable source of truth, but live UI cards can
5558 /// briefly be ahead of the manager-list refresh. Include those live rows so
5559 /// the command does not say "no agents" while the footer/sidebar already show
5560 /// active delegated work.
5561 pub(crate) fn subagent_view_agents(
5562 app: &App,
5563 manager_agents: &[SubAgentResult],
5564 ) -> Vec<SubAgentResult> {
5565 let mut agents = manager_agents.to_vec();
5566 let manager_agent_count = agents.len();
5567 let mut seen: std::collections::HashSet<String> =
5568 agents.iter().map(|agent| agent.agent_id.clone()).collect();
5569
5570 for (agent_id, progress) in &app.agent_progress {
5571 if seen.insert(agent_id.clone()) {
5572 agents.push(live_subagent_result(
5573 agent_id,
5574 FleetRole::Worker,
5575 SubAgentStatus::Running,
5576 progress,
5577 Some("live"),
5578 None, // live rows compute nickname from agent manager on render
5579 ));
5580 }
5581 }
5582
5583 for cell in &app.history {
5584 match cell {
5585 HistoryCell::SubAgent(SubAgentCell::Delegate(card))
5586 if seen.insert(card.agent_id.clone()) =>
5587 {
5588 let agent_type = FleetRole::from_str(&card.agent_type).unwrap_or(FleetRole::Worker);
5589 agents.push(live_subagent_result(
5590 &card.agent_id,
5591 agent_type,
5592 lifecycle_to_subagent_status(card.status),
5593 card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
5594 Some("transcript"),
5595 None, // transcript-derived rows get nickname from manager on render
5596 ));
5597 }
5598 HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => {
5599 for worker in &card.workers {
5600 if seen.insert(worker.agent_id.clone()) {
5601 let objective = format!(
5602 "{} worker {}",
5603 summarize_tool_output(&card.kind),
5604 summarize_tool_output(&worker.worker_id)
5605 );
5606 agents.push(live_subagent_result(
5607 &worker.agent_id,
5608 FleetRole::Worker,
5609 lifecycle_to_subagent_status(worker.status),
5610 &objective,
5611 Some(card.kind.as_str()),
5612 None, // fanout worker rows get nickname from manager on render
5613 ));
5614 }
5615 }
5616 }
5617 _ => {}
5618 }
5619 }
5620
5621 let mut display_names = localized_whale_display_names(
5622 agents[..manager_agent_count]
5623 .iter()
5624 .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())),
5625 app.ui_locale.tag(),
5626 );
5627 for agent in &mut agents[..manager_agent_count] {
5628 // The row headline reads `nickname`, so the dispatch name lands there
5629 // when the agent has one; the generated whale names the rest (#5287).
5630 let display_name = crate::tui::sidebar::dispatched_agent_name(agent)
5631 .map(str::to_string)
5632 .or_else(|| display_names.remove(&agent.agent_id));
5633 agent.nickname = display_name;
5634 }
5635 for agent in &mut agents[manager_agent_count..] {
5636 // Progress and transcript rows can arrive before ListSubAgents. Keep
5637 // their stable Agent-N placeholder until the manager snapshot supplies
5638 // the locale-neutral identity needed for generated whale display.
5639 agent.nickname = app.agent_label_map.get(&agent.agent_id).cloned();
5640 }
5641
5642 agents
5643 }
5644
5645 fn lifecycle_to_subagent_status(status: AgentLifecycle) -> SubAgentStatus {
5646 match status {
5647 AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running,
5648 AgentLifecycle::Completed => SubAgentStatus::Completed,
5649 AgentLifecycle::Failed => SubAgentStatus::Failed("failed in transcript".to_string()),
5650 AgentLifecycle::Cancelled => SubAgentStatus::Cancelled,
5651 AgentLifecycle::Interrupted => {
5652 SubAgentStatus::Interrupted("interrupted in transcript".to_string())
5653 }
5654 }
5655 }
5656
5657 fn live_subagent_result(
5658 agent_id: &str,
5659 agent_type: FleetRole,
5660 status: SubAgentStatus,
5661 objective: &str,
5662 role: Option<&str>,
5663 nickname: Option<String>,
5664 ) -> SubAgentResult {
5665 SubAgentResult {
5666 usage: None,
5667 name: agent_id.to_string(),
5668 agent_id: agent_id.to_string(),
5669 context_mode: "fresh".to_string(),
5670 fork_context: false,
5671 workspace: None,
5672 git_branch: None,
5673 agent_type,
5674 assignment: SubAgentAssignment {
5675 objective: summarize_tool_output(objective),
5676 role: role.map(str::to_string),
5677 },
5678 model: String::new(),
5679 nickname,
5680 status,
5681 worker_status: None,
5682 runtime_permissions: None,
5683 parent_run_id: None,
5684 spawn_depth: 0,
5685 child_route: None,
5686 result: None,
5687 steps_taken: 0,
5688 checkpoint: None,
5689 needs_input: None,
5690 duration_ms: 0,
5691 started_at: None,
5692 from_prior_session: false,
5693 }
5694 }
5695
5696 impl SubAgentsView {
5697 pub fn new(agents: Vec<SubAgentResult>) -> Self {
5698 Self {
5699 agents,
5700 scroll: 0,
5701 selected: 0,
5702 row_lines: std::cell::RefCell::new(Vec::new()),
5703 body_area: std::cell::Cell::new(Rect::default()),
5704 last_render_scroll: std::cell::Cell::new(0),
5705 last_visible_lines: std::cell::Cell::new(0),
5706 motion: crate::tui::motion::mode::MotionMode::Still,
5707 locale: Locale::En,
5708 opened_at: std::time::Instant::now(),
5709 back_to_fleet_roster: false,
5710 }
5711 }
5712
5713 /// Open with the app's motion policy and locale so the whale rows follow
5714 /// the user's reduced-motion setting and language.
5715 pub fn for_app(app: &App, agents: Vec<SubAgentResult>) -> Self {
5716 let mut view = Self::new(agents);
5717 view.motion = app.motion_policy().mode();
5718 view.locale = app.ui_locale;
5719 view
5720 }
5721
5722 /// Mark this view as pushed on top of the Fleet roster (#5954), so the
5723 /// footer says `back` and `F` pops to the parked roster.
5724 #[must_use]
5725 pub fn over_fleet_roster(mut self) -> Self {
5726 self.back_to_fleet_roster = true;
5727 self
5728 }
5729
5730 /// Footer label for `Esc`: `back` while the roster is parked underneath,
5731 /// `close` at the root. The hint has to name what the key actually does.
5732 fn esc_hint_label(&self) -> std::borrow::Cow<'static, str> {
5733 if self.back_to_fleet_roster {
5734 tr(self.locale, MessageId::SetupActionBack)
5735 } else {
5736 tr(self.locale, MessageId::SessionsActionClose)
5737 }
5738 }
5739
5740 /// Working-wake frame for this render: 0 unless motion is Full.
5741 fn whale_frame(&self) -> usize {
5742 let now_ms = u64::try_from(self.opened_at.elapsed().as_millis()).unwrap_or(0);
5743 crate::tui::whales::working_frame(now_ms, self.motion)
5744 }
5745
5746 /// The five status groups in render order, each sorted the way the view
5747 /// paints them. Selection, Enter, and click resolution all consume this
5748 /// so the highlighted row and the opened agent can never diverge.
5749 fn grouped(agents: &[SubAgentResult]) -> [Vec<&SubAgentResult>; 5] {
5750 let mut running = Vec::new();
5751 let mut completed = Vec::new();
5752 let mut interrupted = Vec::new();
5753 let mut failed = Vec::new();
5754 let mut cancelled = Vec::new();
5755
5756 for agent in agents {
5757 match agent.status {
5758 SubAgentStatus::Running => running.push(agent),
5759 SubAgentStatus::Completed => completed.push(agent),
5760 SubAgentStatus::Interrupted(_) => interrupted.push(agent),
5761 SubAgentStatus::Failed(_) => failed.push(agent),
5762 SubAgentStatus::Cancelled => cancelled.push(agent),
5763 SubAgentStatus::BudgetExhausted => failed.push(agent),
5764 }
5765 }
5766 for group in [
5767 &mut running,
5768 &mut completed,
5769 &mut interrupted,
5770 &mut failed,
5771 &mut cancelled,
5772 ] {
5773 group.sort_by(|a, b| {
5774 agent_type_order(&a.agent_type)
5775 .cmp(&agent_type_order(&b.agent_type))
5776 .then_with(|| a.agent_id.cmp(&b.agent_id))
5777 });
5778 }
5779 [running, completed, interrupted, failed, cancelled]
5780 }
5781
5782 fn ordered_agent_ids(&self) -> Vec<String> {
5783 Self::grouped(&self.agents)
5784 .iter()
5785 .flatten()
5786 .map(|agent| agent.agent_id.clone())
5787 .collect()
5788 }
5789
5790 /// Keep the selected agent's block inside the visible body, using the
5791 /// last render's layout (stale by at most one frame).
5792 fn follow_selection(&mut self) {
5793 let row_lines = self.row_lines.borrow();
5794 let Some((first, count, _)) = row_lines.get(self.selected) else {
5795 return;
5796 };
5797 let visible = self.last_visible_lines.get().max(1);
5798 let end = first + count;
5799 if *first < self.scroll {
5800 self.scroll = *first;
5801 } else if end > self.scroll + visible {
5802 self.scroll = end.saturating_sub(visible);
5803 }
5804 }
5805 }
5806
5807 impl ModalView for SubAgentsView {
5808 fn kind(&self) -> ModalKind {
5809 ModalKind::SubAgents
5810 }
5811
5812 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
5813 self
5814 }
5815
5816 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
5817 use crossterm::event::KeyCode;
5818
5819 match key.code {
5820 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
5821 // Enter opens the selected agent's transcript — the same primary
5822 // destination the Work strip and sidebar resolve to (v0.9.7). On
5823 // an empty register Enter keeps its old refresh meaning.
5824 KeyCode::Enter => match self.ordered_agent_ids().get(self.selected).cloned() {
5825 Some(agent_id) => ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }),
5826 None => ViewAction::Emit(ViewEvent::SubAgentsRefresh),
5827 },
5828 KeyCode::Char('r') | KeyCode::Char('R') => {
5829 ViewAction::Emit(ViewEvent::SubAgentsRefresh)
5830 }
5831 // Manage: stop the selected worker. Terminal workers ignore the
5832 // key; the cancel receipt names what happened either way.
5833 KeyCode::Char('x') | KeyCode::Char('X') => {
5834 match self.ordered_agent_ids().get(self.selected).cloned() {
5835 Some(agent_id) => ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }),
5836 None => ViewAction::None,
5837 }
5838 }
5839 // The roster is the same destination either way: pop back to the
5840 // parked one when there is one (#5954) — re-running `/fleet`
5841 // would stack a duplicate roster and lose its cursor.
5842 KeyCode::Char('f') | KeyCode::Char('F') if self.back_to_fleet_roster => {
5843 ViewAction::Close
5844 }
5845 KeyCode::Char('f') | KeyCode::Char('F') => {
5846 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
5847 action: CommandPaletteAction::ExecuteCommand {
5848 command: "/fleet".to_string(),
5849 },
5850 })
5851 }
5852 KeyCode::Up | KeyCode::Char('k') => {
5853 self.selected = self.selected.saturating_sub(1);
5854 self.follow_selection();
5855 ViewAction::None
5856 }
5857 KeyCode::Down | KeyCode::Char('j') => {
5858 self.selected = self
5859 .selected
5860 .saturating_add(1)
5861 .min(self.agents.len().saturating_sub(1));
5862 self.follow_selection();
5863 ViewAction::None
5864 }
5865 _ => ViewAction::None,
5866 }
5867 }
5868
5869 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
5870 match mouse.kind {
5871 MouseEventKind::ScrollUp => {
5872 self.scroll = self.scroll.saturating_sub(3);
5873 ViewAction::None
5874 }
5875 MouseEventKind::ScrollDown => {
5876 // Clamped to the real maximum at render time.
5877 self.scroll = self.scroll.saturating_add(3);
5878 ViewAction::None
5879 }
5880 MouseEventKind::Down(MouseButton::Left) => {
5881 let area = self.body_area.get();
5882 if mouse.column < area.x
5883 || mouse.column >= area.x.saturating_add(area.width)
5884 || mouse.row < area.y
5885 || mouse.row >= area.y.saturating_add(area.height)
5886 {
5887 return ViewAction::None;
5888 }
5889 let line = usize::from(mouse.row - area.y) + self.last_render_scroll.get();
5890 let hit = self
5891 .row_lines
5892 .borrow()
5893 .iter()
5894 .enumerate()
5895 .find(|(_, (first, count, _))| line >= *first && line < first + count)
5896 .map(|(index, (_, _, agent_id))| (index, agent_id.clone()));
5897 match hit {
5898 Some((index, agent_id)) => {
5899 self.selected = index;
5900 // Click opens the same door Enter does.
5901 ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id })
5902 }
5903 None => ViewAction::None,
5904 }
5905 }
5906 _ => ViewAction::None,
5907 }
5908 }
5909
5910 fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool {
5911 let selected_id = self.ordered_agent_ids().get(self.selected).cloned();
5912 self.agents = agents.to_vec();
5913 let last = self.agents.len().saturating_sub(1);
5914 self.scroll = self.scroll.min(last);
5915 self.selected = selected_id
5916 .and_then(|id| {
5917 self.ordered_agent_ids()
5918 .iter()
5919 .position(|candidate| candidate == &id)
5920 })
5921 .unwrap_or_else(|| self.selected.min(last));
5922 true
5923 }
5924
5925 fn render(&self, area: Rect, buf: &mut Buffer) {
5926 Clear.render(area, buf);
5927 Block::default()
5928 .style(Style::default().bg(palette::WHALE_BG))
5929 .render(area, buf);
5930
5931 let mut lines: Vec<Line> = Vec::new();
5932 let mut row_lines: Vec<(usize, usize, String)> = Vec::new();
5933 let content_width = area.width.saturating_sub(4) as usize;
5934
5935 if self.agents.is_empty() {
5936 lines.push(Line::from(Span::styled(
5937 tr(
5938 self.locale,
5939 MessageId::SubagentsNoCurrentSessionFleetWorkers,
5940 ),
5941 Style::default().fg(palette::TEXT_MUTED),
5942 )));
5943 lines.push(Line::from(Span::styled(
5944 tr(self.locale, MessageId::SubagentsEmptyGuidance),
5945 Style::default().fg(palette::TEXT_DIM),
5946 )));
5947 } else {
5948 let [running, completed, interrupted, failed, cancelled] = Self::grouped(&self.agents);
5949 let selected_id = self
5950 .ordered_agent_ids()
5951 .get(self.selected)
5952 .cloned()
5953 .unwrap_or_default();
5954
5955 let status_summary = [
5956 (
5957 MessageId::SubagentsStatusRunning,
5958 running.len(),
5959 palette::STATUS_WARNING,
5960 ),
5961 (
5962 MessageId::SubagentsStatusCompleted,
5963 completed.len(),
5964 palette::STATUS_SUCCESS,
5965 ),
5966 (
5967 MessageId::SubagentsStatusInterrupted,
5968 interrupted.len(),
5969 palette::STATUS_WARNING,
5970 ),
5971 (
5972 MessageId::SubagentsStatusFailed,
5973 failed.len(),
5974 palette::WHALE_ERROR,
5975 ),
5976 (
5977 MessageId::SubagentsStatusCancelled,
5978 cancelled.len(),
5979 palette::TEXT_MUTED,
5980 ),
5981 ];
5982
5983 lines.push(Line::from(Span::styled(
5984 tr(
5985 self.locale,
5986 MessageId::SubagentsCurrentSessionFleetWorkersTitle,
5987 ),
5988 Style::default().fg(palette::WHALE_ACTION).bold(),
5989 )));
5990 lines.push(Line::from(Span::styled(
5991 tr(
5992 self.locale,
5993 MessageId::SubagentsCurrentSessionFleetWorkerRoles,
5994 ),
5995 Style::default().fg(palette::TEXT_DIM),
5996 )));
5997
5998 let mut summary_parts = Vec::new();
5999 for (label_id, count, color) in status_summary {
6000 let label = tr(self.locale, label_id);
6001 let count = count.to_string();
6002 summary_parts.push(Line::from(Span::styled(
6003 tr(self.locale, MessageId::SubagentsSummaryItem)
6004 .replace("{label}", label.as_ref())
6005 .replace("{count}", &count),
6006 Style::default().fg(color),
6007 )));
6008 }
6009
6010 let mut summary = vec![Span::styled(" ", Style::default().fg(palette::TEXT_DIM))];
6011 for (idx, part) in summary_parts.into_iter().enumerate() {
6012 if idx > 0 {
6013 summary.push(Span::raw(" · "));
6014 }
6015 summary.extend(part);
6016 }
6017 lines.push(Line::from(summary));
6018 lines.push(Line::from(Span::styled(
6019 "",
6020 Style::default().fg(palette::TEXT_DIM),
6021 )));
6022
6023 for (title_id, style, group) in [
6024 (
6025 MessageId::SubagentsStatusRunning,
6026 ratatui::style::Style::from(palette::STATUS_WARNING),
6027 &running,
6028 ),
6029 (
6030 MessageId::SubagentsStatusCompleted,
6031 palette::STATUS_SUCCESS.into(),
6032 &completed,
6033 ),
6034 (
6035 MessageId::SubagentsStatusInterrupted,
6036 palette::STATUS_WARNING.into(),
6037 &interrupted,
6038 ),
6039 (
6040 MessageId::SubagentsStatusFailed,
6041 palette::WHALE_ERROR.into(),
6042 &failed,
6043 ),
6044 (
6045 MessageId::SubagentsStatusCancelled,
6046 palette::TEXT_MUTED.into(),
6047 &cancelled,
6048 ),
6049 ] {
6050 let title = tr(self.locale, title_id);
6051 append_subagent_group(
6052 &mut lines,
6053 &mut row_lines,
6054 title.as_ref(),
6055 style,
6056 group,
6057 content_width,
6058 &selected_id,
6059 WhaleRowContext {
6060 locale: self.locale,
6061 frame: self.whale_frame(),
6062 },
6063 );
6064 }
6065 }
6066
6067 let content = render_modal_footer(
6068 area,
6069 buf,
6070 &[
6071 ActionHint::new("Esc", self.esc_hint_label()),
6072 ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)),
6073 ActionHint::new("Enter", tr(self.locale, MessageId::ExtensionsActionFocus)),
6074 ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)),
6075 ActionHint::new("R", tr(self.locale, MessageId::SubagentsActionRefresh)),
6076 ActionHint::new("F", tr(self.locale, MessageId::SubagentsActionRosterSetup)),
6077 ],
6078 );
6079 let shell = ratatui::layout::Layout::default()
6080 .direction(ratatui::layout::Direction::Vertical)
6081 .constraints([
6082 ratatui::layout::Constraint::Length(3),
6083 ratatui::layout::Constraint::Min(1),
6084 ])
6085 .split(content);
6086 Paragraph::new(vec![
6087 Line::from(vec![
6088 Span::styled(
6089 format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)),
6090 Style::default().fg(palette::WHALE_ACTION).bold(),
6091 ),
6092 Span::styled(
6093 "──────────────────────── ",
6094 Style::default().fg(palette::BORDER_COLOR),
6095 ),
6096 Span::styled(
6097 format!(
6098 "{} {} ",
6099 tr(self.locale, MessageId::SubagentsHeaderRoster),
6100 tr(self.locale, MessageId::FleetRosterTabSetup),
6101 ),
6102 Style::default().fg(palette::TEXT_MUTED),
6103 ),
6104 Span::styled(
6105 tr(self.locale, MessageId::FleetRosterWorkers),
6106 Style::default().fg(palette::WHALE_ACTION).bold(),
6107 ),
6108 Span::styled(
6109 " ─────────────────",
6110 Style::default().fg(palette::BORDER_COLOR),
6111 ),
6112 ]),
6113 Line::from(""),
6114 Line::from(Span::styled(
6115 format!(" {}", tr(self.locale, MessageId::SubagentsHeaderColumns)),
6116 Style::default().fg(palette::TEXT_MUTED),
6117 )),
6118 ])
6119 .render(shell[0], buf);
6120
6121 let total_lines = lines.len();
6122 let visible_lines = usize::from(shell[1].height).max(1);
6123 let max_scroll = total_lines.saturating_sub(visible_lines);
6124 let scroll = self.scroll.min(max_scroll);
6125
6126 // Cache the layout for Enter/click resolution and scroll-follow.
6127 self.row_lines.replace(row_lines);
6128 self.body_area.set(shell[1]);
6129 self.last_render_scroll.set(scroll);
6130 self.last_visible_lines.set(visible_lines);
6131
6132 Paragraph::new(lines)
6133 .scroll((scroll as u16, 0))
6134 .render(shell[1], buf);
6135 }
6136 }
6137
6138 /// Locale and working-wake frame for the whale badge on each worker row.
6139 #[derive(Debug, Clone, Copy)]
6140 struct WhaleRowContext {
6141 locale: Locale,
6142 frame: usize,
6143 }
6144
6145 #[allow(clippy::too_many_arguments)]
6146 fn append_subagent_group(
6147 lines: &mut Vec<ratatui::text::Line<'static>>,
6148 row_lines: &mut Vec<(usize, usize, String)>,
6149 title: &str,
6150 section_style: ratatui::style::Style,
6151 agents: &[&SubAgentResult],
6152 content_width: usize,
6153 selected_id: &str,
6154 whale: WhaleRowContext,
6155 ) {
6156 use ratatui::{
6157 style::Style,
6158 text::{Line, Span},
6159 };
6160 if agents.is_empty() {
6161 return;
6162 }
6163
6164 lines.push(Line::from(Span::styled(
6165 tr(whale.locale, MessageId::SubagentsGroupHeading)
6166 .replace("{label}", title)
6167 .replace("{count}", &agents.len().to_string()),
6168 section_style.bold(),
6169 )));
6170
6171 for agent in agents {
6172 let block_start = lines.len();
6173 let is_selected = agent.agent_id == selected_id;
6174 let id = truncate_view_text(&agent.agent_id, 11);
6175 let display_name = agent
6176 .nickname
6177 .as_deref()
6178 .map(|nick| format!("{nick:<12}"))
6179 .unwrap_or_else(|| format!("{id:<12}"));
6180 let kind = format_agent_type(whale.locale, &agent.agent_type);
6181 let (status, status_style, status_detail) =
6182 format_agent_status(whale.locale, &agent.status);
6183
6184 let name_style = if is_selected {
6185 Style::default().fg(palette::WHALE_ACTION).bold()
6186 } else {
6187 Style::default().fg(palette::TEXT_PRIMARY)
6188 };
6189 // Whale Teams: species badge from the worker's Fleet role (or its
6190 // advisory role hint), then the six-state word derived from the
6191 // child's real status — never from elapsed time.
6192 let species = agent
6193 .assignment
6194 .role
6195 .as_deref()
6196 .map(crate::tui::whales::WhaleSpecies::for_role_id)
6197 .filter(|species| *species != crate::tui::whales::WhaleSpecies::Plain)
6198 .unwrap_or_else(|| crate::tui::whales::WhaleSpecies::for_fleet_role(&agent.agent_type));
6199 let whale_state = crate::tui::whales::WhaleState::for_subagent(agent);
6200 let mut row = vec![
6201 // The selection cursor: Enter (or a click) opens this agent's
6202 // transcript, matching every other agent surface.
6203 Span::styled(
6204 if is_selected { "\u{25B8} " } else { " " },
6205 Style::default().fg(palette::WHALE_ACTION),
6206 ),
6207 ];
6208 row.extend(crate::tui::whales::badge(species, &palette::UI_THEME));
6209 row.push(Span::raw(" "));
6210 row.extend([
6211 Span::styled(display_name, name_style),
6212 Span::raw(" "),
6213 Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)),
6214 Span::styled(
6215 format!("{kind:<9}"),
6216 Style::default().fg(palette::TEXT_MUTED),
6217 ),
6218 Span::raw(" "),
6219 Span::styled(format!("{status:<10}"), status_style),
6220 Span::raw(" "),
6221 Span::styled(
6222 format!("{:>4}✦", agent.steps_taken),
6223 Style::default().fg(palette::TEXT_DIM),
6224 ),
6225 Span::raw(" "),
6226 Span::styled(
6227 format!("{:>6}ms", agent.duration_ms),
6228 Style::default().fg(palette::TEXT_DIM),
6229 ),
6230 ]);
6231 lines.push(Line::from(row));
6232
6233 // The whale's own state word, paired with its glyph cue, so the row
6234 // says "Waiting for you" / "Blocked" in the user's language next to
6235 // the raw runtime status above. No caption text beyond that.
6236 let mut whale_line = vec![Span::raw(" ")];
6237 whale_line.extend(crate::tui::whales::badge_with_state_frame(
6238 species,
6239 Some(whale_state),
6240 whale.frame,
6241 &palette::UI_THEME,
6242 whale.locale,
6243 ));
6244 lines.push(Line::from(whale_line));
6245
6246 if let Some(detail) = status_detail {
6247 let max_len = content_width.saturating_sub(10);
6248 let detail = truncate_view_text(detail, max_len);
6249 lines.push(Line::from(vec![
6250 Span::styled(
6251 tr(whale.locale, MessageId::SubagentsLabelReason),
6252 Style::default().fg(palette::TEXT_MUTED),
6253 ),
6254 Span::styled(detail, Style::default().fg(palette::WHALE_ERROR)),
6255 ]));
6256 }
6257
6258 if let Some(role) = agent.assignment.role.as_deref() {
6259 let max_len = content_width.saturating_sub(14);
6260 let role = truncate_view_text(role, max_len);
6261 lines.push(Line::from(vec![
6262 Span::styled(
6263 tr(whale.locale, MessageId::SubagentsLabelRole),
6264 Style::default().fg(palette::TEXT_MUTED),
6265 ),
6266 Span::styled(role, Style::default().fg(palette::WHALE_ACTION)),
6267 ]));
6268 }
6269
6270 if let Some(permissions) = agent.runtime_permissions.as_ref() {
6271 let network = tr(
6272 whale.locale,
6273 if permissions.network {
6274 MessageId::SubagentsValueOn
6275 } else {
6276 MessageId::SubagentsValueOff
6277 },
6278 );
6279 let shell = format_subagent_shell(whale.locale, &permissions.shell);
6280 let write = tr(
6281 whale.locale,
6282 if permissions.write {
6283 MessageId::SubagentsValueOn
6284 } else {
6285 MessageId::SubagentsValueOff
6286 },
6287 );
6288 let posture = tr(whale.locale, MessageId::SubagentsPostureDetails)
6289 .replace("{network}", network.as_ref())
6290 .replace("{shell}", shell.as_ref())
6291 .replace("{write}", write.as_ref());
6292 let max_len = content_width.saturating_sub(18);
6293 let posture = truncate_view_text(&posture, max_len);
6294 lines.push(Line::from(vec![
6295 Span::styled(
6296 tr(whale.locale, MessageId::SubagentsLabelPosture),
6297 Style::default().fg(palette::TEXT_MUTED),
6298 ),
6299 Span::styled(posture, Style::default().fg(palette::WHALE_ACTION)),
6300 ]));
6301 }
6302
6303 if let Some(branch) = agent.git_branch.as_deref() {
6304 let workspace = agent
6305 .workspace
6306 .as_deref()
6307 .and_then(|path| path.file_name())
6308 .and_then(|name| name.to_str())
6309 .filter(|name| !name.is_empty());
6310 let branch_detail = match workspace {
6311 Some(workspace) => tr(whale.locale, MessageId::SubagentsBranchWithWorkspace)
6312 .replace("{branch}", branch)
6313 .replace("{workspace}", workspace),
6314 None => tr(whale.locale, MessageId::SubagentsBranch).replace("{branch}", branch),
6315 };
6316 let max_len = content_width.saturating_sub(14);
6317 let branch_detail = truncate_view_text(&branch_detail, max_len);
6318 lines.push(Line::from(vec![
6319 Span::styled(
6320 tr(whale.locale, MessageId::SubagentsLabelGit),
6321 Style::default().fg(palette::TEXT_MUTED),
6322 ),
6323 Span::styled(branch_detail, Style::default().fg(palette::WHALE_ACTION)),
6324 ]));
6325 }
6326
6327 let max_len = content_width.saturating_sub(18);
6328 let objective = truncate_view_text(&agent.assignment.objective, max_len);
6329 lines.push(Line::from(vec![
6330 Span::styled(
6331 tr(whale.locale, MessageId::SubagentsLabelObjective),
6332 Style::default().fg(palette::TEXT_MUTED),
6333 ),
6334 Span::styled(objective, Style::default().fg(palette::TEXT_DIM)),
6335 ]));
6336
6337 if let Some(result) = agent.result.as_ref() {
6338 let max_len = content_width.saturating_sub(16);
6339 let preview = truncate_view_text(result, max_len);
6340 lines.push(Line::from(vec![
6341 Span::styled(
6342 tr(whale.locale, MessageId::SubagentsLabelResult),
6343 Style::default().fg(palette::TEXT_MUTED),
6344 ),
6345 Span::styled(preview, Style::default().fg(palette::TEXT_DIM)),
6346 ]));
6347 }
6348
6349 row_lines.push((
6350 block_start,
6351 lines.len() - block_start,
6352 agent.agent_id.clone(),
6353 ));
6354 }
6355
6356 lines.push(Line::from(""));
6357 }
6358
6359 fn agent_type_order(agent_type: &FleetRole) -> u8 {
6360 match agent_type {
6361 FleetRole::Worker => 0,
6362 FleetRole::Scout => 1,
6363 FleetRole::Planner => 2,
6364 FleetRole::Builder => 3,
6365 FleetRole::Verifier => 4,
6366 FleetRole::Reviewer => 5,
6367 FleetRole::Consultant => 6,
6368 FleetRole::Custom => 7,
6369 }
6370 }
6371
6372 fn format_agent_type(locale: Locale, agent_type: &FleetRole) -> Cow<'static, str> {
6373 // `FleetRole::as_str()` is the durable runtime/schema identifier. The
6374 // register is a localized user surface, so map only the known roles here
6375 // and leave the internal identifier untouched everywhere else.
6376 let message_id = match agent_type {
6377 FleetRole::Worker => MessageId::SubagentsRoleWorker,
6378 FleetRole::Scout => MessageId::SubagentsRoleScout,
6379 FleetRole::Planner => MessageId::SubagentsRolePlanner,
6380 FleetRole::Builder => MessageId::SubagentsRoleBuilder,
6381 FleetRole::Verifier => MessageId::SubagentsRoleVerifier,
6382 FleetRole::Reviewer => MessageId::SubagentsRoleReviewer,
6383 FleetRole::Consultant => MessageId::SubagentsRoleConsultant,
6384 FleetRole::Custom => MessageId::SubagentsRoleCustom,
6385 };
6386 tr(locale, message_id)
6387 }
6388
6389 fn format_agent_status(
6390 locale: Locale,
6391 status: &SubAgentStatus,
6392 ) -> (Cow<'static, str>, ratatui::style::Style, Option<&str>) {
6393 use ratatui::style::Style;
6394
6395 match status {
6396 SubAgentStatus::Running => (
6397 tr(locale, MessageId::AutomationRunStatusRunning),
6398 Style::default().fg(palette::WHALE_ACTION),
6399 None,
6400 ),
6401 SubAgentStatus::Completed => (
6402 tr(locale, MessageId::AutomationRunStatusCompleted),
6403 Style::default().fg(palette::STATUS_SUCCESS),
6404 None,
6405 ),
6406 SubAgentStatus::Interrupted(reason) => (
6407 tr(locale, MessageId::SubagentsRowStatusInterrupted),
6408 Style::default().fg(palette::STATUS_WARNING),
6409 Some(reason.as_str()),
6410 ),
6411 SubAgentStatus::Cancelled => (
6412 tr(locale, MessageId::SubagentsRowStatusCancelled),
6413 Style::default().fg(palette::TEXT_MUTED),
6414 None,
6415 ),
6416 SubAgentStatus::BudgetExhausted => (
6417 tr(locale, MessageId::SubagentsRowStatusBudgetExhausted),
6418 Style::default().fg(palette::STATUS_WARNING),
6419 None,
6420 ),
6421 SubAgentStatus::Failed(reason) => (
6422 tr(locale, MessageId::AutomationRunStatusFailed),
6423 Style::default().fg(palette::WHALE_ERROR),
6424 Some(reason.as_str()),
6425 ),
6426 }
6427 }
6428
6429 fn format_subagent_shell(locale: Locale, shell: &str) -> Cow<'static, str> {
6430 match shell {
6431 "none" => tr(locale, MessageId::SubagentsShellNone),
6432 "read_only" => tr(locale, MessageId::SubagentsShellReadOnly),
6433 "full" => tr(locale, MessageId::SubagentsShellFull),
6434 // A future runtime may add a posture before the TUI knows how to
6435 // localize it. Preserve that exact runtime value instead of guessing.
6436 _ => Cow::Owned(shell.to_string()),
6437 }
6438 }
6439
6440 fn truncate_view_text(text: &str, max_chars: usize) -> String {
6441 if max_chars == 0 {
6442 return String::new();
6443 }
6444 match text.char_indices().nth(max_chars) {
6445 Some((idx, _)) => text[..idx].to_string(),
6446 None => text.to_string(),
6447 }
6448 }
6449
6450 fn fit_config_column(text: &str, width: usize) -> String {
6451 let mut fitted = crate::tui::ui_text::truncate_line_to_width(text, width);
6452 let padding = width.saturating_sub(crate::tui::ui_text::text_display_width(&fitted));
6453 fitted.push_str(&" ".repeat(padding));
6454 fitted
6455 }
6456
6457 #[cfg(test)]
6458 mod tests {
6459 use super::{
6460 ActionHint, ConfigCategory, ConfigListItem, ConfigRowKind, ConfigScope, ConfigView,
6461 EmptyState, FocusTextureMode, HelpView, ListDetailLayout, ModalKind, ModalView,
6462 SettingKind, SettingStore, SettingsRegistry, ViewAction, ViewEvent, ViewStack,
6463 action_footer_lines, canonical_config_choice, centered_modal_area, config_choice_detail,
6464 config_choice_label, config_choice_values, config_label_for_key,
6465 config_label_for_key_for_locale, render_modal_footer_with_gutter,
6466 render_underwater_surface, subagent_view_agents, truncate_view_text,
6467 };
6468 use crate::config::Config;
6469 use crate::settings::Settings;
6470 use crate::tools::subagent::{FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus};
6471 use crate::tui::app::{App, TuiOptions};
6472 use crate::tui::history::{HistoryCell, SubAgentCell};
6473 use crate::tui::views::{CommandPaletteAction, SubAgentsView};
6474 use crate::tui::widgets::agent_card::{AgentLifecycle, FanoutCard};
6475 use codewhale_localization::{Locale, MessageId, tr, tr_key};
6476 use codewhale_palette as palette;
6477 use crossterm::event::{
6478 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
6479 };
6480 use ratatui::{
6481 buffer::Buffer,
6482 layout::Rect,
6483 style::{Color, Style},
6484 };
6485 use std::borrow::Cow;
6486 use std::fs;
6487 use std::path::PathBuf;
6488 use tempfile::TempDir;
6489 use unicode_width::UnicodeWidthStr;
6490
6491 /// Terminal sizes the v0.8.66 modal blocker (#3732) requires every overlay
6492 /// to remain readable and fully operable at.
6493 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
6494
6495 /// Render a modal through the `ViewStack` (so the shared opaque backdrop is
6496 /// painted exactly as in production) over a sentinel-filled buffer, then
6497 /// assert: every `required_label` is visible, no sentinel `X` survives
6498 /// anywhere (fully opaque), the center cell carries the modal ink, and no
6499 /// row overflows the frame width.
6500 fn assert_modal_usable_and_opaque<V: ModalView + 'static>(
6501 make: impl Fn() -> V,
6502 required_labels: &[&str],
6503 ) {
6504 for (w, h) in BLOCKER_SIZES {
6505 let area = Rect::new(0, 0, w, h);
6506 let mut buf = Buffer::empty(area);
6507 let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green);
6508 for y in 0..h {
6509 for x in 0..w {
6510 buf[(x, y)].set_symbol("X").set_style(sentinel_style);
6511 }
6512 }
6513 let mut stack = ViewStack::new();
6514 stack.push(make());
6515 stack.render(area, &mut buf);
6516
6517 let rows: Vec<String> = (0..h)
6518 .map(|y| {
6519 (0..w)
6520 .map(|x| buf[(x, y)].symbol().to_string())
6521 .collect::<String>()
6522 })
6523 .collect();
6524 let text = rows.join("\n");
6525
6526 for label in required_labels {
6527 assert!(text.contains(label), "{w}x{h}: missing '{label}'");
6528 }
6529 let unpainted = (0..h).find_map(|y| {
6530 (0..w).find_map(|x| {
6531 let cell = &buf[(x, y)];
6532 (cell.symbol() == "X" && cell.fg == Color::Magenta && cell.bg == Color::Green)
6533 .then_some((x, y))
6534 })
6535 });
6536 assert!(
6537 unpainted.is_none(),
6538 "{w}x{h}: background bleed-through at {unpainted:?}"
6539 );
6540 assert_eq!(
6541 buf[(w / 2, h / 2)].bg,
6542 palette::WHALE_BG,
6543 "{w}x{h}: modal interior must be opaque"
6544 );
6545 for (y, row) in rows.iter().enumerate() {
6546 assert!(
6547 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
6548 "{w}x{h}: row {y} overflows width: {row:?}"
6549 );
6550 }
6551 }
6552 }
6553
6554 #[test]
6555 fn config_modal_is_usable_and_opaque_at_blocker_sizes() {
6556 let _lock = crate::test_support::lock_test_env();
6557 // "Search" is the hardcoded English search-row label; asserting it (plus
6558 // the opacity/overflow checks) proves the modal renders fully and its
6559 // footer wraps inside bounds rather than clipping.
6560 assert_modal_usable_and_opaque(|| create_config_view(Locale::En), &["Search"]);
6561 }
6562
6563 #[test]
6564 fn subagents_modal_is_usable_and_opaque_at_blocker_sizes() {
6565 assert_modal_usable_and_opaque(
6566 || SubAgentsView::new(Vec::new()),
6567 &["close", "refresh", "setup"],
6568 );
6569 }
6570
6571 /// #5954: the `Esc` hint has to name what the key does — `back` while the
6572 /// Fleet roster is parked underneath, `close` on direct entry.
6573 #[test]
6574 fn subagents_esc_hint_says_back_over_the_roster_and_close_at_the_root() {
6575 let area = Rect::new(0, 0, 160, 40);
6576
6577 let direct = SubAgentsView::new(Vec::new());
6578 let mut direct_buf = Buffer::empty(area);
6579 direct.render(area, &mut direct_buf);
6580 let direct_text = buffer_text(&direct_buf, area);
6581 assert!(
6582 direct_text.contains("Esc close"),
6583 "direct entry must still promise close: {direct_text}"
6584 );
6585
6586 let over_roster = SubAgentsView::new(Vec::new()).over_fleet_roster();
6587 let mut back_buf = Buffer::empty(area);
6588 over_roster.render(area, &mut back_buf);
6589 let back_text = buffer_text(&back_buf, area);
6590 assert!(
6591 back_text.contains("Esc back"),
6592 "over the roster the hint must promise back: {back_text}"
6593 );
6594 assert!(
6595 !back_text.contains("Esc close"),
6596 "over the roster the hint must not still promise close: {back_text}"
6597 );
6598 }
6599
6600 /// #5954: workers opened directly (`/fleet workers`, the Work dock) is the
6601 /// root of its own stack, so `Esc` closes the window as before.
6602 #[test]
6603 fn subagents_opened_directly_closes_on_esc() {
6604 let mut stack = ViewStack::new();
6605 stack.push(SubAgentsView::new(Vec::new()));
6606 stack.handle_key(KeyEvent::new(
6607 crossterm::event::KeyCode::Esc,
6608 crossterm::event::KeyModifiers::NONE,
6609 ));
6610 assert!(stack.is_empty(), "direct entry must close on Esc");
6611 }
6612
6613 /// #5954: `F` in workers is the roster door. With a roster parked below,
6614 /// it pops back to it instead of re-running `/fleet` and stacking a
6615 /// duplicate roster.
6616 #[test]
6617 fn subagents_f_pops_back_to_the_parked_roster() {
6618 let mut over_roster = SubAgentsView::new(Vec::new()).over_fleet_roster();
6619 assert!(matches!(
6620 over_roster.handle_key(KeyEvent::new(
6621 crossterm::event::KeyCode::Char('F'),
6622 crossterm::event::KeyModifiers::NONE,
6623 )),
6624 ViewAction::Close
6625 ));
6626
6627 let mut direct = SubAgentsView::new(Vec::new());
6628 assert!(matches!(
6629 direct.handle_key(KeyEvent::new(
6630 crossterm::event::KeyCode::Char('F'),
6631 crossterm::event::KeyModifiers::NONE,
6632 )),
6633 ViewAction::Emit(ViewEvent::CommandPaletteSelected { .. })
6634 ));
6635 }
6636
6637 #[test]
6638 fn subagents_modal_names_current_session_pod_workers_in_each_locale() {
6639 let area = Rect::new(0, 0, 160, 40);
6640 let app = create_test_app();
6641
6642 let empty = SubAgentsView::for_app(&app, Vec::new());
6643 let mut empty_buf = Buffer::empty(area);
6644 empty.render(area, &mut empty_buf);
6645 let empty_text = buffer_text(&empty_buf, area);
6646 assert!(
6647 empty_text.contains("No current-session fleet workers."),
6648 "{empty_text}"
6649 );
6650 assert!(
6651 empty_text.contains("Set up roles with /fleet."),
6652 "{empty_text}"
6653 );
6654
6655 let english = SubAgentsView::for_app(
6656 &app,
6657 vec![manager_agent("agent_live", SubAgentStatus::Running)],
6658 );
6659 let mut english_buf = Buffer::empty(area);
6660 english.render(area, &mut english_buf);
6661 let english_text = buffer_text(&english_buf, area);
6662 assert!(
6663 english_text.contains("Current-session fleet workers"),
6664 "{english_text}"
6665 );
6666 assert!(
6667 english_text.contains("Sub-agent roles are current-session fleet worker roles."),
6668 "{english_text}"
6669 );
6670
6671 let mut zh_hans_app = create_test_app();
6672 zh_hans_app.ui_locale = Locale::ZhHans;
6673 let zh_hans = SubAgentsView::for_app(
6674 &zh_hans_app,
6675 vec![manager_agent("agent_live", SubAgentStatus::Running)],
6676 );
6677 let mut zh_hans_buf = Buffer::empty(area);
6678 zh_hans.render(area, &mut zh_hans_buf);
6679 let zh_hans_text = buffer_text(&zh_hans_buf, area);
6680 // Ratatui gives each CJK glyph a trailing buffer cell. Strip those
6681 // layout spaces before asserting the actual rendered copy.
6682 let zh_hans_compact = zh_hans_text
6683 .chars()
6684 .filter(|ch| !ch.is_whitespace())
6685 .collect::<String>();
6686 assert_eq!(
6687 tr(
6688 Locale::ZhHans,
6689 MessageId::SubagentsCurrentSessionFleetWorkersTitle
6690 ),
6691 "当前会话的舰队工作器"
6692 );
6693 assert!(
6694 zh_hans_compact.contains("当前会话的舰队工作器"),
6695 "{zh_hans_text}"
6696 );
6697 assert!(
6698 zh_hans_compact.contains("子代理角色是当前会话的舰队工作器角色。"),
6699 "{zh_hans_text}"
6700 );
6701 assert!(
6702 !zh_hans_text.contains("Current-session fleet workers"),
6703 "{zh_hans_text}"
6704 );
6705 }
6706
6707 #[test]
6708 fn subagents_modal_localizes_worker_rows_and_preserves_english_status_rendering() {
6709 let area = Rect::new(0, 0, 200, 60);
6710 let mut agent = manager_agent("agent_live", SubAgentStatus::Running);
6711 agent.agent_type = FleetRole::Builder;
6712 agent.assignment.role = Some("release".to_string());
6713 agent.assignment.objective = "verify localized row".to_string();
6714 agent.runtime_permissions = Some(codewhale_protocol::fleet::FleetEffectivePermissions {
6715 write: true,
6716 network: true,
6717 shell: "read_only".to_string(),
6718 tool_scope: "inherit".to_string(),
6719 tools: Vec::new(),
6720 background: false,
6721 max_spawn_depth: 0,
6722 profile_id: None,
6723 profile_origin: None,
6724 source: "test".to_string(),
6725 });
6726 agent.git_branch = Some("feature/localize".to_string());
6727 agent.workspace = Some(PathBuf::from("/tmp/fleet-workers"));
6728 agent.result = Some("all checks passed".to_string());
6729 let mut interrupted = manager_agent(
6730 "agent_interrupted",
6731 SubAgentStatus::Interrupted("manual review".to_string()),
6732 );
6733 interrupted.agent_type = FleetRole::Reviewer;
6734
6735 let app = create_test_app();
6736 let english = SubAgentsView::for_app(&app, vec![agent.clone(), interrupted.clone()]);
6737 let mut english_buf = Buffer::empty(area);
6738 english.render(area, &mut english_buf);
6739 let english_text = buffer_text(&english_buf, area);
6740 for expected in [
6741 "Current-session fleet workers",
6742 "Running: 1",
6743 "Completed: 0",
6744 "Interrupted: 1",
6745 "Failed: 0",
6746 "Cancelled: 0",
6747 "Running (1)",
6748 "implement",
6749 "running",
6750 "reason: manual review",
6751 "role: release",
6752 "posture: network=on · shell=read-only · write=on",
6753 "git: branch feature/localize @ fleet-workers",
6754 "objective: verify localized row",
6755 "result: all checks passed",
6756 "live worker status · role · objective · model · elapsed",
6757 "close",
6758 "select",
6759 "focus",
6760 "stop",
6761 "refresh",
6762 "roster/setup",
6763 ] {
6764 assert!(
6765 english_text.contains(expected),
6766 "missing {expected:?}: {english_text}"
6767 );
6768 }
6769
6770 let mut zh_hans_app = create_test_app();
6771 zh_hans_app.ui_locale = Locale::ZhHans;
6772 let zh_hans = SubAgentsView::for_app(&zh_hans_app, vec![agent, interrupted]);
6773 let mut zh_hans_buf = Buffer::empty(area);
6774 zh_hans.render(area, &mut zh_hans_buf);
6775 let zh_hans_text = buffer_text(&zh_hans_buf, area);
6776 let zh_hans_compact = zh_hans_text
6777 .chars()
6778 .filter(|ch| !ch.is_whitespace())
6779 .collect::<String>();
6780 for expected in [
6781 "当前会话的舰队工作器",
6782 "运行中:1",
6783 "已中断:1",
6784 "名册设置工作器",
6785 "实时工作器状态·角色·目标·模型·已用时间",
6786 "运行中(1)",
6787 "构建者",
6788 "原因:manualreview",
6789 "角色:release",
6790 "权限:网络=开·Shell=只读·写入=开",
6791 "Git:分支feature/localize@fleet-workers",
6792 "目标:verifylocalizedrow",
6793 "结果:allcheckspassed",
6794 "刷新",
6795 "名册/设置",
6796 ] {
6797 assert!(
6798 zh_hans_compact.contains(expected),
6799 "missing {expected:?}: {zh_hans_text}"
6800 );
6801 }
6802 assert!(
6803 !zh_hans_text.contains("Running: 1"),
6804 "English status leaked into zh-Hans modal: {zh_hans_text}"
6805 );
6806 }
6807
6808 /// Focus-texture prototype (#4823): with a mode forced on, a real
6809 /// full-screen modal must render exactly as before — the texture pass
6810 /// no-ops because the focus region covers (nearly) the whole frame.
6811 /// The default `Off` case is pinned by the existing
6812 /// `*_modal_is_usable_and_opaque_at_blocker_sizes` tests above: they run
6813 /// unmodified because `ViewStack::new()` defaults to `Off`, which leaves
6814 /// the buffer byte-identical to the pre-prototype render.
6815 #[test]
6816 fn focus_texture_modes_keep_fullscreen_modal_usable_and_opaque() {
6817 let _lock = crate::test_support::lock_test_env();
6818 let theme = codewhale_palette::ThemeId::Whale.ui_theme();
6819 for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] {
6820 for (w, h) in BLOCKER_SIZES {
6821 let area = Rect::new(0, 0, w, h);
6822 let mut buf = Buffer::empty(area);
6823 let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green);
6824 for y in 0..h {
6825 for x in 0..w {
6826 buf[(x, y)].set_symbol("X").set_style(sentinel_style);
6827 }
6828 }
6829 let mut stack = ViewStack::new();
6830 stack.push(create_config_view(Locale::En));
6831 stack.set_focus_texture(mode, theme);
6832 stack.render(area, &mut buf);
6833
6834 let rows: Vec<String> = (0..h)
6835 .map(|y| {
6836 (0..w)
6837 .map(|x| buf[(x, y)].symbol().to_string())
6838 .collect::<String>()
6839 })
6840 .collect();
6841 let text = rows.join("\n");
6842
6843 assert!(
6844 text.contains("Search"),
6845 "{mode:?} {w}x{h}: missing 'Search'"
6846 );
6847 let unpainted = (0..h).find_map(|y| {
6848 (0..w).find_map(|x| {
6849 let cell = &buf[(x, y)];
6850 (cell.symbol() == "X"
6851 && cell.fg == Color::Magenta
6852 && cell.bg == Color::Green)
6853 .then_some((x, y))
6854 })
6855 });
6856 assert!(
6857 unpainted.is_none(),
6858 "{mode:?} {w}x{h}: background bleed-through at {unpainted:?}"
6859 );
6860 assert_eq!(
6861 buf[(w / 2, h / 2)].bg,
6862 palette::WHALE_BG,
6863 "{mode:?} {w}x{h}: modal interior must be opaque"
6864 );
6865 }
6866 }
6867 }
6868
6869 /// The texture actually engages outside an *inline* modal's band: the
6870 /// approval prompt only occupies a bottom strip, so the sentinel field
6871 /// above it goes through the scrim/grain pass. The modal is painted
6872 /// after the texture, so its band stays fully opaque and its labels
6873 /// survive at every blocker size.
6874 #[test]
6875 fn focus_texture_modes_keep_inline_modal_usable() {
6876 let theme = codewhale_palette::ThemeId::Whale.ui_theme();
6877 for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] {
6878 for (w, h) in BLOCKER_SIZES {
6879 let area = Rect::new(0, 0, w, h);
6880 let mut buf = Buffer::empty(area);
6881 let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green);
6882 for y in 0..h {
6883 for x in 0..w {
6884 buf[(x, y)].set_symbol("X").set_style(sentinel_style);
6885 }
6886 }
6887 let request = crate::tui::approval::ApprovalRequest::new(
6888 "test-id",
6889 "read_file",
6890 "Read a file from disk",
6891 &serde_json::json!({"path": "src/main.rs"}),
6892 "tool:read_file",
6893 );
6894 let mut stack = ViewStack::new();
6895 stack.push(crate::tui::approval::ApprovalView::new(request));
6896 stack.set_focus_texture(mode, theme);
6897 let focus = stack
6898 .top_occupied_region(area)
6899 .expect("approval view on the stack");
6900 stack.render(area, &mut buf);
6901
6902 let rows: Vec<String> = (0..h)
6903 .map(|y| {
6904 (0..w)
6905 .map(|x| buf[(x, y)].symbol().to_string())
6906 .collect::<String>()
6907 })
6908 .collect();
6909 let text = rows.join("\n");
6910
6911 assert!(
6912 text.contains("Do you want to proceed?") && text.contains("read_file"),
6913 "{mode:?} {w}x{h}: approval prompt must survive the texture"
6914 );
6915 // Zero sentinel bleed INSIDE the focused band: the backdrop
6916 // and the modal own every cell there. Outside the band the
6917 // texture intentionally leaves the sentinel glyphs in place
6918 // (Scrim only re-colors; Grain never overwrites text).
6919 let mut whale_bg_cells = 0_u32;
6920 for y in focus.top()..focus.bottom() {
6921 for x in focus.left()..focus.right() {
6922 let cell = &buf[(x, y)];
6923 assert!(
6924 !(cell.symbol() == "X"
6925 && cell.fg == Color::Magenta
6926 && cell.bg == Color::Green),
6927 "{mode:?} {w}x{h}: sentinel bleed inside focus at ({x},{y})"
6928 );
6929 if cell.bg == palette::WHALE_BG {
6930 whale_bg_cells += 1;
6931 }
6932 }
6933 }
6934 // The band keeps the opaque modal ink. (Not every cell: the
6935 // selected option row carries its own highlight background.)
6936 assert!(
6937 whale_bg_cells > 0,
6938 "{mode:?} {w}x{h}: modal band lost its opaque WHALE_BG surface"
6939 );
6940 }
6941 }
6942 }
6943
6944 #[test]
6945 fn centered_modal_area_clamps_and_centers() {
6946 // Roomy frame: preferred size honoured, centered.
6947 let area = Rect::new(0, 0, 160, 40);
6948 let rect = centered_modal_area(area, 80, 20, 40, 10);
6949 assert_eq!((rect.width, rect.height), (80, 20));
6950 assert_eq!(rect.x, (160 - 80) / 2);
6951 assert_eq!(rect.y, (40 - 20) / 2);
6952
6953 // Tiny frame: never exceeds the frame even below the requested minimum.
6954 let tiny = Rect::new(0, 0, 30, 8);
6955 let rect = centered_modal_area(tiny, 80, 20, 40, 10);
6956 assert!(rect.width <= tiny.width, "width must fit frame");
6957 assert!(rect.height <= tiny.height, "height must fit frame");
6958 assert!(rect.x + rect.width <= tiny.width);
6959 assert!(rect.y + rect.height <= tiny.height);
6960 }
6961
6962 #[test]
6963 fn action_footer_wraps_instead_of_overflowing() {
6964 let hints = [
6965 ActionHint::new("↑↓", "move"),
6966 ActionHint::new("a-z", "jump"),
6967 ActionHint::new("Enter", "apply"),
6968 ActionHint::new("R", "edit key"),
6969 ActionHint::new("M", "models"),
6970 ActionHint::new("Esc", "cancel"),
6971 ];
6972
6973 // Wide enough for a single row.
6974 let wide = action_footer_lines(&hints, 120);
6975 assert_eq!(wide.len(), 1);
6976 assert!(wide[0].width() <= 120);
6977
6978 // Narrow forces wrapping but never truncates: every action survives and
6979 // no produced line exceeds the available width.
6980 let narrow = action_footer_lines(&hints, 28);
6981 assert!(narrow.len() >= 2, "narrow footer should wrap to >1 row");
6982 for line in &narrow {
6983 assert!(
6984 line.width() <= 28,
6985 "wrapped footer row overflows: {} cols",
6986 line.width()
6987 );
6988 }
6989 let joined: String = narrow
6990 .iter()
6991 .flat_map(|l| l.spans.iter())
6992 .map(|s| s.content.as_ref())
6993 .collect();
6994 for label in ["move", "jump", "apply", "edit key", "models", "cancel"] {
6995 assert!(joined.contains(label), "footer dropped action: {label}");
6996 }
6997 }
6998
6999 #[test]
7000 fn render_modal_footer_reserves_rows_and_returns_body() {
7001 let inner = Rect::new(2, 2, 40, 10);
7002 let mut buf = Buffer::empty(Rect::new(0, 0, 44, 14));
7003 let hints = [
7004 ActionHint::new("Enter", "save"),
7005 ActionHint::new("Esc", "cancel"),
7006 ];
7007 let body = render_modal_footer_with_gutter(inner, &mut buf, &hints);
7008 // Normal-height overlays reserve a single quiet gutter above the
7009 // one-row footer, so body prose never runs into the action rail.
7010 assert_eq!(body.y, inner.y);
7011 assert_eq!(body.height, inner.height - 2);
7012 assert_eq!(body.y + body.height, inner.y + inner.height - 2);
7013 let gutter_y = inner.y + inner.height - 2;
7014 assert!(
7015 (inner.x..inner.right()).all(|x| buf[(x, gutter_y)].symbol().trim().is_empty()),
7016 "modal footer gutter should stay visually quiet"
7017 );
7018 }
7019
7020 #[test]
7021 fn list_detail_layout_splits_wide_and_stacks_narrow() {
7022 let wide = ListDetailLayout::split(Rect::new(0, 0, 120, 24), 34);
7023 assert!(!wide.stacked);
7024 assert!(wide.list.width >= 30);
7025 assert!(wide.detail.width >= 34);
7026 assert_eq!(wide.list.height, 24);
7027 assert_eq!(wide.detail.height, 24);
7028 assert!(wide.list.right() < wide.detail.left());
7029
7030 let narrow = ListDetailLayout::split(Rect::new(0, 0, 80, 20), 34);
7031 assert!(narrow.stacked);
7032 assert_eq!(narrow.list.width, 80);
7033 assert_eq!(narrow.detail.width, 80);
7034 assert!(narrow.list.bottom() <= narrow.detail.top());
7035 assert!(narrow.list.height > 0);
7036 }
7037
7038 #[test]
7039 fn empty_state_renders_copy_and_actions() {
7040 let area = Rect::new(0, 0, 48, 8);
7041 let mut buf = Buffer::empty(area);
7042 EmptyState::new("Nothing here", "Use search or switch categories.")
7043 .primary_action("/", "filter")
7044 .secondary_action("Esc", "cancel")
7045 .render(area, &mut buf);
7046
7047 let text = (0..area.height)
7048 .map(|y| {
7049 (0..area.width)
7050 .map(|x| buf[(x, y)].symbol().to_string())
7051 .collect::<String>()
7052 })
7053 .collect::<Vec<_>>()
7054 .join("\n");
7055 for expected in ["Nothing here", "Use search", "filter", "cancel"] {
7056 assert!(
7057 text.contains(expected),
7058 "empty state missing {expected:?}: {text:?}"
7059 );
7060 }
7061 }
7062
7063 struct ConfigSettingsEnvGuard {
7064 _config_path: crate::test_support::EnvVarGuard,
7065 _tmp: TempDir,
7066 _lock: crate::test_support::TestEnvLock,
7067 }
7068
7069 impl ConfigSettingsEnvGuard {
7070 fn new(settings_toml: &str) -> Self {
7071 let lock = crate::test_support::lock_test_env();
7072 let tmp = TempDir::new().expect("settings tempdir");
7073 let config_path = tmp.path().join(".deepseek").join("config.toml");
7074 let settings_path = config_path
7075 .parent()
7076 .expect("settings parent")
7077 .join("settings.toml");
7078 std::fs::create_dir_all(config_path.parent().expect("config parent"))
7079 .expect("config dir");
7080 std::fs::write(&settings_path, settings_toml).expect("settings file");
7081 let config_path_guard =
7082 crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
7083 Self {
7084 _config_path: config_path_guard,
7085 _tmp: tmp,
7086 _lock: lock,
7087 }
7088 }
7089 }
7090
7091 fn create_test_app() -> App {
7092 static NEXT_CONFIG_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
7093 let config_id = NEXT_CONFIG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7094 let isolated_config_path = std::env::temp_dir().join(format!(
7095 "codewhale-config-view-test-{}-{config_id}.toml",
7096 std::process::id()
7097 ));
7098 let options = TuiOptions {
7099 // ConfigView consults the app's persisted config. Point generic
7100 // tests at a unique absent file so developer or concurrent test
7101 // settings cannot silently change which controls are editable.
7102 config_path: Some(isolated_config_path),
7103 ..crate::test_support::test_tui_options(PathBuf::from("."))
7104 };
7105 let mut app = App::new(options, &Config::default());
7106 app.api_provider = crate::config::ApiProvider::Deepseek;
7107 app
7108 }
7109
7110 fn cost_currency_row_for_settings(
7111 settings_toml: &str,
7112 ) -> (String, String, crate::pricing::CostCurrency, Locale) {
7113 let _guard = ConfigSettingsEnvGuard::new(settings_toml);
7114 let app = create_test_app();
7115 let view = ConfigView::new_for_app(&app);
7116 let row = view
7117 .rows
7118 .iter()
7119 .find(|row| row.key == "cost_currency")
7120 .expect("cost_currency row");
7121
7122 (
7123 row.value.clone(),
7124 view.row_display_value(row),
7125 app.cost_currency,
7126 app.ui_locale,
7127 )
7128 }
7129
7130 fn type_filter(view: &mut ConfigView, text: &str) {
7131 for ch in text.chars() {
7132 let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
7133 assert!(matches!(action, ViewAction::None));
7134 }
7135 }
7136
7137 fn manager_agent(id: &str, status: SubAgentStatus) -> SubAgentResult {
7138 SubAgentResult {
7139 usage: None,
7140 name: id.to_string(),
7141 agent_id: id.to_string(),
7142 context_mode: "fresh".to_string(),
7143 fork_context: false,
7144 workspace: None,
7145 git_branch: None,
7146 agent_type: FleetRole::Scout,
7147 assignment: SubAgentAssignment {
7148 objective: "read the docs".to_string(),
7149 role: None,
7150 },
7151 model: "deepseek-v4-flash".to_string(),
7152 nickname: None,
7153 status,
7154 worker_status: None,
7155 runtime_permissions: None,
7156 parent_run_id: None,
7157 spawn_depth: 0,
7158 child_route: None,
7159 result: None,
7160 steps_taken: 1,
7161 checkpoint: None,
7162 needs_input: None,
7163 duration_ms: 10,
7164 started_at: None,
7165 from_prior_session: false,
7166 }
7167 }
7168
7169 #[test]
7170 fn worker_register_update_preserves_selected_agent_across_new_spawns() {
7171 let mut view = SubAgentsView::new(vec![manager_agent("b", SubAgentStatus::Running)]);
7172 view.update_subagents(&[
7173 manager_agent("a", SubAgentStatus::Running),
7174 manager_agent("b", SubAgentStatus::Running),
7175 ]);
7176 assert_eq!(view.ordered_agent_ids()[view.selected], "b");
7177 view.update_subagents(&[
7178 manager_agent("a", SubAgentStatus::Running),
7179 manager_agent("b", SubAgentStatus::Completed),
7180 ]);
7181 assert_eq!(view.ordered_agent_ids()[view.selected], "b");
7182 }
7183
7184 #[test]
7185 fn subagent_view_agents_includes_progress_only_running_agent() {
7186 let mut app = create_test_app();
7187 app.ensure_agent_label("agent_live");
7188 app.agent_progress
7189 .insert("agent_live".to_string(), "reading code".to_string());
7190
7191 let agents = subagent_view_agents(&app, &[]);
7192
7193 assert_eq!(agents.len(), 1);
7194 assert_eq!(agents[0].agent_id, "agent_live");
7195 assert!(matches!(agents[0].status, SubAgentStatus::Running));
7196 assert_eq!(agents[0].assignment.role.as_deref(), Some("live"));
7197 assert!(agents[0].assignment.objective.contains("reading code"));
7198 assert_eq!(agents[0].nickname.as_deref(), Some("Agent 1"));
7199 }
7200
7201 #[test]
7202 fn subagent_view_replaces_progress_placeholder_after_manager_snapshot() {
7203 let mut app = create_test_app();
7204 app.ui_locale = Locale::En;
7205 app.ensure_agent_label("agent_live");
7206 app.agent_progress
7207 .insert("agent_live".to_string(), "reading code".to_string());
7208
7209 let progress_only = subagent_view_agents(&app, &[]);
7210 assert_eq!(progress_only[0].nickname.as_deref(), Some("Agent 1"));
7211
7212 let mut manager = manager_agent("agent_live", SubAgentStatus::Running);
7213 manager.nickname = Some(crate::tools::subagent::whale_name_for_id_in_locale(
7214 "agent_live",
7215 "ja",
7216 ));
7217 let manager_backed = subagent_view_agents(&app, &[manager]);
7218 assert_eq!(
7219 manager_backed[0].nickname.as_deref(),
7220 Some(crate::tools::subagent::whale_name_for_id_in_locale("agent_live", "en").as_str())
7221 );
7222 }
7223
7224 #[test]
7225 fn subagent_view_headlines_the_dispatch_name_over_the_whale() {
7226 // #5287: `/subagents` spells the identity column from `nickname`, so a
7227 // named dispatch lands there and only an unnamed one gets a whale.
7228 let mut app = create_test_app();
7229 app.ui_locale = Locale::En;
7230 let mut named = manager_agent("agent_named_lane", SubAgentStatus::Running);
7231 named.name = "branch-triage".to_string();
7232 let plain = manager_agent("agent_plain_lane", SubAgentStatus::Running);
7233
7234 let agents = subagent_view_agents(&app, &[named, plain]);
7235 assert_eq!(agents[0].nickname.as_deref(), Some("branch-triage"));
7236 assert_eq!(
7237 agents[1].nickname.as_deref(),
7238 Some(
7239 crate::tools::subagent::whale_name_for_id_in_locale("agent_plain_lane", "en")
7240 .as_str()
7241 )
7242 );
7243 }
7244
7245 #[test]
7246 fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() {
7247 let mut app = create_test_app();
7248 let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]);
7249 card.upsert_worker("chunk_1", AgentLifecycle::Completed);
7250 card.upsert_worker("chunk_2", AgentLifecycle::Running);
7251 app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
7252 app.last_fanout_card_index = Some(app.history.len().saturating_sub(1));
7253
7254 let agents = subagent_view_agents(&app, &[]);
7255
7256 assert_eq!(agents.len(), 2);
7257 assert_eq!(agents[0].agent_id, "chunk_1");
7258 assert!(matches!(agents[0].status, SubAgentStatus::Completed));
7259 assert_eq!(agents[1].agent_id, "chunk_2");
7260 assert!(matches!(agents[1].status, SubAgentStatus::Running));
7261 assert_eq!(agents[1].assignment.role.as_deref(), Some("rlm"));
7262 }
7263
7264 #[test]
7265 fn subagent_view_agents_deduplicates_manager_rows_over_live_rows() {
7266 let mut app = create_test_app();
7267 app.agent_progress
7268 .insert("agent_cached".to_string(), "live duplicate".to_string());
7269 let manager = vec![manager_agent("agent_cached", SubAgentStatus::Running)];
7270
7271 let agents = subagent_view_agents(&app, &manager);
7272
7273 assert_eq!(agents.len(), 1);
7274 assert_eq!(agents[0].agent_type, FleetRole::Scout);
7275 assert_eq!(agents[0].assignment.objective, "read the docs");
7276 }
7277
7278 #[test]
7279 fn fleet_worker_status_view_can_jump_to_fleet_setup() {
7280 let mut view = SubAgentsView::new(Vec::new());
7281
7282 let action = view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
7283
7284 match action {
7285 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
7286 action: CommandPaletteAction::ExecuteCommand { command },
7287 }) => assert_eq!(command, "/fleet"),
7288 other => panic!("expected /fleet jump action, got {other:?}"),
7289 }
7290 }
7291
7292 /// One agent, one destination (v0.9.7): Enter on a `/agents` row opens
7293 /// the selected agent's transcript — the same destination the Work strip
7294 /// and sidebar resolve to. Selection follows render order (running before
7295 /// completed), and an empty register keeps Enter's refresh meaning.
7296 #[test]
7297 fn subagents_enter_opens_the_selected_agents_transcript() {
7298 let mut view = SubAgentsView::new(vec![
7299 manager_agent("agent_done", SubAgentStatus::Completed),
7300 manager_agent("agent_live", SubAgentStatus::Running),
7301 ]);
7302
7303 // Render order groups running first, so the initial selection is the
7304 // running agent even though the completed one was pushed first.
7305 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
7306 ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => {
7307 assert_eq!(agent_id, "agent_live");
7308 }
7309 other => panic!("expected transcript open, got {other:?}"),
7310 }
7311
7312 let _ = view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
7313 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
7314 ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => {
7315 assert_eq!(agent_id, "agent_done");
7316 }
7317 other => panic!("expected transcript open, got {other:?}"),
7318 }
7319
7320 let mut empty = SubAgentsView::new(Vec::new());
7321 assert!(matches!(
7322 empty.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
7323 ViewAction::Emit(ViewEvent::SubAgentsRefresh)
7324 ));
7325 }
7326
7327 /// Whale Teams rows: every worker carries its species badge and a state
7328 /// word derived from the real status (running → Working, completed →
7329 /// Resting, failed → Blocked, interrupted → Waiting for you), and the
7330 /// working wake holds the poster frame outside Full motion.
7331 #[test]
7332 fn subagents_rows_carry_species_badges_and_truthful_state_words() {
7333 let mut interrupted = manager_agent("agent_wait", SubAgentStatus::Interrupted("q".into()));
7334 interrupted.agent_type = FleetRole::Builder;
7335 let mut failed = manager_agent("agent_fail", SubAgentStatus::Failed("boom".into()));
7336 failed.agent_type = FleetRole::Reviewer;
7337 let view = SubAgentsView::new(vec![
7338 manager_agent("agent_done", SubAgentStatus::Completed),
7339 manager_agent("agent_live", SubAgentStatus::Running),
7340 interrupted,
7341 failed,
7342 ]);
7343 assert_eq!(view.whale_frame(), 0, "Still motion holds the poster frame");
7344 let area = Rect::new(0, 0, 100, 40);
7345 let mut buf = Buffer::empty(area);
7346 view.render(area, &mut buf);
7347 let text = buffer_text(&buf, area);
7348 // Scout (manager_agent default role) → beak badge; Builder → Patch
7349 // bracket; Reviewer → Lantern lens.
7350 assert!(text.contains("◂▰ agent_live"), "{text}");
7351 assert!(text.contains("◂▰ · Working"), "{text}");
7352 assert!(text.contains("◂▰ Resting"), "{text}");
7353 assert!(text.contains("▰] ◆ Waiting for you"), "{text}");
7354 assert!(text.contains("◇▰ ▌ Blocked"), "{text}");
7355 assert!(
7356 !text.contains("Scout · research"),
7357 "no caption labels: {text}"
7358 );
7359 assert!(
7360 !text.contains("Lantern · review"),
7361 "no caption labels: {text}"
7362 );
7363 }
7364
7365 /// A click on a rendered `/agents` row opens the clicked agent's
7366 /// transcript and moves the selection cursor onto it.
7367 #[test]
7368 fn subagents_click_opens_the_clicked_agents_transcript() {
7369 let mut view = SubAgentsView::new(vec![
7370 manager_agent("agent_done", SubAgentStatus::Completed),
7371 manager_agent("agent_live", SubAgentStatus::Running),
7372 ]);
7373 let area = Rect::new(0, 0, 100, 30);
7374 let mut buf = Buffer::empty(area);
7375 view.render(area, &mut buf);
7376
7377 // Resolve the completed agent's on-screen row from the recorded
7378 // layout, exactly as a click does in reverse.
7379 let (first_line, _, agent_id) = view
7380 .row_lines
7381 .borrow()
7382 .iter()
7383 .find(|(_, _, id)| id == "agent_done")
7384 .cloned()
7385 .expect("completed agent block recorded");
7386 assert_eq!(agent_id, "agent_done");
7387 let body = view.body_area.get();
7388 let scroll = view.last_render_scroll.get();
7389 let click_row = body.y + u16::try_from(first_line - scroll).expect("visible row");
7390
7391 let action = view.handle_mouse(MouseEvent {
7392 kind: MouseEventKind::Down(MouseButton::Left),
7393 column: body.x + 2,
7394 row: click_row,
7395 modifiers: KeyModifiers::NONE,
7396 });
7397 match action {
7398 ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id }) => {
7399 assert_eq!(agent_id, "agent_done");
7400 }
7401 other => panic!("expected transcript open, got {other:?}"),
7402 }
7403 assert_eq!(view.ordered_agent_ids()[view.selected], "agent_done");
7404
7405 // The selection cursor is visible after a re-render.
7406 let mut buf = Buffer::empty(area);
7407 view.render(area, &mut buf);
7408 let text = (0..area.height)
7409 .map(|y| {
7410 (0..area.width)
7411 .map(|x| buf[(x, y)].symbol().to_string())
7412 .collect::<String>()
7413 })
7414 .collect::<Vec<_>>()
7415 .join("\n");
7416 assert!(
7417 text.contains('\u{25B8}'),
7418 "selection cursor missing:\n{text}"
7419 );
7420 }
7421
7422 fn visible_section_labels(view: &ConfigView) -> Vec<Cow<'static, str>> {
7423 view.visible_items()
7424 .into_iter()
7425 .filter_map(|item| match item {
7426 ConfigListItem::Section(section) => Some(section.label(view.locale)),
7427 ConfigListItem::Row(_) => None,
7428 })
7429 .collect()
7430 }
7431
7432 fn create_config_view(locale: Locale) -> ConfigView {
7433 let mut app = create_test_app();
7434 app.ui_locale = locale;
7435 ConfigView::new_for_app(&app)
7436 }
7437
7438 fn visible_row_keys(view: &ConfigView) -> Vec<&str> {
7439 view.visible_items()
7440 .into_iter()
7441 .filter_map(|item| match item {
7442 ConfigListItem::Row(idx) => Some(view.rows[idx].key.as_str()),
7443 ConfigListItem::Section(_) => None,
7444 })
7445 .collect()
7446 }
7447
7448 #[test]
7449 fn truncate_view_text_handles_unicode() {
7450 let text = "abc😀é";
7451 assert_eq!(truncate_view_text(text, 0), "");
7452 assert_eq!(truncate_view_text(text, 1), "a");
7453 assert_eq!(truncate_view_text(text, 3), "abc");
7454 assert_eq!(truncate_view_text(text, 4), "abc😀");
7455 assert_eq!(truncate_view_text(text, 5), "abc😀é");
7456 }
7457
7458 #[test]
7459 fn underwater_surface_ellipsizes_narrow_titles() {
7460 let area = Rect::new(0, 0, 24, 8);
7461 let mut buf = Buffer::empty(area);
7462 render_underwater_surface(area, &mut buf, "Help — Concepts, commands, and keybindings");
7463 let top = (0..area.width)
7464 .map(|x| buf[(x, 0)].symbol())
7465 .collect::<String>();
7466 assert!(
7467 top.contains('…'),
7468 "narrow title should signal truncation: {top}"
7469 );
7470 }
7471
7472 #[test]
7473 fn config_view_groups_rows_by_expected_sections() {
7474 let view = create_config_view(Locale::En);
7475 assert_eq!(
7476 visible_section_labels(&view),
7477 vec!["Display"],
7478 "Settings opens on Appearance"
7479 );
7480 }
7481
7482 #[test]
7483 fn config_view_includes_expected_editable_rows() {
7484 let app = create_test_app();
7485 let view = ConfigView::new_for_app(&app);
7486 let keys = view
7487 .rows
7488 .iter()
7489 .map(|row| row.key.as_str())
7490 .collect::<Vec<_>>();
7491 assert!(keys.contains(&"provider"));
7492 assert!(keys.contains(&"model"));
7493 assert!(keys.contains(&"reasoning_effort"));
7494 assert!(keys.contains(&"base_url"));
7495 assert!(keys.contains(&"external_credentials.openai-codex"));
7496 assert!(keys.contains(&"external_credentials.xai"));
7497 assert!(keys.contains(&"approval_mode"));
7498 assert!(keys.contains(&"permission_posture"));
7499 assert!(keys.contains(&"allow_shell"));
7500 assert!(keys.contains(&"theme"));
7501 assert!(keys.contains(&"locale"));
7502 assert!(keys.contains(&"background_color"));
7503 assert!(keys.contains(&"fancy_animations"));
7504 assert!(keys.contains(&"thinking_default_expanded"));
7505 assert!(keys.contains(&"synchronized_output"));
7506 assert!(keys.contains(&"auto_compact"));
7507 assert!(keys.contains(&"tool_collapse"));
7508 assert!(keys.contains(&"composer_border"));
7509 assert!(keys.contains(&"composer_multiline_mode"));
7510 assert!(keys.contains(&"cost_currency"));
7511 assert!(keys.contains(&"mcp_open"));
7512 assert!(keys.contains(&"mcp_reconnect"));
7513 assert!(keys.contains(&"mcp_diagnose"));
7514 assert!(keys.contains(&"plugins_open"));
7515 assert!(keys.contains(&"mcp_config_path"));
7516 assert!(keys.contains(&"fleet.exec.max_spawn_depth"));
7517 // Retired rows: the backends stay live (`default_model` routing,
7518 // the `vision_model` feature flag) or were derived receipts
7519 // (`fast_model`), but none keeps a table row.
7520 assert!(!keys.contains(&"features.vision_model"));
7521 assert!(!keys.contains(&"fast_model"));
7522 assert!(!keys.contains(&"default_model"));
7523 assert!(keys.contains(&"goal_command"));
7524 assert!(keys.contains(&"workflow"));
7525 assert!(!keys.contains(&"features.subagents"));
7526 assert!(!keys.contains(&"features.web_search"));
7527 assert!(!keys.contains(&"features.apply_patch"));
7528 assert!(!keys.contains(&"features.mcp"));
7529 assert!(!keys.contains(&"features.exec_policy"));
7530 assert!(!keys.contains(&"whaleflow"));
7531 // Diagnostic-only rows, managed permission rows, and live route
7532 // receipts are not editable; everything else outside the
7533 // read-only sections should be.
7534 const DIAGNOSTIC_ONLY: &[&str] = &[
7535 "context_window",
7536 "effective_context_window",
7537 "external_credentials.openai-codex",
7538 "external_credentials.xai",
7539 "base_url",
7540 "provider_url",
7541 // Sub-agent depth stays a read-only config.toml receipt in its
7542 // new Model home; it is edited in the fleet config, not here.
7543 "fleet.exec.max_spawn_depth",
7544 ];
7545 assert!(
7546 view.rows
7547 .iter()
7548 .filter(|row| {
7549 !matches!(
7550 row.section(),
7551 super::ConfigSection::Experimental
7552 | super::ConfigSection::Fleet
7553 | super::ConfigSection::Workflow
7554 | super::ConfigSection::Session
7555 | super::ConfigSection::Legacy
7556 ) && !DIAGNOSTIC_ONLY.contains(&row.key.as_str())
7557 && !row.key.starts_with("managed_")
7558 })
7559 .all(|row| row.editable)
7560 );
7561 assert!(
7562 view.rows
7563 .iter()
7564 .filter(|row| {
7565 matches!(
7566 row.section(),
7567 super::ConfigSection::Experimental
7568 | super::ConfigSection::Fleet
7569 | super::ConfigSection::Workflow
7570 | super::ConfigSection::Session
7571 | super::ConfigSection::Legacy
7572 )
7573 })
7574 .all(|row| !row.editable || row.key.starts_with("notifications."))
7575 );
7576 // Route endpoint rows are provider-specific: DeepSeek routes expose
7577 // `base_url`, every other provider exposes `provider_url`. Whichever
7578 // exists must be a read-only route receipt.
7579 const ROUTE_RECEIPT_KEYS: &[&str] = &["base_url", "provider_url"];
7580 for key in DIAGNOSTIC_ONLY
7581 .iter()
7582 .filter(|key| !ROUTE_RECEIPT_KEYS.contains(key))
7583 {
7584 assert!(
7585 view.rows.iter().any(|row| row.key == *key && !row.editable),
7586 "{key} must remain diagnostic-only"
7587 );
7588 }
7589 let receipt_rows: Vec<_> = view
7590 .rows
7591 .iter()
7592 .filter(|row| ROUTE_RECEIPT_KEYS.contains(&row.key.as_str()))
7593 .collect();
7594 assert_eq!(
7595 receipt_rows.len(),
7596 1,
7597 "exactly one endpoint receipt row must exist for the active route"
7598 );
7599 assert!(
7600 !receipt_rows[0].editable,
7601 "endpoint receipt rows must be read-only"
7602 );
7603 }
7604
7605 #[test]
7606 fn config_view_surfaces_structural_external_consent_without_io() {
7607 let _env = crate::test_support::lock_test_env();
7608 let temp = tempfile::tempdir().expect("config view fixture");
7609 let config_path = temp.path().join("config.toml");
7610 let auth_path = temp.path().join("codex-auth.json");
7611 fs::write(&auth_path, "external-secret-must-not-be-read").expect("auth trap");
7612 fs::write(
7613 &config_path,
7614 format!(
7615 r#"provider = "openai-codex"
7616 [providers.openai_codex]
7617 auth_mode = "oauth"
7618 [providers.openai_codex.external_credentials]
7619 access = "read_only"
7620 provider = "openai-codex"
7621 source = "codex_cli"
7622 path = {:?}
7623 consent_version = 1
7624 "#,
7625 auth_path.display().to_string()
7626 ),
7627 )
7628 .expect("config fixture");
7629 let ambient_path = temp.path().join("new-ambient-codex-auth.json");
7630 let _path = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &ambient_path);
7631 let mut app = create_test_app();
7632 app.config_path = Some(config_path);
7633 crate::external_credentials::reset_side_effect_trap();
7634 let view = ConfigView::new_for_app(&app);
7635 let row = view
7636 .rows
7637 .iter()
7638 .find(|row| row.key == "external_credentials.openai-codex")
7639 .expect("structural consent row");
7640 assert!(row.value.contains("access=read_only"), "{}", row.value);
7641 assert!(row.value.contains("source=codex_cli"), "{}", row.value);
7642 assert!(row.value.contains("version=1"), "{}", row.value);
7643 assert!(row.value.contains("active"), "{}", row.value);
7644 assert!(row.value.contains("remains pinned"), "{}", row.value);
7645 assert!(
7646 row.value
7647 .contains(&codewhale_config::quote_os_path(&auth_path)),
7648 "{}",
7649 row.value
7650 );
7651 assert!(
7652 !row.value.contains(&ambient_path.display().to_string()),
7653 "{}",
7654 row.value
7655 );
7656 assert!(
7657 row.value
7658 .contains("external-revoke --provider openai-codex")
7659 );
7660 assert_eq!(
7661 crate::external_credentials::complete_side_effect_trap_counts(),
7662 (0, 0, 0, 0, 0)
7663 );
7664 }
7665
7666 #[test]
7667 fn config_view_permission_row_tracks_the_controlling_saved_source() {
7668 let explicit_dir = TempDir::new().expect("explicit config tempdir");
7669 let explicit_path = explicit_dir.path().join("config.toml");
7670 fs::write(&explicit_path, "approval_policy = \"auto\"\n").expect("explicit config");
7671 let mut app = create_test_app();
7672 app.config_path = Some(explicit_path);
7673
7674 let mut explicit = ConfigView::new_for_app(&app);
7675 let row = explicit
7676 .rows
7677 .iter()
7678 .find(|row| row.key == "approval_policy")
7679 .expect("explicit approval policy row");
7680 assert_eq!(row.value, "auto");
7681 assert!(row.editable);
7682 assert_eq!(row.scope, ConfigScope::Saved);
7683 assert!(
7684 explicit
7685 .rows
7686 .iter()
7687 .all(|row| row.key != "permission_posture")
7688 );
7689 explicit.focus_key("approval_policy");
7690 explicit.start_edit();
7691 let choices = explicit
7692 .editing
7693 .as_ref()
7694 .and_then(|edit| edit.choices.as_ref())
7695 .expect("approval posture choices");
7696 assert_eq!(
7697 choices,
7698 &vec![
7699 "use-tui-default".to_string(),
7700 "ask".to_string(),
7701 "auto-review".to_string(),
7702 "full-access".to_string(),
7703 ]
7704 );
7705 let area = Rect::new(0, 0, 110, 30);
7706 let mut buf = Buffer::empty(area);
7707 explicit.render(area, &mut buf);
7708 let dump = buffer_text(&buf, area);
7709 assert!(
7710 dump.contains("4. Full Access"),
7711 "root permission chooser must expose the product posture:\n{dump}"
7712 );
7713 assert!(
7714 !dump.contains("4. Never"),
7715 "root permission chooser leaked the raw fail-closed policy token:\n{dump}"
7716 );
7717 let use_tui_default = explicit
7718 .editing
7719 .as_ref()
7720 .and_then(|edit| edit.choices.as_ref())
7721 .and_then(|choices| {
7722 choices
7723 .iter()
7724 .position(|choice| choice == "use-tui-default")
7725 })
7726 .expect("TUI default choice");
7727 explicit
7728 .editing
7729 .as_mut()
7730 .expect("choice editor")
7731 .selected_choice = use_tui_default;
7732 match explicit.handle_choice_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
7733 ViewAction::Emit(ViewEvent::ConfigUpdated {
7734 key,
7735 value,
7736 persist,
7737 }) => {
7738 assert_eq!(key, "approval_policy");
7739 assert_eq!(value, "use-tui-default");
7740 assert!(persist);
7741 }
7742 other => panic!("expected saved ConfigUpdated event, got {other:?}"),
7743 }
7744
7745 let managed_dir = TempDir::new().expect("managed config tempdir");
7746 let requirements_path = managed_dir.path().join("requirements.toml");
7747 fs::write(
7748 &requirements_path,
7749 "allowed_approval_policies = [\"never\"]\n",
7750 )
7751 .expect("requirements config");
7752 let config_path = managed_dir.path().join("config.toml");
7753 let requirements_value =
7754 toml::Value::String(requirements_path.to_string_lossy().into_owned()).to_string();
7755 fs::write(
7756 &config_path,
7757 format!("approval_policy = \"never\"\nrequirements_path = {requirements_value}\n"),
7758 )
7759 .expect("managed config");
7760 app.config_path = Some(config_path);
7761
7762 let managed = ConfigView::new_for_app(&app);
7763 let row = managed
7764 .rows
7765 .iter()
7766 .find(|row| row.key == "managed_approval_policy")
7767 .expect("managed approval policy row");
7768 assert!(!row.editable);
7769 assert_eq!(row.scope, ConfigScope::Saved);
7770 assert!(
7771 managed
7772 .rows
7773 .iter()
7774 .all(|row| row.key != "permission_posture" && row.key != "approval_policy")
7775 );
7776 }
7777
7778 #[test]
7779 fn config_view_provider_uses_full_picker_and_preserves_custom_provider_id() {
7780 let dir = TempDir::new().expect("custom provider tempdir");
7781 let config_path = dir.path().join("config.toml");
7782 fs::write(
7783 &config_path,
7784 r#"
7785 provider = "acme_ai"
7786
7787 [providers.acme_ai]
7788 kind = "openai-compatible"
7789 base_url = "https://api.example.invalid/v1"
7790 model = "acme-model"
7791 api_key_env = "ACME_API_KEY"
7792 "#,
7793 )
7794 .expect("custom provider config");
7795 let mut app = create_test_app();
7796 app.config_path = Some(config_path);
7797 app.set_provider_identity(crate::config::ApiProvider::Custom, "acme_ai");
7798 let mut view = ConfigView::new_for_app(&app);
7799 view.focus_key("provider");
7800
7801 let row = &view.rows[view.selected];
7802 assert_eq!(row.value, "acme_ai");
7803 assert_eq!(
7804 row.scope,
7805 ConfigScope::Session,
7806 "the provider row shows the live route identity, not saved config"
7807 );
7808 assert!(
7809 config_choice_values("provider").is_none(),
7810 "provider must not be truncated to the generic enum chooser"
7811 );
7812
7813 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
7814 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
7815 action: CommandPaletteAction::ExecuteCommand { command },
7816 }) => assert_eq!(command, "/provider"),
7817 other => panic!("expected full provider picker command, got {other:?}"),
7818 }
7819 assert!(view.editing.is_none());
7820 }
7821
7822 #[test]
7823 fn config_view_active_model_uses_picker_and_retired_rows_are_gone() {
7824 let app = create_test_app();
7825 let mut view = ConfigView::new_for_app(&app);
7826 view.focus_key("model");
7827
7828 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
7829 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
7830 action: CommandPaletteAction::ExecuteCommand { command },
7831 }) => assert_eq!(command, "/model"),
7832 other => panic!("expected full model picker, got {other:?}"),
7833 }
7834 assert!(view.editing.is_none());
7835
7836 // The derived fast-sibling receipt and the legacy DeepSeek fallback
7837 // have no rows: sibling choice happens in the /model picker and the
7838 // fallback stays a `/set`-only compatibility key.
7839 for key in ["fast_model", "default_model"] {
7840 assert!(
7841 view.rows.iter().all(|row| row.key != key),
7842 "{key} must have no settings row"
7843 );
7844 }
7845 }
7846
7847 #[test]
7848 fn config_view_zai_model_row_has_no_derived_rows() {
7849 let _guard = ConfigSettingsEnvGuard::new("");
7850 let mut app = create_test_app();
7851 app.api_provider = crate::config::ApiProvider::Zai;
7852 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
7853
7854 let view = ConfigView::new_for_app(&app);
7855 let active = view
7856 .rows
7857 .iter()
7858 .find(|row| row.key == "model")
7859 .expect("active model row");
7860
7861 assert_eq!(active.value, "Zhipu AI / Z.ai · GLM-5.2");
7862 // Derived receipts retired: the fast sibling is named in the /model
7863 // picker, and the DeepSeek-only fallback never appears as a row.
7864 for key in ["fast_model", "default_model"] {
7865 assert!(
7866 view.rows.iter().all(|row| row.key != key),
7867 "{key} row must be gone for zai"
7868 );
7869 }
7870 }
7871
7872 #[test]
7873 fn config_view_live_route_never_shows_stale_saved_provider() {
7874 // The reported defect: the saved config still said `provider =
7875 // "deepseek"` while the session was actually routed to Z.ai / GLM-5.3,
7876 // and Settings presented the stale saved value as the "Active
7877 // provider". Route rows must show the live route identity; saved
7878 // config is a startup/default fact, never the active receipt.
7879 let temp_root = std::env::temp_dir().join(format!(
7880 "codewhale-stale-saved-provider-view-test-{}",
7881 std::process::id()
7882 ));
7883 fs::create_dir_all(&temp_root).unwrap();
7884 let config_path = temp_root.join("config.toml");
7885 fs::write(
7886 &config_path,
7887 "provider = \"deepseek\"\nbase_url = \"https://api.deepseek.com/v1\"\n",
7888 )
7889 .unwrap();
7890
7891 let mut app = create_test_app();
7892 app.config_path = Some(config_path.clone());
7893 // Live session route, exactly as a /provider switch would leave it.
7894 app.api_provider = crate::config::ApiProvider::Zai;
7895 app.model = "GLM-5.3".to_string();
7896 app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
7897
7898 let view = ConfigView::new_for_app(&app);
7899
7900 let provider_row = view
7901 .rows
7902 .iter()
7903 .find(|row| row.key == "provider")
7904 .expect("provider row");
7905 assert!(
7906 provider_row.value.contains("Z.ai"),
7907 "provider row must show the live route identity: {}",
7908 provider_row.value
7909 );
7910 assert!(
7911 !provider_row.value.to_lowercase().contains("deepseek"),
7912 "stale saved provider must not appear as the active route: {}",
7913 provider_row.value
7914 );
7915 assert_eq!(provider_row.scope, ConfigScope::Session);
7916
7917 let model_row = view
7918 .rows
7919 .iter()
7920 .find(|row| row.key == "model")
7921 .expect("model row");
7922 assert_eq!(model_row.value, "Zhipu AI / Z.ai · GLM-5.3");
7923
7924 let url_row = view
7925 .rows
7926 .iter()
7927 .find(|row| row.key == "provider_url")
7928 .expect("endpoint row for the live Z.ai route");
7929 assert_eq!(url_row.value, crate::config::DEFAULT_ZAI_BASE_URL);
7930 assert!(!view.rows.iter().any(|row| row.key == "base_url"));
7931 }
7932
7933 #[test]
7934 fn config_view_shows_no_deepseek_fallback_row_on_any_provider() {
7935 let _guard = ConfigSettingsEnvGuard::new("");
7936 let mut app = create_test_app();
7937 for provider in [
7938 crate::config::ApiProvider::Zai,
7939 crate::config::ApiProvider::Xai,
7940 crate::config::ApiProvider::Openrouter,
7941 crate::config::ApiProvider::Ollama,
7942 crate::config::ApiProvider::Deepseek,
7943 ] {
7944 app.api_provider = provider;
7945 let view = ConfigView::new_for_app(&app);
7946 assert!(
7947 view.rows.iter().all(|row| row.key != "default_model"),
7948 "default_model must have no row for {:?}",
7949 provider
7950 );
7951 }
7952 }
7953
7954 #[test]
7955 fn config_view_saved_deepseek_fallback_is_a_read_only_migration_input() {
7956 // Old fallback values still parse, but new model choices belong to
7957 // the canonical config selection writer.
7958 let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n");
7959 let mut app = create_test_app();
7960 app.api_provider = crate::config::ApiProvider::Zai;
7961
7962 let view = ConfigView::new_for_app(&app);
7963 assert!(
7964 view.rows.iter().all(|row| row.key != "default_model"),
7965 "saved legacy fallback must not surface a row"
7966 );
7967 let mut settings = Settings::default();
7968 let error = settings
7969 .set("default_model", "deepseek-v4-pro")
7970 .expect_err("legacy model settings must not become another writer");
7971 assert!(error.to_string().contains("config.toml"));
7972 assert!(settings.default_model.is_none());
7973 }
7974
7975 /// Retired rows leave no section behind: sub-agent depth moved into the
7976 /// Model group, the legacy fallback and the vision flag lost their rows,
7977 /// and `/goal` + Workflow keep their own sections. Persisted keys are
7978 /// unchanged.
7979 #[test]
7980 fn config_view_settings_rows_land_in_truthful_sections() {
7981 let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n");
7982 let mut app = create_test_app();
7983 app.api_provider = crate::config::ApiProvider::Zai;
7984 let view = ConfigView::new_for_app(&app);
7985
7986 let section_of = |key: &str| {
7987 view.rows
7988 .iter()
7989 .find(|row| row.key == key)
7990 .unwrap_or_else(|| panic!("{key} row"))
7991 .section()
7992 };
7993 assert_eq!(
7994 section_of("fleet.exec.max_spawn_depth"),
7995 super::ConfigSection::Model
7996 );
7997 assert_eq!(section_of("goal_command"), super::ConfigSection::Session);
7998 assert_eq!(section_of("workflow"), super::ConfigSection::Workflow);
7999
8000 // The retired rows are gone on every provider, even with a saved
8001 // fallback value still on disk.
8002 for key in ["default_model", "fast_model", "features.vision_model"] {
8003 assert!(
8004 view.rows.iter().all(|row| row.key != key),
8005 "{key} must have no row"
8006 );
8007 }
8008
8009 // …and their sections retire with them: no Legacy, Experimental, or
8010 // Fleet headings may survive with zero rows behind them.
8011 let retired_sections = [
8012 super::ConfigSection::Legacy,
8013 super::ConfigSection::Experimental,
8014 super::ConfigSection::Fleet,
8015 ];
8016 for row in &view.rows {
8017 assert!(
8018 !retired_sections.contains(&row.section()),
8019 "{} still files under a retired section",
8020 row.key
8021 );
8022 }
8023
8024 // Relabelling is presentation only: the persisted key and value
8025 // round-trip unchanged, so existing config files keep loading
8026 // identically.
8027 let depth = view
8028 .rows
8029 .iter()
8030 .find(|row| row.key == "fleet.exec.max_spawn_depth")
8031 .expect("sub-agent depth row");
8032 assert_eq!(depth.scope, ConfigScope::Saved);
8033 assert!(!depth.editable);
8034 assert_eq!(config_label_for_key(&depth.key), "sub-agent depth");
8035
8036 // Workflow keeps its own name and its `/workflow` wording.
8037 let workflow = view
8038 .rows
8039 .iter()
8040 .find(|row| row.section() == super::ConfigSection::Workflow)
8041 .expect("workflow row");
8042 assert_eq!(workflow.key, "workflow");
8043 assert!(workflow.value.starts_with("/workflow "), "{workflow:?}");
8044 assert_eq!(config_label_for_key("workflow"), "Workflow");
8045 }
8046
8047 #[test]
8048 fn config_view_experimental_features_leave_no_rows() {
8049 // The vision row retired: even a configured beta flag surfaces no
8050 // table row. The flag itself stays live in the feature backend,
8051 // diagnosed where vision runs instead of in Advanced.
8052 let temp_root = std::env::temp_dir().join(format!(
8053 "codewhale-experimental-config-view-test-{}",
8054 std::process::id()
8055 ));
8056 fs::create_dir_all(&temp_root).unwrap();
8057 let config_path = temp_root.join("config.toml");
8058 fs::write(
8059 &config_path,
8060 r#"
8061 [features]
8062 web_search = false
8063 vision_model = true
8064 "#,
8065 )
8066 .unwrap();
8067
8068 let mut app = create_test_app();
8069 app.config_path = Some(config_path);
8070 let view = ConfigView::new_for_app(&app);
8071
8072 for key in [
8073 "features.web_search",
8074 "features.vision_model",
8075 "features.subagents",
8076 ] {
8077 assert!(
8078 view.rows.iter().all(|row| row.key != key),
8079 "{key} must have no settings row"
8080 );
8081 }
8082 }
8083
8084 #[test]
8085 fn config_view_shows_fleet_max_spawn_depth_from_config() {
8086 let temp_root = std::env::temp_dir().join(format!(
8087 "codewhale-fleet-config-view-test-{}",
8088 std::process::id()
8089 ));
8090 fs::create_dir_all(&temp_root).unwrap();
8091 let config_path = temp_root.join("config.toml");
8092 fs::write(
8093 &config_path,
8094 r#"
8095 [fleet.exec]
8096 max_spawn_depth = 2
8097 "#,
8098 )
8099 .unwrap();
8100
8101 let mut app = create_test_app();
8102 app.config_path = Some(config_path);
8103 let view = ConfigView::new_for_app(&app);
8104
8105 let row = view
8106 .rows
8107 .iter()
8108 .find(|row| row.key == "fleet.exec.max_spawn_depth")
8109 .expect("fleet spawn depth row");
8110 assert_eq!(row.value, "2");
8111 assert!(!row.editable);
8112 }
8113
8114 #[test]
8115 fn config_view_retired_experimental_section_stays_gone() {
8116 let mut view = create_config_view(Locale::En);
8117
8118 // The Experimental group retired with the vision row: the flag stays
8119 // live in the backend, but no section or row answers to it anymore.
8120 view.update_filter(|filter| filter.push_str("experimental"));
8121 assert!(visible_section_labels(&view).is_empty());
8122 assert!(visible_row_keys(&view).is_empty());
8123
8124 view.clear_filter();
8125 type_filter(&mut view, "feature vision");
8126 assert!(visible_section_labels(&view).is_empty());
8127 assert!(visible_row_keys(&view).is_empty());
8128
8129 view.clear_filter();
8130 type_filter(&mut view, "goal");
8131 assert_eq!(visible_section_labels(&view), vec!["Session"]);
8132 assert_eq!(visible_row_keys(&view), vec!["goal_command"]);
8133
8134 // The `workflow` row keeps its key and its name; #4751 only moved it
8135 // out of Fleet into its own Workflow section.
8136 view.clear_filter();
8137 type_filter(&mut view, "workflow");
8138 assert_eq!(visible_section_labels(&view), vec!["Workflow"]);
8139 let workflow_keys = visible_row_keys(&view);
8140 assert_eq!(workflow_keys.first(), Some(&"workflow"));
8141 assert_eq!(
8142 workflow_keys.len(),
8143 1 + codewhale_config::notifications::NotificationSetting::ALL.len()
8144 );
8145 assert!(workflow_keys[1..].iter().all(|key| {
8146 codewhale_config::notifications::NotificationSetting::parse(key).is_some()
8147 }));
8148
8149 view.clear_filter();
8150 type_filter(&mut view, "whaleflow");
8151 assert!(visible_row_keys(&view).is_empty());
8152 }
8153
8154 #[test]
8155 fn config_view_base_url_reflects_active_route_receipt() {
8156 let mut app = create_test_app();
8157 app.active_route_base_url = "https://ui-config-view.local/v1".to_string();
8158 let view = ConfigView::new_for_app(&app);
8159
8160 let row = view
8161 .rows
8162 .iter()
8163 .find(|row| row.key == "base_url")
8164 .expect("base_url row missing");
8165 assert_eq!(
8166 config_label_for_key(&row.key),
8167 "Provider API URL (DeepSeek route)"
8168 );
8169 // The endpoint row is a read-only receipt for the live route; it must
8170 // not re-read config files, which may describe a different saved
8171 // route than the one the session is actually using.
8172 assert_eq!(row.value, "https://ui-config-view.local/v1");
8173 assert!(!row.editable);
8174 assert_eq!(row.scope, ConfigScope::Session);
8175 }
8176
8177 #[test]
8178 fn config_view_uses_provider_url_for_non_deepseek_provider() {
8179 let temp_root = std::env::temp_dir().join(format!(
8180 "codewhale-provider-url-view-test-{}",
8181 std::process::id()
8182 ));
8183 fs::create_dir_all(&temp_root).unwrap();
8184 let config_path = temp_root.join("config.toml");
8185 fs::write(
8186 &config_path,
8187 r#"
8188 provider = "xiaomi-mimo"
8189
8190 [providers.xiaomi_mimo]
8191 api_key = "tp-test-token-plan-key"
8192 base_url = "https://api.xiaomimimo.com/v1"
8193 "#,
8194 )
8195 .unwrap();
8196
8197 let mut app = create_test_app();
8198 app.api_provider = crate::config::ApiProvider::XiaomiMimo;
8199 app.active_route_base_url = crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL.to_string();
8200 app.ui_locale = Locale::Es419;
8201 app.config_path = Some(config_path.clone());
8202 let mut view = ConfigView::new_for_app(&app);
8203
8204 let row = view
8205 .rows
8206 .iter()
8207 .find(|row| row.key == "provider_url")
8208 .expect("provider_url row missing");
8209 // The endpoint row reflects the live route identity (the default when
8210 // nothing overrides it), not a config-file re-read, and is a receipt.
8211 assert_eq!(row.value, crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL);
8212 assert!(!row.editable);
8213 assert!(!view.rows.iter().any(|row| row.key == "base_url"));
8214
8215 view.focus_key("provider_url");
8216 let hint = view
8217 .setting_detail_lines(&view.rows[view.selected], 200)
8218 .iter()
8219 .map(ToString::to_string)
8220 .collect::<Vec<_>>()
8221 .join("\n");
8222 let es_hint = tr(Locale::Es419, MessageId::ConfigHintProviderUrl);
8223 assert!(hint.contains(es_hint.as_ref()), "{hint}");
8224 assert!(hint.contains("pago por uso"), "{hint}");
8225 assert!(
8226 !hint.contains(tr(Locale::En, MessageId::ConfigHintProviderUrl).as_ref()),
8227 "the Spanish settings view must not leak the English guidance: {hint}"
8228 );
8229 }
8230
8231 #[test]
8232 fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() {
8233 let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n");
8234 let app = create_test_app();
8235 assert_eq!(app.ui_locale, Locale::ZhHans);
8236 assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
8237
8238 let view = ConfigView::new_for_app(&app);
8239 let row = view
8240 .rows
8241 .iter()
8242 .find(|row| row.key == "cost_currency")
8243 .expect("cost_currency row");
8244
8245 assert_eq!(row.value, "usd");
8246 assert_eq!(view.row_display_value(row), "usd (实际 cny)");
8247 assert_eq!(Settings::load().expect("settings").cost_currency, "usd");
8248 }
8249
8250 #[test]
8251 fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() {
8252 for alias in ["rmb", "yuan", "¥"] {
8253 let (saved_value, display_value, effective_currency, locale) =
8254 cost_currency_row_for_settings(&format!(
8255 "locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n"
8256 ));
8257
8258 assert_eq!(locale, Locale::ZhHans);
8259 assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
8260 assert_eq!(saved_value, alias);
8261 assert_eq!(display_value, alias);
8262 }
8263 }
8264
8265 #[test]
8266 fn config_view_cost_currency_matching_cny_setting_is_silent() {
8267 let (saved_value, display_value, effective_currency, locale) =
8268 cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n");
8269
8270 assert_eq!(locale, Locale::ZhHans);
8271 assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
8272 assert_eq!(saved_value, "cny");
8273 assert_eq!(display_value, "cny");
8274 }
8275
8276 #[test]
8277 fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() {
8278 let (saved_value, display_value, effective_currency, locale) =
8279 cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n");
8280
8281 assert_eq!(locale, Locale::En);
8282 assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
8283 assert_eq!(saved_value, "cny");
8284 assert_eq!(display_value, "cny");
8285 }
8286
8287 /// The panel's contract, cell-exact: tabs across the top, the groups
8288 /// column beside the list at 120 columns and folded into the headings at
8289 /// 80, the selected setting's sentence in the band, and the live footer
8290 /// preview under it. A visual change that cannot show as a golden diff
8291 /// did not happen.
8292 #[test]
8293 fn config_panel_golden_at_eighty_and_one_twenty() {
8294 let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n");
8295 let app = create_test_app();
8296 let view = ConfigView::new_for_app(&app);
8297 for (width, height) in [(40u16, 12u16), (80u16, 24u16), (120u16, 32u16)] {
8298 let rendered = crate::tui::golden_harness::render_golden_text(width, height, |buf| {
8299 view.render(Rect::new(0, 0, width, height), buf);
8300 });
8301 crate::tui::golden_harness::assert_matches_golden(
8302 &format!("config_panel_{width}x{height}"),
8303 &rendered,
8304 );
8305 }
8306 }
8307
8308 /// Slice C: cell-exact goldens for Edit Theme with the underwater
8309 /// default open — title, scope/current lanes, the 14 theme rows, and
8310 /// the Apply/Cancel controls. Empty settings mean the editor opens on
8311 /// the default theme, so these goldens pin the default end to end.
8312 /// Re-bless with `CODEWHALE_BLESS_GOLDENS=1`.
8313 #[test]
8314 fn edit_theme_matches_goldens_at_blocker_sizes() {
8315 let _guard = ConfigSettingsEnvGuard::new("");
8316 let app = create_test_app();
8317 let mut view = ConfigView::new_for_app(&app);
8318 view.focus_key("theme");
8319 view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8320 assert!(
8321 view.editing
8322 .as_ref()
8323 .is_some_and(|edit| edit.key == "theme"),
8324 "Enter must open the theme editor"
8325 );
8326 for (width, height) in [(80u16, 24u16), (120u16, 32u16)] {
8327 let rendered = crate::tui::golden_harness::render_golden_text(width, height, |buf| {
8328 view.render(Rect::new(0, 0, width, height), buf);
8329 });
8330 crate::tui::golden_harness::assert_matches_golden(
8331 &format!("edit_theme_{width}x{height}"),
8332 &trim_golden_rows(&rendered),
8333 );
8334 }
8335 }
8336
8337 /// Goldens are stored without cell padding: every row is right-trimmed
8338 /// and trailing empty rows are dropped, so `git diff --check` stays
8339 /// clean.
8340 fn trim_golden_rows(text: &str) -> String {
8341 let mut rows: Vec<&str> = text.lines().map(str::trim_end).collect();
8342 while rows.last().is_some_and(|row| row.is_empty()) {
8343 rows.pop();
8344 }
8345 let mut out = rows.join("\n");
8346 out.push('\n');
8347 out
8348 }
8349
8350 #[test]
8351 fn notification_rows_keep_saved_and_live_values_distinct_after_reopening() {
8352 let _guard = ConfigSettingsEnvGuard::new("");
8353 let temp = tempfile::tempdir().unwrap();
8354 let path = temp.path().join("config.toml");
8355 std::fs::write(&path, "[notifications]\nquiet = false\nsound = \"off\"\n").unwrap();
8356 let mut app = create_test_app();
8357 app.config_path = Some(path.clone());
8358 let mut config = Config::load(Some(path), None).unwrap();
8359 crate::tui::ui::apply_notification_update(
8360 &mut app,
8361 &mut config,
8362 crate::config::NotificationConfigUpdate::Quiet(true),
8363 )
8364 .unwrap();
8365 crate::tui::ui::apply_notification_update(
8366 &mut app,
8367 &mut config,
8368 crate::config::NotificationConfigUpdate::Sound(Some(
8369 crate::config::CompletionSound::Whale,
8370 )),
8371 )
8372 .unwrap();
8373 for _ in 0..2 {
8374 let mut view = ConfigView::new_for_app(&app);
8375 let row = view
8376 .rows
8377 .iter()
8378 .find(|row| row.key == "notifications.quiet")
8379 .unwrap();
8380 assert_eq!(row.value, "false");
8381 assert_eq!(row.edit_value(), "true");
8382 let fact = view.setting_fact(row).unwrap();
8383 assert_ne!(fact.saved, fact.current);
8384 view.focus_key("notifications.quiet");
8385 assert!(
8386 matches!(view.toggle_selected_boolean(), Some(ViewAction::Emit(ViewEvent::ConfigUpdated { value, .. })) if value == "false")
8387 );
8388 view.focus_key("notifications.sound");
8389 view.start_edit();
8390 let edit = view.editing.as_ref().unwrap();
8391 assert_eq!(
8392 edit.choices.as_ref().unwrap()[edit.selected_choice],
8393 "whale"
8394 );
8395 }
8396 app.refresh_notification_settings(&Config::default());
8397 }
8398
8399 /// The settings screen is a projection of the schema: its rail tabs, the
8400 /// group headings inside them, and the row order are the schema's
8401 /// declaration order, not a second table's. This is the one table test
8402 /// that keeps the projection honest.
8403 #[test]
8404 fn settings_tree_equals_the_schema_projection() {
8405 let mut view = create_config_view(Locale::En);
8406 let built: std::collections::HashSet<&str> =
8407 view.rows.iter().map(|row| row.key.as_str()).collect();
8408 let schema_keys: std::collections::HashSet<&str> =
8409 codewhale_config::schema_rows().map(|def| def.key).collect();
8410
8411 // Every schema row must appear in the view; `ui: None` settings stay
8412 // declared but off-screen, and anything the schema says is visible must
8413 // have a row here. A few rows are mutually exclusive: exactly one member
8414 // of each set is shown, chosen by which store controls the fact.
8415 let conditional_pairs: &[&[&str]] = &[
8416 &[
8417 "permission_posture",
8418 "approval_policy",
8419 "managed_approval_policy",
8420 ],
8421 &["allow_shell", "managed_allow_shell"],
8422 &["base_url", "provider_url"],
8423 ];
8424 for def in codewhale_config::schema_rows() {
8425 let present_in_pair = conditional_pairs
8426 .iter()
8427 .any(|pair| pair.contains(&def.key) && pair.iter().any(|key| built.contains(key)));
8428 // Experimental feature rows exist only when the flag is configured
8429 // or non-default (`experimental_feature_rows`).
8430 let configured_only = def.key.starts_with("features.");
8431 assert!(
8432 def.ui.is_none() || built.contains(def.key) || present_in_pair || configured_only,
8433 "{} is declared in schema_rows but has no ConfigRow",
8434 def.key
8435 );
8436 }
8437
8438 // No row should exist that is not declared in the schema.
8439 for key in &built {
8440 assert!(
8441 schema_keys.contains(key),
8442 "{key} has a ConfigRow but is not declared in schema_rows"
8443 );
8444 }
8445
8446 assert_eq!(
8447 codewhale_config::schema_tabs(),
8448 ConfigCategory::ALL
8449 .iter()
8450 .map(|category| category.id())
8451 .collect::<Vec<_>>(),
8452 "rail order and schema tab order have drifted apart"
8453 );
8454
8455 let mut expected: Vec<String> = Vec::new();
8456 for tab in codewhale_config::schema_tabs() {
8457 let mut group: Option<&str> = None;
8458 for def in codewhale_config::schema_rows() {
8459 let ui = def.ui.as_ref().expect("schema_rows filters on ui");
8460 if ui.tab != tab || !built.contains(def.key) {
8461 continue;
8462 }
8463 if group != Some(ui.group) {
8464 group = Some(ui.group);
8465 expected.push(format!("{tab}/{}", ui.group));
8466 }
8467 expected.push(format!("{tab}/{}/{}", ui.group, def.key));
8468 }
8469 }
8470
8471 let mut actual: Vec<String> = Vec::new();
8472 for category in ConfigCategory::ALL {
8473 view.category = category;
8474 for item in view.visible_items() {
8475 match item {
8476 ConfigListItem::Section(section) => {
8477 actual.push(format!("{}/{}", category.id(), section.id()));
8478 }
8479 ConfigListItem::Row(idx) => {
8480 let row = &view.rows[idx];
8481 actual.push(format!(
8482 "{}/{}/{}",
8483 category.id(),
8484 row.section().id(),
8485 row.key
8486 ));
8487 }
8488 }
8489 }
8490 }
8491
8492 assert_eq!(actual, expected);
8493 }
8494
8495 /// Every row the screen shows must land in a store. `settings.toml` rows
8496 /// round-trip through `Settings`; the rest are actions, receipts, or
8497 /// `config.toml` keys, and that list is spelled out so a new row cannot
8498 /// quietly become one that discards the user's edit.
8499 #[test]
8500 fn every_settings_row_reaches_a_store() {
8501 let _guard = crate::test_support::lock_test_env();
8502 // Not `settings.toml`: opens another surface, reports a fact, or is
8503 // persisted to config.toml by `set_config_value`.
8504 const NOT_SETTINGS_TOML: &[&str] = &[
8505 "provider",
8506 "model",
8507 "fleet.exec.max_spawn_depth",
8508 "goal_command",
8509 "workflow",
8510 "mcp_open",
8511 "mcp_reconnect",
8512 "mcp_diagnose",
8513 "plugins_open",
8514 "mcp_config_path",
8515 "approval_mode",
8516 "permission_posture",
8517 "approval_policy",
8518 "managed_approval_policy",
8519 "allow_shell",
8520 "managed_allow_shell",
8521 "telemetry",
8522 "context_window",
8523 "effective_context_window",
8524 "fast_model",
8525 "features.vision_model",
8526 "features.subagents",
8527 "features.web_search",
8528 "features.apply_patch",
8529 "features.mcp",
8530 "features.exec_policy",
8531 "base_url",
8532 "provider_url",
8533 "effective_context_window",
8534 "external_credentials.openai-codex",
8535 "external_credentials.xai",
8536 ];
8537
8538 for def in codewhale_config::schema_rows() {
8539 if let Some(setting) =
8540 codewhale_config::notifications::NotificationSetting::parse(def.key)
8541 {
8542 let samples = match setting {
8543 codewhale_config::notifications::NotificationSetting::SoundFile => {
8544 vec!["call with spaces.wav".to_string()]
8545 }
8546 codewhale_config::notifications::NotificationSetting::EventSoundEvents => {
8547 vec![r#"["input-needed", "model-notify"]"#.to_string()]
8548 }
8549 _ => def
8550 .values()
8551 .map(|values| values.into_iter().map(str::to_string).collect())
8552 .unwrap_or_else(|| vec!["37".to_string()]),
8553 };
8554 let temp = tempfile::tempdir().unwrap();
8555 let path = temp.path().join("config.toml");
8556 for sample in samples {
8557 let edit =
8558 crate::config::NotificationConfigUpdate::parse(setting, &sample).unwrap();
8559 edit.persist(&path).unwrap();
8560 let loaded = Config::load(Some(path.clone()), None)
8561 .unwrap()
8562 .notifications_config();
8563 assert_eq!(
8564 loaded.display(setting),
8565 edit.display(),
8566 "{} must reach the TUI config store",
8567 def.key
8568 );
8569 }
8570 continue;
8571 }
8572 // At least one value per row that is not the default, so a row
8573 // whose store silently drops writes cannot pass by looking like
8574 // an untouched `Settings`: bools and enums try every value, an
8575 // int tries the default plus one, free text tries a sentinel.
8576 // `config_choice_values` also covers the two registry-backed
8577 // strings (theme, locale), whose value set is the shipped list.
8578 let samples: Vec<String> = match config_choice_values(def.key) {
8579 Some(values) => values,
8580 // Validated free text: a value the store's own parser accepts.
8581 None if def.key == "background_color" => vec!["#1a1b26".to_string()],
8582 None if def.key == "default_model" => vec!["deepseek-v4-pro".to_string()],
8583 None if def.is_int() => {
8584 let default: i64 = def.default.parse().unwrap_or(0);
8585 vec![(default + 1).to_string()]
8586 }
8587 None if def.is_float() => {
8588 let default: f64 = def.default.parse().unwrap_or(50.0);
8589 vec![(default + 0.5).to_string()]
8590 }
8591 None => vec!["roundtrip-probe".to_string()],
8592 };
8593 assert!(
8594 !samples.is_empty(),
8595 "{} yields no sample to round-trip",
8596 def.key
8597 );
8598 let mut settings = Settings::default();
8599 let accepted = samples
8600 .first()
8601 .is_some_and(|sample| settings.set(def.key, sample).is_ok());
8602 if !accepted {
8603 assert!(
8604 NOT_SETTINGS_TOML.contains(&def.key),
8605 "{} shows a row that settings.toml will not take",
8606 def.key
8607 );
8608 continue;
8609 }
8610 for sample in &samples {
8611 let mut settings = Settings::default();
8612 settings
8613 .set(def.key, sample)
8614 .unwrap_or_else(|error| panic!("{} rejects {sample}: {error}", def.key));
8615 let written = toml::to_string(&settings)
8616 .unwrap_or_else(|error| panic!("{} will not serialize: {error}", def.key));
8617 let reloaded: Settings = toml::from_str(&written)
8618 .unwrap_or_else(|error| panic!("{} will not reload: {error}", def.key));
8619 assert_eq!(
8620 toml::to_string(&reloaded).expect("reloaded settings serialize"),
8621 written,
8622 "{} does not survive a settings.toml round trip at {sample}",
8623 def.key
8624 );
8625 }
8626 }
8627 }
8628
8629 /// `/set` and the settings screen read one declaration. A key `/set`
8630 /// accepts but the schema does not declare would be settable and
8631 /// unplaceable — no kind, no label, no home.
8632 #[test]
8633 fn every_available_setting_is_declared_in_the_schema() {
8634 for (key, _) in Settings::available_settings() {
8635 assert!(
8636 codewhale_config::setting(key).is_some(),
8637 "`/set {key}` is accepted but undeclared in SETTINGS_SCHEMA"
8638 );
8639 }
8640 }
8641
8642 /// Every field `Settings` persists to settings.toml is declared in
8643 /// SETTINGS_SCHEMA — a row for editable values, a hidden def for picker
8644 /// memory and one-way flags. A persisted field without a declaration has
8645 /// no kind, no provenance layer, and no resolver home.
8646 #[test]
8647 fn every_persisted_settings_field_is_declared_in_the_schema() {
8648 use crate::settings::PinnedModel;
8649
8650 // Options serialize as absent when None; force them present so the
8651 // table below names every key settings.toml can hold.
8652 let settings = Settings {
8653 background_color: Some("#1a1b26".to_string()),
8654 default_provider: Some("deepseek".to_string()),
8655 default_model: Some("deepseek-v4-pro".to_string()),
8656 reasoning_effort: Some("medium".to_string()),
8657 permission_posture: Some("ask".to_string()),
8658 sandbox_mode: Some("read-only".to_string()),
8659 provider_models: Some(std::collections::HashMap::from([(
8660 "deepseek".to_string(),
8661 "deepseek-v4-pro".to_string(),
8662 )])),
8663 enabled_models: Some(std::collections::HashMap::from([(
8664 "deepseek".to_string(),
8665 vec!["deepseek-v4-pro".to_string()],
8666 )])),
8667 pinned_models: vec![PinnedModel {
8668 provider: "deepseek".to_string(),
8669 model: "deepseek-v4-pro".to_string(),
8670 label: None,
8671 }],
8672 behavioral_tip_impressions: std::collections::BTreeMap::from([(
8673 "probe".to_string(),
8674 1u8,
8675 )]),
8676 footer_hint_uses: std::collections::BTreeMap::from([("probe".to_string(), 1u8)]),
8677 ..Settings::default()
8678 };
8679 let table = toml::Value::try_from(&settings)
8680 .expect("settings serialize")
8681 .as_table()
8682 .expect("settings are a table")
8683 .clone();
8684 // The probe is only trustworthy if it actually names the keys whose
8685 // only declaration is hidden; a future `skip_serializing` would
8686 // silently drop a key from this table instead of failing below.
8687 for key in [
8688 "tool_collapse_mode",
8689 "max_input_history",
8690 "default_provider",
8691 "sandbox_mode",
8692 "provider_models",
8693 "enabled_models",
8694 "pinned_models",
8695 "feature_intro_shown",
8696 "yolo_deprecation_shown",
8697 "work_surface_bottom_migrated",
8698 "behavioral_tip_impressions",
8699 "footer_hint_uses",
8700 ] {
8701 assert!(
8702 table.contains_key(key),
8703 "probe settings undercovers settings.toml: `{key}` did not serialize"
8704 );
8705 }
8706 for key in table.keys() {
8707 assert!(
8708 codewhale_config::setting(key).is_some(),
8709 "settings.toml persists `{key}` with no SETTINGS_SCHEMA declaration"
8710 );
8711 }
8712 }
8713
8714 /// Every message key declared by the schema must resolve to a localized
8715 /// string in every shipped locale. `tr_key` returns the key itself when a
8716 /// pack is missing the entry, so this fails fast on a stale binding.
8717 #[test]
8718 fn settings_schema_message_keys_are_localized() {
8719 let mut keys: Vec<&'static str> = Vec::new();
8720 for def in codewhale_config::SETTINGS_SCHEMA {
8721 if let Some(ui) = def.ui {
8722 if !ui.label.is_empty() {
8723 keys.push(ui.label);
8724 }
8725 if !ui.description.is_empty() {
8726 keys.push(ui.description);
8727 }
8728 }
8729 let options = match def.kind {
8730 codewhale_config::SettingKind::Bool(options) => options,
8731 codewhale_config::SettingKind::Enum(options) => options,
8732 codewhale_config::SettingKind::Int
8733 | codewhale_config::SettingKind::String
8734 | codewhale_config::SettingKind::Float => &[],
8735 };
8736 for option in options {
8737 if !option.label.is_empty() {
8738 keys.push(option.label);
8739 }
8740 if !option.description.is_empty() {
8741 keys.push(option.description);
8742 }
8743 }
8744 }
8745
8746 for locale in Locale::shipped() {
8747 for key in &keys {
8748 let resolved = tr_key(*locale, key);
8749 assert_ne!(
8750 resolved.as_ref(),
8751 *key,
8752 "{key} is not localized for {locale:?}"
8753 );
8754 }
8755 }
8756 }
8757
8758 #[test]
8759 fn config_view_exposes_configured_and_effective_context_window() {
8760 let temp = tempfile::tempdir().expect("config fixture");
8761 let config_path = temp.path().join("config.toml");
8762 std::fs::write(
8763 &config_path,
8764 r#"
8765 provider = "moonshot"
8766 [providers.moonshot]
8767 model = "kimi-k3"
8768 context_window = 262144
8769 "#,
8770 )
8771 .expect("config");
8772 let mut app = create_test_app();
8773 app.config_path = Some(config_path);
8774 app.api_provider = crate::config::ApiProvider::Moonshot;
8775 app.model = "kimi-k3".to_string();
8776 app.active_route_limits = Some(codewhale_config::route::RouteLimits {
8777 context_tokens: Some(262_144),
8778 ..Default::default()
8779 });
8780 app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
8781
8782 let view = ConfigView::new_for_app(&app);
8783 let configured = view
8784 .rows
8785 .iter()
8786 .find(|row| row.key == "context_window")
8787 .expect("configured context row");
8788 let effective = view
8789 .rows
8790 .iter()
8791 .find(|row| row.key == "effective_context_window")
8792 .expect("effective context row");
8793
8794 assert_eq!(configured.value, "262144");
8795 assert_eq!(effective.value, "262144 tokens · configured");
8796 }
8797
8798 #[test]
8799 fn config_view_displays_saved_codex_reasoning_effort_label() {
8800 let _guard = ConfigSettingsEnvGuard::new("reasoning_effort = \"max\"\n");
8801 let mut app = create_test_app();
8802 app.api_provider = crate::config::ApiProvider::OpenaiCodex;
8803
8804 let view = ConfigView::new_for_app(&app);
8805 let row = view
8806 .rows
8807 .iter()
8808 .find(|row| row.key == "reasoning_effort")
8809 .expect("reasoning_effort row");
8810
8811 assert_eq!(row.value, "max");
8812 }
8813
8814 #[test]
8815 fn config_view_editing_localized_default_placeholders_starts_blank() {
8816 let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\n");
8817 let app = create_test_app();
8818 let mut view = ConfigView::new_for_app(&app);
8819
8820 for (key, message_id) in [
8821 ("reasoning_effort", MessageId::ConfigDefaultReasoning),
8822 ("background_color", MessageId::ConfigDefaultValue),
8823 ] {
8824 view.focus_key(key);
8825 view.start_edit();
8826
8827 let edit = view.editing.as_ref().expect("editing should start");
8828 assert_eq!(edit.original_value, tr(Locale::ZhHans, message_id));
8829 assert!(
8830 edit.buffer.is_empty(),
8831 "localized default placeholder should not become edit text for {key}"
8832 );
8833
8834 view.editing = None;
8835 }
8836 }
8837
8838 #[test]
8839 fn config_view_filter_matches_group_and_rows() {
8840 let mut view = create_config_view(Locale::En);
8841
8842 type_filter(&mut view, "workbar");
8843
8844 assert_eq!(view.filter, "workbar");
8845 assert_eq!(visible_section_labels(&view), vec!["Workbar"]);
8846 assert_eq!(
8847 visible_row_keys(&view),
8848 vec![
8849 "work_surface_placement",
8850 "work_surface_top_height",
8851 "work_surface_side_width",
8852 "rail_panel",
8853 ]
8854 );
8855 assert_eq!(view.rows[view.selected].key, "work_surface_placement");
8856 }
8857
8858 #[test]
8859 fn localized_config_view_filter_matches_english_section_and_scope_labels() {
8860 let mut view = create_config_view(Locale::PtBr);
8861
8862 type_filter(&mut view, "workbar saved");
8863
8864 assert_eq!(view.filter, "workbar saved");
8865 assert_eq!(visible_section_labels(&view), vec!["Barra lateral"]);
8866 assert_eq!(
8867 visible_row_keys(&view),
8868 vec![
8869 "work_surface_placement",
8870 "work_surface_top_height",
8871 "work_surface_side_width",
8872 "rail_panel",
8873 ]
8874 );
8875 }
8876
8877 #[test]
8878 fn config_view_filter_accepts_unicode_case() {
8879 let app = create_test_app();
8880 let mut view = ConfigView::new_for_app(&app);
8881
8882 type_filter(&mut view, "thinking");
8883 assert_eq!(
8884 visible_row_keys(&view),
8885 vec![
8886 // `reasoning_effort` joined this filter when the thinking
8887 // ladder gave it a config row; the schema files it under
8888 // Models, so it now sorts after the appearance rows.
8889 "show_thinking",
8890 "thinking_default_expanded",
8891 "thinking_preview_lines",
8892 "thinking_highlight",
8893 "reasoning_effort"
8894 ]
8895 );
8896
8897 view.clear_filter();
8898 view.rows[0].value = "CAFÉ".to_string();
8899 type_filter(&mut view, "café");
8900 assert_eq!(visible_row_keys(&view), vec!["theme"]);
8901 }
8902
8903 fn assert_config_search_owns_text(query: &str) {
8904 let mut view = create_config_view(Locale::En);
8905 // Start on an actionable boolean so a stolen Space would emit a
8906 // persisted update, and a stolen e would open an editor.
8907 view.focus_key("low_motion");
8908 let values = view
8909 .rows
8910 .iter()
8911 .map(|row| row.value.clone())
8912 .collect::<Vec<_>>();
8913 let mut stack = ViewStack::new();
8914 stack.push(view);
8915 for ch in query.chars() {
8916 assert!(
8917 stack
8918 .handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
8919 .is_empty(),
8920 "{query:?}"
8921 );
8922 assert_eq!(stack.top_kind(), Some(ModalKind::Config), "{query:?}");
8923 }
8924 let mut modal = stack.pop().unwrap();
8925 let view = modal.as_any_mut().downcast_mut::<ConfigView>().unwrap();
8926 assert_eq!(view.filter, query);
8927 assert!(
8928 view.editing.is_none(),
8929 "search text must not enter a settings editor"
8930 );
8931 assert_eq!(
8932 view.rows
8933 .iter()
8934 .map(|row| row.value.clone())
8935 .collect::<Vec<_>>(),
8936 values
8937 );
8938 assert!(matches!(
8939 view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
8940 ViewAction::None
8941 ));
8942 assert!(view.filter.is_empty());
8943 assert!(matches!(
8944 view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
8945 ViewAction::Close
8946 ));
8947 }
8948
8949 #[test]
8950 fn config_search_owns_initial_q() {
8951 assert_config_search_owns_text("quiet");
8952 assert_config_search_owns_text("Queue");
8953 }
8954
8955 #[test]
8956 fn config_search_owns_initial_e() {
8957 assert_config_search_owns_text("effort");
8958 assert_config_search_owns_text("Effort");
8959 }
8960
8961 #[test]
8962 fn config_search_owns_initial_j() {
8963 assert_config_search_owns_text("json");
8964 }
8965
8966 #[test]
8967 fn config_search_owns_initial_k() {
8968 assert_config_search_owns_text("key");
8969 }
8970
8971 #[test]
8972 fn config_search_owns_initial_space() {
8973 assert_config_search_owns_text(" 队列é");
8974 }
8975
8976 #[test]
8977 fn config_view_filter_matches_friendly_labels_and_hints() {
8978 let mut view = create_config_view(Locale::En);
8979
8980 type_filter(&mut view, "shell access");
8981 assert_eq!(visible_row_keys(&view), vec!["allow_shell"]);
8982
8983 view.clear_filter();
8984 type_filter(&mut view, "reasoning level");
8985 assert_eq!(visible_row_keys(&view), vec!["reasoning_effort"]);
8986
8987 view.clear_filter();
8988 type_filter(&mut view, "fan-out/fan-in");
8989 assert_eq!(visible_row_keys(&view), vec!["workflow"]);
8990 }
8991
8992 /// #5134 filed an issue to ask how to raise the context window, because
8993 /// the rows that answer it are keyed `context_window` and only findable by
8994 /// someone who already knows that name. The filter has to answer the words
8995 /// a user actually types.
8996 #[test]
8997 fn config_view_filter_finds_context_window_by_user_vocabulary() {
8998 let mut view = create_config_view(Locale::En);
8999
9000 for phrase in ["context length", "context size", "max context length"] {
9001 view.clear_filter();
9002 type_filter(&mut view, phrase);
9003 let keys = visible_row_keys(&view);
9004 assert!(
9005 keys.contains(&"context_window"),
9006 "`{phrase}` must surface the context_window row: {keys:?}"
9007 );
9008 assert!(
9009 keys.contains(&"effective_context_window"),
9010 "`{phrase}` must surface the resolved window row: {keys:?}"
9011 );
9012 }
9013
9014 // The adjacent knob the same user reaches for next.
9015 view.clear_filter();
9016 type_filter(&mut view, "compaction threshold");
9017 let keys = visible_row_keys(&view);
9018 assert!(
9019 keys.contains(&"auto_compact_threshold_percent"),
9020 "`compaction threshold` must surface the auto-compaction trigger: {keys:?}"
9021 );
9022 }
9023
9024 #[test]
9025 fn config_view_renders_friendly_setting_labels() {
9026 let mut view = create_config_view(Locale::En);
9027 assert_ne!(
9028 config_label_for_key("show_thinking"),
9029 config_label_for_key("thinking_highlight"),
9030 "reasoning visibility and background controls need distinct labels"
9031 );
9032 view.category = ConfigCategory::ModelsProviders;
9033 view.select_first_visible_row();
9034 let area = Rect::new(0, 0, 100, 40);
9035 let mut buf = Buffer::empty(area);
9036
9037 view.render(area, &mut buf);
9038
9039 let dump = buffer_text(&buf, area);
9040 assert!(
9041 dump.contains("Active provider"),
9042 "missing provider label:\n{dump}"
9043 );
9044 assert!(
9045 dump.contains("Models & providers"),
9046 "missing settings rail:\n{dump}"
9047 );
9048
9049 view.category = ConfigCategory::Trust;
9050 view.select_first_visible_row();
9051 let mut permission_buf = Buffer::empty(area);
9052 view.render(area, &mut permission_buf);
9053 let permission_dump = buffer_text(&permission_buf, area);
9054 assert!(
9055 permission_dump.contains("Shell access"),
9056 "missing shell label:\n{permission_dump}"
9057 );
9058 }
9059
9060 #[test]
9061 fn localized_config_view_renders_at_narrow_width() {
9062 let mut app = create_test_app();
9063 app.ui_locale = Locale::PtBr;
9064 let mut view = ConfigView::new_for_app(&app);
9065 view.category = ConfigCategory::ModelsProviders;
9066 view.select_first_visible_row();
9067 let area = Rect::new(0, 0, 60, 18);
9068 let mut buf = Buffer::empty(area);
9069
9070 view.render(area, &mut buf);
9071
9072 let dump = buffer_text(&buf, area);
9073 assert!(dump.contains("Provedor"), "missing localized rows:\n{dump}");
9074 assert!(
9075 !dump.contains("MISSING"),
9076 "missing-key marker leaked:\n{dump}"
9077 );
9078 }
9079
9080 #[test]
9081 fn config_view_selected_row_uses_muted_selection_highlight() {
9082 let mut view = create_config_view(Locale::En);
9083 view.selected = view
9084 .rows
9085 .iter()
9086 .position(|row| row.key == "theme")
9087 .expect("theme row");
9088 view.category = ConfigCategory::Appearance;
9089 view.adjust_scroll(8);
9090 let area = Rect::new(0, 0, 100, 24);
9091 let mut buf = Buffer::empty(area);
9092
9093 view.render(area, &mut buf);
9094
9095 let y = view
9096 .last_row_hitboxes
9097 .borrow()
9098 .iter()
9099 .find_map(|(rect, idx)| (*idx == view.selected).then_some(rect.y))
9100 .expect("selected config row should have a hitbox");
9101 let highlighted_cells = (area.x..area.x.saturating_add(area.width))
9102 .filter(|&x| {
9103 let cell = &buf[(x, y)];
9104 !cell.symbol().trim().is_empty()
9105 && cell.bg == palette::SELECTION_BG
9106 && cell.fg == palette::SELECTION_TEXT
9107 })
9108 .count();
9109
9110 assert!(
9111 highlighted_cells >= 4,
9112 "selected config row should render readable selection text"
9113 );
9114 assert!(
9115 !(area.x..area.x.saturating_add(area.width))
9116 .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION),
9117 "selected config row should not use the bright accent background"
9118 );
9119 }
9120
9121 #[test]
9122 fn config_view_keeps_scope_column_aligned_for_long_keys() {
9123 let mut view = create_config_view(Locale::ZhHans);
9124 type_filter(&mut view, "composer");
9125 let area = Rect::new(0, 0, 100, 24);
9126 let mut buf = Buffer::empty(area);
9127
9128 view.render(area, &mut buf);
9129
9130 let dump = buffer_text(&buf, area);
9131 assert!(
9132 dump.contains("粘 贴 检 测"),
9133 "localized config labels should stay readable:\n{dump}"
9134 );
9135 let scope_columns = (area.y..area.y.saturating_add(area.height))
9136 .filter_map(|y| {
9137 // One dumped char per cell (wide glyphs dump as glyph +
9138 // continuation cell), so a char count is the cell column.
9139 // Every list row paints an affordance in the same column, so
9140 // the first affordance glyph is the shared alignment anchor.
9141 let line = buffer_row_text(&buf, area, y);
9142 if !line.contains("已 保 存") {
9143 return None;
9144 }
9145 line.find(['‹', '[', '✎'])
9146 .map(|byte| line[..byte].chars().count())
9147 })
9148 .collect::<Vec<_>>();
9149 assert!(
9150 scope_columns.len() >= 2,
9151 "expected composer config rows with scopes:\n{dump}"
9152 );
9153 assert!(
9154 scope_columns
9155 .iter()
9156 .all(|column| *column == scope_columns[0]),
9157 "scope column should stay aligned even for long keys ({scope_columns:?}):\n{dump}"
9158 );
9159 }
9160
9161 #[test]
9162 fn config_view_filter_no_match_does_not_edit_hidden_row() {
9163 let app = create_test_app();
9164 let mut view = ConfigView::new_for_app(&app);
9165
9166 type_filter(&mut view, "zzzz");
9167 assert!(visible_row_keys(&view).is_empty());
9168
9169 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9170 assert!(matches!(action, ViewAction::None));
9171 assert!(view.editing.is_none());
9172
9173 let clear = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
9174 assert!(matches!(clear, ViewAction::None));
9175 assert!(view.filter.is_empty());
9176 assert!(!visible_row_keys(&view).is_empty());
9177 }
9178
9179 #[test]
9180 fn config_view_can_edit_filtered_row() {
9181 let app = create_test_app();
9182 let mut view = ConfigView::new_for_app(&app);
9183
9184 type_filter(&mut view, "mcp_config");
9185 assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]);
9186
9187 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9188 assert!(matches!(start, ViewAction::None));
9189 assert!(view.editing.is_some());
9190
9191 let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
9192 assert!(matches!(clear, ViewAction::None));
9193 type_filter(&mut view, "servers.json");
9194
9195 let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9196 match submit {
9197 ViewAction::Emit(ViewEvent::ConfigUpdated {
9198 key,
9199 value,
9200 persist,
9201 }) => {
9202 assert_eq!(key, "mcp_config_path");
9203 assert_eq!(value, "servers.json");
9204 assert!(persist);
9205 }
9206 other => panic!("expected config update emit, got {other:?}"),
9207 }
9208 }
9209
9210 #[test]
9211 fn config_view_enter_and_ctrl_u_emit_config_updated() {
9212 let app = create_test_app();
9213 let mut view = ConfigView::new_for_app(&app);
9214 view.focus_key("background_color");
9215
9216 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9217 assert!(matches!(start, ViewAction::None));
9218 assert!(view.editing.is_some());
9219
9220 let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
9221 assert!(matches!(clear, ViewAction::None));
9222 let cleared = view
9223 .editing
9224 .as_ref()
9225 .expect("editing should remain active after Ctrl+U");
9226 assert!(cleared.buffer.is_empty());
9227
9228 for ch in "55".chars() {
9229 let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
9230 assert!(matches!(action, ViewAction::None));
9231 }
9232
9233 let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9234 match submit {
9235 ViewAction::Emit(ViewEvent::ConfigUpdated {
9236 key,
9237 value,
9238 persist,
9239 }) => {
9240 assert_eq!(key, "background_color");
9241 assert_eq!(value, "55");
9242 assert!(persist);
9243 }
9244 other => panic!("expected config update emit, got {other:?}"),
9245 }
9246 assert!(view.editing.is_none());
9247 }
9248
9249 #[test]
9250 fn config_view_boolean_rows_toggle_without_text_editing() {
9251 let app = create_test_app();
9252 let mut view = ConfigView::new_for_app(&app);
9253 view.focus_key("low_motion");
9254 let expected =
9255 if canonical_config_choice("low_motion", &view.rows[view.selected].value) == "true" {
9256 "false"
9257 } else {
9258 "true"
9259 };
9260
9261 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9262
9263 match action {
9264 ViewAction::Emit(ViewEvent::ConfigUpdated {
9265 key,
9266 value,
9267 persist,
9268 }) => {
9269 assert_eq!(key, "low_motion");
9270 assert_eq!(value, expected);
9271 assert!(persist);
9272 }
9273 other => panic!("expected direct boolean update, got {other:?}"),
9274 }
9275 assert!(view.editing.is_none());
9276 }
9277
9278 #[test]
9279 fn config_view_enum_rows_use_a_bounded_choice_list() {
9280 let app = create_test_app();
9281 let mut view = ConfigView::new_for_app(&app);
9282 view.focus_key("default_mode");
9283
9284 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9285 assert!(matches!(start, ViewAction::None));
9286 let edit = view.editing.as_ref().expect("choice editor");
9287 assert_eq!(
9288 edit.choices.as_deref(),
9289 Some(
9290 &[
9291 "agent".to_string(),
9292 "plan".to_string(),
9293 "operate".to_string(),
9294 ][..]
9295 )
9296 );
9297 assert!(
9298 edit.choices
9299 .as_ref()
9300 .expect("startup choices")
9301 .iter()
9302 .all(|choice| choice != "yolo")
9303 );
9304
9305 let _ = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
9306 let apply = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
9307 match apply {
9308 ViewAction::Emit(ViewEvent::ConfigUpdated {
9309 key,
9310 value,
9311 persist,
9312 }) => {
9313 assert_eq!(key, "default_mode");
9314 assert_eq!(value, "operate");
9315 assert!(persist);
9316 }
9317 other => panic!("expected startup choice update, got {other:?}"),
9318 }
9319
9320 assert_eq!(
9321 canonical_config_choice("default_mode", "Operate"),
9322 "operate"
9323 );
9324 assert_eq!(
9325 config_choice_label(Locale::En, "default_mode", "operate"),
9326 "Operate"
9327 );
9328 assert!(!config_choice_detail(Locale::En, "default_mode", "operate").is_empty());
9329 }
9330
9331 #[test]
9332 fn locale_choices_cover_shipped_registry_and_mark_partial_packs() {
9333 let choices = config_choice_values("locale").expect("locale choices");
9334 let expected = std::iter::once("auto".to_string())
9335 .chain(
9336 Locale::shipped()
9337 .iter()
9338 .map(|locale| locale.tag().to_string()),
9339 )
9340 .collect::<Vec<_>>();
9341 assert_eq!(
9342 choices, expected,
9343 "native locale choices must match Locale::shipped()"
9344 );
9345
9346 let partial_badge = tr(Locale::En, MessageId::ConfigLocalePartialBadge);
9347 let partial_detail = tr(Locale::En, MessageId::ConfigLocalePartialDetail);
9348 for locale in Locale::shipped() {
9349 let canonical = canonical_config_choice("locale", locale.tag());
9350 assert_eq!(canonical, locale.tag());
9351
9352 let label = config_choice_label(Locale::En, "locale", &canonical);
9353 assert_eq!(
9354 label.contains(partial_badge.as_ref()),
9355 locale.is_partial_pack(),
9356 "{} partial-pack badge drifted",
9357 locale.tag()
9358 );
9359
9360 let detail = config_choice_detail(Locale::En, "locale", &canonical);
9361 assert_eq!(
9362 !detail.is_empty(),
9363 locale.is_partial_pack(),
9364 "{} partial-pack detail drifted",
9365 locale.tag()
9366 );
9367 if locale.is_partial_pack() {
9368 assert_eq!(detail, partial_detail);
9369 }
9370 }
9371 }
9372
9373 #[test]
9374 fn locale_choice_editor_submits_newly_admitted_locales() {
9375 for tag in ["ko", "vi", "zh-Hant"] {
9376 let mut view = create_config_view(Locale::En);
9377 view.focus_key("locale");
9378 view.start_edit();
9379 let edit = view.editing.as_mut().expect("locale choice editor");
9380 edit.selected_choice = edit
9381 .choices
9382 .as_ref()
9383 .and_then(|choices| choices.iter().position(|choice| choice == tag))
9384 .unwrap_or_else(|| panic!("locale choices must include {tag}"));
9385
9386 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
9387 ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => {
9388 assert_eq!(key, "locale");
9389 assert_eq!(value, tag);
9390 }
9391 other => panic!("selecting locale {tag} must submit ConfigUpdated, got {other:?}"),
9392 }
9393 }
9394 }
9395
9396 #[test]
9397 fn complete_locale_shows_no_partial_badge_at_minimum_terminal_layout() {
9398 // zh-Hant reached full en.json parity in #5143 and no shipped pack is
9399 // partial anymore, so the picker must not render the partial badge.
9400 let mut view = create_config_view(Locale::En);
9401 view.focus_key("locale");
9402 view.start_edit();
9403 let edit = view.editing.as_mut().expect("locale choice editor");
9404 edit.selected_choice = edit
9405 .choices
9406 .as_ref()
9407 .and_then(|choices| choices.iter().position(|choice| choice == "zh-Hant"))
9408 .expect("zh-Hant choice");
9409
9410 let area = Rect::new(0, 0, 40, 12);
9411 let mut buf = Buffer::empty(area);
9412 view.render(area, &mut buf);
9413 let dump = buffer_text(&buf, area);
9414 assert!(
9415 dump.contains("zh-Hant"),
9416 "zh-Hant choice must render at minimum layout: {dump:?}"
9417 );
9418 assert!(
9419 !dump.contains("zh-Hant (partial)"),
9420 "zh-Hant is a complete pack and must not show the partial badge: {dump:?}"
9421 );
9422 }
9423
9424 #[test]
9425 fn settings_registry_types_every_config_row() {
9426 let app = create_test_app();
9427 let view = ConfigView::new_for_app(&app);
9428 let registry = SettingsRegistry::new(&view);
9429
9430 let kind_for = |key: &str| {
9431 let row = view
9432 .rows
9433 .iter()
9434 .find(|row| row.key == key)
9435 .unwrap_or_else(|| panic!("missing config row {key}"));
9436 registry.meta(row).kind
9437 };
9438
9439 assert_eq!(kind_for("provider"), SettingKind::Action);
9440 assert_eq!(kind_for("model"), SettingKind::Action);
9441 assert_eq!(kind_for("low_motion"), SettingKind::Boolean);
9442 assert_eq!(kind_for("default_mode"), SettingKind::Choice);
9443 assert_eq!(kind_for("thinking_preview_lines"), SettingKind::Integer);
9444 assert_eq!(kind_for("mcp_open"), SettingKind::Action);
9445 assert_eq!(kind_for("mcp_reconnect"), SettingKind::Action);
9446 assert_eq!(kind_for("mcp_diagnose"), SettingKind::Action);
9447 assert_eq!(kind_for("plugins_open"), SettingKind::Action);
9448 assert_eq!(kind_for("mcp_config_path"), SettingKind::Text);
9449 assert_eq!(
9450 kind_for("fleet.exec.max_spawn_depth"),
9451 SettingKind::ReadOnly
9452 );
9453
9454 for row in &view.rows {
9455 let meta = registry.meta(row);
9456 assert_eq!(meta.category, row.section());
9457 assert_eq!(
9458 meta.kind == SettingKind::Choice || meta.kind == SettingKind::Boolean,
9459 meta.choices.is_some(),
9460 "choice metadata drifted for {}",
9461 row.key
9462 );
9463 }
9464 }
9465
9466 #[test]
9467 fn config_labels_are_consumed_from_complete_locale_packs() {
9468 for locale in Locale::shipped_complete() {
9469 assert_eq!(
9470 config_label_for_key_for_locale(*locale, "provider"),
9471 tr(*locale, MessageId::ConfigLabelProvider)
9472 );
9473 assert_eq!(
9474 config_label_for_key_for_locale(*locale, "features.mcp"),
9475 tr(*locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", "Mcp")
9476 );
9477 }
9478 assert_ne!(
9479 config_label_for_key_for_locale(Locale::Ja, "provider"),
9480 config_label_for_key_for_locale(Locale::En, "provider")
9481 );
9482 }
9483
9484 #[test]
9485 fn model_row_hint_names_the_model_picker() {
9486 let app = create_test_app();
9487 let mut view = ConfigView::new_for_app(&app);
9488 view.focus_key("model");
9489
9490 let hint = view.activation_copy(&view.rows[view.selected]);
9491 assert!(hint.contains("Enter opens model picker"), "{hint}");
9492 assert!(!hint.contains("Enter opens provider picker"), "{hint}");
9493 assert!(
9494 hint.starts_with(&en(MessageId::ConfigActivateAgain)),
9495 "{hint}"
9496 );
9497 }
9498
9499 #[test]
9500 fn config_view_mouse_wheel_moves_rows_and_choice_selection() {
9501 let app = create_test_app();
9502 let mut view = ConfigView::new_for_app(&app);
9503 let first_row = view.selected;
9504
9505 let _ = view.handle_mouse(MouseEvent {
9506 kind: MouseEventKind::ScrollDown,
9507 column: 0,
9508 row: 0,
9509 modifiers: KeyModifiers::NONE,
9510 });
9511 assert!(
9512 view.selected > first_row,
9513 "wheel should move the settings list"
9514 );
9515
9516 view.focus_key("default_mode");
9517 view.start_edit();
9518 view.editing
9519 .as_mut()
9520 .expect("choice editor")
9521 .selected_choice = 0;
9522 let _ = view.handle_mouse(MouseEvent {
9523 kind: MouseEventKind::ScrollDown,
9524 column: 0,
9525 row: 0,
9526 modifiers: KeyModifiers::NONE,
9527 });
9528 assert_eq!(
9529 view.editing
9530 .as_ref()
9531 .expect("choice editor")
9532 .selected_choice,
9533 1
9534 );
9535 }
9536
9537 #[test]
9538 fn config_view_mouse_click_selects_row() {
9539 let app = create_test_app();
9540 let mut view = ConfigView::new_for_app(&app);
9541 view.category = ConfigCategory::ModelsProviders;
9542 view.select_first_visible_row();
9543 let area = Rect::new(0, 0, 100, 30);
9544 let mut buf = Buffer::empty(area);
9545 view.render(area, &mut buf);
9546
9547 let hitboxes = view.last_row_hitboxes.borrow().clone();
9548 let (_, row_idx) = hitboxes
9549 .iter()
9550 .find(|(_, idx)| view.rows.get(*idx).is_some_and(|row| row.key == "model"))
9551 .copied()
9552 .expect("model row should have a hitbox");
9553 let y = hitboxes
9554 .iter()
9555 .find_map(|(rect, idx)| (*idx == row_idx).then_some(rect.y))
9556 .expect("selected row should have a y coordinate");
9557
9558 let action = view.handle_mouse(MouseEvent {
9559 kind: MouseEventKind::Down(MouseButton::Left),
9560 column: 20,
9561 row: y,
9562 modifiers: KeyModifiers::NONE,
9563 });
9564
9565 assert!(matches!(action, ViewAction::None));
9566 assert_eq!(view.selected, row_idx);
9567
9568 let second = view.handle_mouse(MouseEvent {
9569 kind: MouseEventKind::Down(MouseButton::Left),
9570 column: 20,
9571 row: y,
9572 modifiers: KeyModifiers::NONE,
9573 });
9574 match second {
9575 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
9576 action: CommandPaletteAction::ExecuteCommand { command },
9577 }) => assert_eq!(command, "/model"),
9578 other => panic!("second click should open the model picker, got {other:?}"),
9579 }
9580 assert!(view.editing.is_none());
9581 }
9582
9583 #[test]
9584 fn config_view_hover_tints_without_moving_selection() {
9585 let app = create_test_app();
9586 let mut view = ConfigView::new_for_app(&app);
9587 let area = Rect::new(0, 0, 120, 32);
9588 let mut buf = Buffer::empty(area);
9589 view.render(area, &mut buf);
9590 let selected_before = view.selected;
9591
9592 // Hover a non-selected row: the tint lands, the selection holds.
9593 let (rect, row_idx) = view
9594 .last_row_hitboxes
9595 .borrow()
9596 .iter()
9597 .copied()
9598 .find(|(_, idx)| *idx != selected_before)
9599 .expect("a non-selected row");
9600 let action = view.handle_mouse(MouseEvent {
9601 kind: MouseEventKind::Moved,
9602 column: rect.x.saturating_add(1),
9603 row: rect.y,
9604 modifiers: KeyModifiers::NONE,
9605 });
9606 assert!(matches!(action, ViewAction::None));
9607 assert_eq!(view.hovered_row, Some(row_idx));
9608 assert_eq!(view.selected, selected_before);
9609
9610 // Repaint: the hovered row wears the shared hover band.
9611 let mut buf = Buffer::empty(area);
9612 view.render(area, &mut buf);
9613 assert_eq!(
9614 buf[(rect.x, rect.y)].bg,
9615 palette::SURFACE_ELEVATED,
9616 "hovered row must show the shared hover band"
9617 );
9618
9619 // Hover a strip chip: the rail tint lands, the tab holds.
9620 let (chip, _) = view
9621 .last_rail_hitboxes
9622 .borrow()
9623 .iter()
9624 .copied()
9625 .find(|(_, category)| *category == ConfigCategory::Advanced)
9626 .expect("Advanced chip");
9627 let action = view.handle_mouse(MouseEvent {
9628 kind: MouseEventKind::Moved,
9629 column: chip.x.saturating_add(1),
9630 row: chip.y,
9631 modifiers: KeyModifiers::NONE,
9632 });
9633 assert!(matches!(action, ViewAction::None));
9634 assert_eq!(view.hovered_rail, Some(ConfigCategory::Advanced));
9635 assert_eq!(view.category, ConfigCategory::Appearance);
9636
9637 // The search line is no target: hovering it clears every tint.
9638 let action = view.handle_mouse(MouseEvent {
9639 kind: MouseEventKind::Moved,
9640 column: 5,
9641 row: 1,
9642 modifiers: KeyModifiers::NONE,
9643 });
9644 assert!(matches!(action, ViewAction::None));
9645 assert_eq!(view.hovered_row, None);
9646 assert_eq!(view.hovered_rail, None);
9647 }
9648
9649 #[test]
9650 fn config_view_rail_categories_are_clickable() {
9651 let app = create_test_app();
9652 let mut view = ConfigView::new_for_app(&app);
9653 assert_eq!(view.category, ConfigCategory::Appearance);
9654 // 120 columns: the vertical rail; 100 columns (96 inner): the strip.
9655 for width in [120u16, 100] {
9656 view.category = ConfigCategory::Appearance;
9657 view.select_first_visible_row();
9658 let area = Rect::new(0, 0, width, 30);
9659 let mut buf = Buffer::empty(area);
9660 view.render(area, &mut buf);
9661
9662 let hitboxes = view.last_rail_hitboxes.borrow().clone();
9663 assert_eq!(hitboxes.len(), ConfigCategory::ALL.len(), "{width}");
9664 let (rect, category) = hitboxes
9665 .iter()
9666 .copied()
9667 .find(|(_, category)| *category == ConfigCategory::Advanced)
9668 .expect("Advanced should have a hitbox");
9669
9670 let action = view.handle_mouse(MouseEvent {
9671 kind: MouseEventKind::Down(MouseButton::Left),
9672 column: rect.x.saturating_add(1),
9673 row: rect.y,
9674 modifiers: KeyModifiers::NONE,
9675 });
9676 assert!(matches!(action, ViewAction::None));
9677 assert_eq!(category, ConfigCategory::Advanced);
9678 assert_eq!(view.category, ConfigCategory::Advanced, "{width}");
9679 assert!(
9680 view.rows
9681 .get(view.selected)
9682 .is_some_and(|row| ConfigCategory::for_row(row) == ConfigCategory::Advanced)
9683 );
9684 }
9685 }
9686
9687 #[test]
9688 fn config_categories_cover_every_row_with_the_approved_seven() {
9689 let app = create_test_app();
9690 let view = ConfigView::new_for_app(&app);
9691 let labels: Vec<Cow<'static, str>> = ConfigCategory::ALL
9692 .iter()
9693 .map(|category| category.label(Locale::En))
9694 .collect();
9695 assert_eq!(
9696 labels,
9697 [
9698 "Appearance",
9699 "Models & providers",
9700 "Work",
9701 "Tools & MCP",
9702 "Trust",
9703 "Motion",
9704 "Advanced",
9705 ]
9706 );
9707 let category_of = |key: &str| {
9708 let row = view
9709 .rows
9710 .iter()
9711 .find(|row| row.key == key)
9712 .unwrap_or_else(|| panic!("row {key}"));
9713 ConfigCategory::for_row(row)
9714 };
9715 assert_eq!(category_of("theme"), ConfigCategory::Appearance);
9716 assert_eq!(category_of("provider"), ConfigCategory::ModelsProviders);
9717 assert_eq!(category_of("model"), ConfigCategory::ModelsProviders);
9718 assert_eq!(
9719 category_of("reasoning_effort"),
9720 ConfigCategory::ModelsProviders
9721 );
9722 // Raw endpoint, credential receipt, and context diagnostic rows live
9723 // under Advanced so default categories read as product language.
9724 for key in ["base_url", "context_window", "effective_context_window"] {
9725 assert_eq!(category_of(key), ConfigCategory::Advanced, "{key}");
9726 }
9727 assert!(
9728 view.rows
9729 .iter()
9730 .filter(|row| ConfigCategory::for_row(row) == ConfigCategory::ModelsProviders)
9731 .all(|row| !row.key.starts_with("external_credentials.")),
9732 "credential receipts must not surface in Models & providers"
9733 );
9734 // Sub-agent depth moved out of the one-row Fleet tab into Models.
9735 assert_eq!(
9736 category_of("fleet.exec.max_spawn_depth"),
9737 ConfigCategory::ModelsProviders
9738 );
9739 assert_eq!(category_of("composer_density"), ConfigCategory::Work);
9740 assert_eq!(category_of("work_surface_placement"), ConfigCategory::Work);
9741 assert_eq!(category_of("auto_compact"), ConfigCategory::Work);
9742 assert_eq!(category_of("mcp_open"), ConfigCategory::ToolsMcp);
9743 assert_eq!(category_of("approval_mode"), ConfigCategory::Trust);
9744 assert_eq!(category_of("allow_shell"), ConfigCategory::Trust);
9745 assert_eq!(category_of("telemetry"), ConfigCategory::Trust);
9746 assert_eq!(category_of("low_motion"), ConfigCategory::Motion);
9747 assert_eq!(category_of("fancy_animations"), ConfigCategory::Motion);
9748 for category in ConfigCategory::ALL {
9749 assert!(
9750 view.rows.iter().any(|row| category.contains(row)),
9751 "{} lists no rows",
9752 category.label(Locale::En)
9753 );
9754 }
9755 for category in ConfigCategory::ALL {
9756 assert_eq!(category.next().prev(), category);
9757 }
9758 }
9759
9760 /// English copy for an id, for asserting on rendered chrome.
9761 fn en(id: MessageId) -> String {
9762 tr(Locale::En, id).into_owned()
9763 }
9764
9765 fn render_dump(view: &ConfigView, width: u16, height: u16) -> String {
9766 let area = Rect::new(0, 0, width, height);
9767 let mut buf = Buffer::empty(area);
9768 view.render(area, &mut buf);
9769 buffer_text(&buf, area)
9770 }
9771
9772 #[test]
9773 fn config_shell_uses_strip_and_list_at_80x24_and_rail_list_detail_when_wide() {
9774 let app = create_test_app();
9775 let view = ConfigView::new_for_app(&app);
9776 assert_eq!(view.category, ConfigCategory::Appearance);
9777
9778 let dump = render_dump(&view, 80, 24);
9779 assert!(
9780 dump.contains("Appearance"),
9781 "strip shows the active category:\n{dump}"
9782 );
9783 assert!(dump.contains("Theme"), "list missing theme row:\n{dump}");
9784 assert!(
9785 !dump.contains("▸ Appearance"),
9786 "no vertical rail at 80 columns:\n{dump}"
9787 );
9788 assert!(
9789 !dump.contains(&format!("{:<10}", en(MessageId::ConfigFactSource))),
9790 "detail pane must shed at 80 columns:\n{dump}"
9791 );
9792 assert!(
9793 dump.contains(&en(MessageId::ConfigActivateAgain)),
9794 "selected row spells out its activation:\n{dump}"
9795 );
9796 assert!(
9797 dump.contains(&en(MessageId::ConfigFactCurrent))
9798 && dump.contains(&en(MessageId::ConfigFactSaved)),
9799 "narrow status row folds the lanes:\n{dump}"
9800 );
9801 {
9802 let rows = view.last_row_hitboxes.borrow();
9803 assert!(!rows.is_empty(), "list rows must stay clickable at 80x24");
9804 assert!(
9805 rows.iter()
9806 .all(|(rect, _)| rect.width > 20 && rect.height == 1),
9807 "row hitboxes are exact rects: {rows:?}"
9808 );
9809 let strip = view.last_rail_hitboxes.borrow();
9810 assert!(!strip.is_empty() && strip.len() <= 8, "{strip:?}");
9811 assert!(
9812 strip.iter().all(|(rect, _)| rect.y < rows[0].0.y),
9813 "strip sits above the list: {strip:?}"
9814 );
9815 }
9816
9817 let dump = render_dump(&view, 120, 32);
9818 assert!(
9819 dump.contains("❯ Display"),
9820 "groups column lists the active tab's groups:\n{dump}"
9821 );
9822 for label in [
9823 &en(MessageId::ConfigFactCurrent),
9824 &en(MessageId::ConfigFactSaved),
9825 &en(MessageId::ConfigFactStartup),
9826 &en(MessageId::ConfigFactSource),
9827 &en(MessageId::ConfigFactScope),
9828 &en(MessageId::ConfigFactApply),
9829 &en(MessageId::ConfigFactKind),
9830 &en(MessageId::ConfigFactAvailable),
9831 ] {
9832 assert!(
9833 dump.contains(&format!("{label:<10}")),
9834 "detail pane missing {label:?}:\n{dump}"
9835 );
9836 }
9837 assert!(
9838 dump.contains(&en(MessageId::ConfigSourceUserSettings)),
9839 "source names the store:\n{dump}"
9840 );
9841 assert!(
9842 dump.contains(&en(MessageId::ConfigApplyOnSave)),
9843 "apply semantics:\n{dump}"
9844 );
9845 assert!(
9846 dump.contains(&en(MessageId::ConfigKindChoice)),
9847 "editor kind painted for the theme row:\n{dump}"
9848 );
9849 assert_eq!(view.last_rail_hitboxes.borrow().len(), 7);
9850
9851 for (w, h) in [(0u16, 0u16), (20, 4), (44, 12), (60, 18), (300, 60)] {
9852 let _ = render_dump(&view, w, h);
9853 }
9854 }
9855
9856 #[test]
9857 fn config_shell_blocker_sizes_keep_active_category_list_and_affordances() {
9858 let app = create_test_app();
9859 let mut view = ConfigView::new_for_app(&app);
9860 for (w, h) in [(40u16, 12u16), (80, 24), (100, 30), (120, 32)] {
9861 view.category = ConfigCategory::Appearance;
9862 view.select_first_visible_row();
9863 let area = Rect::new(0, 0, w, h);
9864 let mut buf = Buffer::empty(area);
9865 view.render(area, &mut buf);
9866 let dump = buffer_text(&buf, area);
9867 assert!(dump.contains("Appearance"), "{w}x{h}:\n{dump}");
9868 assert!(dump.contains("Search:"), "{w}x{h} keeps search:\n{dump}");
9869 let rows = view.last_row_hitboxes.borrow().clone();
9870 assert!(!rows.is_empty(), "{w}x{h} lists rows:\n{dump}");
9871 // Every listed row paints its affordance glyph inside its own
9872 // hitbox cells (toggle / choose / edit / open / read-only).
9873 for (rect, idx) in rows {
9874 let row = &view.rows[idx];
9875 let kind = view.editor_kind(row);
9876 let line: String = (rect.x..rect.right())
9877 .map(|x| buf[(x, rect.y)].symbol().to_string())
9878 .collect();
9879 let glyph = super::setting_affordance(kind, Some(true));
9880 let glyph_off = super::setting_affordance(kind, Some(false));
9881 assert!(
9882 line.contains(glyph) || line.contains(glyph_off),
9883 "{w}x{h} row {} lacks its {kind:?} affordance: {line:?}",
9884 row.key
9885 );
9886 }
9887 }
9888 }
9889
9890 #[test]
9891 fn config_shell_short_heights_keep_advanced_reachable_by_keys_and_pointer() {
9892 let app = create_test_app();
9893 let mut view = ConfigView::new_for_app(&app);
9894 for (w, h) in [(40u16, 12u16), (44, 12), (60, 16)] {
9895 view.category = ConfigCategory::Appearance;
9896 view.select_first_visible_row();
9897 for _ in 0..6 {
9898 let _ = view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
9899 }
9900 assert_eq!(view.category, ConfigCategory::Advanced, "{w}x{h}");
9901 let area = Rect::new(0, 0, w, h);
9902 let mut buf = Buffer::empty(area);
9903 view.render(area, &mut buf);
9904 let dump = buffer_text(&buf, area);
9905 assert!(
9906 dump.contains("Advanced"),
9907 "{w}x{h} active category:\n{dump}"
9908 );
9909 let strip = view.last_rail_hitboxes.borrow().clone();
9910 let (advanced, _) = strip
9911 .iter()
9912 .copied()
9913 .find(|(_, category)| *category == ConfigCategory::Advanced)
9914 .unwrap_or_else(|| panic!("{w}x{h} Advanced hitbox: {dump}"));
9915 let cells: String = (advanced.x..advanced.right())
9916 .map(|x| buf[(x, advanced.y)].symbol().to_string())
9917 .collect();
9918 assert!(
9919 cells.contains("Advanced"),
9920 "{w}x{h} hitbox cells: {cells:?}"
9921 );
9922 // Pointer parity: the neighbour chip or compact Previous control
9923 // moves the category exactly as ← does.
9924 let _ = view.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
9925 let by_key = view.category;
9926 let _ = view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
9927 let mut buf = Buffer::empty(area);
9928 view.render(area, &mut buf);
9929 let strip = view.last_rail_hitboxes.borrow().clone();
9930 let motion = strip
9931 .iter()
9932 .find(|(_, category)| *category == by_key)
9933 .map(|(rect, _)| *rect)
9934 .or_else(|| {
9935 view.last_nav_controls
9936 .borrow()
9937 .iter()
9938 .find(|(_, step)| *step == super::NavStep::Previous)
9939 .map(|(rect, _)| *rect)
9940 })
9941 .unwrap_or_else(|| panic!("{w}x{h} {by_key:?} navigation target"));
9942 let action = view.handle_mouse(MouseEvent {
9943 kind: MouseEventKind::Down(MouseButton::Left),
9944 column: motion.x,
9945 row: motion.y,
9946 modifiers: KeyModifiers::NONE,
9947 });
9948 assert!(matches!(action, ViewAction::None));
9949 assert_eq!(view.category, by_key, "{w}x{h} pointer parity");
9950 }
9951 }
9952
9953 #[test]
9954 fn config_detail_never_synthesizes_current_from_saved() {
9955 let app = create_test_app();
9956 let mut view = ConfigView::new_for_app(&app);
9957 let low_motion = view
9958 .rows
9959 .iter()
9960 .position(|row| row.key == "low_motion")
9961 .expect("low_motion row");
9962 view.rows[low_motion].value = "false".to_string();
9963 view.rows[low_motion].facts.effective = Some("true".to_string());
9964 // Pin the authority: the host motion-override probe (a legacy console
9965 // host, NO_ANIMATIONS, an SSH session, …) would otherwise relabel this
9966 // row and make the test host-dependent. The subject here is the
9967 // saved/effective synthesis contract, not override detection.
9968 view.rows[low_motion].facts.authority = super::SettingAuthority::UserSettings;
9969 view.rows[low_motion].facts.authority_detail = None;
9970 view.category = ConfigCategory::Motion;
9971 view.selected = low_motion;
9972
9973 let fact = view
9974 .setting_fact(&view.rows[low_motion])
9975 .expect("setting fact");
9976 assert_eq!(fact.effective.as_deref(), Some("On"));
9977 assert_eq!(fact.saved.as_deref(), Some("Off"));
9978 assert_eq!(fact.startup.as_deref(), Some("Off"));
9979 assert_eq!(fact.authority, super::SettingAuthority::UserSettings);
9980 assert_eq!(fact.apply, super::SettingApplySemantics::Immediate);
9981
9982 let dump = render_dump(&view, 120, 32);
9983 let lane = |name: &str| {
9984 dump.lines()
9985 .find(|line| line.contains(&format!("{name:<10}")))
9986 .unwrap_or_else(|| panic!("{name} lane:\n{dump}"))
9987 .to_string()
9988 };
9989 assert!(lane("current").contains("On"), "{}", lane("current"));
9990 assert!(lane("saved").contains("Off"), "{}", lane("saved"));
9991
9992 // A saved row with no App observation reports current as unobserved
9993 // instead of echoing the persisted value.
9994 let calm = view
9995 .rows
9996 .iter()
9997 .find(|row| row.key == "calm_mode")
9998 .expect("calm_mode row");
9999 let fact = view.setting_fact(calm).expect("setting fact");
10000 assert!(fact.effective.is_none() && fact.current.is_none());
10001 assert_eq!(
10002 fact.saved.as_deref(),
10003 Some(config_choice_label(Locale::En, "calm_mode", &calm.value).as_str())
10004 );
10005 assert!(
10006 view.setting_detail_summary(calm)
10007 .contains(&en(MessageId::ConfigLaneUnobserved))
10008 );
10009
10010 // Theme and locale report the value the app is actually running
10011 // with, from App, not the persisted string.
10012 let theme = view
10013 .rows
10014 .iter()
10015 .find(|row| row.key == "theme")
10016 .expect("theme row");
10017 assert_eq!(
10018 theme.facts.effective.as_deref(),
10019 Some(app.theme_id.name()),
10020 "theme current lane comes from App"
10021 );
10022 let locale = view
10023 .rows
10024 .iter()
10025 .find(|row| row.key == "locale")
10026 .expect("locale row");
10027 assert_eq!(locale.facts.effective.as_deref(), Some(app.ui_locale.tag()));
10028
10029 // Session-owned facts never claim a saved default they did not read.
10030 let provider = view
10031 .rows
10032 .iter()
10033 .find(|row| row.key == "provider")
10034 .expect("provider row");
10035 let fact = view.setting_fact(provider).expect("setting fact");
10036 assert_eq!(fact.authority, super::SettingAuthority::Session);
10037 assert_eq!(fact.effective, view.snapshot.provider.effective);
10038 assert!(fact.saved.is_none() && fact.startup.is_none());
10039 }
10040
10041 #[test]
10042 fn config_rows_carry_truthful_apply_semantics_and_kinds() {
10043 use super::{ConfigRowKind, SettingApplySemantics, SettingAuthority};
10044 let app = create_test_app();
10045 let view = ConfigView::new_for_app(&app);
10046 let row = |key: &str| {
10047 view.rows
10048 .iter()
10049 .find(|row| row.key == key)
10050 .unwrap_or_else(|| panic!("row {key}"))
10051 };
10052 assert_eq!(
10053 row("default_mode").facts.apply,
10054 SettingApplySemantics::NextSession
10055 );
10056 assert_eq!(
10057 row("mcp_config_path").facts.apply,
10058 SettingApplySemantics::ReloadRequired
10059 );
10060 assert_eq!(row("theme").facts.apply, SettingApplySemantics::Immediate);
10061 for key in ["mcp_open", "mcp_reconnect", "mcp_diagnose", "plugins_open"] {
10062 let action = row(key);
10063 assert_eq!(action.facts.kind, ConfigRowKind::Action, "{key}");
10064 assert!(view.setting_fact(action).is_none(), "{key} is not a fact");
10065 assert!(
10066 view.setting_detail_summary(action)
10067 .contains(&en(MessageId::ConfigRowActionNote)),
10068 "{key}"
10069 );
10070 }
10071 for key in ["effective_context_window", "base_url"] {
10072 let receipt = row(key);
10073 assert_eq!(receipt.facts.kind, ConfigRowKind::Diagnostic, "{key}");
10074 assert!(view.setting_fact(receipt).is_none(), "{key}");
10075 }
10076 assert_eq!(
10077 row("permission_posture").facts.authority,
10078 SettingAuthority::UserSettings
10079 );
10080 assert_eq!(
10081 row("allow_shell").facts.authority,
10082 SettingAuthority::WorkspaceConfiguration
10083 );
10084 assert_eq!(
10085 super::ConfigRowFacts::read_only_setting(SettingAuthority::ManagedPolicy).apply,
10086 SettingApplySemantics::ReadOnly
10087 );
10088 assert_eq!(
10089 row("telemetry").facts.authority,
10090 SettingAuthority::WorkspaceConfiguration
10091 );
10092 assert_eq!(
10093 super::setting_apply_label(Locale::En, SettingApplySemantics::ReloadRequired),
10094 en(MessageId::ConfigApplyReload)
10095 );
10096 assert_eq!(
10097 super::setting_apply_label(Locale::En, SettingApplySemantics::UiNowEngineRestart),
10098 en(MessageId::ConfigApplyUiNowEngineRestart)
10099 );
10100 assert_eq!(
10101 super::setting_apply_label(Locale::ZhHans, SettingApplySemantics::ReloadRequired),
10102 tr(Locale::ZhHans, MessageId::ConfigApplyReload)
10103 );
10104 }
10105
10106 #[test]
10107 fn config_list_clicks_outside_row_rects_never_select_or_activate() {
10108 let app = create_test_app();
10109 let mut view = ConfigView::new_for_app(&app);
10110 let area = Rect::new(0, 0, 120, 32);
10111 let mut buf = Buffer::empty(area);
10112 view.render(area, &mut buf);
10113 let before = view.selected;
10114 let rows = view.last_row_hitboxes.borrow().clone();
10115 let (first, _) = rows[0];
10116 let list_bottom = rows.iter().map(|(rect, _)| rect.bottom()).max().unwrap();
10117 let tabs = view.last_rail_hitboxes.borrow().clone();
10118 let tabs_bottom = tabs.iter().map(|(rect, _)| rect.bottom()).max().unwrap();
10119 assert!(tabs_bottom <= first.y, "tabs must not overlap the list");
10120
10121 let click = |view: &mut ConfigView, column: u16, row: u16| {
10122 view.handle_mouse(MouseEvent {
10123 kind: MouseEventKind::Down(MouseButton::Left),
10124 column,
10125 row,
10126 modifiers: KeyModifiers::NONE,
10127 })
10128 };
10129 // Rail area on a row that is not a category, the divider between
10130 // rail and list, the detail pane, the status row, and the footer.
10131 let probes = [
10132 (first.x.saturating_sub(4), list_bottom.saturating_sub(1)),
10133 (first.x.saturating_sub(1), first.y),
10134 (first.right().saturating_add(3), first.y),
10135 (first.x.saturating_add(2), area.bottom().saturating_sub(4)),
10136 (first.x.saturating_add(2), area.bottom().saturating_sub(2)),
10137 ];
10138 for (column, row) in probes {
10139 // Twice: a second click is the activation gesture on a row.
10140 let _ = click(&mut view, column, row);
10141 let action = click(&mut view, column, row);
10142 assert!(matches!(action, ViewAction::None), "({column},{row})");
10143 assert_eq!(view.selected, before, "({column},{row}) must not select");
10144 assert!(view.editing.is_none(), "({column},{row}) must not activate");
10145 }
10146 // Inside the rect: selects on the first click.
10147 let (rect, idx) = rows[rows.len() - 1];
10148 let _ = click(&mut view, rect.x, rect.y);
10149 assert_eq!(view.selected, idx);
10150 }
10151
10152 #[test]
10153 fn config_search_indexes_categories_and_category_click_clears_filter() {
10154 let app = create_test_app();
10155 let mut view = ConfigView::new_for_app(&app);
10156 type_filter(&mut view, "fleet");
10157 assert!(
10158 visible_row_keys(&view).contains(&"fleet.exec.max_spawn_depth"),
10159 "{:?}",
10160 visible_row_keys(&view)
10161 );
10162 view.clear_filter();
10163 type_filter(&mut view, "trust");
10164 let keys = visible_row_keys(&view);
10165 assert!(keys.contains(&"approval_mode"), "{keys:?}");
10166 assert!(keys.contains(&"telemetry"), "{keys:?}");
10167
10168 let area = Rect::new(0, 0, 80, 24);
10169 let mut buf = Buffer::empty(area);
10170 view.render(area, &mut buf);
10171 // The strip windows from the active (Appearance) chip at 80 columns;
10172 // Trust is inside that window.
10173 let (rect, category) = view
10174 .last_rail_hitboxes
10175 .borrow()
10176 .iter()
10177 .copied()
10178 .find(|(_, category)| *category == ConfigCategory::Trust)
10179 .expect("Trust chip visible while filtering");
10180 let action = view.handle_mouse(MouseEvent {
10181 kind: MouseEventKind::Down(MouseButton::Left),
10182 column: rect.x,
10183 row: rect.y,
10184 modifiers: KeyModifiers::NONE,
10185 });
10186 assert!(matches!(action, ViewAction::None));
10187 assert_eq!(category, ConfigCategory::Trust);
10188 assert!(view.filter.is_empty(), "category click clears the filter");
10189 assert_eq!(view.category, ConfigCategory::Trust);
10190 let keys = visible_row_keys(&view);
10191 assert!(keys.contains(&"approval_mode"), "{keys:?}");
10192 assert!(keys.contains(&"telemetry"), "{keys:?}");
10193 assert!(
10194 keys.iter().all(|key| ConfigCategory::Trust
10195 .contains(view.rows.iter().find(|row| row.key == *key).unwrap())),
10196 "only Trust rows remain after the click: {keys:?}"
10197 );
10198 }
10199
10200 /// Interaction evidence at the blocker sizes: the real `ConfigView`
10201 /// driven by keys and pointer at 40x12, 80x24, 100x30, and 120x32. The
10202 /// rendered buffers are printed (`--nocapture`) as harness evidence — the
10203 /// real renderer into a ratatui `Buffer`, not a terminal capture.
10204 #[test]
10205 #[allow(
10206 clippy::print_stdout,
10207 reason = "prints the rendered buffers as interaction evidence under --nocapture"
10208 )]
10209 fn config_shell_interaction_evidence_at_blocker_sizes() {
10210 let app = create_test_app();
10211 let key = |view: &mut ConfigView, code: KeyCode| {
10212 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
10213 };
10214 let click = |view: &mut ConfigView, column: u16, row: u16| {
10215 view.handle_mouse(MouseEvent {
10216 kind: MouseEventKind::Down(MouseButton::Left),
10217 column,
10218 row,
10219 modifiers: KeyModifiers::NONE,
10220 })
10221 };
10222 for (w, h) in [(40u16, 12u16), (80, 24), (100, 30), (120, 32)] {
10223 let mut view = ConfigView::new_for_app(&app);
10224 let area = Rect::new(0, 0, w, h);
10225 let snapshot = |view: &ConfigView, step: &str| {
10226 let mut buf = Buffer::empty(area);
10227 view.render(area, &mut buf);
10228 let dump = buffer_text(&buf, area);
10229 println!("== {w}x{h} · {step} ==\n{dump}");
10230 dump
10231 };
10232
10233 // Opens on Appearance with the first Appearance row selected.
10234 assert_eq!(view.category, ConfigCategory::Appearance);
10235 assert_eq!(view.rows[view.selected].key, "theme");
10236 let dump = snapshot(&view, "open");
10237 assert!(dump.contains("Appearance"), "{w}x{h}:\n{dump}");
10238 assert!(dump.contains("Search:"), "{w}x{h}:\n{dump}");
10239
10240 // → lands on Models & providers (the one-row Fleet tab is gone;
10241 // sub-agent depth moved into the Model group). Focus it and check
10242 // the read-only config.toml posture.
10243 assert!(matches!(key(&mut view, KeyCode::Right), ViewAction::None));
10244 assert_eq!(view.category, ConfigCategory::ModelsProviders);
10245 view.focus_key("fleet.exec.max_spawn_depth");
10246 assert_eq!(view.rows[view.selected].key, "fleet.exec.max_spawn_depth");
10247 let dump = snapshot(&view, "after → (Models & providers)");
10248 assert!(dump.contains("Models & providers"), "{w}x{h}:\n{dump}");
10249 assert!(
10250 dump.contains(super::setting_affordance(SettingKind::ReadOnly, None)),
10251 "{w}x{h} read-only affordance:\n{dump}"
10252 );
10253 assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None));
10254 assert!(view.editing.is_none(), "{w}x{h} read-only rows never edit");
10255
10256 // Tab ×4 → Motion; ↓ → fancy_animations; Enter toggles it and
10257 // emits the persisted update without opening an editor.
10258 for _ in 0..4 {
10259 assert!(matches!(key(&mut view, KeyCode::Tab), ViewAction::None));
10260 }
10261 assert_eq!(view.category, ConfigCategory::Motion);
10262 assert_eq!(view.rows[view.selected].key, "low_motion");
10263 assert!(matches!(key(&mut view, KeyCode::Down), ViewAction::None));
10264 assert_eq!(view.rows[view.selected].key, "fancy_animations");
10265 let dump = snapshot(&view, "Motion · ↓ to fancy_animations");
10266 assert!(
10267 if w < 50 {
10268 dump.contains("Enter")
10269 } else {
10270 dump.contains(&en(MessageId::ConfigActivateAgain))
10271 },
10272 "{w}x{h} activation copy:\n{dump}"
10273 );
10274 match key(&mut view, KeyCode::Enter) {
10275 ViewAction::Emit(ViewEvent::ConfigUpdated { key, persist, .. }) => {
10276 assert_eq!(key, "fancy_animations");
10277 assert!(persist);
10278 }
10279 other => panic!("{w}x{h} Enter should toggle, got {other:?}"),
10280 }
10281 assert!(view.editing.is_none());
10282
10283 // Pointer parity: click a visible non-active chip or the compact
10284 // Previous control, then select and activate the first row.
10285 let mut buf = Buffer::empty(area);
10286 view.render(area, &mut buf);
10287 let (chip, target) = view
10288 .last_rail_hitboxes
10289 .borrow()
10290 .iter()
10291 .copied()
10292 .find(|(_, category)| *category != ConfigCategory::Motion)
10293 .or_else(|| {
10294 view.last_nav_controls
10295 .borrow()
10296 .iter()
10297 .find(|(_, step)| *step == super::NavStep::Previous)
10298 .map(|(rect, _)| (*rect, ConfigCategory::Trust))
10299 })
10300 .expect("another category is reachable through a painted target");
10301 assert!(matches!(click(&mut view, chip.x, chip.y), ViewAction::None));
10302 assert_eq!(view.category, target, "{w}x{h} chip click");
10303 let mut buf = Buffer::empty(area);
10304 view.render(area, &mut buf);
10305 let (row_rect, row_idx) = view.last_row_hitboxes.borrow()[0];
10306 assert!(matches!(
10307 click(&mut view, row_rect.x + 1, row_rect.y),
10308 ViewAction::None
10309 ));
10310 assert_eq!(view.selected, row_idx, "{w}x{h} first click selects");
10311 let dump = snapshot(
10312 &view,
10313 &format!("pointer · {} chip, row selected", target.label(Locale::En)),
10314 );
10315 assert!(dump.contains("❯"), "{w}x{h} selected row marker:\n{dump}");
10316 let second = click(&mut view, row_rect.x + 1, row_rect.y);
10317 let row = &view.rows[row_idx];
10318 match (row.editable, row.facts.command) {
10319 (false, _) => {
10320 assert!(matches!(second, ViewAction::None));
10321 assert!(view.editing.is_none());
10322 }
10323 (true, Some((command, _))) => match second {
10324 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
10325 action: CommandPaletteAction::ExecuteCommand { command: emitted },
10326 }) => assert_eq!(emitted, command),
10327 ViewAction::Emit(ViewEvent::ExecutePanelCommand {
10328 command: emitted,
10329 pager_title: Some(_),
10330 }) => assert_eq!(emitted, command),
10331 other => panic!("{w}x{h} second click should open {command}: {other:?}"),
10332 },
10333 (true, None) => assert!(
10334 view.editing.is_some() || matches!(second, ViewAction::Emit(_)),
10335 "{w}x{h} second click must edit or emit: {second:?}"
10336 ),
10337 }
10338 if view.editing.is_some() {
10339 let _ = snapshot(&view, "editor after second click");
10340 assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::None));
10341 assert!(view.editing.is_none());
10342 }
10343
10344 // Search: typing filters across categories; Esc clears; Esc again
10345 // closes the view.
10346 for ch in "telemetry".chars() {
10347 assert!(matches!(
10348 key(&mut view, KeyCode::Char(ch)),
10349 ViewAction::None
10350 ));
10351 }
10352 assert_eq!(visible_row_keys(&view), vec!["telemetry"]);
10353 let dump = snapshot(&view, "search \"telemetry\"");
10354 assert!(dump.contains("telemetry"), "{w}x{h}:\n{dump}");
10355 assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::None));
10356 assert!(view.filter.is_empty());
10357 assert!(matches!(key(&mut view, KeyCode::Esc), ViewAction::Close));
10358 }
10359 }
10360
10361 /// P1.1: a store that fails to load never becomes a default labelled
10362 /// saved/startup. The row is unavailable, read-only, and its lanes carry
10363 /// the load error; App-observed lanes (low_motion) still render.
10364 #[test]
10365 fn config_rows_report_unreadable_stores_instead_of_defaults() {
10366 let _lock = crate::test_support::lock_test_env();
10367 let tmp = TempDir::new().expect("tempdir");
10368 let config_path = tmp.path().join(".deepseek").join("config.toml");
10369 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
10370 std::fs::write(
10371 config_path.parent().unwrap().join("settings.toml"),
10372 "theme = [broken\n",
10373 )
10374 .unwrap();
10375 std::fs::write(&config_path, "approval_policy = [broken\n").unwrap();
10376 let _guard = crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
10377 let mut app = create_test_app();
10378 app.config_path = Some(config_path.clone());
10379 let mut view = ConfigView::new_for_app(&app);
10380
10381 let theme = view.rows.iter().find(|row| row.key == "theme").unwrap();
10382 assert!(
10383 !theme.editable,
10384 "an unreadable store is never written through"
10385 );
10386 assert_eq!(theme.value, en(MessageId::ConfigUnavailable));
10387 let error = theme
10388 .facts
10389 .store_error
10390 .clone()
10391 .expect("settings load error is preserved");
10392 assert!(!error.is_empty());
10393 let fact = view.setting_fact(theme).expect("setting fact");
10394 assert!(fact.saved.is_none() && fact.startup.is_none(), "{fact:?}");
10395 assert_eq!(
10396 fact.effective.as_deref(),
10397 Some(app.theme_id.name()),
10398 "the live theme is still known from App"
10399 );
10400 let summary = view.setting_detail_summary(theme);
10401 assert!(
10402 summary.contains(&en(MessageId::ConfigLaneUnavailable).replace("{error}", &error)),
10403 "{summary}"
10404 );
10405
10406 let telemetry = view.rows.iter().find(|row| row.key == "telemetry").unwrap();
10407 assert!(!telemetry.editable);
10408 assert!(
10409 telemetry.facts.store_error.is_some(),
10410 "config.toml error is preserved"
10411 );
10412 let low_motion = view
10413 .rows
10414 .iter()
10415 .find(|row| row.key == "low_motion")
10416 .unwrap();
10417 let fact = view.setting_fact(low_motion).expect("setting fact");
10418 assert!(
10419 fact.saved.is_none(),
10420 "no persisted lane from a broken store"
10421 );
10422 assert!(
10423 fact.effective.is_some(),
10424 "App still supplies the live value"
10425 );
10426
10427 // Rendered: the detail pane names the failure, never a default.
10428 view.focus_key("theme");
10429 let lines = view.setting_detail_lines(&view.rows[view.selected], 400);
10430 let expected = en(MessageId::ConfigLaneUnavailable).replace("{error}", &error);
10431 // The lane is one line (the error is folded) and may be ellipsized
10432 // past the pane width, so its head is the stable part.
10433 assert!(
10434 !error.contains('\n'),
10435 "store error is folded onto one line: {error:?}"
10436 );
10437 let head: String = expected.chars().take(60).collect();
10438 assert!(
10439 lines.iter().any(|line| line.to_string().contains(&head)),
10440 "saved lane carries the load error: {expected}"
10441 );
10442 let dump = render_dump(&view, 120, 32);
10443 let head: String = en(MessageId::ConfigUnavailable).chars().take(7).collect();
10444 assert!(
10445 dump.contains(&head),
10446 "unavailable value painted in the list:\n{dump}"
10447 );
10448 let unavailable_prefix = en(MessageId::ConfigLaneUnavailable)
10449 .split("{error}")
10450 .next()
10451 .unwrap()
10452 .to_string();
10453 assert!(
10454 dump.contains(unavailable_prefix.trim_end()),
10455 "unavailable lane painted:\n{dump}"
10456 );
10457 // Session rows are untouched by store failures.
10458 let provider = view.rows.iter().find(|row| row.key == "provider").unwrap();
10459 assert!(provider.editable && provider.facts.store_error.is_none());
10460 }
10461
10462 /// An environment/terminal override wins the effective decision but does
10463 /// not repair a broken store: the overridden motion row must still report
10464 /// the settings.toml load failure instead of synthesizing a saved lane.
10465 /// (Windows CI caught this through the legacy-console probe; store truth
10466 /// is keyed on the row's store, not its current authority.)
10467 #[test]
10468 fn overridden_motion_row_still_reports_a_broken_settings_store() {
10469 let _lock = crate::test_support::lock_test_env();
10470 let tmp = TempDir::new().expect("tempdir");
10471 let config_dir = tmp.path().join(".deepseek");
10472 std::fs::create_dir_all(&config_dir).unwrap();
10473 std::fs::write(config_dir.join("settings.toml"), "theme = [broken\n").unwrap();
10474 let config_path = config_dir.join("config.toml");
10475 std::fs::write(&config_path, "approval_policy = [broken\n").unwrap();
10476 let _guard = crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
10477 let _override = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1");
10478 let app = create_test_app();
10479 let view = ConfigView::new_for_app(&app);
10480
10481 let row = view
10482 .rows
10483 .iter()
10484 .find(|row| row.key == "low_motion")
10485 .expect("low_motion row");
10486 assert_eq!(row.facts.authority, super::SettingAuthority::Environment);
10487 assert!(
10488 row.facts.store_error.is_some(),
10489 "the override wins the effective decision; the broken store is still reported"
10490 );
10491 let fact = view.setting_fact(row).expect("setting fact");
10492 assert!(fact.saved.is_none() && fact.startup.is_none(), "{fact:?}");
10493 assert!(
10494 fact.effective.is_some(),
10495 "App still supplies the live value"
10496 );
10497 }
10498
10499 /// P1.2: when a runtime overlay forces low motion, the row's source is
10500 /// that override, not `settings.toml`.
10501 #[test]
10502 fn motion_rows_name_the_winning_environment_override() {
10503 let _lock = crate::test_support::lock_test_env();
10504 let _no_animations = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1");
10505 let app = create_test_app();
10506 let mut view = ConfigView::new_for_app(&app);
10507 for key in ["low_motion", "fancy_animations"] {
10508 let row = view.rows.iter().find(|row| row.key == key).unwrap();
10509 assert_eq!(
10510 row.facts.authority,
10511 super::SettingAuthority::Environment,
10512 "{key}"
10513 );
10514 assert_eq!(row.facts.authority_detail, Some("NO_ANIMATIONS"), "{key}");
10515 }
10516 let theme = view.rows.iter().find(|row| row.key == "theme").unwrap();
10517 assert_eq!(theme.facts.authority, super::SettingAuthority::UserSettings);
10518 view.focus_key("low_motion");
10519 let expected = en(MessageId::ConfigSourceEnvironment).replace("{name}", "NO_ANIMATIONS");
10520 // The detail pane is 39 columns wide at 120x32 and ellipsizes long
10521 // values, so the unbounded detail lines carry the full label and the
10522 // rendered pane carries its visible head.
10523 let lines = view.setting_detail_lines(&view.rows[view.selected], 200);
10524 assert!(
10525 lines
10526 .iter()
10527 .any(|line| line.to_string().contains(&expected)),
10528 "detail names the override: {expected}"
10529 );
10530 let dump = render_dump(&view, 120, 32);
10531 let head: String = expected.chars().take(20).collect();
10532 assert!(dump.contains(&head), "source names the override:\n{dump}");
10533 }
10534
10535 /// Slice C: Edit Theme live preview — highlighting a theme row emits a
10536 /// session-only `ConfigUpdated` (the surface repaints immediately) while
10537 /// only Enter/Apply persists; Esc reverts to the opening value.
10538 #[test]
10539 fn edit_theme_highlight_previews_without_persisting_and_esc_reverts() {
10540 let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n");
10541 let app = create_test_app();
10542 let mut view = ConfigView::new_for_app(&app);
10543 view.focus_key("theme");
10544 let key = |view: &mut ConfigView, code: KeyCode| {
10545 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
10546 };
10547 assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None));
10548 assert!(
10549 view.editing
10550 .as_ref()
10551 .is_some_and(|edit| edit.key == "theme"),
10552 "Enter must open the theme editor"
10553 );
10554
10555 // ↓ highlights shoreline: preview (persist:false), editor stays open.
10556 match key(&mut view, KeyCode::Down) {
10557 ViewAction::Emit(ViewEvent::ConfigUpdated {
10558 key,
10559 value,
10560 persist,
10561 }) => {
10562 assert_eq!(key, "theme");
10563 assert_eq!(value, "shoreline");
10564 assert!(!persist, "highlighting must not persist");
10565 }
10566 other => panic!("highlight must preview, got {other:?}"),
10567 }
10568 assert!(view.editing.is_some(), "preview keeps the editor open");
10569
10570 // Esc reverts the live surface to the opening value, session-only.
10571 match key(&mut view, KeyCode::Esc) {
10572 ViewAction::Emit(ViewEvent::ConfigUpdated {
10573 key,
10574 value,
10575 persist,
10576 }) => {
10577 assert_eq!(key, "theme");
10578 assert_eq!(value, "terminal");
10579 assert!(!persist, "revert must not persist");
10580 }
10581 other => panic!("esc must revert the preview, got {other:?}"),
10582 }
10583 assert!(view.editing.is_none());
10584 }
10585
10586 /// Slice C: Enter/Apply in Edit Theme persists the highlighted theme.
10587 #[test]
10588 fn edit_theme_enter_persists_the_highlighted_theme() {
10589 let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n");
10590 let app = create_test_app();
10591 let mut view = ConfigView::new_for_app(&app);
10592 view.focus_key("theme");
10593 let key = |view: &mut ConfigView, code: KeyCode| {
10594 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
10595 };
10596 assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None));
10597 let _ = key(&mut view, KeyCode::Down);
10598 match key(&mut view, KeyCode::Enter) {
10599 ViewAction::Emit(ViewEvent::ConfigUpdated {
10600 key,
10601 value,
10602 persist,
10603 }) => {
10604 assert_eq!(key, "theme");
10605 assert_eq!(value, "shoreline");
10606 assert!(persist, "Apply must persist");
10607 }
10608 other => panic!("enter must persist the highlight, got {other:?}"),
10609 }
10610 assert!(view.editing.is_none());
10611 }
10612
10613 /// Slice C: Esc without moving the highlight previews nothing and
10614 /// reverts nothing.
10615 #[test]
10616 fn edit_theme_esc_without_preview_is_silent() {
10617 let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n");
10618 let app = create_test_app();
10619 let mut view = ConfigView::new_for_app(&app);
10620 view.focus_key("theme");
10621 let key = |view: &mut ConfigView, code: KeyCode| {
10622 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
10623 };
10624 assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None));
10625 assert!(
10626 matches!(key(&mut view, KeyCode::Esc), ViewAction::None),
10627 "no preview happened, so there is nothing to revert"
10628 );
10629 }
10630
10631 /// Slice C: live preview is theme-only — other choice editors keep
10632 /// their silent highlight behavior.
10633 #[test]
10634 fn edit_choice_highlight_previews_only_the_theme_key() {
10635 let _guard = ConfigSettingsEnvGuard::new("");
10636 let app = create_test_app();
10637 let mut view = ConfigView::new_for_app(&app);
10638 view.focus_key("default_mode");
10639 let key = |view: &mut ConfigView, code: KeyCode| {
10640 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
10641 };
10642 assert!(matches!(key(&mut view, KeyCode::Enter), ViewAction::None));
10643 assert!(
10644 view.editing
10645 .as_ref()
10646 .is_some_and(|edit| edit.key == "default_mode"),
10647 "Enter must open the default_mode editor"
10648 );
10649 assert!(
10650 matches!(key(&mut view, KeyCode::Down), ViewAction::None),
10651 "non-theme highlight must stay silent"
10652 );
10653 assert!(
10654 matches!(key(&mut view, KeyCode::Esc), ViewAction::None),
10655 "no preview means no revert"
10656 );
10657 }
10658
10659 /// Slice C (global hover rule): hovering an Edit Theme choice row
10660 /// highlights it and live-previews; hovering the same row again is
10661 /// silent.
10662 #[test]
10663 fn edit_theme_hover_highlights_and_previews() {
10664 let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n");
10665 let app = create_test_app();
10666 let mut view = ConfigView::new_for_app(&app);
10667 view.focus_key("theme");
10668 view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
10669 let area = Rect::new(0, 0, 120, 32);
10670 let mut buf = Buffer::empty(area);
10671 view.render(area, &mut buf);
10672 let hover = |view: &mut ConfigView, column: u16, row: u16| {
10673 view.handle_mouse(MouseEvent {
10674 kind: MouseEventKind::Moved,
10675 column,
10676 row,
10677 modifiers: KeyModifiers::NONE,
10678 })
10679 };
10680 // Choice index 2 is shoreline (system, terminal, shoreline, …).
10681 let (rect, _) = view
10682 .last_choice_hitboxes
10683 .borrow()
10684 .iter()
10685 .copied()
10686 .find(|(_, idx)| *idx == 2)
10687 .expect("rendered shoreline hitbox");
10688 match hover(&mut view, rect.x, rect.y) {
10689 ViewAction::Emit(ViewEvent::ConfigUpdated {
10690 key,
10691 value,
10692 persist,
10693 }) => {
10694 assert_eq!(key, "theme");
10695 assert_eq!(value, "shoreline");
10696 assert!(!persist, "hover preview must not persist");
10697 }
10698 other => panic!("hover must preview, got {other:?}"),
10699 }
10700 assert!(
10701 matches!(hover(&mut view, rect.x, rect.y), ViewAction::None),
10702 "hovering the highlighted row is silent"
10703 );
10704 assert!(
10705 matches!(hover(&mut view, 0, 0), ViewAction::None),
10706 "hovering outside every row is silent"
10707 );
10708 }
10709
10710 /// P1.3: at 40 columns every category is reachable with the pointer alone
10711 /// — visible chips are clicked directly, hidden ones through the › and ‹
10712 /// overflow markers, which are themselves hitboxes.
10713 #[test]
10714 fn config_strip_categories_are_reachable_by_pointer_alone_at_40_columns() {
10715 let app = create_test_app();
10716 let mut view = ConfigView::new_for_app(&app);
10717 let area = Rect::new(0, 0, 40, 12);
10718 let click = |view: &mut ConfigView, rect: Rect| {
10719 view.handle_mouse(MouseEvent {
10720 kind: MouseEventKind::Down(MouseButton::Left),
10721 column: rect.x,
10722 row: rect.y,
10723 modifiers: KeyModifiers::NONE,
10724 })
10725 };
10726 let mut reached = vec![view.category];
10727 for target in ConfigCategory::ALL {
10728 // Walk to `target` using only painted hitboxes.
10729 for _ in 0..16 {
10730 if view.category == target {
10731 break;
10732 }
10733 let mut buf = Buffer::empty(area);
10734 view.render(area, &mut buf);
10735 let chip = view
10736 .last_rail_hitboxes
10737 .borrow()
10738 .iter()
10739 .find(|(_, category)| *category == target)
10740 .map(|(rect, _)| *rect);
10741 let step = |step: super::NavStep| {
10742 view.last_nav_controls
10743 .borrow()
10744 .iter()
10745 .find(|(_, s)| *s == step)
10746 .map(|(rect, _)| *rect)
10747 };
10748 let rect = chip
10749 .or_else(|| step(super::NavStep::Next))
10750 .or_else(|| step(super::NavStep::Previous))
10751 .expect("a chip or an overflow marker is always clickable");
10752 // The marker cells are painted, not empty.
10753 let cells: String = (rect.x..rect.right())
10754 .map(|x| buf[(x, rect.y)].symbol().to_string())
10755 .collect();
10756 assert!(!cells.trim().is_empty(), "{target:?}: {cells:?}");
10757 assert!(matches!(click(&mut view, rect), ViewAction::None));
10758 }
10759 assert_eq!(view.category, target, "pointer-only path to {target:?}");
10760 reached.push(target);
10761 }
10762 for category in ConfigCategory::ALL {
10763 assert!(reached.contains(&category));
10764 }
10765 // And back to the front by pointer only: the Appearance chip once it
10766 // scrolls into view, ‹ until then.
10767 for _ in 0..16 {
10768 if view.category == ConfigCategory::Appearance {
10769 break;
10770 }
10771 let mut buf = Buffer::empty(area);
10772 view.render(area, &mut buf);
10773 let chip = view
10774 .last_rail_hitboxes
10775 .borrow()
10776 .iter()
10777 .find(|(_, category)| *category == ConfigCategory::Appearance)
10778 .map(|(rect, _)| *rect);
10779 let rect = chip
10780 .or_else(|| {
10781 view.last_nav_controls
10782 .borrow()
10783 .iter()
10784 .find(|(_, s)| *s == super::NavStep::Previous)
10785 .map(|(rect, _)| *rect)
10786 })
10787 .expect("the Appearance chip or ‹ is painted");
10788 let _ = click(&mut view, rect);
10789 }
10790 assert_eq!(view.category, ConfigCategory::Appearance);
10791 }
10792
10793 /// P1.4: choice and text editors expose clickable Apply / Cancel controls
10794 /// and exact choice hitboxes at 40x12 and 80x24.
10795 #[test]
10796 fn config_editors_apply_and_cancel_by_pointer_with_exact_hitboxes() {
10797 let app = create_test_app();
10798 let click = |view: &mut ConfigView, column: u16, row: u16| {
10799 view.handle_mouse(MouseEvent {
10800 kind: MouseEventKind::Down(MouseButton::Left),
10801 column,
10802 row,
10803 modifiers: KeyModifiers::NONE,
10804 })
10805 };
10806 for (w, h) in [(40u16, 12u16), (80, 24)] {
10807 let area = Rect::new(0, 0, w, h);
10808 // Choice editor.
10809 let mut view = ConfigView::new_for_app(&app);
10810 view.focus_key("default_mode");
10811 assert!(matches!(
10812 view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
10813 ViewAction::None
10814 ));
10815 assert!(view.editing.is_some());
10816 let mut buf = Buffer::empty(area);
10817 view.render(area, &mut buf);
10818 let dump = buffer_text(&buf, area);
10819 let controls = view.last_editor_controls.borrow().clone();
10820 let apply = controls
10821 .iter()
10822 .find(|(_, c)| *c == super::EditorControl::Apply)
10823 .map(|(r, _)| *r)
10824 .unwrap_or_else(|| panic!("{w}x{h} Apply control:\n{dump}"));
10825 let cancel = controls
10826 .iter()
10827 .find(|(_, c)| *c == super::EditorControl::Cancel)
10828 .map(|(r, _)| *r)
10829 .unwrap_or_else(|| panic!("{w}x{h} Cancel control:\n{dump}"));
10830 for (rect, id) in [
10831 (apply, MessageId::ConfigEditorApply),
10832 (cancel, MessageId::ConfigEditorCancel),
10833 ] {
10834 let cells: String = (rect.x..rect.right())
10835 .map(|x| buf[(x, rect.y)].symbol().to_string())
10836 .collect();
10837 assert!(cells.contains(&en(id)), "{w}x{h} {cells:?}");
10838 assert!(rect.bottom() <= area.bottom());
10839 }
10840 assert!(
10841 dump.contains(&en(MessageId::ConfigEditChooseLabel)),
10842 "{w}x{h}:\n{dump}"
10843 );
10844 let choices = view.last_choice_hitboxes.borrow().clone();
10845 let (operate_rect, operate_idx) = choices
10846 .iter()
10847 .copied()
10848 .find(|(_, idx)| *idx == 2)
10849 .unwrap_or_else(|| panic!("{w}x{h} third choice painted:\n{dump}"));
10850 // Clicking a choice selects it; clicking beside the controls does
10851 // nothing; clicking Apply emits exactly that choice.
10852 assert!(matches!(
10853 click(&mut view, operate_rect.x + 2, operate_rect.y),
10854 ViewAction::None
10855 ));
10856 assert_eq!(view.editing.as_ref().unwrap().selected_choice, operate_idx);
10857 let beside = apply.right().saturating_add(1);
10858 if beside < cancel.x {
10859 assert!(matches!(
10860 click(&mut view, beside, apply.y),
10861 ViewAction::None
10862 ));
10863 assert!(view.editing.is_some(), "{w}x{h} gap click is inert");
10864 }
10865 match click(&mut view, apply.x + 1, apply.y) {
10866 ViewAction::Emit(ViewEvent::ConfigUpdated {
10867 key,
10868 value,
10869 persist,
10870 }) => {
10871 assert_eq!(key, "default_mode");
10872 assert_eq!(value, "operate");
10873 assert!(persist);
10874 }
10875 other => panic!("{w}x{h} Apply click should emit: {other:?}"),
10876 }
10877 assert!(view.editing.is_none());
10878
10879 // Cancel by pointer.
10880 view.focus_key("default_mode");
10881 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
10882 let mut buf = Buffer::empty(area);
10883 view.render(area, &mut buf);
10884 let cancel = view
10885 .last_editor_controls
10886 .borrow()
10887 .iter()
10888 .find(|(_, c)| *c == super::EditorControl::Cancel)
10889 .map(|(r, _)| *r)
10890 .unwrap();
10891 assert!(matches!(
10892 click(&mut view, cancel.x, cancel.y),
10893 ViewAction::None
10894 ));
10895 assert!(view.editing.is_none(), "{w}x{h} Cancel leaves the editor");
10896 assert_eq!(
10897 view.status.as_deref(),
10898 Some(en(MessageId::ConfigEditCancelled).as_str())
10899 );
10900
10901 // Text editor: type, then Apply by pointer.
10902 view.focus_key("thinking_preview_lines");
10903 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
10904 for ch in "77".chars() {
10905 let _ = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
10906 }
10907 let mut buf = Buffer::empty(area);
10908 view.render(area, &mut buf);
10909 let dump = buffer_text(&buf, area);
10910 let apply = view
10911 .last_editor_controls
10912 .borrow()
10913 .iter()
10914 .find(|(_, c)| *c == super::EditorControl::Apply)
10915 .map(|(r, _)| *r)
10916 .unwrap_or_else(|| panic!("{w}x{h} text editor Apply:\n{dump}"));
10917 assert!(
10918 dump.contains(&en(MessageId::ConfigEditNewLabel).trim_end().to_string()),
10919 "{w}x{h} value line stays visible above the controls:\n{dump}"
10920 );
10921 match click(&mut view, apply.x, apply.y) {
10922 ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => {
10923 assert_eq!(key, "thinking_preview_lines");
10924 assert_eq!(value, "77");
10925 }
10926 other => panic!("{w}x{h} text Apply should emit: {other:?}"),
10927 }
10928 }
10929 }
10930
10931 /// P1.5: second-click activation is disarmed by every keyboard step, so no
10932 /// single click after navigation can mutate anything.
10933 #[test]
10934 fn config_second_click_arming_resets_on_every_keyboard_step() {
10935 let app = create_test_app();
10936 let mut view = ConfigView::new_for_app(&app);
10937 view.category = ConfigCategory::Motion;
10938 view.select_first_visible_row();
10939 let area = Rect::new(0, 0, 80, 24);
10940 let click_row = |view: &mut ConfigView, key: &str| -> ViewAction {
10941 let mut buf = Buffer::empty(area);
10942 view.render(area, &mut buf);
10943 let rect = view
10944 .last_row_hitboxes
10945 .borrow()
10946 .iter()
10947 .find(|(_, idx)| view.rows[*idx].key == key)
10948 .map(|(rect, _)| *rect)
10949 .unwrap_or_else(|| panic!("{key} row painted"));
10950 view.handle_mouse(MouseEvent {
10951 kind: MouseEventKind::Down(MouseButton::Left),
10952 column: rect.x + 1,
10953 row: rect.y,
10954 modifiers: KeyModifiers::NONE,
10955 })
10956 };
10957 let key = |view: &mut ConfigView, code: KeyCode| {
10958 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
10959 };
10960 let sequences: Vec<Vec<KeyCode>> = vec![
10961 vec![KeyCode::Down, KeyCode::Up],
10962 vec![KeyCode::Right, KeyCode::Left],
10963 vec![KeyCode::Tab, KeyCode::BackTab],
10964 vec![KeyCode::Char('x'), KeyCode::Backspace],
10965 vec![KeyCode::PageDown, KeyCode::PageUp],
10966 ];
10967 for sequence in sequences {
10968 // The previous iteration's checking click left the row armed; a
10969 // keyboard step (like any real navigation) disarms it first.
10970 let _ = key(&mut view, KeyCode::Up);
10971 assert!(matches!(
10972 click_row(&mut view, "low_motion"),
10973 ViewAction::None
10974 ));
10975 for code in &sequence {
10976 let _ = key(&mut view, *code);
10977 }
10978 assert_eq!(view.category, ConfigCategory::Motion, "{sequence:?}");
10979 let action = click_row(&mut view, "low_motion");
10980 assert!(
10981 matches!(action, ViewAction::None),
10982 "{sequence:?} then one click must not mutate: {action:?}"
10983 );
10984 assert!(view.editing.is_none(), "{sequence:?}");
10985 assert_eq!(view.rows[view.selected].key, "low_motion");
10986 }
10987 // Control: two consecutive clicks with nothing in between activate
10988 // (the previous check click is disarmed by a keyboard step first).
10989 let _ = key(&mut view, KeyCode::Up);
10990 assert!(matches!(
10991 click_row(&mut view, "low_motion"),
10992 ViewAction::None
10993 ));
10994 assert!(matches!(
10995 click_row(&mut view, "low_motion"),
10996 ViewAction::Emit(ViewEvent::ConfigUpdated { .. })
10997 ));
10998 // A rebuilt focus (the host re-renders after applying) is disarmed
10999 // even though the emitting click left the row armed.
11000 view.focus_key("low_motion");
11001 assert!(matches!(
11002 click_row(&mut view, "low_motion"),
11003 ViewAction::None
11004 ));
11005 }
11006
11007 /// P1.6: the reachable editor surface renders from the packs — search
11008 /// label, choose label, choice labels and details, footer, controls, and
11009 /// hints — with no English fallbacks in zh-Hans.
11010 #[test]
11011 fn config_editor_surface_is_localized() {
11012 let mut app = create_test_app();
11013 app.ui_locale = Locale::ZhHans;
11014 let mut view = ConfigView::new_for_app(&app);
11015 let zh = |id: MessageId| tr(Locale::ZhHans, id).into_owned();
11016 let spaced = |text: &str| -> String {
11017 text.chars()
11018 .map(|ch| {
11019 if UnicodeWidthStr::width(ch.to_string().as_str()) > 1 {
11020 format!("{ch} ")
11021 } else {
11022 ch.to_string()
11023 }
11024 })
11025 .collect()
11026 };
11027 let dump = render_dump(&view, 80, 24);
11028 assert!(
11029 dump.contains(spaced(&zh(MessageId::ConfigSearchLabel)).trim()),
11030 "search label:\n{dump}"
11031 );
11032 view.focus_key("default_mode");
11033 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
11034 let dump = render_dump(&view, 80, 24);
11035 for id in [
11036 MessageId::ConfigEditChooseLabel,
11037 MessageId::ConfigChoiceModeAct,
11038 MessageId::ConfigChoiceModePlan,
11039 MessageId::ConfigChoiceModeOperate,
11040 MessageId::ConfigChoiceDetailModeAgent,
11041 MessageId::ConfigEditorApply,
11042 MessageId::ConfigEditorCancel,
11043 ] {
11044 let text = zh(id);
11045 assert!(
11046 dump.contains(spaced(&text).trim_end()),
11047 "localized {id:?} = {text}:\n{dump}"
11048 );
11049 }
11050 assert!(
11051 dump.contains(spaced(&zh(MessageId::ConfigChoiceFooter)).trim())
11052 || dump.contains(spaced(&zh(MessageId::ConfigChoiceFooterCompact)).trim()),
11053 "localized choice footer:\n{dump}"
11054 );
11055 for english in ["Choose:", "Full Access", "Apply", "Cancel", "Search:"] {
11056 assert!(!dump.contains(english), "English leaked: {english}\n{dump}");
11057 }
11058 let _ = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
11059 view.focus_key("low_motion");
11060 let dump = render_dump(&view, 120, 32);
11061 let hint = zh(MessageId::ConfigHintLowMotion);
11062 assert!(
11063 dump.contains(spaced(&hint).trim_end()),
11064 "localized hint in the detail pane:\n{dump}"
11065 );
11066 }
11067
11068 #[test]
11069 fn config_shell_renders_localized_chrome_without_missing_markers() {
11070 let mut app = create_test_app();
11071 app.ui_locale = Locale::ZhHans;
11072 let view = ConfigView::new_for_app(&app);
11073 for (w, h) in [(80u16, 24u16), (120, 32)] {
11074 let dump = render_dump(&view, w, h);
11075 assert!(!dump.contains("MISSING"), "{w}x{h}:\n{dump}");
11076 let section = tr(Locale::ZhHans, MessageId::ConfigSectionDisplay);
11077 let spaced: String = section.chars().map(|ch| format!("{ch} ")).collect();
11078 assert!(
11079 dump.contains(spaced.trim_end()),
11080 "{w}x{h} localized section label {section}:\n{dump}"
11081 );
11082 // The new shell chrome renders from the packs too: the active
11083 // category chip/rail row and, when wide, the detail fact labels.
11084 let category = tr(Locale::ZhHans, MessageId::ConfigCategoryAppearance);
11085 let spaced: String = category.chars().map(|ch| format!("{ch} ")).collect();
11086 assert!(
11087 dump.contains(spaced.trim_end()),
11088 "{w}x{h} localized category {category}:\n{dump}"
11089 );
11090 if w >= 100 {
11091 for id in [MessageId::ConfigFactCurrent, MessageId::ConfigFactApply] {
11092 let label = tr(Locale::ZhHans, id);
11093 let spaced: String = label.chars().map(|ch| format!("{ch} ")).collect();
11094 assert!(
11095 dump.contains(spaced.trim_end()),
11096 "{w}x{h} localized fact label {label}:\n{dump}"
11097 );
11098 }
11099 let unobserved = tr(Locale::ZhHans, MessageId::ConfigLaneUnobserved);
11100 let spaced: String = unobserved.chars().map(|ch| format!("{ch} ")).collect();
11101 assert!(
11102 dump.contains(spaced.trim_end()),
11103 "{w}x{h} localized unobserved lane:\n{dump}"
11104 );
11105 }
11106 let scope = tr(Locale::ZhHans, MessageId::ConfigScopeSaved);
11107 let spaced: String = scope.chars().map(|ch| format!("{ch} ")).collect();
11108 assert!(
11109 dump.contains(spaced.trim_end()) || dump.contains(scope.as_ref()),
11110 "{w}x{h} localized scope badge {scope}:\n{dump}"
11111 );
11112 }
11113 }
11114
11115 #[test]
11116 fn config_view_mcp_action_rows_run_existing_commands() {
11117 let app = create_test_app();
11118 let mut view = ConfigView::new_for_app(&app);
11119 for (key, command) in [
11120 ("mcp_open", "/mcp"),
11121 ("mcp_reconnect", "/mcp reload"),
11122 ("mcp_diagnose", "/mcp validate"),
11123 ("plugins_open", "/plugin"),
11124 ] {
11125 view.focus_key(key);
11126 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
11127 match action {
11128 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
11129 action: CommandPaletteAction::ExecuteCommand { command: emitted },
11130 }) => {
11131 assert_eq!(emitted, command, "{key}");
11132 assert!(!emitted.contains("/mcp auth"), "{key}");
11133 }
11134 other => panic!("{key} should run {command}, got {other:?}"),
11135 }
11136 }
11137 }
11138
11139 #[test]
11140 fn config_view_bottom_hint_semantically_truncates_at_narrow_width() {
11141 // The dense bottom status line must truncate on a word boundary with an
11142 // ellipsis instead of leaving a mid-word fragment clipped by the
11143 // terminal (#3987).
11144 let mut app = create_test_app();
11145 app.ui_locale = Locale::En;
11146 let mut view = ConfigView::new_for_app(&app);
11147 view.status = Some(
11148 "CFGSTATUS persisted the configuration override to disk successfully \
11149 without clipping the trailing MARKEREND status text"
11150 .to_string(),
11151 );
11152
11153 let area = Rect::new(0, 0, 100, 40);
11154 let mut buf = Buffer::empty(area);
11155 view.render(area, &mut buf);
11156
11157 let rows: Vec<String> = (0..area.height)
11158 .map(|y| {
11159 (0..area.width)
11160 .map(|x| buf[(x, y)].symbol())
11161 .collect::<String>()
11162 })
11163 .collect();
11164
11165 // No rendered row may overflow the available columns.
11166 for (idx, row) in rows.iter().enumerate() {
11167 assert!(
11168 crate::tui::ui_text::text_display_width(row) <= usize::from(area.width),
11169 "line {idx} overflows: {row:?}"
11170 );
11171 }
11172
11173 let status_line = rows
11174 .iter()
11175 .find(|row| row.contains("CFGSTATUS"))
11176 .expect("bottom status hint should be rendered");
11177 assert!(
11178 status_line.contains('…'),
11179 "status should be truncated with an ellipsis: {status_line:?}"
11180 );
11181 assert!(
11182 !status_line.contains("MARKEREND"),
11183 "truncated status must drop trailing text: {status_line:?}"
11184 );
11185 }
11186
11187 #[test]
11188 fn config_view_typing_replaces_on_first_char() {
11189 let app = create_test_app();
11190 let mut view = ConfigView::new_for_app(&app);
11191 view.focus_key("background_color");
11192
11193 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
11194 let edit = view.editing.as_ref().expect("editing should be active");
11195 assert!(edit.select_all, "editor should start with select-all");
11196
11197 let _ = view.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
11198 let edit = view.editing.as_ref().expect("editing should remain active");
11199 assert_eq!(edit.buffer.iter().collect::<String>(), "x");
11200 }
11201
11202 #[test]
11203 fn config_view_escape_cancels_editing() {
11204 let mut app = create_test_app();
11205 app.ui_locale = Locale::En;
11206 let mut view = ConfigView::new_for_app(&app);
11207 view.focus_key("thinking_preview_lines");
11208 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
11209 assert!(view.editing.is_some());
11210
11211 let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
11212 assert!(matches!(cancel, ViewAction::None));
11213 assert!(view.editing.is_none());
11214 assert_eq!(
11215 view.status.as_deref(),
11216 Some(&*tr(Locale::En, MessageId::ConfigEditCancelled))
11217 );
11218 }
11219
11220 /// A modal that doesn't override `handle_paste` must report
11221 /// "not consumed" so the host can fall through to the composer.
11222 /// Regression: views/mod.rs previously inverted the boolean, swallowing
11223 /// every Cmd-V while any modal was on top.
11224 #[test]
11225 fn default_modal_does_not_consume_paste() {
11226 let mut stack = ViewStack::new();
11227 stack.push(HelpView::new_for_locale(codewhale_localization::Locale::En));
11228 assert!(!stack.handle_paste("hello"));
11229 assert_eq!(stack.top_kind(), Some(ModalKind::Help));
11230 }
11231
11232 struct BareModal;
11233
11234 impl ModalView for BareModal {
11235 fn kind(&self) -> ModalKind {
11236 ModalKind::ContextMenu
11237 }
11238
11239 fn handle_key(&mut self, _key: KeyEvent) -> ViewAction {
11240 ViewAction::None
11241 }
11242
11243 fn render(&self, area: Rect, buf: &mut Buffer) {
11244 let x = area.x + area.width / 2;
11245 let y = area.y + area.height / 2;
11246 buf[(x, y)]
11247 .set_symbol("M")
11248 .set_style(Style::default().fg(Color::White).bg(Color::Red));
11249 }
11250
11251 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
11252 self
11253 }
11254 }
11255
11256 #[test]
11257 fn view_stack_paints_opaque_backdrop_before_modal() {
11258 let area = Rect::new(0, 0, 24, 8);
11259 let modal_x = area.x + area.width / 2;
11260 let modal_y = area.y + area.height / 2;
11261 let mut buf = Buffer::empty(area);
11262 for y in area.top()..area.bottom() {
11263 for x in area.left()..area.right() {
11264 buf[(x, y)]
11265 .set_symbol("X")
11266 .set_style(Style::default().fg(Color::Red).bg(Color::Blue));
11267 }
11268 }
11269
11270 let mut stack = ViewStack::new();
11271 stack.push(BareModal);
11272 stack.render(area, &mut buf);
11273
11274 assert_eq!(buf[(modal_x, modal_y)].symbol(), "M");
11275 for y in area.top()..area.bottom() {
11276 for x in area.left()..area.right() {
11277 if x == modal_x && y == modal_y {
11278 continue;
11279 }
11280 let cell = &buf[(x, y)];
11281 assert_eq!(
11282 cell.symbol(),
11283 " ",
11284 "stale glyph at ({x},{y}) must be cleared"
11285 );
11286 assert_eq!(
11287 cell.bg,
11288 palette::WHALE_BG,
11289 "backdrop at ({x},{y}) must be opaque"
11290 );
11291 }
11292 }
11293 }
11294
11295 #[test]
11296 fn view_stack_masks_links_behind_opaque_modals() {
11297 let area = Rect::new(0, 0, 24, 8);
11298 crate::tui::osc8::set_frame_links(vec![crate::tui::osc8::LinkRegion {
11299 row: 3,
11300 col_start: 2,
11301 col_end: 18,
11302 target: "https://example.invalid/under-modal".to_string(),
11303 }]);
11304 let mut stack = ViewStack::new();
11305 stack.push(BareModal);
11306 stack.render(area, &mut Buffer::empty(area));
11307 assert!(crate::tui::osc8::take_frame_links().is_empty());
11308 }
11309
11310 fn buffer_text(buf: &Buffer, area: Rect) -> String {
11311 let mut out = String::new();
11312 for y in area.top()..area.bottom() {
11313 for x in area.left()..area.right() {
11314 out.push_str(buf[(x, y)].symbol());
11315 }
11316 out.push('\n');
11317 }
11318 out
11319 }
11320
11321 fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String {
11322 (area.left()..area.right())
11323 .map(|x| buf[(x, y)].symbol())
11324 .collect()
11325 }
11326
11327 /// 40x12 regression: the compact tier must surrender secondary chrome
11328 /// (in-body title, column captions, separator) before it surrenders the
11329 /// settings rows, and the wrapped footer height must come out of the
11330 /// table budget instead of silently clipping rows.
11331 #[test]
11332 fn config_compact_theme_category_and_footer_remain_legible() {
11333 let _guard = ConfigSettingsEnvGuard::new("theme = \"shoreline\"\n");
11334 let mut view = create_config_view(Locale::En);
11335 view.focus_key("theme");
11336 let area = Rect::new(0, 0, 40, 12);
11337 let mut buf = Buffer::empty(area);
11338 view.render(area, &mut buf);
11339 let dump = buffer_text(&buf, area);
11340 let theme_rect = view
11341 .last_row_hitboxes
11342 .borrow()
11343 .iter()
11344 .find(|(_, idx)| view.rows[*idx].key == "theme")
11345 .unwrap()
11346 .0;
11347 assert!(
11348 buffer_row_text(&buf, area, theme_rect.y).contains("shoreline"),
11349 "{dump}"
11350 );
11351 assert!(dump.contains("Appearance 1/7"), "{dump}");
11352 let footer = dump
11353 .lines()
11354 .find(|line| line.contains("Enter") && line.contains("Esc"))
11355 .expect("all compact action hints share one line");
11356 assert!(footer.contains("Tab"));
11357 for category in ConfigCategory::ALL {
11358 view.category = category;
11359 view.select_first_visible_row();
11360 view.render(area, &mut buf);
11361 let dump = buffer_text(&buf, area);
11362 assert!(
11363 dump.contains(category.label(Locale::En).as_ref()),
11364 "{category:?}: {dump}"
11365 );
11366 assert_eq!(view.last_nav_controls.borrow().len(), 2);
11367 }
11368 }
11369
11370 #[test]
11371 fn config_sandbox_search_opens_observed_status_without_editing_policy() {
11372 let mut view = create_config_view(Locale::En);
11373 for query in [
11374 "sandbox",
11375 "filesystem",
11376 "unenforced",
11377 "bubblewrap",
11378 "doctor",
11379 ] {
11380 view.restore_filter(query.to_string());
11381 let matches = view.matching_row_indices();
11382 let index = *matches
11383 .iter()
11384 .find(|&&idx| view.rows[idx].key == "sandbox_details")
11385 .expect("sandbox explanation discoverable");
11386 view.selected = index;
11387 let row = &view.rows[index];
11388 assert_eq!(row.facts.kind, ConfigRowKind::Action);
11389 assert_eq!(row.facts.store, SettingStore::None);
11390 assert!(
11391 matches!(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
11392 ViewAction::Emit(ViewEvent::ExecutePanelCommand { command, pager_title: Some(_) }) if command == "/status")
11393 );
11394 assert!(view.editing.is_none());
11395 }
11396 assert!(!view.rows.iter().any(|row| row.key == "sandbox_mode"));
11397 }
11398
11399 #[test]
11400 fn config_view_compact_heights_always_show_a_selectable_setting() {
11401 let mut view = create_config_view(Locale::En);
11402 for (width, height, label) in [(40u16, 12u16, "40x12"), (60, 16, "60x16")] {
11403 let area = Rect::new(0, 0, width, height);
11404 let mut buf = Buffer::empty(area);
11405
11406 view.render(area, &mut buf);
11407
11408 let dump = buffer_text(&buf, area);
11409 let (selected_y, selected_idx) = {
11410 let hitboxes = view.last_row_hitboxes.borrow();
11411 assert!(
11412 !hitboxes.is_empty(),
11413 "{label} should register selectable setting hitboxes:\n{dump}"
11414 );
11415 hitboxes
11416 .iter()
11417 .find(|(_, idx)| *idx == view.selected)
11418 .copied()
11419 .unwrap_or_else(|| {
11420 panic!("{label} selected setting should be rendered:\n{dump}")
11421 })
11422 };
11423 let row = buffer_row_text(&buf, area, selected_y.y);
11424 let row_label = config_label_for_key(&view.rows[selected_idx].key);
11425 let prefix: String = row_label.chars().take(8).collect();
11426 assert!(
11427 row.contains(&prefix),
11428 "{label} hitbox row should contain the selected setting ({row_label:?}); got {row:?}"
11429 );
11430 assert!(
11431 dump.contains("Search:"),
11432 "{label} should keep the search affordance:\n{dump}"
11433 );
11434 }
11435
11436 // The selection anchor must hold while navigating across sections at
11437 // the smallest supported size.
11438 let area = Rect::new(0, 0, 40, 12);
11439 for step in 0..12 {
11440 view.move_selection(1);
11441 let mut buf = Buffer::empty(area);
11442 view.render(area, &mut buf);
11443 let rendered = view
11444 .last_row_hitboxes
11445 .borrow()
11446 .iter()
11447 .any(|(_, idx)| *idx == view.selected);
11448 assert!(
11449 rendered,
11450 "selected setting fell out of the 40x12 window after {} moves",
11451 step + 1
11452 );
11453 }
11454 }
11455
11456 /// 40x12 regression: the edit surface must keep the editable value line
11457 /// (and its hint) above the wrapped footer.
11458 #[test]
11459 fn config_view_compact_edit_surface_keeps_value_line_visible() {
11460 let mut view = create_config_view(Locale::En);
11461 view.focus_key("approval_mode");
11462 view.start_edit();
11463 assert!(view.editing.is_some(), "approval_mode should be editable");
11464 assert_eq!(
11465 view.editing
11466 .as_ref()
11467 .and_then(|edit| edit.choices.as_ref())
11468 .expect("session permission choices"),
11469 &vec![
11470 "ask".to_string(),
11471 "auto-review".to_string(),
11472 "full-access".to_string(),
11473 ]
11474 );
11475 let area = Rect::new(0, 0, 40, 12);
11476 let mut buf = Buffer::empty(area);
11477
11478 view.render(area, &mut buf);
11479
11480 let dump = buffer_text(&buf, area);
11481 assert!(
11482 dump.contains("Choose:") && dump.contains("Full Access"),
11483 "the choice list must stay visible at 40x12:\n{dump}"
11484 );
11485 }
11486 }
11487
11487 lines RUST