返回 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::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::localization::{
17 Locale, MessageId, configured_locale_is_partial_pack, normalize_configured_locale, tr,
18 };
19 use crate::palette;
20 use crate::settings::Settings;
21 use crate::tools::UserInputResponse;
22 use crate::tools::subagent::{
23 FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus, localized_whale_display_names,
24 };
25 use crate::tui::app::App;
26 use crate::tui::approval::{ElevationOption, ReviewDecision};
27 use crate::tui::focus_texture::FocusTextureMode;
28 use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
29 use crate::tui::menu_style;
30 use crate::tui::widgets::agent_card::AgentLifecycle;
31
32 pub mod fleet_detail;
33 pub mod fleet_list;
34 pub mod fleet_roster;
35 pub mod fleet_setup;
36 pub mod mode_picker;
37 pub mod route_save_prompt;
38 pub mod skills_manager;
39 pub mod status_picker;
40
41 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 pub enum ModalKind {
43 Approval,
44 Elevation,
45 UserInput,
46 CommandPalette,
47 Help,
48 SubAgents,
49 Pager,
50 LiveTranscript,
51 SessionPicker,
52 Config,
53 ModelPicker,
54 ProviderPicker,
55 ModePicker,
56 FleetRoster,
57 FleetSetup,
58 FleetList,
59 FleetDetail,
60 HotbarSetup,
61 SetupWizard,
62 FilePicker,
63 StatusPicker,
64 FeedbackPicker,
65 ThemePicker,
66 ContextMenu,
67 ContextInspector,
68 SkillsManager,
69 /// Native git worktree manager (list / create / switch / compare).
70 WorktreeManager,
71 }
72
73 /// Clear and paint a modal popup with an opaque surface.
74 ///
75 /// Older modals often called `Clear` only, which left reset-background blank
76 /// cells that could read as translucent on terminals with a non-default app
77 /// background. This helper makes the popup area explicit and keeps the small
78 /// shadow from inheriting stale transcript glyphs.
79 pub(crate) fn render_modal_surface(area: Rect, popup_area: Rect, buf: &mut Buffer) {
80 let shadow_x = popup_area.x.saturating_add(1);
81 let shadow_y = popup_area.y.saturating_add(1);
82 let shadow_right = area.x.saturating_add(area.width);
83 let shadow_bottom = area.y.saturating_add(area.height);
84 let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x));
85 let shadow_height = popup_area
86 .height
87 .min(shadow_bottom.saturating_sub(shadow_y));
88
89 if shadow_width > 0 && shadow_height > 0 {
90 Block::default()
91 .style(Style::default().bg(palette::SURFACE_ELEVATED))
92 .render(
93 Rect {
94 x: shadow_x,
95 y: shadow_y,
96 width: shadow_width,
97 height: shadow_height,
98 },
99 buf,
100 );
101 }
102
103 Clear.render(popup_area, buf);
104 Block::default()
105 .style(Style::default().bg(palette::WHALE_BG))
106 .render(popup_area, buf);
107 }
108
109 /// Paint a full-screen underwater instrument surface and return its body.
110 ///
111 /// Secondary rooms use one title hairline and one bottom action rail instead
112 /// of a centered generic card. A one-cell outer margin is retained when the
113 /// terminal can afford it; compact panes use every cell.
114 pub(crate) fn render_underwater_surface(
115 area: Rect,
116 buf: &mut Buffer,
117 title: impl Into<String>,
118 ) -> Rect {
119 let margin_x = u16::from(area.width >= 44);
120 let margin_y = u16::from(area.height >= 14);
121 let surface = Rect {
122 x: area.x.saturating_add(margin_x),
123 y: area.y.saturating_add(margin_y),
124 width: area.width.saturating_sub(margin_x.saturating_mul(2)),
125 height: area.height.saturating_sub(margin_y.saturating_mul(2)),
126 };
127 Clear.render(area, buf);
128 Block::default()
129 .style(Style::default().bg(palette::WHALE_BG))
130 .render(area, buf);
131 // Ratatui clips long block titles at the border edge without signalling
132 // that anything is missing. Reserve the corner cells and semantic-ellipsis
133 // the title so compact terminals still read as intentional instruments.
134 let title_width = usize::from(surface.width.saturating_sub(4));
135 let title = crate::tui::ui_text::semantic_truncate(&title.into(), title_width);
136 let block = Block::default()
137 .title(Line::from(Span::styled(
138 format!(" {title} "),
139 Style::default()
140 .fg(palette::WHALE_ACTION)
141 .add_modifier(Modifier::BOLD),
142 )))
143 .borders(Borders::TOP | Borders::BOTTOM)
144 .border_style(Style::default().fg(palette::BORDER_COLOR))
145 .style(Style::default().bg(palette::WHALE_BG))
146 .padding(Padding::new(1, 1, 1, 1));
147 let inner = block.inner(surface);
148 block.render(surface, buf);
149 inner
150 }
151
152 /// Paint a scrollbar on the exact right edge of the panel it controls and
153 /// return the content rect with that rail reserved. Nothing is drawn when all
154 /// rows fit, so narrow surfaces do not spend a column on a fictional control.
155 pub(crate) fn render_panel_scroll_rail(
156 area: Rect,
157 buf: &mut Buffer,
158 total_rows: usize,
159 offset: usize,
160 visible_rows: usize,
161 focused: bool,
162 ) -> Rect {
163 if area.width < 2 || area.height == 0 || total_rows <= visible_rows.max(1) {
164 return area;
165 }
166 let rail_x = area.right().saturating_sub(1);
167 let rail_height = usize::from(area.height);
168 let visible = visible_rows.max(1).min(total_rows);
169 let thumb_height = ((rail_height * visible).div_ceil(total_rows)).clamp(1, rail_height);
170 let max_offset = total_rows.saturating_sub(visible);
171 let travel = rail_height.saturating_sub(thumb_height);
172 let thumb_top = travel
173 .saturating_mul(offset.min(max_offset))
174 .checked_div(max_offset)
175 .unwrap_or(0);
176 let thumb_color = if focused {
177 palette::TEXT_MUTED
178 } else {
179 palette::TEXT_DIM
180 };
181 for local_y in 0..area.height {
182 let y = area.y.saturating_add(local_y);
183 let local = usize::from(local_y);
184 let is_thumb = local >= thumb_top && local < thumb_top + thumb_height;
185 buf[(rail_x, y)]
186 .set_symbol(if is_thumb { "█" } else { "│" })
187 .set_style(Style::default().fg(if is_thumb {
188 thumb_color
189 } else {
190 palette::BORDER_COLOR
191 }));
192 }
193 Rect {
194 width: area.width.saturating_sub(1),
195 ..area
196 }
197 }
198
199 fn render_modal_backdrop(area: Rect, buf: &mut Buffer) {
200 for y in area.top()..area.bottom() {
201 for x in area.left()..area.right() {
202 buf[(x, y)]
203 .set_symbol(" ")
204 .set_style(Style::default().bg(palette::WHALE_BG));
205 }
206 }
207 }
208
209 /// Compute a centered, responsive popup rect for a modal.
210 ///
211 /// The size starts from `preferred_*`, but is clamped so it never exceeds the
212 /// frame (leaving a small breathing-room margin when there is space) and never
213 /// drops below `min_*` unless the frame itself is smaller. Centering the result
214 /// inside `area` replaces the repeated, error-prone
215 /// `N.min(area.width.saturating_sub(..))` arithmetic scattered across modals so
216 /// every overlay sizes itself the same way at 80x24, 100x30, 120x32, 160x40,
217 /// and beyond. See #3732.
218 pub(crate) fn centered_modal_area(
219 area: Rect,
220 preferred_width: u16,
221 preferred_height: u16,
222 min_width: u16,
223 min_height: u16,
224 ) -> Rect {
225 // Keep a 2-cell margin on each axis when the frame can spare it so the
226 // backdrop stays visible around the card; otherwise fill the frame.
227 let avail_width = area.width.saturating_sub(2).max(1);
228 let avail_height = area.height.saturating_sub(2).max(1);
229 let width = preferred_width.clamp(min_width.min(avail_width), avail_width);
230 let height = preferred_height.clamp(min_height.min(avail_height), avail_height);
231 Rect {
232 x: area.x + area.width.saturating_sub(width) / 2,
233 y: area.y + area.height.saturating_sub(height) / 2,
234 width,
235 height,
236 }
237 }
238
239 /// A single key/label hint shown in a modal's action footer.
240 ///
241 /// Footers built from `ActionHint`s are laid out by [`action_footer_lines`],
242 /// which wraps to additional rows instead of letting an action run off the
243 /// right edge of the modal — the core overflow bug behind #3732. Use this for
244 /// action/navigation hints; truncate only identifiers/paths/hashes elsewhere.
245 pub(crate) struct ActionHint {
246 key: Cow<'static, str>,
247 label: Cow<'static, str>,
248 }
249
250 impl ActionHint {
251 pub(crate) fn new(
252 key: impl Into<Cow<'static, str>>,
253 label: impl Into<Cow<'static, str>>,
254 ) -> Self {
255 Self {
256 key: key.into(),
257 label: label.into(),
258 }
259 }
260
261 /// Display columns this hint occupies: ` key ` (key padded by a space on
262 /// each side) followed by the label.
263 fn width(&self) -> usize {
264 UnicodeWidthStr::width(self.key.as_ref()) + 2 + UnicodeWidthStr::width(self.label.as_ref())
265 }
266
267 fn spans(&self) -> [Span<'static>; 2] {
268 [
269 Span::styled(
270 format!(" {} ", self.key),
271 Style::default()
272 .fg(palette::WHALE_INFO)
273 .add_modifier(Modifier::BOLD),
274 ),
275 Span::styled(
276 self.label.clone().into_owned(),
277 Style::default().fg(palette::TEXT_MUTED),
278 ),
279 ]
280 }
281 }
282
283 /// Lay out action hints into one or more lines that each fit within `width`.
284 ///
285 /// Hints are packed greedily; when the next hint would overflow the current row
286 /// the layout starts a new row rather than truncating. No action is ever
287 /// dropped or clipped (a single hint wider than `width` is emitted alone, which
288 /// only happens at degenerate widths below the modal minimums). This is the
289 /// shared replacement for the single-line `title_bottom` footers that silently
290 /// pushed actions off-screen.
291 pub(crate) fn action_footer_lines(hints: &[ActionHint], width: u16) -> Vec<Line<'static>> {
292 let width = usize::from(width);
293 if hints.is_empty() || width == 0 {
294 return Vec::new();
295 }
296 const GAP: usize = 1;
297 let mut lines: Vec<Line<'static>> = Vec::new();
298 let mut current: Vec<Span<'static>> = Vec::new();
299 let mut current_width = 0usize;
300 for hint in hints {
301 let hint_width = hint.width();
302 let needed = if current.is_empty() {
303 hint_width
304 } else {
305 current_width + GAP + hint_width
306 };
307 if !current.is_empty() && needed > width {
308 lines.push(Line::from(std::mem::take(&mut current)));
309 current_width = 0;
310 }
311 if !current.is_empty() {
312 current.push(Span::raw(" ".repeat(GAP)));
313 current_width += GAP;
314 }
315 current.extend(hint.spans());
316 current_width += hint_width;
317 }
318 if !current.is_empty() {
319 lines.push(Line::from(current));
320 }
321 lines
322 }
323
324 /// Reserve `lines` worth of rows at the bottom of `inner`, paint them, and
325 /// return the content area that remains above. Shared by the action-hint and
326 /// free-text modal footers.
327 fn place_footer_lines(
328 inner: Rect,
329 buf: &mut Buffer,
330 lines: Vec<Line<'static>>,
331 quiet_gutter: bool,
332 ) -> Rect {
333 if lines.is_empty() || inner.height == 0 {
334 return inner;
335 }
336 let footer_height = u16::try_from(lines.len())
337 .unwrap_or(u16::MAX)
338 .min(inner.height);
339 // Opted-in overlays keep one quiet row between scrollable body copy and
340 // the action rail. Degenerate heights keep every row for content.
341 let gutter_height = u16::from(quiet_gutter && inner.height >= footer_height.saturating_add(4));
342 let footer_area = Rect {
343 x: inner.x,
344 y: inner.y + inner.height - footer_height,
345 width: inner.width,
346 height: footer_height,
347 };
348 Paragraph::new(lines).render(footer_area, buf);
349 Rect {
350 x: inner.x,
351 y: inner.y,
352 width: inner.width,
353 height: inner
354 .height
355 .saturating_sub(footer_height.saturating_add(gutter_height)),
356 }
357 }
358
359 /// Render a wrapping action footer anchored to the bottom of `inner` and
360 /// return the content area that remains above it.
361 ///
362 /// Modals call this after painting their block so the footer reserves exactly
363 /// as many rows as it needs (bounded by the available height) and the body
364 /// fills the rest. Centralizing it keeps every modal's action row visible and
365 /// reachable at narrow widths.
366 pub(crate) fn render_modal_footer(inner: Rect, buf: &mut Buffer, hints: &[ActionHint]) -> Rect {
367 let lines = action_footer_lines(hints, inner.width);
368 place_footer_lines(inner, buf, lines, false)
369 }
370
371 /// Render a modal action footer with one quiet body-to-footer row when the
372 /// caller's responsive layout has explicitly budgeted for it.
373 pub(crate) fn render_modal_footer_with_gutter(
374 inner: Rect,
375 buf: &mut Buffer,
376 hints: &[ActionHint],
377 ) -> Rect {
378 let lines = action_footer_lines(hints, inner.width);
379 place_footer_lines(inner, buf, lines, true)
380 }
381
382 /// Word-wrap a free-form footer string into styled lines that each fit `width`.
383 ///
384 /// For footers that are pre-composed prose/sentences (e.g. localized config
385 /// hints) rather than discrete key/label hints. Wrapping on whitespace keeps
386 /// every word visible instead of clipping the tail at the modal edge.
387 pub(crate) fn wrapped_footer_lines(text: &str, width: u16, style: Style) -> Vec<Line<'static>> {
388 let width = usize::from(width);
389 if text.trim().is_empty() || width == 0 {
390 return Vec::new();
391 }
392 let mut lines: Vec<Line<'static>> = Vec::new();
393 let mut current = String::new();
394 let mut current_width = 0usize;
395 for word in text.split_whitespace() {
396 let word_width = UnicodeWidthStr::width(word);
397 let needed = if current.is_empty() {
398 word_width
399 } else {
400 current_width + 1 + word_width
401 };
402 if !current.is_empty() && needed > width {
403 lines.push(Line::from(Span::styled(
404 std::mem::take(&mut current),
405 style,
406 )));
407 current_width = 0;
408 }
409 if !current.is_empty() {
410 current.push(' ');
411 current_width += 1;
412 }
413 current.push_str(word);
414 current_width += word_width;
415 }
416 if !current.is_empty() {
417 lines.push(Line::from(Span::styled(current, style)));
418 }
419 lines
420 }
421
422 /// Render a wrapping free-text footer anchored to the bottom of `inner` and
423 /// return the content area above it. The prose counterpart to
424 /// [`render_modal_footer`].
425 pub(crate) fn render_modal_text_footer(
426 inner: Rect,
427 buf: &mut Buffer,
428 text: &str,
429 style: Style,
430 ) -> Rect {
431 let lines = wrapped_footer_lines(text, inner.width, style);
432 // Free-text status footers are already separated semantically from their
433 // table body and can carry the last visible receipt themselves. Do not
434 // spend another row here; action-rail layouts can opt into that gutter.
435 place_footer_lines(inner, buf, lines, false)
436 }
437
438 /// Shared list/detail geometry for modal managers and pickers.
439 ///
440 /// Wide modals get a stable left list and a right detail pane. Narrow modals
441 /// stack the list over the detail so neither side becomes unreadably thin.
442 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
443 pub(crate) struct ListDetailLayout {
444 pub(crate) list: Rect,
445 pub(crate) detail: Rect,
446 pub(crate) stacked: bool,
447 }
448
449 impl ListDetailLayout {
450 #[must_use]
451 pub(crate) fn split(area: Rect, min_detail_width: u16) -> Self {
452 if area.width == 0 || area.height == 0 {
453 return Self {
454 list: area,
455 detail: area,
456 stacked: true,
457 };
458 }
459
460 let gap = 1;
461 let min_list_width = 30.min(area.width);
462 let can_split = area.width >= 96
463 && area
464 .width
465 .saturating_sub(gap)
466 .saturating_sub(min_list_width)
467 >= min_detail_width;
468 if can_split {
469 let max_list_width = area.width.saturating_sub(gap + min_detail_width);
470 let preferred = area.width.saturating_mul(42) / 100;
471 let list_width = preferred.clamp(min_list_width, max_list_width.min(52));
472 let detail_width = area.width.saturating_sub(list_width + gap);
473 return Self {
474 list: Rect {
475 x: area.x,
476 y: area.y,
477 width: list_width,
478 height: area.height,
479 },
480 detail: Rect {
481 x: area.x + list_width + gap,
482 y: area.y,
483 width: detail_width,
484 height: area.height,
485 },
486 stacked: false,
487 };
488 }
489
490 let gap = if area.height >= 8 { 1 } else { 0 };
491 let min_detail_height = 4.min(area.height);
492 let max_list_height = area.height.saturating_sub(gap + min_detail_height);
493 let preferred = area.height.saturating_mul(3) / 5;
494 let list_height = preferred.clamp(1, max_list_height.max(1));
495 let detail_height = area.height.saturating_sub(list_height + gap);
496 Self {
497 list: Rect {
498 x: area.x,
499 y: area.y,
500 width: area.width,
501 height: list_height,
502 },
503 detail: Rect {
504 x: area.x,
505 y: area.y + list_height + gap,
506 width: area.width,
507 height: detail_height,
508 },
509 stacked: true,
510 }
511 }
512 }
513
514 /// Plain empty-state copy for modal list/detail bodies.
515 #[derive(Debug, Clone, PartialEq, Eq)]
516 pub(crate) struct EmptyState {
517 title: Cow<'static, str>,
518 body: Cow<'static, str>,
519 primary_action: Option<(Cow<'static, str>, Cow<'static, str>)>,
520 secondary_action: Option<(Cow<'static, str>, Cow<'static, str>)>,
521 }
522
523 impl EmptyState {
524 pub(crate) fn new(
525 title: impl Into<Cow<'static, str>>,
526 body: impl Into<Cow<'static, str>>,
527 ) -> Self {
528 Self {
529 title: title.into(),
530 body: body.into(),
531 primary_action: None,
532 secondary_action: None,
533 }
534 }
535
536 #[must_use]
537 pub(crate) fn primary_action(
538 mut self,
539 key: impl Into<Cow<'static, str>>,
540 label: impl Into<Cow<'static, str>>,
541 ) -> Self {
542 self.primary_action = Some((key.into(), label.into()));
543 self
544 }
545
546 #[must_use]
547 pub(crate) fn secondary_action(
548 mut self,
549 key: impl Into<Cow<'static, str>>,
550 label: impl Into<Cow<'static, str>>,
551 ) -> Self {
552 self.secondary_action = Some((key.into(), label.into()));
553 self
554 }
555
556 pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) {
557 let mut lines = vec![
558 Line::from(Span::styled(
559 self.title.clone().into_owned(),
560 Style::default()
561 .fg(palette::TEXT_PRIMARY)
562 .add_modifier(Modifier::BOLD),
563 )),
564 Line::from(""),
565 Line::from(Span::styled(
566 self.body.clone().into_owned(),
567 Style::default().fg(palette::TEXT_MUTED),
568 )),
569 ];
570 if self.primary_action.is_some() || self.secondary_action.is_some() {
571 lines.push(Line::from(""));
572 }
573 for (key, label) in [self.primary_action.as_ref(), self.secondary_action.as_ref()]
574 .into_iter()
575 .flatten()
576 {
577 let hint = ActionHint::new(key.clone(), label.clone());
578 lines.push(Line::from(hint.spans().to_vec()));
579 }
580 Paragraph::new(lines)
581 .style(Style::default().fg(palette::TEXT_PRIMARY))
582 .wrap(Wrap { trim: true })
583 .render(area, buf);
584 }
585 }
586
587 #[derive(Debug, Clone)]
588 pub enum CommandPaletteAction {
589 ExecuteCommand { command: String },
590 InsertText { text: String },
591 OpenTextPager { title: String, content: String },
592 }
593
594 #[derive(Debug, Clone, PartialEq, Eq)]
595 pub enum ContextMenuAction {
596 CopySelection,
597 OpenSelection,
598 ClearSelection,
599 CopyCell {
600 cell_index: usize,
601 },
602 OpenDetails {
603 cell_index: usize,
604 },
605 Paste,
606 OpenCommandPalette,
607 OpenContextInspector,
608 OpenHelp,
609 /// Open the selected file:line in the user's editor.
610 OpenFileAtLine {
611 cell_index: usize,
612 },
613 /// Hide a transcript cell. Adds the cell's index to `collapsed_cells`.
614 HideCell {
615 cell_index: usize,
616 },
617 /// Show a previously hidden cell (when right-clicking near it).
618 ShowCell {
619 cell_index: usize,
620 },
621 /// Show all currently hidden cells.
622 ShowAllHidden,
623 /// Execute a slash command associated with a contextual UI row.
624 ExecuteCommand {
625 command: String,
626 },
627 /// Copy a pre-resolved text payload (e.g. a sidebar row's full text)
628 /// to the clipboard.
629 CopyText {
630 text: String,
631 },
632 }
633
634 #[derive(Debug, Clone)]
635 pub enum ViewEvent {
636 CommandPaletteSelected {
637 action: CommandPaletteAction,
638 },
639 OpenTextPager {
640 title: String,
641 content: String,
642 },
643 ApprovalDecision {
644 tool_id: String,
645 tool_name: String,
646 decision: ReviewDecision,
647 timed_out: bool,
648 /// Exact-argument fingerprint, used to scope *denials* (#1617).
649 approval_key: String,
650 /// Lossy / arity-aware fingerprint, used to scope *approvals*.
651 approval_grouping_key: String,
652 /// Permission rules to append when the decision approves.
653 persistent_rules: Vec<codewhale_config::ToolAskRule>,
654 },
655 ElevationDecision {
656 tool_id: String,
657 tool_name: String,
658 option: ElevationOption,
659 },
660 UserInputSubmitted {
661 tool_id: String,
662 response: UserInputResponse,
663 },
664 UserInputCancelled {
665 tool_id: String,
666 },
667 ConfigUpdated {
668 key: String,
669 value: String,
670 persist: bool,
671 },
672 SubAgentsRefresh,
673 SidebarAgentCancel {
674 agent_id: String,
675 },
676 /// Agent Details requests the existing artifact-first exact transcript.
677 OpenAgentTranscript {
678 agent_id: String,
679 },
680 /// Agent Details was popped with Esc/q/Left. The Work surface uses this
681 /// to release only its detail-open owner while retaining selection.
682 AgentDetailsClosed {
683 agent_id: String,
684 },
685 /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a
686 /// candidate. The handler should insert `@<path>` at the composer's cursor
687 /// position.
688 FilePickerSelected {
689 path: String,
690 },
691 SessionSelected {
692 session_id: String,
693 },
694 SessionRenamed {
695 metadata: Box<crate::session_manager::SessionMetadata>,
696 },
697 /// A session's archive flag was flipped (#2934 / #4397).
698 ///
699 /// Distinct from `SessionRenamed` so the receipt can say what actually
700 /// happened; reusing rename would report "Renamed session …" for an
701 /// archive, which is exactly the kind of small lie that erodes trust in
702 /// every other receipt.
703 SessionArchived {
704 metadata: crate::session_manager::SessionMetadata,
705 },
706 SessionDeleted {
707 session_id: String,
708 title: String,
709 },
710 /// Emitted by the `/model` picker on Enter or Shift+D. Carries both the
711 /// chosen model id and reasoning effort tier so the UI handler can update
712 /// App state and forward `Op::SetModel` to the running engine.
713 /// `save_as_startup_default` is true only for the explicit Shift+D action;
714 /// ordinary Enter remains a session-local route change. `previous_*`
715 /// fields let the handler skip work when nothing changed and craft a clear
716 /// status message.
717 ModelPickerApplied {
718 model: String,
719 provider: Option<crate::config::ApiProvider>,
720 /// Exact named custom route key when the selected provider enum is
721 /// `Custom`; built-in routes leave this unset.
722 provider_id: Option<String>,
723 effort: crate::tui::app::ReasoningEffort,
724 previous_model: String,
725 previous_effort: crate::tui::app::ReasoningEffort,
726 save_as_startup_default: bool,
727 },
728 /// Emitted by the `/model` picker on Esc so the next open can restore
729 /// the browsing context — view mode and highlighted row (#4109 / #4115).
730 ModelPickerDismissed {
731 /// True when the dismissed view browses beyond configured providers
732 /// (Catalog / Recent / Coding / Cheap / Long context).
733 catalog_view: bool,
734 /// Named view key (`configured`, `catalog`, `recent`, `coding`,
735 /// `cheap`, `long_context`) for reopen restore (#4115).
736 view: String,
737 selected_row_id: Option<String>,
738 },
739 /// Enter on a locked (unauthenticated) model: explain why selection is
740 /// blocked and open the provider auth/setup path when possible.
741 /// Re-resolve readiness + rebuild catalog rows for the open model picker.
742 ModelPickerRefresh,
743 ModelPickerTogglePin {
744 provider: crate::config::ApiProvider,
745 /// Exact named route for `Custom`; built-in providers leave this unset.
746 provider_id: Option<String>,
747 model: String,
748 },
749 ModelPickerMovePin {
750 provider: crate::config::ApiProvider,
751 /// Exact named route for `Custom`; built-in providers leave this unset.
752 provider_id: Option<String>,
753 model: String,
754 delta: isize,
755 },
756 ModelPickerNeedsAuth {
757 provider: crate::config::ApiProvider,
758 model: String,
759 reason: String,
760 },
761 /// Transient status toast from a modal (e.g. locked-model explanation).
762 StatusMessage {
763 message: String,
764 },
765 /// Emitted by the `/provider` picker on Esc so the next open can restore
766 /// the browsing context — view mode and highlighted row.
767 ProviderPickerDismissed {
768 catalog_view: bool,
769 selected_provider_id: Option<String>,
770 },
771 /// Emitted by the `/provider` picker when the user selects a provider
772 /// that already has credentials — the handler should perform the same
773 /// switch as `AppAction::SwitchProvider`.
774 ProviderPickerApplied {
775 provider: crate::config::ApiProvider,
776 provider_id: Option<String>,
777 },
778 /// Emitted by the `/provider` picker after the user types an API key
779 /// inline for a provider that lacked one. The handler validates the key
780 /// live; on success it reopens the guided flow at the model-pick stage
781 /// without persisting yet (#3875).
782 ProviderPickerApiKeySubmitted {
783 provider: crate::config::ApiProvider,
784 provider_id: Option<String>,
785 api_key: String,
786 /// Endpoint chosen in the wizard's billing-route stage, applied to the
787 /// verification config only — nothing is written until confirm (#4526).
788 base_url: Option<String>,
789 },
790 /// Emitted by the `/provider` guided setup confirm stage after the user
791 /// accepted provider + model. The handler persists the key (and model)
792 /// via the comment-preserving config path, then performs the switch.
793 ProviderPickerSetupConfirmed {
794 provider: crate::config::ApiProvider,
795 provider_id: Option<String>,
796 api_key: String,
797 model: String,
798 context_window: Option<u32>,
799 /// Endpoint the key was verified against, persisted to the provider's
800 /// own `base_url` before the key is saved (#4526).
801 base_url: Option<String>,
802 },
803 /// Emitted by the `/provider` picker after the custom provider form is
804 /// completed. The handler persists a named OpenAI-compatible provider
805 /// table and switches to it without storing raw secrets.
806 ProviderPickerCustomProviderSubmitted {
807 provider_id: String,
808 base_url: String,
809 model: Option<String>,
810 api_key_env: Option<String>,
811 },
812 /// Emitted by provider/setup UI when xAI device-code OAuth is requested.
813 ProviderPickerXaiOAuthRequested,
814 /// Emitted only after the picker showed owner, exact path, and the full
815 /// read-only side-effect contract and the user explicitly confirmed it.
816 ProviderPickerExternalConsentConfirmed {
817 provider: crate::config::ApiProvider,
818 consent_provider: codewhale_config::ProviderKind,
819 source: codewhale_config::ExternalCredentialSource,
820 path: std::path::PathBuf,
821 },
822 /// One-step revocation from a provider row that currently has consent.
823 ProviderPickerExternalConsentRevoked {
824 provider: crate::config::ApiProvider,
825 },
826 /// Emitted by the `/provider` picker (the `M` action) to jump straight to
827 /// the `/model` picker pre-filtered to the highlighted provider (#3083).
828 ProviderPickerOpenModels {
829 provider: crate::config::ApiProvider,
830 provider_id: Option<String>,
831 },
832 /// Emitted by the `/mode` picker when the user chooses a mode.
833 ModeSelected {
834 mode: crate::tui::app::AppMode,
835 },
836 /// Emitted by the `/statusline` picker every time the user toggles an
837 /// item (live preview) and once more on Enter (final). The handler
838 /// updates `app.status_items` immediately and persists on `final_save`
839 /// so the footer animates without a write per keystroke.
840 StatusItemsUpdated {
841 items: Vec<crate::config::StatusItem>,
842 final_save: bool,
843 },
844 /// Emitted by the `/hotbar` setup wizard when the user saves the draft
845 /// bindings. The host updates live config state; disk persistence is
846 /// handled by the follow-up persistence slice.
847 HotbarSetupSaved {
848 bindings: Vec<codewhale_config::HotbarBindingToml>,
849 },
850 /// Emitted by the constitution-first setup shell when a staged setup-state
851 /// record should be committed atomically to `$CODEWHALE_HOME/setup_state.json`.
852 SetupStateCommitRequested {
853 state: codewhale_config::SetupState,
854 message: String,
855 },
856 /// Emitted by the constitution-first setup shell when accepting a guided
857 /// structured user-global constitution. The host commits the constitution
858 /// and matching setup-state record together.
859 SetupConstitutionCommitRequested {
860 constitution: codewhale_config::UserConstitution,
861 state: codewhale_config::SetupState,
862 message: String,
863 },
864 /// Emitted by the setup Constitution card (`A`, provider route ready) to
865 /// ask the user's first configured model to draft the constitution from
866 /// the guided answers plus an optional bounded own-words note. The host
867 /// performs the one-shot call, pushes the sanitized/bounded draft back into the wizard, and opens the
868 /// ratification preview; on any failure it reports why and leaves the
869 /// deterministic guided draft standing. Nothing is persisted by this
870 /// event — saving still goes through the ratify keypress and
871 /// [`SetupConstitutionCommitRequested`](Self::SetupConstitutionCommitRequested).
872 SetupConstitutionModelDraftRequested {
873 draft: crate::tui::setup::GuidedConstitutionDraft,
874 freeform_note: Option<String>,
875 locale: crate::localization::Locale,
876 },
877 /// Emitted by the fleet setup Review step (`m`) to ask the configured
878 /// model to draft the agent profile the wizard describes. The host
879 /// performs the one-shot call, pushes the sanitized/bounded draft back
880 /// into the wizard, and opens the rendered-TOML preview; on failure it
881 /// reports why and the manual authoring flow stands. Nothing is
882 /// persisted by this event.
883 FleetProfileModelDraftRequested {
884 role: String,
885 /// Target model for the worker: a concrete model id, or "inherit".
886 model: String,
887 /// Canonical provider id for a concrete cross-provider route pick, or
888 /// `None` for `inherit` (#4093). Carried so the model-drafted profile
889 /// keeps the picked provider instead of collapsing to an ambiguous,
890 /// provider-scoped profile — the exact bug #4093 fixes.
891 provider: Option<String>,
892 /// Canonical reasoning tier selected by the wizard, or `None` for
893 /// inherit (#4137). Carried with the async draft for the same reason
894 /// as `provider`: the ratified profile must preserve the operator's
895 /// explicit choice, not whatever the model echoed.
896 reasoning_effort: Option<String>,
897 locale: crate::localization::Locale,
898 },
899 /// Emitted by the `/fleet` roster view (`s` / Enter) to hand off to the
900 /// setup wizard for authoring or overriding a roster member. The roster
901 /// view itself never writes anything.
902 FleetRosterOpenSetupRequested {
903 /// Canonical Fleet role carried from the selected roster member so
904 /// setup can continue at model selection without asking twice.
905 role: String,
906 },
907 /// Open the live workers tab from the unified Fleet surface.
908 FleetRosterOpenWorkersRequested,
909
910 /// The roster asks the host to open the secondary named-Fleet switcher
911 /// (`/fleet fleets`). Editing stays on setup; this is pick/select only.
912 FleetRosterOpenFleetsRequested,
913
914 /// The Fleet list view asks the host to open a saved Fleet's detail view.
915 FleetListOpenDetailRequested {
916 name: String,
917 scope: crate::fleet::store::FleetScope,
918 },
919 /// A Fleet store mutation happened (select/save/delete/rename/copy).
920 /// The message is the exact receipt; the host refreshes roster state.
921 FleetStoreChanged {
922 message: String,
923 },
924 /// Emitted by the fleet setup Review step after the user previewed a
925 /// model-drafted profile and pressed the explicit ratify key. The host
926 /// renders TOML deterministically from the validated draft and persists it
927 /// atomically in the explicitly selected project or personal scope.
928 FleetProfileDraftCommitRequested {
929 draft: Box<crate::fleet::profile::FleetProfileDraft>,
930 scope: crate::fleet::profile::FleetProfileScope,
931 },
932 /// Emitted by the Fleet setup Model step when the user selects a route that
933 /// has structurally valid external-consent credentials but is not the
934 /// active session provider. The host performs a route-scoped validation
935 /// (minting the read capability only for this exact provider/source/path)
936 /// and records the result in the session health snapshot so the same row
937 /// becomes selectable on the next render. The parent session provider and
938 /// model are never changed.
939 FleetSetupExternalConsentActivationRequested {
940 provider_id: String,
941 model: String,
942 },
943 /// Emitted by the setup Runtime Posture card after the user has previewed
944 /// and confirmed an explicit preset/config diff.
945 SetupRuntimePresetApplyRequested {
946 preset: crate::tui::setup::SetupRuntimePreset,
947 state: codewhale_config::SetupState,
948 message: String,
949 },
950 /// Emitted by the setup Provider/Model readiness card to hand off to the
951 /// existing provider manager instead of duplicating provider auth UI.
952 SetupOpenProviderRequested,
953 /// Emitted by the setup Provider/Model readiness card to hand off to the
954 /// existing provider-qualified model route picker.
955 SetupOpenModelRequested,
956 /// Emitted by the setup Operate/Fleet readiness card to hand off to the
957 /// existing Fleet setup wizard without writing Fleet config itself.
958 SetupOpenFleetRequested,
959 /// Emitted by the setup Hotbar card to hand off to the existing Hotbar
960 /// setup wizard without rewriting bindings itself.
961 SetupOpenHotbarRequested,
962 /// Emitted by the setup Runtime Posture card to hand off to the existing
963 /// work-mode picker.
964 SetupOpenModeRequested,
965 /// Emitted by the setup Runtime Posture card to hand off to the existing
966 /// config view for approval/sandbox/network details.
967 SetupOpenConfigRequested,
968 /// Emitted by the `/hotbar` setup wizard when the user chooses "Disable
969 /// Hotbar". The host persists `hotbar = []` and hides the panel.
970 HotbarDisableRequested,
971 /// Emitted by the live-transcript overlay while in backtrack preview
972 /// mode (#133) when the user steps the highlighted user message with
973 /// Left or Right. The handler advances `app.backtrack`, refreshes the
974 /// overlay's `selected_idx`, and pins scroll near the new highlight.
975 BacktrackStep {
976 direction: crate::tui::backtrack::Direction,
977 },
978 /// Emitted by the live-transcript overlay when the user presses Enter
979 /// in backtrack preview mode (#133). The handler calls
980 /// `app.backtrack.confirm()`, trims `app.history`/`api_messages` to
981 /// the selected user message, populates the composer with the
982 /// dropped user text, and closes the overlay.
983 BacktrackConfirm,
984 /// Emitted by the live-transcript overlay when the user presses Esc
985 /// in backtrack preview mode (#133). The handler resets
986 /// `app.backtrack` and closes the overlay without trimming.
987 BacktrackCancel,
988 ContextMenuSelected {
989 action: ContextMenuAction,
990 },
991 /// Emitted by the pager (`c` / `y`) to copy its body to the system
992 /// clipboard. The host handler writes via `app.clipboard` and surfaces a
993 /// status message — modal views cannot reach `app` directly. `label` is
994 /// the noun shown in the success / failure status (e.g. "Pager content").
995 CopyToClipboard {
996 text: String,
997 label: String,
998 },
999 /// Emitted by the skills manager when the user confirms an install /
1000 /// import / update / remove / trust action. The host runs the mutation
1001 /// controller and rebuilds the open manager view.
1002 SkillMutationRequested {
1003 request: crate::skills::mutation::SkillMutationRequest,
1004 },
1005 /// Toggle owned-only vs compatible audit scan inside the skills manager.
1006 SkillsManagerToggleCompatible,
1007 }
1008
1009 #[derive(Debug, Clone)]
1010 pub enum ViewAction {
1011 None,
1012 Close,
1013 Emit(ViewEvent),
1014 EmitAndClose(ViewEvent),
1015 }
1016
1017 pub trait ModalView: std::any::Any {
1018 fn kind(&self) -> ModalKind;
1019 fn handle_key(&mut self, key: KeyEvent) -> ViewAction;
1020 /// Returns `true` if the modal consumed the paste; `false` to let the
1021 /// host route the text elsewhere (e.g. drop it because a modal is open,
1022 /// or insert it into the composer when no modal wants it). The default
1023 /// is `false` so modals that don't care about paste don't silently
1024 /// swallow Cmd-V.
1025 fn handle_paste(&mut self, _text: &str) -> bool {
1026 false
1027 }
1028
1029 fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction {
1030 ViewAction::None
1031 }
1032 fn render(&self, area: Rect, buf: &mut Buffer);
1033 /// The region this modal actually paints within the full frame `area`.
1034 ///
1035 /// Defaults to the whole frame, which is the legacy full-screen overlay
1036 /// behaviour every picker/menu still relies on. Inline modals (the
1037 /// approval prompt) override this to return a bottom-anchored band so the
1038 /// backdrop only dims their strip and the transcript above stays visible.
1039 /// The returned rect MUST match the region the modal renders into, or the
1040 /// dim and the painted content will disagree.
1041 fn occupied_region(&self, area: Rect) -> Rect {
1042 area
1043 }
1044 fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool {
1045 false
1046 }
1047 fn tick(&mut self) -> ViewAction {
1048 ViewAction::None
1049 }
1050 /// Erased downcast hook for views that need a typed reference back from
1051 /// the boxed trait object (e.g. the live transcript overlay needs `&mut`
1052 /// access from outside the trait so it can refresh its snapshot of the
1053 /// app's transcript state right before render).
1054 fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
1055 }
1056
1057 #[derive(Default)]
1058 pub struct ViewStack {
1059 views: Vec<Box<dyn ModalView>>,
1060 /// Focus-context texture prototype mode (#4823). `Off` by default, which
1061 /// keeps the render output byte-identical to the pre-prototype path.
1062 focus_texture: FocusTextureMode,
1063 /// Theme snapshot for the texture pass, set alongside the mode each
1064 /// frame. `None` (e.g. tests that never opt in) disables the texture.
1065 focus_texture_theme: Option<crate::palette::UiTheme>,
1066 }
1067
1068 impl ViewStack {
1069 pub fn new() -> Self {
1070 Self {
1071 views: Vec::new(),
1072 focus_texture: FocusTextureMode::Off,
1073 focus_texture_theme: None,
1074 }
1075 }
1076
1077 /// Set the focus-context texture mode and theme for subsequent renders
1078 /// (#4823 prototype). Called once per frame from the UI render path with
1079 /// the parsed setting; a plain enum/theme copy, no allocation.
1080 pub fn set_focus_texture(&mut self, mode: FocusTextureMode, theme: crate::palette::UiTheme) {
1081 self.focus_texture = mode;
1082 self.focus_texture_theme = Some(theme);
1083 }
1084
1085 pub fn is_empty(&self) -> bool {
1086 self.views.is_empty()
1087 }
1088
1089 pub fn top_kind(&self) -> Option<ModalKind> {
1090 self.views.last().map(|view| view.kind())
1091 }
1092
1093 pub fn contains_kind(&self, kind: ModalKind) -> bool {
1094 self.views.iter().any(|view| view.kind() == kind)
1095 }
1096
1097 /// Close the named view and any child modal opened above it. This keeps a
1098 /// shell-global toggle from stacking a duplicate parent behind its picker.
1099 pub fn pop_through_kind(&mut self, kind: ModalKind) -> bool {
1100 while let Some(view) = self.pop() {
1101 if view.kind() == kind {
1102 return true;
1103 }
1104 }
1105 false
1106 }
1107
1108 pub fn top_occupied_region(&self, area: Rect) -> Option<Rect> {
1109 self.views.last().map(|view| view.occupied_region(area))
1110 }
1111
1112 pub fn push<V: ModalView + 'static>(&mut self, view: V) {
1113 let kind = view.kind();
1114 self.views.push(Box::new(view));
1115 tracing::debug!(target: "codewhale_tui::view_stack", action = "push", kind = ?kind, depth = self.views.len(), "view pushed");
1116 }
1117
1118 /// Push an already-boxed view back onto the stack. Used by call sites
1119 /// that pop a view, mutate it externally, and need to restore it without
1120 /// the generic `push` re-boxing dance.
1121 pub fn push_boxed(&mut self, view: Box<dyn ModalView>) {
1122 let kind = view.kind();
1123 self.views.push(view);
1124 tracing::debug!(target: "codewhale_tui::view_stack", action = "push_boxed", kind = ?kind, depth = self.views.len(), "view pushed");
1125 }
1126
1127 pub fn pop(&mut self) -> Option<Box<dyn ModalView>> {
1128 let popped = self.views.pop();
1129 if let Some(view) = popped.as_ref() {
1130 tracing::debug!(target: "codewhale_tui::view_stack", action = "pop", kind = ?view.kind(), depth = self.views.len(), "view popped");
1131 }
1132 popped
1133 }
1134
1135 pub fn render(&self, area: Rect, buf: &mut Buffer) {
1136 // Focus-context texture prototype (#4823): runs over the already
1137 // rendered background BEFORE any backdrop or view paint, so the
1138 // focused modal is painted afterwards at full strength and the
1139 // texture can never overwrite it. `Off` (the default) leaves the
1140 // buffer untouched, keeping output byte-identical to the
1141 // pre-prototype path.
1142 if self.focus_texture != FocusTextureMode::Off
1143 && let (Some(focus), Some(theme)) =
1144 (self.top_occupied_region(area), self.focus_texture_theme)
1145 {
1146 crate::tui::focus_texture::apply_focus_texture(
1147 area,
1148 buf,
1149 focus,
1150 &theme,
1151 self.focus_texture,
1152 crate::tui::color_compat::ascii_safe_enabled(),
1153 );
1154 }
1155 // Dim each view's own occupied region rather than the whole frame, so
1156 // an inline modal (the approval prompt) leaves the transcript above it
1157 // visible instead of blacking out the screen. Full-screen modals keep
1158 // the default `occupied_region` of the entire frame, so their backdrop
1159 // is unchanged.
1160 for view in &self.views {
1161 let region = view.occupied_region(area);
1162 crate::tui::osc8::overlay_frame_links(region, Vec::new());
1163 render_modal_backdrop(region, buf);
1164 view.render(area, buf);
1165 }
1166 }
1167
1168 pub fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool {
1169 self.views
1170 .last_mut()
1171 .map(|view| view.update_subagents(agents))
1172 .unwrap_or(false)
1173 }
1174
1175 pub fn handle_key(&mut self, key: KeyEvent) -> Vec<ViewEvent> {
1176 let action = self
1177 .views
1178 .last_mut()
1179 .map(|view| view.handle_key(key))
1180 .unwrap_or(ViewAction::None);
1181 self.apply_action(action)
1182 }
1183
1184 pub fn handle_paste(&mut self, text: &str) -> bool {
1185 self.views
1186 .last_mut()
1187 .map(|view| view.handle_paste(text))
1188 .unwrap_or(false)
1189 }
1190
1191 pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec<ViewEvent> {
1192 let action = self
1193 .views
1194 .last_mut()
1195 .map(|view| view.handle_mouse(mouse))
1196 .unwrap_or(ViewAction::None);
1197 self.apply_action(action)
1198 }
1199
1200 pub fn tick(&mut self) -> Vec<ViewEvent> {
1201 let action = self
1202 .views
1203 .last_mut()
1204 .map(|view| view.tick())
1205 .unwrap_or(ViewAction::None);
1206 self.apply_action(action)
1207 }
1208
1209 fn apply_action(&mut self, action: ViewAction) -> Vec<ViewEvent> {
1210 let mut events = Vec::new();
1211 match action {
1212 ViewAction::None => {}
1213 ViewAction::Close => {
1214 if let Some(view) = self.views.pop() {
1215 tracing::debug!(target: "codewhale_tui::view_stack", action = "close", kind = ?view.kind(), depth = self.views.len(), "view closed via action");
1216 }
1217 }
1218 ViewAction::Emit(event) => {
1219 events.push(event);
1220 }
1221 ViewAction::EmitAndClose(event) => {
1222 events.push(event);
1223 if let Some(view) = self.views.pop() {
1224 tracing::debug!(target: "codewhale_tui::view_stack", action = "emit_and_close", kind = ?view.kind(), depth = self.views.len(), "view closed via action");
1225 }
1226 }
1227 }
1228 events
1229 }
1230 }
1231
1232 impl fmt::Debug for ViewStack {
1233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1234 f.debug_struct("ViewStack")
1235 .field("len", &self.views.len())
1236 .field("top", &self.top_kind())
1237 .finish()
1238 }
1239 }
1240
1241 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1242 enum ConfigScope {
1243 Session,
1244 Saved,
1245 }
1246
1247 impl ConfigScope {
1248 fn label(self, locale: Locale) -> Cow<'static, str> {
1249 tr(
1250 locale,
1251 match self {
1252 ConfigScope::Session => MessageId::ConfigScopeSession,
1253 ConfigScope::Saved => MessageId::ConfigScopeSaved,
1254 },
1255 )
1256 }
1257
1258 fn persist(self) -> bool {
1259 matches!(self, ConfigScope::Saved)
1260 }
1261 }
1262
1263 #[derive(Debug, Clone)]
1264 struct ConfigRow {
1265 section: ConfigSection,
1266 key: String,
1267 value: String,
1268 editable: bool,
1269 scope: ConfigScope,
1270 }
1271
1272 /// Editor behavior for one Settings entry. This is intentionally independent
1273 /// from where the value is stored: category/scope describe ownership, while
1274 /// kind determines the interaction and validation surface.
1275 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1276 enum SettingKind {
1277 Boolean,
1278 Choice,
1279 Integer,
1280 Text,
1281 Action,
1282 ReadOnly,
1283 }
1284
1285 #[derive(Debug, Clone)]
1286 struct SettingMeta {
1287 kind: SettingKind,
1288 category: ConfigSection,
1289 choices: Option<Vec<String>>,
1290 }
1291
1292 #[derive(Debug, Clone, Copy)]
1293 struct SettingsRegistry {
1294 provider: ApiProvider,
1295 }
1296
1297 impl SettingsRegistry {
1298 fn new(provider: ApiProvider) -> Self {
1299 Self { provider }
1300 }
1301
1302 fn meta(self, row: &ConfigRow) -> SettingMeta {
1303 let choices = config_choice_values(&row.key, self.provider);
1304 let kind = if !row.editable {
1305 SettingKind::ReadOnly
1306 } else if matches!(row.key.as_str(), "provider" | "model") {
1307 SettingKind::Action
1308 } else if config_boolean_key(&row.key) {
1309 SettingKind::Boolean
1310 } else if choices.is_some() {
1311 SettingKind::Choice
1312 } else if config_integer_key(&row.key) {
1313 SettingKind::Integer
1314 } else {
1315 SettingKind::Text
1316 };
1317 SettingMeta {
1318 kind,
1319 category: row.section,
1320 choices,
1321 }
1322 }
1323 }
1324
1325 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1326 enum ConfigSection {
1327 Provider,
1328 Model,
1329 Permissions,
1330 Network,
1331 Display,
1332 Composer,
1333 Sidebar,
1334 History,
1335 Mcp,
1336 Fleet,
1337 /// Workflow orchestration (`/workflow`). Kept out of Fleet: a Fleet is
1338 /// *who*, a Workflow is *what order* the work follows over it.
1339 Workflow,
1340 /// Session-scoped drivers such as `/goal`.
1341 Session,
1342 /// Explicitly legacy compatibility settings that are not a live choice —
1343 /// e.g. the DeepSeek-only `default_model` fallback (#4751).
1344 Legacy,
1345 Experimental,
1346 }
1347
1348 /// App-style settings tabs (v0.9.1 redesign). Groups fine-grained sections.
1349 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350 enum ConfigTab {
1351 General,
1352 Models,
1353 Permissions,
1354 Display,
1355 Advanced,
1356 }
1357
1358 impl ConfigTab {
1359 const ALL: [ConfigTab; 5] = [
1360 ConfigTab::General,
1361 ConfigTab::Models,
1362 ConfigTab::Permissions,
1363 ConfigTab::Display,
1364 ConfigTab::Advanced,
1365 ];
1366
1367 fn label(self) -> &'static str {
1368 match self {
1369 ConfigTab::General => "General",
1370 ConfigTab::Models => "Models",
1371 ConfigTab::Permissions => "Permissions",
1372 ConfigTab::Display => "Display",
1373 ConfigTab::Advanced => "Advanced",
1374 }
1375 }
1376
1377 fn contains(self, section: ConfigSection) -> bool {
1378 match self {
1379 ConfigTab::General => matches!(
1380 section,
1381 ConfigSection::Provider
1382 | ConfigSection::Network
1383 | ConfigSection::Composer
1384 | ConfigSection::Sidebar
1385 | ConfigSection::History
1386 ),
1387 ConfigTab::Models => matches!(section, ConfigSection::Model),
1388 ConfigTab::Permissions => matches!(section, ConfigSection::Permissions),
1389 ConfigTab::Display => matches!(section, ConfigSection::Display),
1390 ConfigTab::Advanced => matches!(
1391 section,
1392 ConfigSection::Mcp
1393 | ConfigSection::Fleet
1394 | ConfigSection::Workflow
1395 | ConfigSection::Session
1396 | ConfigSection::Legacy
1397 | ConfigSection::Experimental
1398 ),
1399 }
1400 }
1401
1402 fn for_section(section: ConfigSection) -> Self {
1403 Self::ALL
1404 .into_iter()
1405 .find(|tab| tab.contains(section))
1406 .unwrap_or(Self::General)
1407 }
1408
1409 fn next(self) -> Self {
1410 match self {
1411 ConfigTab::General => ConfigTab::Models,
1412 ConfigTab::Models => ConfigTab::Permissions,
1413 ConfigTab::Permissions => ConfigTab::Display,
1414 ConfigTab::Display => ConfigTab::Advanced,
1415 ConfigTab::Advanced => ConfigTab::General,
1416 }
1417 }
1418
1419 fn prev(self) -> Self {
1420 match self {
1421 ConfigTab::General => ConfigTab::Advanced,
1422 ConfigTab::Models => ConfigTab::General,
1423 ConfigTab::Permissions => ConfigTab::Models,
1424 ConfigTab::Display => ConfigTab::Permissions,
1425 ConfigTab::Advanced => ConfigTab::Display,
1426 }
1427 }
1428 }
1429
1430 impl ConfigSection {
1431 fn label(self, locale: Locale) -> Cow<'static, str> {
1432 tr(
1433 locale,
1434 match self {
1435 ConfigSection::Provider => MessageId::ConfigSectionProvider,
1436 ConfigSection::Model => MessageId::ConfigSectionModel,
1437 ConfigSection::Permissions => MessageId::ConfigSectionPermissions,
1438 ConfigSection::Network => MessageId::ConfigSectionNetwork,
1439 ConfigSection::Display => MessageId::ConfigSectionDisplay,
1440 ConfigSection::Composer => MessageId::ConfigSectionComposer,
1441 ConfigSection::Sidebar => MessageId::ConfigSectionSidebar,
1442 ConfigSection::History => MessageId::ConfigSectionHistory,
1443 ConfigSection::Mcp => MessageId::ConfigSectionMcp,
1444 ConfigSection::Fleet => MessageId::ConfigSectionFleet,
1445 ConfigSection::Workflow => MessageId::ConfigSectionWorkflow,
1446 ConfigSection::Session => MessageId::ConfigSectionSession,
1447 ConfigSection::Legacy => MessageId::ConfigSectionLegacy,
1448 ConfigSection::Experimental => MessageId::ConfigSectionExperimental,
1449 },
1450 )
1451 }
1452 }
1453
1454 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1455 enum ConfigListItem {
1456 Section(ConfigSection),
1457 Row(usize),
1458 }
1459
1460 #[derive(Debug, Clone)]
1461 struct ConfigEdit {
1462 key: String,
1463 original_value: String,
1464 buffer: Vec<char>,
1465 cursor: usize,
1466 select_all: bool,
1467 scope: ConfigScope,
1468 choices: Option<Vec<String>>,
1469 selected_choice: usize,
1470 }
1471
1472 pub struct ConfigView {
1473 rows: Vec<ConfigRow>,
1474 selected: usize,
1475 scroll: usize,
1476 editing: Option<ConfigEdit>,
1477 filter: String,
1478 status: Option<String>,
1479 locale: Locale,
1480 effective_cost_currency: String,
1481 effective_low_motion: bool,
1482 effective_fancy_animations: bool,
1483 last_visible_rows: Cell<usize>,
1484 /// Selection-anchored scroll actually used by the last render; keeps the
1485 /// panel scroll rail truthful when the stored scroll predates a resize.
1486 last_render_scroll: Cell<usize>,
1487 last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
1488 last_choice_hitboxes: RefCell<Vec<(u16, usize)>>,
1489 last_mouse_selected: Option<usize>,
1490 api_provider: ApiProvider,
1491 /// Category tab for the app-style settings shell (v0.9.1).
1492 active_tab: ConfigTab,
1493 }
1494
1495 const CONFIG_MIN_KEY_COLUMN_WIDTH: usize = 19;
1496 const CONFIG_VALUE_COLUMN_WIDTH: usize = 44;
1497 const CONFIG_MIN_VALUE_COLUMN_WIDTH: usize = 10;
1498 const CONFIG_SCOPE_COLUMN_WIDTH: usize = 7;
1499 const CONFIG_ROW_PREFIX_WIDTH: usize = 2;
1500 const CONFIG_COLUMN_GAPS_WIDTH: usize = 2;
1501
1502 impl ConfigView {
1503 pub fn new_for_app(app: &App) -> Self {
1504 let settings = Settings::load_persisted().unwrap_or_else(|_| Settings::default());
1505 let config = Config::load(app.config_path.clone(), app.config_profile.as_deref())
1506 .unwrap_or_default();
1507 let permission_control = config.approval_policy_control(
1508 app.config_path.as_deref(),
1509 app.config_profile.as_deref(),
1510 &app.workspace,
1511 );
1512 let saved_permission_row = match permission_control {
1513 ApprovalPolicyControl::Unset => ConfigRow {
1514 section: ConfigSection::Permissions,
1515 key: "permission_posture".to_string(),
1516 value: settings
1517 .permission_posture
1518 .as_deref()
1519 .unwrap_or("ask")
1520 .to_string(),
1521 editable: true,
1522 scope: ConfigScope::Saved,
1523 },
1524 ApprovalPolicyControl::RootConfig => ConfigRow {
1525 section: ConfigSection::Permissions,
1526 key: "approval_policy".to_string(),
1527 value: config
1528 .approval_policy
1529 .as_deref()
1530 .unwrap_or("ask")
1531 .to_string(),
1532 editable: permission_control.editable_root(),
1533 scope: ConfigScope::Saved,
1534 },
1535 source => ConfigRow {
1536 section: ConfigSection::Permissions,
1537 key: "managed_approval_policy".to_string(),
1538 value: format!(
1539 "{} · {}",
1540 app.approval_mode.permission_chip_label(),
1541 source.label()
1542 ),
1543 editable: false,
1544 scope: ConfigScope::Saved,
1545 },
1546 };
1547 let approval_session_editable = matches!(permission_control, ApprovalPolicyControl::Unset);
1548 let shell_control = config.allow_shell_control(
1549 app.config_path.as_deref(),
1550 app.config_profile.as_deref(),
1551 &app.workspace,
1552 );
1553 let shell_row = if shell_control.editable_root() {
1554 ConfigRow {
1555 section: ConfigSection::Permissions,
1556 key: "allow_shell".to_string(),
1557 value: app.allow_shell.to_string(),
1558 editable: true,
1559 scope: ConfigScope::Saved,
1560 }
1561 } else {
1562 ConfigRow {
1563 section: ConfigSection::Permissions,
1564 key: "managed_allow_shell".to_string(),
1565 value: format!("{} · {}", app.allow_shell, shell_control.label()),
1566 editable: false,
1567 scope: ConfigScope::Saved,
1568 }
1569 };
1570 let routing_model = if app.auto_model {
1571 app.last_effective_model
1572 .as_deref()
1573 .unwrap_or(app.model.as_str())
1574 } else {
1575 app.model.as_str()
1576 };
1577 let fast_model =
1578 crate::model_routing::provider_router_candidates(app.api_provider, routing_model)
1579 .cheap
1580 .unwrap_or_else(|| {
1581 if app.auto_model && app.last_effective_model.is_none() {
1582 "available after Auto selects a route".to_string()
1583 } else {
1584 "no known fast sibling".to_string()
1585 }
1586 });
1587 let mut rows = vec![
1588 ConfigRow {
1589 section: ConfigSection::Provider,
1590 key: "provider".to_string(),
1591 value: config_provider_row_value(app, &config),
1592 editable: true,
1593 scope: ConfigScope::Saved,
1594 },
1595 ConfigRow {
1596 section: ConfigSection::Provider,
1597 key: config_base_url_row_key(app.api_provider).to_string(),
1598 value: config_base_url_row_value(app),
1599 editable: true,
1600 scope: ConfigScope::Saved,
1601 },
1602 ConfigRow {
1603 section: ConfigSection::Provider,
1604 key: "context_window".to_string(),
1605 value: config
1606 .context_window_for_provider_config(app.api_provider)
1607 .map_or_else(|| "(not set)".to_string(), |tokens| tokens.to_string()),
1608 editable: false,
1609 scope: ConfigScope::Saved,
1610 },
1611 ConfigRow {
1612 section: ConfigSection::Provider,
1613 key: "effective_context_window".to_string(),
1614 value: format!(
1615 "{} tokens · {}",
1616 crate::route_budget::route_context_window_tokens(
1617 app.api_provider,
1618 app.effective_model_for_budget(),
1619 app.active_route_limits,
1620 ),
1621 app.active_context_window_source.label()
1622 ),
1623 editable: false,
1624 scope: ConfigScope::Session,
1625 },
1626 ConfigRow {
1627 section: ConfigSection::Model,
1628 key: "model".to_string(),
1629 value: format!(
1630 "{} / {}",
1631 app.api_provider.as_str(),
1632 app.model_display_label()
1633 ),
1634 editable: true,
1635 scope: ConfigScope::Saved,
1636 },
1637 ConfigRow {
1638 section: ConfigSection::Model,
1639 key: "fast_model".to_string(),
1640 value: fast_model,
1641 editable: false,
1642 scope: ConfigScope::Session,
1643 },
1644 // DeepSeek-only legacy fallback: hide on non-DeepSeek providers so
1645 // it is not misread as an active setting (#4717). Keep the field
1646 // and routing behavior; surface the row only for DeepSeek routes
1647 // (or when an explicit value is set and the operator needs to see it).
1648 // Built below after provider check so non-DeepSeek menus stay clean.
1649 ConfigRow {
1650 section: ConfigSection::Model,
1651 key: "reasoning_effort".to_string(),
1652 value: settings.reasoning_effort.as_deref().map_or_else(
1653 || tr(app.ui_locale, MessageId::ConfigDefaultReasoning).to_string(),
1654 |value| {
1655 crate::tui::app::ReasoningEffort::from_setting_for_provider(
1656 value,
1657 app.api_provider,
1658 )
1659 .as_setting_for_provider(app.api_provider)
1660 .to_string()
1661 },
1662 ),
1663 editable: true,
1664 scope: ConfigScope::Saved,
1665 },
1666 ConfigRow {
1667 section: ConfigSection::Permissions,
1668 key: "approval_mode".to_string(),
1669 value: app.approval_mode.permission_chip_label().to_string(),
1670 editable: approval_session_editable,
1671 scope: ConfigScope::Session,
1672 },
1673 saved_permission_row,
1674 ConfigRow {
1675 section: ConfigSection::Permissions,
1676 key: "default_mode".to_string(),
1677 value: settings.default_mode.clone(),
1678 editable: true,
1679 scope: ConfigScope::Saved,
1680 },
1681 shell_row,
1682 ConfigRow {
1683 section: ConfigSection::Network,
1684 key: "stream_chunk_timeout_secs".to_string(),
1685 value: app.stream_chunk_timeout_secs.to_string(),
1686 editable: true,
1687 scope: ConfigScope::Session,
1688 },
1689 ConfigRow {
1690 section: ConfigSection::Display,
1691 key: "theme".to_string(),
1692 value: settings.theme.clone(),
1693 editable: true,
1694 scope: ConfigScope::Saved,
1695 },
1696 ConfigRow {
1697 section: ConfigSection::Display,
1698 key: "locale".to_string(),
1699 value: settings.locale.clone(),
1700 editable: true,
1701 scope: ConfigScope::Saved,
1702 },
1703 ConfigRow {
1704 section: ConfigSection::Display,
1705 key: "background_color".to_string(),
1706 value: settings.background_color.clone().unwrap_or_else(|| {
1707 tr(app.ui_locale, MessageId::ConfigDefaultValue).to_string()
1708 }),
1709 editable: true,
1710 scope: ConfigScope::Saved,
1711 },
1712 ConfigRow {
1713 section: ConfigSection::Display,
1714 key: "ocean_treatment".to_string(),
1715 value: settings.ocean_treatment.clone(),
1716 editable: true,
1717 scope: ConfigScope::Saved,
1718 },
1719 ConfigRow {
1720 section: ConfigSection::Display,
1721 key: "focus_texture".to_string(),
1722 value: settings.focus_texture.clone(),
1723 editable: true,
1724 scope: ConfigScope::Saved,
1725 },
1726 ConfigRow {
1727 section: ConfigSection::Display,
1728 key: "calm_mode".to_string(),
1729 value: settings.calm_mode.to_string(),
1730 editable: true,
1731 scope: ConfigScope::Saved,
1732 },
1733 ConfigRow {
1734 section: ConfigSection::Display,
1735 key: "low_motion".to_string(),
1736 value: settings.low_motion.to_string(),
1737 editable: true,
1738 scope: ConfigScope::Saved,
1739 },
1740 ConfigRow {
1741 section: ConfigSection::Display,
1742 key: "fancy_animations".to_string(),
1743 value: settings.fancy_animations.to_string(),
1744 editable: true,
1745 scope: ConfigScope::Saved,
1746 },
1747 ConfigRow {
1748 section: ConfigSection::Display,
1749 key: "launch_screen".to_string(),
1750 value: settings.launch_screen.to_string(),
1751 editable: true,
1752 scope: ConfigScope::Saved,
1753 },
1754 ConfigRow {
1755 section: ConfigSection::Display,
1756 key: "show_thinking".to_string(),
1757 value: settings.show_thinking.to_string(),
1758 editable: true,
1759 scope: ConfigScope::Saved,
1760 },
1761 ConfigRow {
1762 section: ConfigSection::Display,
1763 key: "thinking_default_expanded".to_string(),
1764 value: settings.thinking_default_expanded.to_string(),
1765 editable: true,
1766 scope: ConfigScope::Saved,
1767 },
1768 ConfigRow {
1769 section: ConfigSection::Display,
1770 key: "thinking_highlight".to_string(),
1771 value: settings.thinking_highlight.to_string(),
1772 editable: true,
1773 scope: ConfigScope::Saved,
1774 },
1775 ConfigRow {
1776 section: ConfigSection::Display,
1777 key: "show_tool_details".to_string(),
1778 value: settings.show_tool_details.to_string(),
1779 editable: true,
1780 scope: ConfigScope::Saved,
1781 },
1782 ConfigRow {
1783 section: ConfigSection::Display,
1784 key: "inline_diffs".to_string(),
1785 value: settings.inline_diffs.clone(),
1786 editable: true,
1787 scope: ConfigScope::Saved,
1788 },
1789 ConfigRow {
1790 section: ConfigSection::Display,
1791 key: "status_indicator".to_string(),
1792 value: settings.status_indicator.clone(),
1793 editable: true,
1794 scope: ConfigScope::Saved,
1795 },
1796 ConfigRow {
1797 section: ConfigSection::Display,
1798 key: "synchronized_output".to_string(),
1799 value: settings.synchronized_output.clone(),
1800 editable: true,
1801 scope: ConfigScope::Saved,
1802 },
1803 ConfigRow {
1804 section: ConfigSection::Display,
1805 key: "cost_currency".to_string(),
1806 value: settings.cost_currency.clone(),
1807 editable: true,
1808 scope: ConfigScope::Saved,
1809 },
1810 ConfigRow {
1811 section: ConfigSection::Display,
1812 key: "transcript_spacing".to_string(),
1813 value: settings.transcript_spacing.clone(),
1814 editable: true,
1815 scope: ConfigScope::Saved,
1816 },
1817 ConfigRow {
1818 section: ConfigSection::Display,
1819 key: "tool_collapse".to_string(),
1820 value: settings.tool_collapse_mode.clone(),
1821 editable: true,
1822 scope: ConfigScope::Saved,
1823 },
1824 ConfigRow {
1825 section: ConfigSection::Composer,
1826 key: "composer_density".to_string(),
1827 value: settings.composer_density.clone(),
1828 editable: true,
1829 scope: ConfigScope::Saved,
1830 },
1831 ConfigRow {
1832 section: ConfigSection::Composer,
1833 key: "composer_border".to_string(),
1834 value: settings.composer_border.to_string(),
1835 editable: true,
1836 scope: ConfigScope::Saved,
1837 },
1838 ConfigRow {
1839 section: ConfigSection::Composer,
1840 key: "composer_vim_mode".to_string(),
1841 value: settings.composer_vim_mode.clone(),
1842 editable: true,
1843 scope: ConfigScope::Saved,
1844 },
1845 ConfigRow {
1846 section: ConfigSection::Composer,
1847 key: "bracketed_paste".to_string(),
1848 value: settings.bracketed_paste.to_string(),
1849 editable: true,
1850 scope: ConfigScope::Saved,
1851 },
1852 ConfigRow {
1853 section: ConfigSection::Composer,
1854 key: "paste_burst_detection".to_string(),
1855 value: settings.paste_burst_detection.to_string(),
1856 editable: true,
1857 scope: ConfigScope::Saved,
1858 },
1859 ConfigRow {
1860 section: ConfigSection::Composer,
1861 key: "mention_menu_limit".to_string(),
1862 value: settings.mention_menu_limit.to_string(),
1863 editable: true,
1864 scope: ConfigScope::Saved,
1865 },
1866 ConfigRow {
1867 section: ConfigSection::Composer,
1868 key: "mention_menu_behavior".to_string(),
1869 value: settings.mention_menu_behavior.clone(),
1870 editable: true,
1871 scope: ConfigScope::Saved,
1872 },
1873 ConfigRow {
1874 section: ConfigSection::Composer,
1875 key: "mention_walk_depth".to_string(),
1876 value: settings.mention_walk_depth.to_string(),
1877 editable: true,
1878 scope: ConfigScope::Saved,
1879 },
1880 ConfigRow {
1881 section: ConfigSection::Composer,
1882 key: "workspace_follow_symlinks".to_string(),
1883 value: settings.workspace_follow_symlinks.to_string(),
1884 editable: true,
1885 scope: ConfigScope::Saved,
1886 },
1887 ConfigRow {
1888 section: ConfigSection::Sidebar,
1889 key: "work_surface_placement".to_string(),
1890 value: settings.work_surface_placement.clone(),
1891 editable: true,
1892 scope: ConfigScope::Saved,
1893 },
1894 ConfigRow {
1895 section: ConfigSection::Sidebar,
1896 key: "work_surface_top_height".to_string(),
1897 value: settings.work_surface_top_height.to_string(),
1898 editable: true,
1899 scope: ConfigScope::Saved,
1900 },
1901 ConfigRow {
1902 section: ConfigSection::Sidebar,
1903 key: "work_surface_side_width".to_string(),
1904 value: settings.work_surface_side_width.to_string(),
1905 editable: true,
1906 scope: ConfigScope::Saved,
1907 },
1908 ConfigRow {
1909 section: ConfigSection::Sidebar,
1910 key: "rail_panel".to_string(),
1911 value: settings.rail_panel.clone(),
1912 editable: true,
1913 scope: ConfigScope::Saved,
1914 },
1915 ConfigRow {
1916 section: ConfigSection::Sidebar,
1917 key: "context_panel".to_string(),
1918 value: settings.context_panel.to_string(),
1919 editable: true,
1920 scope: ConfigScope::Saved,
1921 },
1922 ConfigRow {
1923 section: ConfigSection::Sidebar,
1924 key: "sessions_rail".to_string(),
1925 value: settings.sessions_rail.to_string(),
1926 editable: true,
1927 scope: ConfigScope::Saved,
1928 },
1929 // Read at startup by `main`, not held on `App`, so the row reflects
1930 // the persisted value rather than a live field (#2934).
1931 ConfigRow {
1932 section: ConfigSection::Sidebar,
1933 key: "session_auto_resume".to_string(),
1934 value: settings.session_auto_resume.to_string(),
1935 editable: true,
1936 scope: ConfigScope::Saved,
1937 },
1938 ConfigRow {
1939 section: ConfigSection::History,
1940 key: "auto_compact".to_string(),
1941 value: settings.auto_compact.to_string(),
1942 editable: true,
1943 scope: ConfigScope::Saved,
1944 },
1945 ConfigRow {
1946 section: ConfigSection::History,
1947 key: "auto_compact_threshold_percent".to_string(),
1948 value: format!("{:.0}", settings.auto_compact_threshold_percent),
1949 editable: true,
1950 scope: ConfigScope::Saved,
1951 },
1952 ConfigRow {
1953 section: ConfigSection::History,
1954 key: "effective_auto_compact".to_string(),
1955 value: format!(
1956 "{} · {:.0}% · {} tokens",
1957 if app.auto_compact { "on" } else { "off" },
1958 app.auto_compact_threshold_percent,
1959 app.compact_threshold
1960 ),
1961 editable: false,
1962 scope: ConfigScope::Session,
1963 },
1964 ConfigRow {
1965 section: ConfigSection::History,
1966 key: "max_history".to_string(),
1967 value: settings.max_input_history.to_string(),
1968 editable: true,
1969 scope: ConfigScope::Saved,
1970 },
1971 ConfigRow {
1972 section: ConfigSection::Mcp,
1973 key: "mcp_config_path".to_string(),
1974 value: app.mcp_config_path.display().to_string(),
1975 editable: true,
1976 scope: ConfigScope::Saved,
1977 },
1978 ConfigRow {
1979 section: ConfigSection::Fleet,
1980 key: "fleet.exec.max_spawn_depth".to_string(),
1981 value: config
1982 .fleet
1983 .as_ref()
1984 .map(|fleet| fleet.exec.max_spawn_depth)
1985 .unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth)
1986 .to_string(),
1987 editable: false,
1988 scope: ConfigScope::Saved,
1989 },
1990 ];
1991 // #4717: only show the DeepSeek-only fallback model row when the active
1992 // provider is a DeepSeek route (or an explicit value is set, so operators
1993 // can still see/clear a leftover). Non-DeepSeek providers use
1994 // provider-scoped models; the legacy row is inert there.
1995 let show_deepseek_fallback = matches!(
1996 app.api_provider,
1997 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
1998 ) || settings.default_model.is_some();
1999 if show_deepseek_fallback {
2000 // #4751: an inert DeepSeek-only compatibility field is not a model
2001 // choice and never a Fleet choice — exact-Fleet users switch
2002 // Fleets, not fallback models. Keep the persisted `default_model`
2003 // key (the runtime still reads it) but present it in the explicitly
2004 // Legacy section at the end, not among live Model settings.
2005 rows.push(ConfigRow {
2006 section: ConfigSection::Legacy,
2007 key: "default_model".to_string(),
2008 value: settings
2009 .default_model
2010 .as_deref()
2011 .unwrap_or(&*tr(app.ui_locale, MessageId::ConfigDefaultValue))
2012 .to_string(),
2013 editable: false,
2014 scope: ConfigScope::Saved,
2015 });
2016 }
2017 let external_status_rows = [ApiProvider::OpenaiCodex, ApiProvider::Xai]
2018 .into_iter()
2019 .filter_map(|provider| {
2020 config
2021 .external_credential_consent_status(provider)
2022 .map(|status| {
2023 let state = if status.route_state == "active" {
2024 tr(app.ui_locale, MessageId::CtxInspActive)
2025 } else {
2026 tr(app.ui_locale, MessageId::ProviderExternalDormant)
2027 };
2028 let scope = tr(app.ui_locale, MessageId::ProviderExternalDetailScope)
2029 .replace("{access}", status.access.as_str())
2030 .replace("{provider}", &status.provider)
2031 .replace("{source}", status.source.as_str())
2032 .replace("{version}", &status.consent_version.to_string())
2033 .replace("{state}", &state);
2034 let owner_path = tr(app.ui_locale, MessageId::ProviderExternalOwnerPath)
2035 .replace("{owner}", status.owner)
2036 .replace("{path}", &codewhale_config::quote_os_path(&status.path));
2037 let pinned_warning = status.ambient_path_changed.then(|| {
2038 tr(app.ui_locale, MessageId::ProviderExternalPinnedPathWarning)
2039 .replace("{owner}", status.owner)
2040 .replace("{path}", &codewhale_config::quote_os_path(&status.path))
2041 });
2042 let semantics = match status.access {
2043 codewhale_config::ExternalCredentialAccess::Disabled => {
2044 tr(app.ui_locale, MessageId::ProviderExternalDisabledDetail)
2045 }
2046 codewhale_config::ExternalCredentialAccess::ReadOnly => {
2047 tr(app.ui_locale, MessageId::ProviderExternalReadOnlySemantics)
2048 }
2049 codewhale_config::ExternalCredentialAccess::Managed => {
2050 tr(app.ui_locale, MessageId::ProviderExternalManagedDetail)
2051 }
2052 };
2053 let semantics_revoke =
2054 tr(app.ui_locale, MessageId::ProviderExternalSemanticsRevoke)
2055 .replace("{semantics}", &semantics)
2056 .replace("{revoke}", &status.revoke_command);
2057 ConfigRow {
2058 section: ConfigSection::Provider,
2059 key: format!("external_credentials.{}", provider.as_str()),
2060 value: match pinned_warning {
2061 Some(warning) => format!(
2062 "{scope} · {owner_path} · {warning} · {semantics_revoke}"
2063 ),
2064 None => format!("{scope} · {owner_path} · {semantics_revoke}"),
2065 },
2066 editable: false,
2067 scope: ConfigScope::Saved,
2068 }
2069 })
2070 });
2071 rows.splice(2..2, external_status_rows);
2072 rows.extend(experimental_config_rows(&config));
2073
2074 Self {
2075 rows,
2076 selected: 0,
2077 scroll: 0,
2078 editing: None,
2079 filter: String::new(),
2080 status: None,
2081 locale: app.ui_locale,
2082 effective_cost_currency: cost_currency_config_value(app),
2083 effective_low_motion: app.low_motion,
2084 effective_fancy_animations: app.fancy_animations,
2085 last_visible_rows: Cell::new(0),
2086 last_render_scroll: Cell::new(0),
2087 last_row_hitboxes: RefCell::new(Vec::new()),
2088 last_choice_hitboxes: RefCell::new(Vec::new()),
2089 last_mouse_selected: None,
2090 api_provider: app.api_provider,
2091 active_tab: ConfigTab::General,
2092 }
2093 }
2094
2095 fn tr(&self, id: MessageId) -> Cow<'static, str> {
2096 tr(self.locale, id)
2097 }
2098
2099 /// Keep the user's place when the host rebuilds this view after applying
2100 /// a setting to the live app.
2101 pub(crate) fn focus_key(&mut self, key: &str) {
2102 if let Some(index) = self.rows.iter().position(|row| row.key == key) {
2103 self.active_tab = ConfigTab::for_section(self.rows[index].section);
2104 self.selected = index;
2105 self.adjust_scroll(self.visible_rows_cached());
2106 }
2107 }
2108
2109 /// Snapshot the active search so live config updates can rebuild the
2110 /// modal without making the user's filtered result set jump away.
2111 pub(crate) fn filter_query(&self) -> &str {
2112 &self.filter
2113 }
2114
2115 pub(crate) fn restore_filter(&mut self, filter: String) {
2116 self.update_filter(|current| *current = filter);
2117 }
2118
2119 fn visible_rows_cached(&self) -> usize {
2120 let cached = self.last_visible_rows.get();
2121 if cached == 0 { 8 } else { cached }
2122 }
2123
2124 fn row_matches_filter(&self, row: &ConfigRow) -> bool {
2125 let filter = self.filter.trim().to_lowercase();
2126 if filter.is_empty() {
2127 return true;
2128 }
2129
2130 let meta = SettingsRegistry::new(self.api_provider).meta(row);
2131 let section = meta.category.label(self.locale).to_lowercase();
2132 let section_en = meta.category.label(Locale::En).to_lowercase();
2133 let label = config_label_for_key_for_locale(self.locale, &row.key).to_lowercase();
2134 let key = row.key.to_lowercase();
2135 let raw_value = row.value.to_lowercase();
2136 let value = self.row_display_value(row).to_lowercase();
2137 let scope = row.scope.label(self.locale).to_lowercase();
2138 let scope_en = row.scope.label(Locale::En).to_lowercase();
2139 let hint = config_hint_for_key(&row.key).to_lowercase();
2140
2141 filter.split_whitespace().all(|term| {
2142 section.contains(term)
2143 || section_en.contains(term)
2144 || label.contains(term)
2145 || key.contains(term)
2146 || raw_value.contains(term)
2147 || value.contains(term)
2148 || scope.contains(term)
2149 || scope_en.contains(term)
2150 || hint.contains(term)
2151 })
2152 }
2153
2154 fn matching_row_indices(&self) -> Vec<usize> {
2155 let filtering = !self.filter.is_empty();
2156 self.rows
2157 .iter()
2158 .enumerate()
2159 .filter_map(|(idx, row)| {
2160 (self.row_matches_filter(row)
2161 && (filtering || self.active_tab.contains(row.section)))
2162 .then_some(idx)
2163 })
2164 .collect()
2165 }
2166
2167 fn visible_items(&self) -> Vec<ConfigListItem> {
2168 let mut items = Vec::new();
2169 let mut current_section = None;
2170 let filtering = !self.filter.is_empty();
2171
2172 for (idx, row) in self.rows.iter().enumerate() {
2173 if !self.row_matches_filter(row) {
2174 continue;
2175 }
2176 // Category tabs filter sections unless the user is searching.
2177 if !filtering && !self.active_tab.contains(row.section) {
2178 continue;
2179 }
2180
2181 if current_section != Some(row.section) {
2182 current_section = Some(row.section);
2183 items.push(ConfigListItem::Section(row.section));
2184 }
2185 items.push(ConfigListItem::Row(idx));
2186 }
2187
2188 items
2189 }
2190
2191 fn select_first_visible_row(&mut self) {
2192 if let Some(idx) = self
2193 .visible_items()
2194 .into_iter()
2195 .find_map(|item| match item {
2196 ConfigListItem::Row(i) => Some(i),
2197 ConfigListItem::Section(_) => None,
2198 })
2199 {
2200 self.selected = idx;
2201 self.scroll = 0;
2202 }
2203 }
2204
2205 fn setting_description(key: &str) -> &'static str {
2206 match key {
2207 "provider" => "Active model provider for this session. Scope: saved route.",
2208 "model" => "Model id for the active provider. Scope: saved / session route.",
2209 "approval_mode" => {
2210 "Session approval posture (ask / auto). Separate from filesystem sandbox."
2211 }
2212 "permission_posture" | "approval_policy" => {
2213 "Saved permission posture. Independent of filesystem sandbox (fs:* chrome)."
2214 }
2215 "allow_shell" => "Whether shell tools may run. Separate from approval posture.",
2216 "sandbox_mode" => "Filesystem sandbox: none / workspace-write / read-only.",
2217 "theme" => "Named UI theme. Scope: saved settings.",
2218 "low_motion" => "Reduce motion: freezes pulses, keeps static highlights.",
2219 "calm_mode" => "Quieter chrome and denser transcript.",
2220 "ocean_treatment" => "Underwater field treatment (ombre / flat / terminal).",
2221 "locale" => "UI language. Scope: saved settings.",
2222 "reasoning_effort" => "Default reasoning effort for capable models.",
2223 "default_mode" => "Startup mode (agent / plan / operate).",
2224 _ => "Enter change · R reset · Esc close. Scope shown on the row badge.",
2225 }
2226 }
2227
2228 fn key_column_width(&self) -> usize {
2229 self.rows
2230 .iter()
2231 .map(|row| {
2232 let label = config_label_for_key_for_locale(self.locale, &row.key);
2233 UnicodeWidthStr::width(label.as_str())
2234 })
2235 .max()
2236 .unwrap_or(CONFIG_MIN_KEY_COLUMN_WIDTH)
2237 .max(CONFIG_MIN_KEY_COLUMN_WIDTH)
2238 }
2239
2240 fn table_column_widths(&self, content_width: usize) -> (usize, usize, usize) {
2241 let fixed_width =
2242 CONFIG_ROW_PREFIX_WIDTH + CONFIG_COLUMN_GAPS_WIDTH + CONFIG_SCOPE_COLUMN_WIDTH;
2243 let key_value_width = content_width.saturating_sub(fixed_width);
2244 let desired_key_width = self.key_column_width();
2245
2246 if key_value_width == 0 {
2247 return (0, 0, CONFIG_SCOPE_COLUMN_WIDTH);
2248 }
2249
2250 let minimum_key_width = CONFIG_MIN_KEY_COLUMN_WIDTH.min(key_value_width);
2251 let key_width = desired_key_width
2252 .min(key_value_width.saturating_sub(CONFIG_MIN_VALUE_COLUMN_WIDTH))
2253 .max(minimum_key_width);
2254 let value_width = key_value_width
2255 .saturating_sub(key_width)
2256 .min(CONFIG_VALUE_COLUMN_WIDTH);
2257
2258 (key_width, value_width, CONFIG_SCOPE_COLUMN_WIDTH)
2259 }
2260
2261 fn selected_row_index(&self) -> Option<usize> {
2262 let selected = self.selected;
2263 self.matching_row_indices()
2264 .into_iter()
2265 .any(|idx| idx == selected)
2266 .then_some(selected)
2267 }
2268
2269 fn selected_display_position(&self, items: &[ConfigListItem]) -> Option<usize> {
2270 items
2271 .iter()
2272 .position(|item| matches!(item, ConfigListItem::Row(idx) if *idx == self.selected))
2273 }
2274
2275 fn sync_selection_to_filter(&mut self) {
2276 let matches = self.matching_row_indices();
2277 if matches.is_empty() {
2278 self.selected = 0;
2279 self.scroll = 0;
2280 return;
2281 }
2282
2283 if !matches.contains(&self.selected) {
2284 self.selected = matches[0];
2285 }
2286 }
2287
2288 fn update_filter(&mut self, update: impl FnOnce(&mut String)) {
2289 update(&mut self.filter);
2290 self.status = None;
2291 self.sync_selection_to_filter();
2292 self.adjust_scroll(self.visible_rows_cached());
2293 }
2294
2295 fn adjust_scroll(&mut self, visible_rows: usize) {
2296 self.sync_selection_to_filter();
2297
2298 let items = self.visible_items();
2299 if items.is_empty() {
2300 self.scroll = 0;
2301 return;
2302 }
2303
2304 let visible_rows = visible_rows.max(1);
2305 let max_scroll = items.len().saturating_sub(visible_rows);
2306 self.scroll = self.scroll.min(max_scroll);
2307
2308 let Some(selected_pos) = self.selected_display_position(&items) else {
2309 self.scroll = 0;
2310 return;
2311 };
2312
2313 if selected_pos < self.scroll {
2314 self.scroll = selected_pos;
2315 }
2316
2317 if selected_pos >= self.scroll + visible_rows {
2318 self.scroll = selected_pos.saturating_sub(visible_rows.saturating_sub(1));
2319 }
2320 }
2321
2322 fn move_selection(&mut self, delta: isize) {
2323 let matches = self.matching_row_indices();
2324 if matches.is_empty() {
2325 return;
2326 }
2327
2328 let current = matches
2329 .iter()
2330 .position(|idx| *idx == self.selected)
2331 .unwrap_or(0);
2332 let next = crate::tui::list_nav::wrap_index(current, matches.len(), delta);
2333
2334 self.selected = matches[next];
2335 let visible_rows = self.visible_rows_cached();
2336 self.adjust_scroll(visible_rows);
2337 }
2338
2339 fn toggle_selected_boolean(&self) -> Option<ViewAction> {
2340 let row = self.rows.get(self.selected_row_index()?)?;
2341 if SettingsRegistry::new(self.api_provider).meta(row).kind != SettingKind::Boolean {
2342 return None;
2343 }
2344 let value = if canonical_config_choice(&row.key, &row.value) == "true" {
2345 "false"
2346 } else {
2347 "true"
2348 };
2349 Some(ViewAction::Emit(ViewEvent::ConfigUpdated {
2350 key: row.key.clone(),
2351 value: value.to_string(),
2352 persist: row.scope.persist(),
2353 }))
2354 }
2355
2356 fn open_selected_catalog_picker(&self) -> Option<ViewAction> {
2357 let row = self.rows.get(self.selected_row_index()?)?;
2358 let command = match row.key.as_str() {
2359 "provider" if row.editable => "/provider",
2360 "model" if row.editable => "/model",
2361 _ => return None,
2362 };
2363 Some(ViewAction::Emit(ViewEvent::CommandPaletteSelected {
2364 action: CommandPaletteAction::ExecuteCommand {
2365 command: command.to_string(),
2366 },
2367 }))
2368 }
2369
2370 fn move_choice(&mut self, delta: isize) {
2371 let Some(edit) = self.editing.as_mut() else {
2372 return;
2373 };
2374 let Some(choices) = edit.choices.as_ref() else {
2375 return;
2376 };
2377 let max = choices.len().saturating_sub(1);
2378 edit.selected_choice = if delta.is_negative() {
2379 edit.selected_choice.saturating_sub(delta.unsigned_abs())
2380 } else {
2381 (edit.selected_choice + delta as usize).min(max)
2382 };
2383 }
2384
2385 fn handle_choice_key(&mut self, key: KeyEvent) -> ViewAction {
2386 match key.code {
2387 KeyCode::Esc => {
2388 self.editing = None;
2389 self.status = Some(self.tr(MessageId::ConfigEditCancelled).to_string());
2390 ViewAction::None
2391 }
2392 KeyCode::Enter => {
2393 let Some(edit) = self.editing.take() else {
2394 return ViewAction::None;
2395 };
2396 let Some(value) = edit
2397 .choices
2398 .as_ref()
2399 .and_then(|choices| choices.get(edit.selected_choice))
2400 .cloned()
2401 else {
2402 return ViewAction::None;
2403 };
2404 ViewAction::Emit(ViewEvent::ConfigUpdated {
2405 key: edit.key,
2406 value,
2407 persist: edit.scope.persist(),
2408 })
2409 }
2410 KeyCode::Up | KeyCode::Left | KeyCode::Char('k') => {
2411 self.move_choice(-1);
2412 ViewAction::None
2413 }
2414 KeyCode::Down | KeyCode::Right | KeyCode::Char('j') => {
2415 self.move_choice(1);
2416 ViewAction::None
2417 }
2418 KeyCode::PageUp => {
2419 self.move_choice(-5);
2420 ViewAction::None
2421 }
2422 KeyCode::PageDown => {
2423 self.move_choice(5);
2424 ViewAction::None
2425 }
2426 KeyCode::Home => {
2427 if let Some(edit) = self.editing.as_mut() {
2428 edit.selected_choice = 0;
2429 }
2430 ViewAction::None
2431 }
2432 KeyCode::End => {
2433 if let Some(edit) = self.editing.as_mut()
2434 && let Some(choices) = edit.choices.as_ref()
2435 {
2436 edit.selected_choice = choices.len().saturating_sub(1);
2437 }
2438 ViewAction::None
2439 }
2440 KeyCode::Char(digit @ '1'..='9') => {
2441 if let Some(edit) = self.editing.as_mut()
2442 && let Some(choices) = edit.choices.as_ref()
2443 {
2444 let index = digit as usize - '1' as usize;
2445 if index < choices.len() {
2446 edit.selected_choice = index;
2447 }
2448 }
2449 ViewAction::None
2450 }
2451 KeyCode::Char(' ') => {
2452 self.move_choice(1);
2453 ViewAction::None
2454 }
2455 _ => ViewAction::None,
2456 }
2457 }
2458
2459 fn handle_editing_key(&mut self, key: KeyEvent) -> ViewAction {
2460 if self
2461 .editing
2462 .as_ref()
2463 .is_some_and(|edit| edit.choices.is_some())
2464 {
2465 return self.handle_choice_key(key);
2466 }
2467 match key.code {
2468 KeyCode::Esc => {
2469 self.editing = None;
2470 self.status = Some(self.tr(MessageId::ConfigEditCancelled).to_string());
2471 ViewAction::None
2472 }
2473 KeyCode::Enter => {
2474 let Some(edit) = self.editing.take() else {
2475 return ViewAction::None;
2476 };
2477 let submitted = edit.buffer.iter().collect::<String>();
2478 let value = submitted.trim().to_string();
2479 ViewAction::Emit(ViewEvent::ConfigUpdated {
2480 key: edit.key,
2481 value,
2482 persist: edit.scope.persist(),
2483 })
2484 }
2485 KeyCode::Backspace => {
2486 if let Some(edit) = self.editing.as_mut() {
2487 if edit.select_all {
2488 edit.buffer.clear();
2489 edit.cursor = 0;
2490 edit.select_all = false;
2491 } else if edit.cursor > 0 {
2492 edit.cursor = edit.cursor.saturating_sub(1);
2493 edit.buffer.remove(edit.cursor);
2494 }
2495 }
2496 ViewAction::None
2497 }
2498 KeyCode::Delete => {
2499 if let Some(edit) = self.editing.as_mut() {
2500 if edit.select_all {
2501 edit.buffer.clear();
2502 edit.cursor = 0;
2503 edit.select_all = false;
2504 } else if edit.cursor < edit.buffer.len() {
2505 edit.buffer.remove(edit.cursor);
2506 }
2507 }
2508 ViewAction::None
2509 }
2510 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2511 if let Some(edit) = self.editing.as_mut() {
2512 edit.buffer.clear();
2513 edit.cursor = 0;
2514 edit.select_all = false;
2515 }
2516 ViewAction::None
2517 }
2518 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2519 if let Some(edit) = self.editing.as_mut() {
2520 edit.cursor = edit.buffer.len();
2521 edit.select_all = true;
2522 }
2523 ViewAction::None
2524 }
2525 KeyCode::Left => {
2526 if let Some(edit) = self.editing.as_mut() {
2527 if edit.select_all {
2528 edit.cursor = 0;
2529 edit.select_all = false;
2530 } else {
2531 edit.cursor = edit.cursor.saturating_sub(1);
2532 }
2533 }
2534 ViewAction::None
2535 }
2536 KeyCode::Right => {
2537 if let Some(edit) = self.editing.as_mut() {
2538 if edit.select_all {
2539 edit.cursor = edit.buffer.len();
2540 edit.select_all = false;
2541 } else {
2542 edit.cursor = (edit.cursor + 1).min(edit.buffer.len());
2543 }
2544 }
2545 ViewAction::None
2546 }
2547 KeyCode::Home => {
2548 if let Some(edit) = self.editing.as_mut() {
2549 edit.cursor = 0;
2550 edit.select_all = false;
2551 }
2552 ViewAction::None
2553 }
2554 KeyCode::End => {
2555 if let Some(edit) = self.editing.as_mut() {
2556 edit.cursor = edit.buffer.len();
2557 edit.select_all = false;
2558 }
2559 ViewAction::None
2560 }
2561 KeyCode::Char(ch)
2562 if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() =>
2563 {
2564 if let Some(edit) = self.editing.as_mut() {
2565 if edit.select_all {
2566 edit.buffer.clear();
2567 edit.cursor = 0;
2568 edit.select_all = false;
2569 }
2570 edit.buffer.insert(edit.cursor, ch);
2571 edit.cursor += 1;
2572 }
2573 ViewAction::None
2574 }
2575 _ => ViewAction::None,
2576 }
2577 }
2578
2579 fn start_edit(&mut self) {
2580 let Some(row_idx) = self.selected_row_index() else {
2581 return;
2582 };
2583 let Some(row) = self.rows.get(row_idx) else {
2584 return;
2585 };
2586 let key = row.key.clone();
2587 let original_value = row.value.clone();
2588 let initial_value = match config_default_placeholder_message(&key) {
2589 Some(message_id)
2590 if original_value == tr(self.locale, message_id)
2591 || original_value == tr(Locale::En, message_id) =>
2592 {
2593 String::new()
2594 }
2595 _ => original_value.clone(),
2596 };
2597
2598 let meta = SettingsRegistry::new(self.api_provider).meta(row);
2599 let choices = meta.choices;
2600 let selected_choice = choices
2601 .as_ref()
2602 .and_then(|choices| {
2603 let current = canonical_config_choice(&key, &initial_value);
2604 choices
2605 .iter()
2606 .position(|choice| canonical_config_choice(&key, choice) == current)
2607 })
2608 .unwrap_or(0);
2609 let buffer: Vec<char> = initial_value.chars().collect();
2610 self.editing = Some(ConfigEdit {
2611 key,
2612 original_value,
2613 cursor: buffer.len(),
2614 buffer,
2615 select_all: true,
2616 scope: row.scope,
2617 choices,
2618 selected_choice,
2619 });
2620 self.status = None;
2621 }
2622
2623 fn clear_filter(&mut self) {
2624 if self.filter.is_empty() {
2625 return;
2626 }
2627
2628 self.update_filter(|filter| filter.clear());
2629 }
2630
2631 fn row_display_value(&self, row: &ConfigRow) -> String {
2632 if row.key == "cost_currency" && row.scope == ConfigScope::Saved {
2633 let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value);
2634 let effective_cost_currency =
2635 crate::pricing::CostCurrency::from_setting(&self.effective_cost_currency);
2636 if saved_cost_currency != effective_cost_currency {
2637 return format!(
2638 "{}{}",
2639 row.value,
2640 self.tr(MessageId::ConfigRowEffective)
2641 .replace("{currency}", &self.effective_cost_currency)
2642 );
2643 }
2644 }
2645
2646 let runtime_value = match row.key.as_str() {
2647 "low_motion" => Some(self.effective_low_motion),
2648 "fancy_animations" => Some(self.effective_fancy_animations),
2649 _ => None,
2650 };
2651 if let Some(runtime_value) = runtime_value
2652 && row.value.parse::<bool>().ok() != Some(runtime_value)
2653 {
2654 let saved = config_choice_label(
2655 self.locale,
2656 &row.key,
2657 &canonical_config_choice(&row.key, &row.value),
2658 );
2659 let effective = config_choice_label(self.locale, &row.key, &runtime_value.to_string());
2660 return format!(
2661 "{}{}",
2662 saved,
2663 self.tr(MessageId::ConfigRowEffective)
2664 .replace("{currency}", &effective)
2665 );
2666 }
2667
2668 // Preserve the exact saved currency alias in the table (for example
2669 // `rmb`) while the chooser highlights its canonical `cny` option.
2670 if row.key == "cost_currency" {
2671 return row.value.clone();
2672 }
2673
2674 if SettingsRegistry::new(self.api_provider)
2675 .meta(row)
2676 .choices
2677 .is_some()
2678 {
2679 if config_default_placeholder_message(&row.key).is_some_and(|message_id| {
2680 row.value == tr(self.locale, message_id) || row.value == tr(Locale::En, message_id)
2681 }) {
2682 return "Provider default".to_string();
2683 }
2684 let canonical = canonical_config_choice(&row.key, &row.value);
2685 return config_choice_label(self.locale, &row.key, &canonical);
2686 }
2687
2688 row.value.clone()
2689 }
2690
2691 fn selected_row_hint(&self) -> Option<String> {
2692 let row_idx = self.selected_row_index()?;
2693 let row = self.rows.get(row_idx)?;
2694 let meta = SettingsRegistry::new(self.api_provider).meta(row);
2695 let label = config_label_for_key_for_locale(self.locale, &row.key);
2696 let hint = config_hint_for_key(&row.key);
2697 let action_id = if row.key == "provider" {
2698 MessageId::ConfigActionOpenProvider
2699 } else if row.key == "model" {
2700 MessageId::ConfigActionOpenModel
2701 } else if meta.kind == SettingKind::Boolean {
2702 MessageId::ConfigActionToggle
2703 } else if meta.kind == SettingKind::Choice {
2704 MessageId::ConfigActionChoose
2705 } else if matches!(meta.kind, SettingKind::Integer | SettingKind::Text) {
2706 MessageId::ConfigActionEdit
2707 } else {
2708 MessageId::ConfigActionReadOnly
2709 };
2710 let action = self.tr(action_id);
2711 if !hint.is_empty() {
2712 return Some(format!("{label}: {hint} · {action}"));
2713 }
2714 if row.editable {
2715 Some(format!("{label}: {action} ({})", row.key))
2716 } else {
2717 Some(format!("{label}: read-only status ({})", row.key))
2718 }
2719 }
2720 }
2721
2722 fn config_base_url_row_key(provider: ApiProvider) -> &'static str {
2723 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
2724 "base_url"
2725 } else {
2726 "provider_url"
2727 }
2728 }
2729
2730 fn config_provider_row_value(app: &App, config: &Config) -> String {
2731 config
2732 .provider
2733 .as_deref()
2734 .filter(|provider| !provider.trim().is_empty())
2735 .unwrap_or_else(|| app.provider_identity_for_persistence())
2736 .to_string()
2737 }
2738
2739 fn config_base_url_row_value(app: &App) -> String {
2740 Config::load(app.config_path.clone(), app.config_profile.as_deref())
2741 .map(|mut config| {
2742 // A named custom provider is represented at runtime as `Custom`,
2743 // but its table lookup still needs the original provider ID.
2744 if config
2745 .provider
2746 .as_deref()
2747 .is_none_or(|provider| provider.trim().is_empty())
2748 {
2749 config.provider = Some(app.provider_identity_for_persistence().to_string());
2750 }
2751 config.deepseek_base_url()
2752 })
2753 .unwrap_or_else(|_| tr(app.ui_locale, MessageId::ConfigUnavailable).to_string())
2754 }
2755
2756 fn cost_currency_config_value(app: &App) -> String {
2757 match app.cost_currency {
2758 crate::pricing::CostCurrency::Usd => "usd",
2759 crate::pricing::CostCurrency::Cny => "cny",
2760 }
2761 .to_string()
2762 }
2763
2764 fn experimental_config_rows(config: &Config) -> Vec<ConfigRow> {
2765 let features = config.features();
2766 let configured = config.features.as_ref().map(|table| &table.entries);
2767 let mut rows = Vec::new();
2768
2769 for spec in FEATURES
2770 .iter()
2771 .filter(|spec| matches!(spec.stage, Stage::Experimental | Stage::Beta))
2772 {
2773 let effective = features.enabled(spec.id);
2774 let configured_value = configured
2775 .and_then(|entries| entries.get(spec.key))
2776 .copied();
2777 rows.push(ConfigRow {
2778 section: ConfigSection::Experimental,
2779 key: format!("features.{}", spec.key),
2780 value: experimental_feature_value(
2781 effective,
2782 spec.default_enabled,
2783 configured_value.is_some(),
2784 ),
2785 editable: false,
2786 scope: ConfigScope::Saved,
2787 });
2788 }
2789
2790 rows.push(ConfigRow {
2791 section: ConfigSection::Session,
2792 key: "goal_command".to_string(),
2793 value:
2794 "/goal sets session objectives with optional token budgets; state shows in Work context"
2795 .to_string(),
2796 editable: false,
2797 scope: ConfigScope::Saved,
2798 });
2799 rows.push(ConfigRow {
2800 // Workflow orchestration is its own section, not a Fleet concern.
2801 section: ConfigSection::Workflow,
2802 key: "workflow".to_string(),
2803 value:
2804 "/workflow runs scripted fan-out/fan-in operations with run cards and cancel support"
2805 .to_string(),
2806 editable: false,
2807 scope: ConfigScope::Saved,
2808 });
2809
2810 rows
2811 }
2812
2813 fn experimental_feature_value(effective: bool, default_enabled: bool, configured: bool) -> String {
2814 let state = if effective { "enabled" } else { "disabled" };
2815 let default_state = if default_enabled {
2816 "enabled"
2817 } else {
2818 "disabled"
2819 };
2820 if configured {
2821 format!("{state} (configured; default {default_state})")
2822 } else {
2823 format!("{state} (default {default_state})")
2824 }
2825 }
2826
2827 fn config_label_message(key: &str) -> Option<MessageId> {
2828 Some(match key {
2829 "provider" => MessageId::ConfigLabelProvider,
2830 "base_url" => MessageId::ConfigLabelBaseUrlDeepseek,
2831 "provider_url" => MessageId::ConfigLabelProviderUrl,
2832 "model" => MessageId::ConfigLabelModel,
2833 "fast_model" => MessageId::ConfigLabelFastModel,
2834 "default_model" => MessageId::ConfigLabelDefaultModel,
2835 "reasoning_effort" => MessageId::ConfigLabelReasoningEffort,
2836 "approval_mode" => MessageId::ConfigLabelApprovalMode,
2837 "permission_posture" => MessageId::ConfigLabelPermissionPosture,
2838 "approval_policy" => MessageId::ConfigLabelApprovalPolicy,
2839 "managed_approval_policy" => MessageId::ConfigLabelManagedApprovalPolicy,
2840 "default_mode" => MessageId::ConfigLabelDefaultMode,
2841 "allow_shell" => MessageId::ConfigLabelAllowShell,
2842 "managed_allow_shell" => MessageId::ConfigLabelManagedAllowShell,
2843 "stream_chunk_timeout_secs" => MessageId::ConfigLabelStreamTimeout,
2844 "theme" => MessageId::ConfigLabelTheme,
2845 "locale" => MessageId::ConfigLabelLocale,
2846 "background_color" => MessageId::ConfigLabelBackground,
2847 "ocean_treatment" => MessageId::ConfigLabelOceanTreatment,
2848 "work_surface_placement" => MessageId::ConfigLabelWorkSurfacePlacement,
2849 "work_surface_top_height" => MessageId::ConfigLabelTopHeight,
2850 "work_surface_side_width" => MessageId::ConfigLabelSideWidth,
2851 "calm_mode" => MessageId::ConfigLabelCalmMode,
2852 "low_motion" => MessageId::ConfigLabelLowMotion,
2853 "fancy_animations" => MessageId::ConfigLabelFancyAnimations,
2854 "launch_screen" => MessageId::ConfigLabelLaunchScreen,
2855 "show_thinking" => MessageId::ConfigLabelShowThinking,
2856 "thinking_highlight" => MessageId::ConfigLabelThinkingHighlight,
2857 "show_tool_details" => MessageId::ConfigLabelShowToolDetails,
2858 "inline_diffs" => MessageId::ConfigLabelInlineDiffs,
2859 "status_indicator" => MessageId::ConfigLabelStatusIndicator,
2860 "synchronized_output" => MessageId::ConfigLabelSynchronizedOutput,
2861 "cost_currency" => MessageId::ConfigLabelCostCurrency,
2862 "transcript_spacing" => MessageId::ConfigLabelTranscriptSpacing,
2863 "tool_collapse" => MessageId::ConfigLabelToolCollapse,
2864 "composer_density" => MessageId::ConfigLabelComposerDensity,
2865 "composer_border" => MessageId::ConfigLabelComposerBorder,
2866 "composer_vim_mode" => MessageId::ConfigLabelComposerVimMode,
2867 "bracketed_paste" => MessageId::ConfigLabelBracketedPaste,
2868 "paste_burst_detection" => MessageId::ConfigLabelPasteBurstDetection,
2869 "mention_menu_limit" => MessageId::ConfigLabelMentionMenuLimit,
2870 "mention_menu_behavior" => MessageId::ConfigLabelMentionMenuBehavior,
2871 "mention_walk_depth" => MessageId::ConfigLabelMentionWalkDepth,
2872 "workspace_follow_symlinks" => MessageId::ConfigLabelWorkspaceFollowSymlinks,
2873 "context_panel" => MessageId::ConfigLabelContextPanel,
2874 "sessions_rail" => MessageId::ConfigLabelSessionsRail,
2875 "session_auto_resume" => MessageId::ConfigLabelSessionAutoResume,
2876 "auto_compact" => MessageId::ConfigLabelAutoCompact,
2877 "auto_compact_threshold_percent" => MessageId::ConfigLabelAutoCompactThreshold,
2878 "max_history" => MessageId::ConfigLabelMaxHistory,
2879 "mcp_config_path" => MessageId::ConfigLabelMcpConfigPath,
2880 "fleet.exec.max_spawn_depth" => MessageId::ConfigLabelFleetSpawnDepth,
2881 "goal_command" => MessageId::ConfigLabelGoalCommand,
2882 "workflow" => MessageId::ConfigLabelWorkflow,
2883 _ => return None,
2884 })
2885 }
2886
2887 fn config_label_for_key_for_locale(locale: Locale, key: &str) -> String {
2888 if let Some(message) = config_label_message(key) {
2889 return tr(locale, message).to_string();
2890 }
2891 let humanized = humanize_config_key(key.strip_prefix("features.").unwrap_or(key));
2892 if key.starts_with("features.") {
2893 tr(locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", &humanized)
2894 } else {
2895 humanized
2896 }
2897 }
2898
2899 #[cfg(test)]
2900 fn config_label_for_key(key: &str) -> String {
2901 config_label_for_key_for_locale(Locale::En, key)
2902 }
2903
2904 fn humanize_config_key(key: &str) -> String {
2905 key.split(['.', '_', '-'])
2906 .filter(|part| !part.is_empty())
2907 .map(|part| {
2908 let mut chars = part.chars();
2909 let Some(first) = chars.next() else {
2910 return String::new();
2911 };
2912 let mut word = first.to_uppercase().collect::<String>();
2913 word.push_str(chars.as_str());
2914 word
2915 })
2916 .collect::<Vec<_>>()
2917 .join(" ")
2918 }
2919
2920 fn config_hint_for_key(key: &str) -> &'static str {
2921 match key {
2922 "model" => "provider-scoped saved route; Enter opens /model",
2923 "fast_model" => {
2924 "used by Auto routing and agent model_strength=faster when this provider has a known sibling"
2925 }
2926 "provider" => "deepseek | openrouter | xiaomi-mimo | fireworks | siliconflow | ...",
2927 "approval_mode" => "this session only: Ask | Auto-Review | Full Access",
2928 "permission_posture" => "default for new sessions: Ask | Auto-Review | Full Access",
2929 "approval_policy" => {
2930 "new sessions: Ask | Auto-Review | Full Access; choosing Full Access releases the raw config override"
2931 }
2932 "managed_approval_policy" => {
2933 "a project, profile, environment, managed config, or organization requirement controls this value"
2934 }
2935 "managed_allow_shell" => {
2936 "a project, profile, environment, or managed config controls shell access"
2937 }
2938 "allow_shell" => "on exposes shell tools in Agent mode; permission rules still apply",
2939 "auto_compact"
2940 | "launch_screen"
2941 | "show_tool_details"
2942 | "composer_border"
2943 | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0",
2944 "composer_density" | "transcript_spacing" => "compact | comfortable | spacious",
2945 "inline_diffs" => "full | summary | off; exact change remains in Alt/Option+V details",
2946 "tool_collapse" => "compact | expanded | calm",
2947 // Derived from the shipped theme/locale registries so these hints
2948 // cannot go stale as new entries land (they previously advertised
2949 // 4 of 12 themes and 4 of 8 locales).
2950 "theme" => {
2951 static THEME_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
2952 THEME_HINT.get_or_init(|| {
2953 crate::palette::SELECTABLE_THEMES
2954 .iter()
2955 .map(|id| id.name())
2956 .collect::<Vec<_>>()
2957 .join(" | ")
2958 })
2959 }
2960 "locale" => {
2961 static LOCALE_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
2962 LOCALE_HINT.get_or_init(|| crate::localization::configured_locale_values(" | "))
2963 }
2964 "background_color" => "#RRGGBB | default",
2965 "work_surface_placement" => {
2966 "top | left | right | off · side rails require Ocean mode and at least 72 columns"
2967 }
2968 "rail_panel" => "tasks | agents | context | pinned · which panel the rail shows",
2969 "work_surface_top_height" => "2..=16 rows · also adjustable by dragging the divider",
2970 "work_surface_side_width" => "26..=80 columns · also adjustable by dragging the divider",
2971 "base_url" => "global DeepSeek/root fallback; e.g. https://api.deepseek.com/beta",
2972 "provider_url" => {
2973 "current provider endpoint; Xiaomi: token-plan | pay-as-you-go | custom URL"
2974 }
2975 // #5134: the filter matches hint text, so the words a confused user
2976 // actually types — "context length", "context size", "max context",
2977 // "1m" — have to appear here or these rows stay unfindable.
2978 "context_window" => {
2979 "max context length / context size limit in tokens · set `[providers.<name>] context_window` in config.toml, e.g. 1048576 for a 1M route; (not set) resolves it automatically"
2980 }
2981 "effective_context_window" => {
2982 "resolved max context length / window size limit in tokens and where the value came from; drives compaction, pressure, and preflight budgets"
2983 }
2984 "cost_currency" => "usd | cny",
2985 "calm_mode" => "quietens transcript chrome and tool detail; independent of live motion",
2986 "low_motion" => "on overrides live-state motion; model output is unchanged",
2987 "fancy_animations" => "on animates truthful tool, status, and ocean live state",
2988 "ocean_treatment" => "ombre | flat (appearance; independent of motion)",
2989 "show_thinking" => "show or hide model reasoning in chat; task lists stay concise",
2990 "thinking_default_expanded" => {
2991 "expand model reasoning by default; Space still toggles each block"
2992 }
2993 "thinking_highlight" => {
2994 "fill the model reasoning background; the dashed rail remains visible when off"
2995 }
2996 "synchronized_output" => "auto | on | off; terminal redraw pacing, not model speed",
2997 "default_mode" => "act (agent) | plan | operate",
2998 "max_history" => "integer (0 allowed)",
2999 "auto_compact_threshold_percent" => {
3000 "10..=100 · compaction threshold: percent of the usable context length at which auto-compaction fires"
3001 }
3002 "default_model" => {
3003 "DeepSeek-only legacy fallback; other providers use their provider-scoped model above"
3004 }
3005 "reasoning_effort" => {
3006 "DeepSeek: auto/off/low/high/max (medium rounds up to high — the wire has no medium); Codex: low/medium/high/xhigh; default clears saved value"
3007 }
3008 "mcp_config_path" => "path to mcp.json",
3009 "fleet.exec.max_spawn_depth" => {
3010 "0 blocks child agents; 3 default (same axis as sub-agents); capped at 8"
3011 }
3012 "features.subagents" => {
3013 "read-only feature flag state; /fleet setup is the user-facing path"
3014 }
3015 "features.web_search" => "read-only feature flag state for web search tools",
3016 "features.apply_patch" => "read-only feature flag state for patch editing tools",
3017 "features.mcp" => "read-only feature flag state for MCP tools",
3018 "features.exec_policy" => "read-only feature flag state for execution policy tools",
3019 "features.vision_model" => "beta feature flag for vision/model image support",
3020 "goal_command" => "/goal sets objectives, budgets, and Work-context status",
3021 "workflow" => "/workflow runs scripted operations with fan-out/fan-in run cards",
3022 _ => "",
3023 }
3024 }
3025
3026 fn config_default_placeholder_message(key: &str) -> Option<MessageId> {
3027 match key {
3028 "default_model" | "background_color" => Some(MessageId::ConfigDefaultValue),
3029 "reasoning_effort" => Some(MessageId::ConfigDefaultReasoning),
3030 _ => None,
3031 }
3032 }
3033
3034 fn config_boolean_key(key: &str) -> bool {
3035 matches!(
3036 key,
3037 "allow_shell"
3038 | "calm_mode"
3039 | "low_motion"
3040 | "fancy_animations"
3041 | "launch_screen"
3042 | "show_thinking"
3043 | "thinking_default_expanded"
3044 | "thinking_highlight"
3045 | "show_tool_details"
3046 | "composer_border"
3047 | "bracketed_paste"
3048 | "paste_burst_detection"
3049 | "workspace_follow_symlinks"
3050 | "context_panel"
3051 | "sessions_rail"
3052 | "session_auto_resume"
3053 | "auto_compact"
3054 )
3055 }
3056
3057 fn config_integer_key(key: &str) -> bool {
3058 matches!(
3059 key,
3060 "stream_chunk_timeout_secs"
3061 | "work_surface_top_height"
3062 | "work_surface_side_width"
3063 | "mention_menu_limit"
3064 | "mention_walk_depth"
3065 | "auto_compact_threshold_percent"
3066 | "max_history"
3067 | "fleet.exec.max_spawn_depth"
3068 )
3069 }
3070
3071 fn config_choice_values(key: &str, provider: ApiProvider) -> Option<Vec<String>> {
3072 let values = match key {
3073 key if config_boolean_key(key) => vec!["false", "true"],
3074 "approval_mode" => vec!["ask", "auto-review", "full-access"],
3075 "permission_posture" => vec!["ask", "auto-review", "full-access"],
3076 "approval_policy" => vec!["use-tui-default", "ask", "auto-review", "full-access"],
3077 "default_mode" => vec!["agent", "plan", "operate"],
3078 "reasoning_effort" if provider == ApiProvider::OpenaiCodex => {
3079 vec!["default", "low", "medium", "high", "xhigh"]
3080 }
3081 "reasoning_effort" => {
3082 vec!["default", "auto", "off", "low", "medium", "high", "max"]
3083 }
3084 "ocean_treatment" => vec!["ombre", "flat"],
3085 "focus_texture" => vec!["off", "scrim", "grain"],
3086 "work_surface_placement" => vec!["top", "left", "right", "off"],
3087 "rail_panel" => vec!["tasks", "agents", "context", "pinned"],
3088 "status_indicator" => vec!["cw", "whale", "dots", "off"],
3089 "synchronized_output" => vec!["auto", "on", "off"],
3090 "cost_currency" => vec!["usd", "cny"],
3091 "transcript_spacing" | "composer_density" => {
3092 vec!["compact", "comfortable", "spacious"]
3093 }
3094 "tool_collapse" => vec!["compact", "expanded", "calm"],
3095 "inline_diffs" => vec!["full", "summary", "off"],
3096 "composer_vim_mode" => vec!["normal", "vim"],
3097 "mention_menu_behavior" => vec!["fuzzy", "browser"],
3098 "theme" => {
3099 return Some(
3100 crate::palette::SELECTABLE_THEMES
3101 .iter()
3102 .map(|id| id.name().to_string())
3103 .collect(),
3104 );
3105 }
3106 "locale" => {
3107 let mut values = vec!["auto".to_string()];
3108 values.extend(
3109 Locale::shipped()
3110 .iter()
3111 .map(|locale| locale.tag().to_string()),
3112 );
3113 return Some(values);
3114 }
3115 _ => return None,
3116 };
3117 Some(values.into_iter().map(str::to_string).collect())
3118 }
3119
3120 fn canonical_config_choice(key: &str, value: &str) -> String {
3121 let normalized = value.trim().to_ascii_lowercase().replace([' ', '_'], "-");
3122 match key {
3123 key if config_boolean_key(key) => match normalized.as_str() {
3124 "true" | "on" | "yes" | "1" | "enabled" => "true".to_string(),
3125 _ => "false".to_string(),
3126 },
3127 "approval_mode" | "permission_posture" | "approval_policy" => match normalized.as_str() {
3128 "ask" | "suggest" | "on-request" | "untrusted" => "ask".to_string(),
3129 "auto" | "auto-review" => "auto-review".to_string(),
3130 "full" | "full-access" | "bypass" | "yolo" => "full-access".to_string(),
3131 "never" | "deny" => "never".to_string(),
3132 _ => normalized,
3133 },
3134 "reasoning_effort" => {
3135 if matches!(normalized.as_str(), "" | "(default)" | "config-default") {
3136 "default".to_string()
3137 } else if normalized == "max" && value.trim().eq_ignore_ascii_case("xhigh") {
3138 "xhigh".to_string()
3139 } else {
3140 normalized
3141 }
3142 }
3143 "cost_currency" => match normalized.as_str() {
3144 "rmb" | "yuan" | "cny" => "cny".to_string(),
3145 _ => "usd".to_string(),
3146 },
3147 "default_mode" => match normalized.as_str() {
3148 "plan" => "plan".to_string(),
3149 "operate" | "operation" | "ops" => "operate".to_string(),
3150 _ => "agent".to_string(),
3151 },
3152 "locale" => normalize_configured_locale(value)
3153 .unwrap_or(value)
3154 .to_string(),
3155 _ => normalized,
3156 }
3157 }
3158
3159 fn config_choice_label(locale: Locale, key: &str, value: &str) -> String {
3160 let label = match (key, value) {
3161 (key, "true") if config_boolean_key(key) => "On".to_string(),
3162 (key, "false") if config_boolean_key(key) => "Off".to_string(),
3163 ("approval_mode" | "permission_posture" | "approval_policy", "ask") => "Ask".to_string(),
3164 ("approval_mode" | "permission_posture" | "approval_policy", "auto-review") => {
3165 "Auto-Review".to_string()
3166 }
3167 ("approval_policy", "use-tui-default") => "Use TUI permission default".to_string(),
3168 ("approval_mode" | "permission_posture" | "approval_policy", "full-access") => {
3169 "Full Access".to_string()
3170 }
3171 ("approval_mode" | "approval_policy", "never") => "Never".to_string(),
3172 ("default_mode", "agent") => "Act".to_string(),
3173 ("default_mode", "plan") => "Plan (read only)".to_string(),
3174 ("default_mode", "operate") => "Operate".to_string(),
3175 ("work_surface_placement", "top") => "Top".to_string(),
3176 ("work_surface_placement", "left") => "Left sidebar".to_string(),
3177 ("work_surface_placement", "right") => "Right sidebar".to_string(),
3178 ("work_surface_placement", "off") => "Off".to_string(),
3179 ("rail_panel", "tasks") => "Tasks".to_string(),
3180 ("rail_panel", "agents") => "Agents".to_string(),
3181 ("rail_panel", "context") => "Context".to_string(),
3182 ("rail_panel", "pinned") => "Pinned".to_string(),
3183 ("reasoning_effort", "default") => "Provider default".to_string(),
3184 ("status_indicator", "cw") => "Codewhale mark".to_string(),
3185 ("status_indicator", "whale") => "Animated whale".to_string(),
3186 ("status_indicator", "dots") => "Animated dots".to_string(),
3187 ("status_indicator", "off") => "Off".to_string(),
3188 ("inline_diffs", "full") => "Full diff".to_string(),
3189 ("inline_diffs", "summary") => "Summary".to_string(),
3190 ("inline_diffs", "off") => "Off".to_string(),
3191 _ => value.to_string(),
3192 };
3193
3194 if key == "locale" && configured_locale_is_partial_pack(value) {
3195 format!(
3196 "{label} ({})",
3197 tr(locale, MessageId::ConfigLocalePartialBadge)
3198 )
3199 } else {
3200 label
3201 }
3202 }
3203
3204 fn config_choice_detail(locale: Locale, key: &str, value: &str) -> Cow<'static, str> {
3205 if key == "locale" && configured_locale_is_partial_pack(value) {
3206 return tr(locale, MessageId::ConfigLocalePartialDetail);
3207 }
3208
3209 Cow::Borrowed(match (key, value) {
3210 ("approval_mode" | "permission_posture" | "approval_policy", "ask") => {
3211 "Ask before tools that can make consequential changes."
3212 }
3213 ("approval_mode" | "permission_posture" | "approval_policy", "auto-review") => {
3214 "Review tool risk automatically and ask when a decision needs you."
3215 }
3216 ("approval_policy", "use-tui-default") => {
3217 "Remove the root config override and use the saved TUI permission choice."
3218 }
3219 ("approval_mode" | "permission_posture" | "approval_policy", "full-access") => {
3220 "Run tools without approval prompts; workspace rules still apply."
3221 }
3222 ("approval_mode" | "approval_policy", "never") => {
3223 "Block every tool that requires approval."
3224 }
3225 ("default_mode", "agent") => "Start ready to collaborate and use tools.",
3226 ("default_mode", "plan") => "Start in a read-only planning workspace.",
3227 ("default_mode", "operate") => {
3228 "Start as a coordinator that delegates work to bounded workers."
3229 }
3230 ("work_surface_placement", "top") => "Show Tasks, To-do, and Workers above the transcript.",
3231 ("work_surface_placement", "left") => {
3232 "Show Tasks, To-do, and Workers in a left sidebar when the terminal is wide enough."
3233 }
3234 ("work_surface_placement", "right") => {
3235 "Show Tasks, To-do, and Workers in a right sidebar when the terminal is wide enough."
3236 }
3237 ("work_surface_placement", "off") => "Hide the rail entirely.",
3238 ("rail_panel", "tasks") => "Rail shows the live Tasks / To-do / Workers list.",
3239 ("rail_panel", "agents") => "Rail shows sub-agents and fan-out state.",
3240 ("rail_panel", "context") => "Rail shows workspace, token, and cost context.",
3241 ("rail_panel", "pinned") => "Rail shows the pinned goal and checklist summary.",
3242 ("low_motion", "true") => "Stops live-state movement without changing model output.",
3243 ("low_motion", "false") => "Allows motion selected by the other appearance settings.",
3244 ("fancy_animations", "true") => "Animates truthful tool, status, and ocean live state.",
3245 ("fancy_animations", "false") => "Keeps live-state markers and the ocean treatment static.",
3246 ("show_thinking", "true") => "Show model reasoning blocks in the transcript.",
3247 ("show_thinking", "false") => {
3248 "Keep model reasoning hidden; answers and tools remain visible."
3249 }
3250 ("thinking_highlight", "true") => "Fill the model reasoning background.",
3251 ("thinking_highlight", "false") => {
3252 "Keep the dashed reasoning rail and italic text without a filled background."
3253 }
3254 ("ocean_treatment", "ombre") => "Use one continuous ocean color field.",
3255 ("ocean_treatment", "flat") => "Use a single flat background color.",
3256 _ => "",
3257 })
3258 }
3259
3260 fn render_config_editor_value_line(
3261 edit: &ConfigEdit,
3262 locale: Locale,
3263 ) -> ratatui::text::Line<'static> {
3264 use ratatui::{
3265 style::Style,
3266 text::{Line, Span},
3267 };
3268
3269 let mut spans = Vec::new();
3270 spans.push(Span::styled(
3271 tr(locale, MessageId::ConfigEditNewLabel),
3272 Style::default().fg(palette::TEXT_MUTED),
3273 ));
3274
3275 let cursor_style = Style::default()
3276 .fg(palette::WHALE_BG)
3277 .bg(palette::WHALE_INFO)
3278 .bold();
3279 let selected_style = Style::default()
3280 .fg(palette::SELECTION_TEXT)
3281 .bg(palette::SELECTION_BG);
3282
3283 if edit.select_all && !edit.buffer.is_empty() {
3284 let text = edit.buffer.iter().collect::<String>();
3285 spans.push(Span::styled(text, selected_style));
3286 spans.push(Span::styled(" ", cursor_style));
3287 return Line::from(spans);
3288 }
3289
3290 let before = edit.buffer.iter().take(edit.cursor).collect::<String>();
3291 spans.push(Span::raw(before));
3292 if edit.cursor < edit.buffer.len() {
3293 let ch = edit.buffer[edit.cursor];
3294 spans.push(Span::styled(ch.to_string(), cursor_style));
3295 let after = edit
3296 .buffer
3297 .iter()
3298 .skip(edit.cursor.saturating_add(1))
3299 .collect::<String>();
3300 spans.push(Span::raw(after));
3301 } else {
3302 spans.push(Span::styled(" ", cursor_style));
3303 }
3304
3305 Line::from(spans)
3306 }
3307
3308 impl ModalView for ConfigView {
3309 fn kind(&self) -> ModalKind {
3310 ModalKind::Config
3311 }
3312
3313 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
3314 self
3315 }
3316
3317 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
3318 if self.editing.is_some() {
3319 return self.handle_editing_key(key);
3320 }
3321
3322 match key.code {
3323 KeyCode::Esc => {
3324 if self.filter.is_empty() {
3325 ViewAction::Close
3326 } else {
3327 self.clear_filter();
3328 ViewAction::None
3329 }
3330 }
3331 KeyCode::Char('q') if self.filter.is_empty() => ViewAction::Close,
3332 KeyCode::Tab
3333 if !key.modifiers.contains(KeyModifiers::SHIFT) && self.filter.is_empty() =>
3334 {
3335 self.active_tab = self.active_tab.next();
3336 self.select_first_visible_row();
3337 ViewAction::None
3338 }
3339 KeyCode::BackTab | KeyCode::Tab
3340 if key.modifiers.contains(KeyModifiers::SHIFT) && self.filter.is_empty() =>
3341 {
3342 self.active_tab = self.active_tab.prev();
3343 self.select_first_visible_row();
3344 ViewAction::None
3345 }
3346 KeyCode::Up => {
3347 self.move_selection(-1);
3348 ViewAction::None
3349 }
3350 KeyCode::Char('k') if self.filter.is_empty() => {
3351 self.move_selection(-1);
3352 ViewAction::None
3353 }
3354 KeyCode::Down => {
3355 self.move_selection(1);
3356 ViewAction::None
3357 }
3358 KeyCode::Char('j') if self.filter.is_empty() => {
3359 self.move_selection(1);
3360 ViewAction::None
3361 }
3362 KeyCode::PageUp => {
3363 self.move_selection(-5);
3364 ViewAction::None
3365 }
3366 KeyCode::PageDown => {
3367 self.move_selection(5);
3368 ViewAction::None
3369 }
3370 KeyCode::Backspace => {
3371 if !self.filter.is_empty() {
3372 self.update_filter(|filter| {
3373 filter.pop();
3374 });
3375 }
3376 ViewAction::None
3377 }
3378 // Ctrl+H is the legacy ASCII backspace many terminals emit.
3379 KeyCode::Char('h')
3380 if key.modifiers.contains(KeyModifiers::CONTROL)
3381 && !key.modifiers.contains(KeyModifiers::ALT) =>
3382 {
3383 if !self.filter.is_empty() {
3384 self.update_filter(|filter| {
3385 filter.pop();
3386 });
3387 }
3388 ViewAction::None
3389 }
3390 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3391 self.clear_filter();
3392 ViewAction::None
3393 }
3394 KeyCode::Char('e') | KeyCode::Char('E') if self.filter.is_empty() => {
3395 if self
3396 .selected_row_index()
3397 .and_then(|idx| self.rows.get(idx))
3398 .is_some_and(|row| row.editable)
3399 {
3400 if let Some(action) = self.open_selected_catalog_picker() {
3401 return action;
3402 }
3403 self.start_edit();
3404 }
3405 ViewAction::None
3406 }
3407 KeyCode::Enter => {
3408 if self
3409 .selected_row_index()
3410 .and_then(|idx| self.rows.get(idx))
3411 .is_some_and(|row| row.editable)
3412 {
3413 if let Some(action) = self.open_selected_catalog_picker() {
3414 return action;
3415 }
3416 if let Some(action) = self.toggle_selected_boolean() {
3417 return action;
3418 }
3419 self.start_edit();
3420 }
3421 ViewAction::None
3422 }
3423 KeyCode::Char(' ') if self.filter.is_empty() => {
3424 if let Some(action) = self.toggle_selected_boolean() {
3425 action
3426 } else {
3427 ViewAction::None
3428 }
3429 }
3430 KeyCode::Char(ch)
3431 if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() =>
3432 {
3433 self.update_filter(|filter| filter.push(ch));
3434 ViewAction::None
3435 }
3436 _ => ViewAction::None,
3437 }
3438 }
3439
3440 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
3441 if self
3442 .editing
3443 .as_ref()
3444 .is_some_and(|edit| edit.choices.is_some())
3445 {
3446 match mouse.kind {
3447 MouseEventKind::ScrollUp => self.move_choice(-1),
3448 MouseEventKind::ScrollDown => self.move_choice(1),
3449 MouseEventKind::Down(MouseButton::Left) => {
3450 if let Some(choice) = self
3451 .last_choice_hitboxes
3452 .borrow()
3453 .iter()
3454 .find_map(|(y, choice)| (*y == mouse.row).then_some(*choice))
3455 && let Some(edit) = self.editing.as_mut()
3456 {
3457 edit.selected_choice = choice;
3458 }
3459 }
3460 _ => {}
3461 }
3462 return ViewAction::None;
3463 }
3464 if self.editing.is_some() {
3465 return ViewAction::None;
3466 }
3467 match mouse.kind {
3468 MouseEventKind::ScrollUp => {
3469 self.move_selection(-3);
3470 self.last_mouse_selected = None;
3471 return ViewAction::None;
3472 }
3473 MouseEventKind::ScrollDown => {
3474 self.move_selection(3);
3475 self.last_mouse_selected = None;
3476 return ViewAction::None;
3477 }
3478 _ => {}
3479 }
3480 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
3481 return ViewAction::None;
3482 }
3483
3484 let selected = self
3485 .last_row_hitboxes
3486 .borrow()
3487 .iter()
3488 .find_map(|(y, row_idx)| (*y == mouse.row).then_some(*row_idx));
3489 if let Some(row_idx) = selected {
3490 let activate = self.last_mouse_selected == Some(row_idx) && self.selected == row_idx;
3491 self.selected = row_idx;
3492 self.status = None;
3493 self.adjust_scroll(self.visible_rows_cached());
3494 self.last_mouse_selected = Some(row_idx);
3495 if activate && self.rows.get(row_idx).is_some_and(|row| row.editable) {
3496 if let Some(action) = self.open_selected_catalog_picker() {
3497 return action;
3498 }
3499 if let Some(action) = self.toggle_selected_boolean() {
3500 return action;
3501 }
3502 self.start_edit();
3503 }
3504 }
3505 ViewAction::None
3506 }
3507
3508 fn render(&self, area: Rect, buf: &mut Buffer) {
3509 use ratatui::{
3510 style::Style,
3511 text::{Line, Span},
3512 widgets::{Paragraph, Widget},
3513 };
3514
3515 let inner =
3516 render_underwater_surface(area, buf, self.tr(MessageId::ConfigModalTitle).to_string());
3517 let (lines, footer) = if let Some(edit) = self.editing.as_ref() {
3518 *self.last_choice_hitboxes.borrow_mut() = Vec::new();
3519 let footer_text = if edit.choices.is_some() {
3520 if inner.width < 56 || inner.height <= 8 {
3521 " ↑/↓ choose · Enter apply · Esc ".to_string()
3522 } else {
3523 " ↑/↓ choose · Enter apply · Esc cancel · 1-9 jump ".to_string()
3524 }
3525 } else {
3526 self.tr(MessageId::ConfigEditFooter).to_string()
3527 };
3528 let reserved_footer_lines =
3529 wrapped_footer_lines(&footer_text, inner.width, Style::default()).len();
3530 // Spacer rows are secondary chrome: give them up before the
3531 // editable value line falls below the wrapped footer on compact
3532 // terminals (#40x12).
3533 let spacious = usize::from(inner.height).saturating_sub(reserved_footer_lines) >= 8;
3534 let mut lines: Vec<Line> = Vec::new();
3535 let edit_label = config_label_for_key_for_locale(self.locale, &edit.key);
3536 let edit_title = if edit_label == edit.key {
3537 format!("{}{}", self.tr(MessageId::ConfigEditTitlePrefix), edit.key)
3538 } else {
3539 format!(
3540 "{}{} [{}]",
3541 self.tr(MessageId::ConfigEditTitlePrefix),
3542 edit_label,
3543 edit.key
3544 )
3545 };
3546 lines.push(Line::from(vec![Span::styled(
3547 edit_title,
3548 Style::default().fg(palette::WHALE_INFO).bold(),
3549 )]));
3550 if spacious {
3551 lines.push(Line::from(""));
3552 }
3553 lines.push(Line::from(vec![
3554 Span::styled(
3555 self.tr(MessageId::ConfigEditScopeLabel),
3556 Style::default().fg(palette::TEXT_MUTED),
3557 ),
3558 Span::raw(edit.scope.label(self.locale)),
3559 ]));
3560 lines.push(Line::from(vec![
3561 Span::styled(
3562 self.tr(MessageId::ConfigEditCurrentLabel),
3563 Style::default().fg(palette::TEXT_MUTED),
3564 ),
3565 Span::raw(truncate_view_text(&edit.original_value, 60)),
3566 ]));
3567 if spacious {
3568 lines.push(Line::from(""));
3569 }
3570 if let Some(choices) = edit.choices.as_ref() {
3571 lines.push(Line::from(Span::styled(
3572 "Choose:",
3573 Style::default().fg(palette::TEXT_MUTED),
3574 )));
3575
3576 // Large catalogs (providers and themes) remain bounded by the
3577 // terminal. Keep the active option centered and mouse-hitbox
3578 // only the slice that is actually visible.
3579 let selected_detail = choices
3580 .get(edit.selected_choice)
3581 .map(|choice| config_choice_detail(self.locale, &edit.key, choice))
3582 .unwrap_or_default();
3583 let available_rows =
3584 usize::from(inner.height).saturating_sub(reserved_footer_lines + lines.len());
3585 // At the minimum supported height, the choices themselves are
3586 // the primary object. Shed the explanatory detail before any
3587 // option; larger surfaces keep one row for that detail.
3588 let detail_rows = usize::from(!selected_detail.is_empty() && available_rows > 3);
3589 let option_budget = available_rows.saturating_sub(detail_rows).max(1);
3590 let visible_options = option_budget.min(choices.len());
3591 let max_start = choices.len().saturating_sub(visible_options);
3592 let start = edit
3593 .selected_choice
3594 .saturating_sub(visible_options / 2)
3595 .min(max_start);
3596 let end = (start + visible_options).min(choices.len());
3597 let mut hitboxes = Vec::new();
3598
3599 for (choice_idx, choice) in choices.iter().enumerate().take(end).skip(start) {
3600 let selected = choice_idx == edit.selected_choice;
3601 let marker = crate::tui::glyphs::selection_marker(selected);
3602 let label = config_choice_label(self.locale, &edit.key, choice);
3603 let line_y = inner.y.saturating_add(lines.len() as u16);
3604 hitboxes.push((line_y, choice_idx));
3605 let mut line = Line::from(format!(
3606 " {marker} {:>2}. {}",
3607 choice_idx + 1,
3608 truncate_view_text(&label, usize::from(inner.width).saturating_sub(8))
3609 ));
3610 line.style = if selected {
3611 menu_style::selected_row_style()
3612 } else {
3613 Style::default().fg(palette::TEXT_PRIMARY)
3614 };
3615 lines.push(line);
3616 }
3617 *self.last_choice_hitboxes.borrow_mut() = hitboxes;
3618
3619 if !selected_detail.is_empty()
3620 && lines.len() + reserved_footer_lines < usize::from(inner.height)
3621 {
3622 lines.push(Line::from(Span::styled(
3623 crate::tui::ui_text::semantic_truncate(
3624 selected_detail.as_ref(),
3625 usize::from(inner.width),
3626 ),
3627 Style::default().fg(palette::TEXT_MUTED),
3628 )));
3629 }
3630 } else {
3631 lines.push(render_config_editor_value_line(edit, self.locale));
3632 if spacious {
3633 lines.push(Line::from(""));
3634 }
3635 let hint = config_hint_for_key(&edit.key);
3636 if !hint.is_empty() {
3637 lines.push(Line::from(vec![
3638 Span::styled(
3639 self.tr(MessageId::ConfigEditHintLabel),
3640 Style::default().fg(palette::TEXT_MUTED),
3641 ),
3642 Span::raw(hint),
3643 ]));
3644 }
3645 }
3646 (lines, footer_text)
3647 } else {
3648 *self.last_choice_hitboxes.borrow_mut() = Vec::new();
3649 let content_height = usize::from(inner.height);
3650 let items = self.visible_items();
3651 let match_count = self.matching_row_indices().len();
3652
3653 // Reserve the action footer by its actual wrapped height: the
3654 // prose hints wrap to two or three rows at compact widths, and
3655 // every wrapped row must come out of the table budget or the
3656 // settings rows silently fall off the bottom of the body.
3657 let footer_height = |id: MessageId| -> usize {
3658 wrapped_footer_lines(&self.tr(id), inner.width, Style::default()).len()
3659 };
3660 let footer_lines = if !self.filter.is_empty() {
3661 footer_height(MessageId::ConfigFooterFiltered)
3662 } else {
3663 footer_height(MessageId::ConfigFooterScrollable)
3664 .max(footer_height(MessageId::ConfigFooterDefault))
3665 }
3666 .max(1);
3667
3668 // Full chrome spends five header rows (in-body title, search,
3669 // blank, column captions, separator) plus a status row under the
3670 // table. That secondary material collapses before the settings
3671 // rows do: compact keeps one search/count line — the surface
3672 // hairline already owns the title — and cedes the rest to the
3673 // rows the room exists to edit.
3674 const FULL_HEADER_LINES: usize = 4;
3675 const FULL_BOTTOM_LINES: usize = 1;
3676 let full_rows =
3677 content_height.saturating_sub(FULL_HEADER_LINES + FULL_BOTTOM_LINES + footer_lines);
3678 let compact = full_rows < 4;
3679 let header_lines = if compact { 2 } else { FULL_HEADER_LINES };
3680 let bottom_lines = if compact {
3681 usize::from(self.status.is_some())
3682 } else {
3683 FULL_BOTTOM_LINES
3684 };
3685 let description_lines = if compact { 0 } else { 4 };
3686 let list_line_budget = content_height
3687 .saturating_sub(header_lines + bottom_lines + description_lines + footer_lines)
3688 .max(1);
3689 self.last_visible_rows.set(list_line_budget);
3690
3691 // The stored scroll can predate this frame's geometry (a resize
3692 // shrinks the window before any key recomputes it), so anchor the
3693 // visible window to the selection here: the row being manipulated
3694 // is always rendered.
3695 let item_line_cost = |item: &ConfigListItem| match item {
3696 ConfigListItem::Section(_) => 2usize,
3697 ConfigListItem::Row(_) => 1usize,
3698 };
3699 let visible_end = |start: usize| {
3700 let mut used = 0usize;
3701 let mut end = start;
3702 while end < items.len() {
3703 let cost = item_line_cost(&items[end]);
3704 if end > start && used.saturating_add(cost) > list_line_budget {
3705 break;
3706 }
3707 used = used.saturating_add(cost);
3708 end += 1;
3709 }
3710 end
3711 };
3712 let mut start = self.scroll.min(items.len().saturating_sub(1));
3713 if let Some(selected_pos) = self.selected_display_position(&items) {
3714 start = start.min(selected_pos);
3715 while selected_pos >= visible_end(start) && start < selected_pos {
3716 start += 1;
3717 }
3718 }
3719 let end = visible_end(start);
3720 let scrollable = start > 0 || end < items.len();
3721 let search_value = if self.filter.is_empty() {
3722 self.tr(MessageId::ConfigSearchPlaceholder).to_string()
3723 } else {
3724 self.filter.clone()
3725 };
3726
3727 let table_width = usize::from(inner.width).saturating_sub(usize::from(scrollable));
3728 let (key_column_width, value_column_width, _scope_column_width) =
3729 self.table_column_widths(table_width);
3730 let search_line = Line::from(vec![
3731 Span::styled(" Search: ", Style::default().fg(palette::TEXT_MUTED)),
3732 Span::raw(search_value),
3733 Span::styled(
3734 format!(" ({match_count}/{})", self.rows.len()),
3735 Style::default().fg(palette::TEXT_MUTED),
3736 ),
3737 ]);
3738 // Category tabs — app-style shell, not ASCII table headers.
3739 let mut tab_spans = Vec::new();
3740 for (i, tab) in ConfigTab::ALL.iter().enumerate() {
3741 if i > 0 {
3742 tab_spans.push(Span::styled(" ", Style::default()));
3743 }
3744 let active = *tab == self.active_tab;
3745 tab_spans.push(Span::styled(
3746 format!(" {} ", tab.label()),
3747 if active {
3748 Style::default()
3749 .fg(palette::SELECTION_TEXT)
3750 .bg(palette::WHALE_ACTION)
3751 .add_modifier(ratatui::style::Modifier::BOLD)
3752 } else {
3753 Style::default().fg(palette::TEXT_MUTED)
3754 },
3755 ));
3756 }
3757 let tab_line = Line::from(tab_spans);
3758 let mut lines: Vec<Line> = if compact {
3759 vec![tab_line, search_line]
3760 } else {
3761 vec![
3762 Line::from(vec![
3763 Span::styled(
3764 self.tr(MessageId::ConfigTitle),
3765 Style::default().fg(palette::WHALE_ACTION).bold(),
3766 ),
3767 Span::styled(
3768 " Tab/Shift+Tab categories",
3769 Style::default().fg(palette::TEXT_HINT),
3770 ),
3771 ]),
3772 tab_line,
3773 search_line,
3774 Line::from(""),
3775 ]
3776 };
3777 let mut row_hitboxes = Vec::new();
3778
3779 for item in &items[start..end] {
3780 match item {
3781 ConfigListItem::Section(section) => {
3782 lines.push(Line::from(""));
3783 lines.push(Line::from(Span::styled(
3784 format!(" {}", section.label(self.locale)),
3785 Style::default().fg(palette::TEXT_HINT).bold(),
3786 )));
3787 }
3788 ConfigListItem::Row(idx) => {
3789 let Some(row) = self.rows.get(*idx) else {
3790 continue;
3791 };
3792 let line_y = inner.y.saturating_add(lines.len() as u16);
3793 row_hitboxes.push((line_y, *idx));
3794 let selected = *idx == self.selected;
3795 let style = if selected {
3796 menu_style::selected_row_style()
3797 } else {
3798 Style::default().fg(palette::TEXT_PRIMARY)
3799 };
3800 let label = config_label_for_key_for_locale(self.locale, &row.key);
3801 let key = fit_config_column(&label, key_column_width);
3802 let value =
3803 fit_config_column(&self.row_display_value(row), value_column_width);
3804 // Quiet saved / session badges (not a full scope column shout).
3805 let scope_badge = match row.scope {
3806 ConfigScope::Saved => "saved",
3807 ConfigScope::Session => "session",
3808 };
3809 let rail = if selected { "▌" } else { " " };
3810 let mut line = Line::from(vec![
3811 Span::styled(
3812 rail,
3813 Style::default().fg(if selected {
3814 palette::WHALE_ACTION
3815 } else {
3816 palette::TEXT_DIM
3817 }),
3818 ),
3819 Span::styled(format!("{key} {value} "), style),
3820 Span::styled(
3821 scope_badge,
3822 Style::default()
3823 .fg(palette::TEXT_HINT)
3824 .add_modifier(ratatui::style::Modifier::DIM),
3825 ),
3826 ]);
3827 if selected {
3828 line.style = menu_style::selected_row_bg_style();
3829 }
3830 lines.push(line);
3831 }
3832 }
3833 }
3834
3835 // Description pane for the selected setting.
3836 if !compact && let Some(row) = self.rows.get(self.selected) {
3837 lines.push(Line::from(""));
3838 lines.push(Line::from(Span::styled(
3839 "────────────────────────────────────────",
3840 Style::default().fg(palette::TEXT_DIM),
3841 )));
3842 let desc = Self::setting_description(&row.key);
3843 lines.push(Line::from(Span::styled(
3844 format!(" {desc}"),
3845 Style::default().fg(palette::TEXT_MUTED),
3846 )));
3847 lines.push(Line::from(Span::styled(
3848 " Enter change · R reset · Esc close",
3849 Style::default().fg(palette::TEXT_HINT),
3850 )));
3851 }
3852 *self.last_row_hitboxes.borrow_mut() = row_hitboxes;
3853
3854 if items.is_empty() {
3855 let message = if self.filter.is_empty() {
3856 self.tr(MessageId::ConfigNoSettings).to_string()
3857 } else {
3858 format!(
3859 "{}\"{}\".",
3860 self.tr(MessageId::ConfigNoMatchesPrefix),
3861 self.filter
3862 )
3863 };
3864 lines.push(Line::from(Span::styled(
3865 message,
3866 Style::default().fg(palette::TEXT_MUTED),
3867 )));
3868 }
3869
3870 if bottom_lines > 0 {
3871 let selected_hint = self.selected_row_hint();
3872 let bottom_text = if let Some(status) = self.status.as_ref() {
3873 status.clone()
3874 } else if !self.filter.is_empty() {
3875 format!(
3876 "{}: {match_count}",
3877 self.tr(MessageId::ConfigFilteredSettings)
3878 )
3879 } else if scrollable && !items.is_empty() {
3880 let showing = format!(
3881 "{} {}-{} / {}",
3882 self.tr(MessageId::ConfigShowing),
3883 start.saturating_add(1),
3884 end,
3885 items.len()
3886 );
3887 if let Some(hint) = selected_hint {
3888 format!("{showing} | {hint}")
3889 } else {
3890 showing
3891 }
3892 } else {
3893 selected_hint.unwrap_or_default()
3894 };
3895 lines.push(Line::from(Span::styled(
3896 crate::tui::ui_text::semantic_truncate(&bottom_text, usize::from(inner.width)),
3897 Style::default().fg(palette::TEXT_MUTED),
3898 )));
3899 }
3900 self.last_render_scroll.set(start);
3901
3902 let footer = if !self.filter.is_empty() {
3903 self.tr(MessageId::ConfigFooterFiltered)
3904 } else if scrollable {
3905 self.tr(MessageId::ConfigFooterScrollable)
3906 } else {
3907 self.tr(MessageId::ConfigFooterDefault)
3908 };
3909 (lines, footer.to_string())
3910 };
3911
3912 // Footer wraps inside the body so its hints can never run off the modal
3913 // edge (#3732); the table renders into the area above it.
3914 let content = render_modal_text_footer(
3915 inner,
3916 buf,
3917 &footer,
3918 Style::default().fg(palette::TEXT_MUTED),
3919 );
3920 let content = if self.editing.is_none() {
3921 render_panel_scroll_rail(
3922 content,
3923 buf,
3924 self.visible_items().len(),
3925 self.last_render_scroll.get(),
3926 self.last_visible_rows.get().max(1),
3927 true,
3928 )
3929 } else {
3930 content
3931 };
3932 Paragraph::new(lines)
3933 .style(Style::default().fg(palette::TEXT_PRIMARY))
3934 .scroll((0, 0))
3935 .render(content, buf);
3936 }
3937 }
3938
3939 pub mod help;
3940
3941 pub use help::HelpView;
3942
3943 pub struct SubAgentsView {
3944 agents: Vec<SubAgentResult>,
3945 scroll: usize,
3946 }
3947
3948 /// Build the agent rows shown by `/subagents`.
3949 ///
3950 /// The engine manager is the durable source of truth, but live UI cards can
3951 /// briefly be ahead of the manager-list refresh. Include those live rows so
3952 /// the command does not say "no agents" while the footer/sidebar already show
3953 /// active delegated work.
3954 pub(crate) fn subagent_view_agents(
3955 app: &App,
3956 manager_agents: &[SubAgentResult],
3957 ) -> Vec<SubAgentResult> {
3958 let mut agents = manager_agents.to_vec();
3959 let manager_agent_count = agents.len();
3960 let mut seen: std::collections::HashSet<String> =
3961 agents.iter().map(|agent| agent.agent_id.clone()).collect();
3962
3963 for (agent_id, progress) in &app.agent_progress {
3964 if seen.insert(agent_id.clone()) {
3965 agents.push(live_subagent_result(
3966 agent_id,
3967 FleetRole::Worker,
3968 SubAgentStatus::Running,
3969 progress,
3970 Some("live"),
3971 None, // live rows compute nickname from agent manager on render
3972 ));
3973 }
3974 }
3975
3976 for cell in &app.history {
3977 match cell {
3978 HistoryCell::SubAgent(SubAgentCell::Delegate(card))
3979 if seen.insert(card.agent_id.clone()) =>
3980 {
3981 let agent_type = FleetRole::from_str(&card.agent_type).unwrap_or(FleetRole::Worker);
3982 agents.push(live_subagent_result(
3983 &card.agent_id,
3984 agent_type,
3985 lifecycle_to_subagent_status(card.status),
3986 card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
3987 Some("transcript"),
3988 None, // transcript-derived rows get nickname from manager on render
3989 ));
3990 }
3991 HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => {
3992 for worker in &card.workers {
3993 if seen.insert(worker.agent_id.clone()) {
3994 let objective = format!(
3995 "{} worker {}",
3996 summarize_tool_output(&card.kind),
3997 summarize_tool_output(&worker.worker_id)
3998 );
3999 agents.push(live_subagent_result(
4000 &worker.agent_id,
4001 FleetRole::Worker,
4002 lifecycle_to_subagent_status(worker.status),
4003 &objective,
4004 Some(card.kind.as_str()),
4005 None, // fanout worker rows get nickname from manager on render
4006 ));
4007 }
4008 }
4009 }
4010 _ => {}
4011 }
4012 }
4013
4014 let mut display_names = localized_whale_display_names(
4015 agents[..manager_agent_count]
4016 .iter()
4017 .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())),
4018 app.ui_locale.tag(),
4019 );
4020 for agent in &mut agents[..manager_agent_count] {
4021 agent.nickname = display_names.remove(&agent.agent_id);
4022 }
4023 for agent in &mut agents[manager_agent_count..] {
4024 // Progress and transcript rows can arrive before ListSubAgents. Keep
4025 // their stable Agent-N placeholder until the manager snapshot supplies
4026 // the locale-neutral identity needed for generated whale display.
4027 agent.nickname = app.agent_label_map.get(&agent.agent_id).cloned();
4028 }
4029
4030 agents
4031 }
4032
4033 fn lifecycle_to_subagent_status(status: AgentLifecycle) -> SubAgentStatus {
4034 match status {
4035 AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running,
4036 AgentLifecycle::Completed => SubAgentStatus::Completed,
4037 AgentLifecycle::Failed => SubAgentStatus::Failed("failed in transcript".to_string()),
4038 AgentLifecycle::Cancelled => SubAgentStatus::Cancelled,
4039 AgentLifecycle::Interrupted => {
4040 SubAgentStatus::Interrupted("interrupted in transcript".to_string())
4041 }
4042 }
4043 }
4044
4045 fn live_subagent_result(
4046 agent_id: &str,
4047 agent_type: FleetRole,
4048 status: SubAgentStatus,
4049 objective: &str,
4050 role: Option<&str>,
4051 nickname: Option<String>,
4052 ) -> SubAgentResult {
4053 SubAgentResult {
4054 name: agent_id.to_string(),
4055 agent_id: agent_id.to_string(),
4056 context_mode: "fresh".to_string(),
4057 fork_context: false,
4058 workspace: None,
4059 git_branch: None,
4060 agent_type,
4061 assignment: SubAgentAssignment {
4062 objective: summarize_tool_output(objective),
4063 role: role.map(str::to_string),
4064 },
4065 model: String::new(),
4066 nickname,
4067 status,
4068 worker_status: None,
4069 runtime_permissions: None,
4070 parent_run_id: None,
4071 spawn_depth: 0,
4072 result: None,
4073 steps_taken: 0,
4074 checkpoint: None,
4075 needs_input: None,
4076 duration_ms: 0,
4077 from_prior_session: false,
4078 }
4079 }
4080
4081 impl SubAgentsView {
4082 pub fn new(agents: Vec<SubAgentResult>) -> Self {
4083 Self { agents, scroll: 0 }
4084 }
4085 }
4086
4087 impl ModalView for SubAgentsView {
4088 fn kind(&self) -> ModalKind {
4089 ModalKind::SubAgents
4090 }
4091
4092 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
4093 self
4094 }
4095
4096 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
4097 use crossterm::event::KeyCode;
4098
4099 match key.code {
4100 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
4101 KeyCode::Enter | KeyCode::Char('r') | KeyCode::Char('R') => {
4102 ViewAction::Emit(ViewEvent::SubAgentsRefresh)
4103 }
4104 KeyCode::Char('f') | KeyCode::Char('F') => {
4105 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
4106 action: CommandPaletteAction::ExecuteCommand {
4107 command: "/fleet".to_string(),
4108 },
4109 })
4110 }
4111 KeyCode::Up | KeyCode::Char('k') => {
4112 self.scroll = self.scroll.saturating_sub(1);
4113 ViewAction::None
4114 }
4115 KeyCode::Down | KeyCode::Char('j') => {
4116 self.scroll = self.scroll.saturating_add(1);
4117 ViewAction::None
4118 }
4119 _ => ViewAction::None,
4120 }
4121 }
4122
4123 fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool {
4124 self.agents = agents.to_vec();
4125 self.scroll = self.scroll.min(self.agents.len().saturating_sub(1));
4126 true
4127 }
4128
4129 fn render(&self, area: Rect, buf: &mut Buffer) {
4130 Clear.render(area, buf);
4131 Block::default()
4132 .style(Style::default().bg(palette::WHALE_BG))
4133 .render(area, buf);
4134
4135 let mut lines: Vec<Line> = Vec::new();
4136 let content_width = area.width.saturating_sub(4) as usize;
4137
4138 if self.agents.is_empty() {
4139 lines.push(Line::from(Span::styled(
4140 "No Fleet workers running.",
4141 Style::default().fg(palette::TEXT_MUTED),
4142 )));
4143 lines.push(Line::from(Span::styled(
4144 "Configure roles and launch posture with /fleet.",
4145 Style::default().fg(palette::TEXT_DIM),
4146 )));
4147 } else {
4148 let mut running = Vec::new();
4149 let mut completed = Vec::new();
4150 let mut interrupted = Vec::new();
4151 let mut failed = Vec::new();
4152 let mut cancelled = Vec::new();
4153
4154 for agent in &self.agents {
4155 match agent.status {
4156 SubAgentStatus::Running => running.push(agent),
4157 SubAgentStatus::Completed => completed.push(agent),
4158 SubAgentStatus::Interrupted(_) => interrupted.push(agent),
4159 SubAgentStatus::Failed(_) => failed.push(agent),
4160 SubAgentStatus::Cancelled => cancelled.push(agent),
4161 SubAgentStatus::BudgetExhausted => failed.push(agent),
4162 }
4163 }
4164
4165 let status_summary = [
4166 ("Running", running.len(), palette::STATUS_WARNING),
4167 ("Completed", completed.len(), palette::STATUS_SUCCESS),
4168 ("Interrupted", interrupted.len(), palette::STATUS_WARNING),
4169 ("Failed", failed.len(), palette::WHALE_ERROR),
4170 ("Cancelled", cancelled.len(), palette::TEXT_MUTED),
4171 ];
4172
4173 lines.push(Line::from(Span::styled(
4174 "Fleet workers",
4175 Style::default().fg(palette::WHALE_INFO).bold(),
4176 )));
4177 lines.push(Line::from(Span::styled(
4178 "Sub-agent roles are Fleet worker roles.",
4179 Style::default().fg(palette::TEXT_DIM),
4180 )));
4181
4182 let mut summary_parts = Vec::new();
4183 for (label, count, color) in status_summary {
4184 summary_parts.push(Line::from(Span::styled(
4185 format!("{label}: {count}"),
4186 Style::default().fg(color),
4187 )));
4188 }
4189
4190 let mut summary = vec![Span::styled(" ", Style::default().fg(palette::TEXT_DIM))];
4191 for (idx, part) in summary_parts.into_iter().enumerate() {
4192 if idx > 0 {
4193 summary.push(Span::raw(" · "));
4194 }
4195 summary.extend(part);
4196 }
4197 lines.push(Line::from(summary));
4198 lines.push(Line::from(Span::styled(
4199 "",
4200 Style::default().fg(palette::TEXT_DIM),
4201 )));
4202
4203 running.sort_by(|a, b| {
4204 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
4205 order.then_with(|| a.agent_id.cmp(&b.agent_id))
4206 });
4207 completed.sort_by(|a, b| {
4208 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
4209 order.then_with(|| a.agent_id.cmp(&b.agent_id))
4210 });
4211 interrupted.sort_by(|a, b| {
4212 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
4213 order.then_with(|| a.agent_id.cmp(&b.agent_id))
4214 });
4215 failed.sort_by(|a, b| {
4216 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
4217 order.then_with(|| a.agent_id.cmp(&b.agent_id))
4218 });
4219 cancelled.sort_by(|a, b| {
4220 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
4221 order.then_with(|| a.agent_id.cmp(&b.agent_id))
4222 });
4223
4224 append_subagent_group(
4225 &mut lines,
4226 "Running",
4227 palette::STATUS_WARNING.into(),
4228 &running,
4229 content_width,
4230 );
4231 append_subagent_group(
4232 &mut lines,
4233 "Completed",
4234 palette::STATUS_SUCCESS.into(),
4235 &completed,
4236 content_width,
4237 );
4238 append_subagent_group(
4239 &mut lines,
4240 "Interrupted",
4241 palette::STATUS_WARNING.into(),
4242 &interrupted,
4243 content_width,
4244 );
4245 append_subagent_group(
4246 &mut lines,
4247 "Failed",
4248 palette::WHALE_ERROR.into(),
4249 &failed,
4250 content_width,
4251 );
4252 append_subagent_group(
4253 &mut lines,
4254 "Cancelled",
4255 palette::TEXT_MUTED.into(),
4256 &cancelled,
4257 content_width,
4258 );
4259 }
4260
4261 let content = render_modal_footer(
4262 area,
4263 buf,
4264 &[
4265 ActionHint::new("Esc", "close"),
4266 ActionHint::new("R", "refresh"),
4267 ActionHint::new("F", "roster/setup"),
4268 ],
4269 );
4270 let shell = ratatui::layout::Layout::default()
4271 .direction(ratatui::layout::Direction::Vertical)
4272 .constraints([
4273 ratatui::layout::Constraint::Length(3),
4274 ratatui::layout::Constraint::Min(1),
4275 ])
4276 .split(content);
4277 Paragraph::new(vec![
4278 Line::from(vec![
4279 Span::styled(
4280 "─ fleet ",
4281 Style::default().fg(palette::WHALE_ACTION).bold(),
4282 ),
4283 Span::styled(
4284 "──────────────────────── ",
4285 Style::default().fg(palette::BORDER_COLOR),
4286 ),
4287 Span::styled("roster setup ", Style::default().fg(palette::TEXT_MUTED)),
4288 Span::styled("workers", Style::default().fg(palette::WHALE_INFO).bold()),
4289 Span::styled(
4290 " ─────────────────",
4291 Style::default().fg(palette::BORDER_COLOR),
4292 ),
4293 ]),
4294 Line::from(""),
4295 Line::from(Span::styled(
4296 " live worker status · role · objective · model · elapsed",
4297 Style::default().fg(palette::TEXT_MUTED),
4298 )),
4299 ])
4300 .render(shell[0], buf);
4301
4302 let total_lines = lines.len();
4303 let visible_lines = usize::from(shell[1].height).max(1);
4304 let max_scroll = total_lines.saturating_sub(visible_lines);
4305 let scroll = self.scroll.min(max_scroll);
4306
4307 Paragraph::new(lines)
4308 .scroll((scroll as u16, 0))
4309 .render(shell[1], buf);
4310 }
4311 }
4312
4313 fn append_subagent_group(
4314 lines: &mut Vec<ratatui::text::Line<'static>>,
4315 title: &str,
4316 section_style: ratatui::style::Style,
4317 agents: &[&SubAgentResult],
4318 content_width: usize,
4319 ) {
4320 use ratatui::{
4321 style::Style,
4322 text::{Line, Span},
4323 };
4324 if agents.is_empty() {
4325 return;
4326 }
4327
4328 lines.push(Line::from(Span::styled(
4329 format!("{title} ({})", agents.len()),
4330 section_style.bold(),
4331 )));
4332
4333 for agent in agents {
4334 let id = truncate_view_text(&agent.agent_id, 11);
4335 let display_name = agent
4336 .nickname
4337 .as_deref()
4338 .map(|nick| format!("{nick:<12}"))
4339 .unwrap_or_else(|| format!("{id:<12}"));
4340 let kind = format_agent_type(&agent.agent_type);
4341 let (status, status_style, status_detail) = format_agent_status(&agent.status);
4342
4343 lines.push(Line::from(vec![
4344 Span::raw(" "),
4345 Span::styled(display_name, Style::default().fg(palette::TEXT_PRIMARY)),
4346 Span::raw(" "),
4347 Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)),
4348 Span::styled(
4349 format!("{kind:<9}"),
4350 Style::default().fg(palette::TEXT_MUTED),
4351 ),
4352 Span::raw(" "),
4353 Span::styled(format!("{status:<10}"), status_style),
4354 Span::raw(" "),
4355 Span::styled(
4356 format!("{:>4}✦", agent.steps_taken),
4357 Style::default().fg(palette::TEXT_DIM),
4358 ),
4359 Span::raw(" "),
4360 Span::styled(
4361 format!("{:>6}ms", agent.duration_ms),
4362 Style::default().fg(palette::TEXT_DIM),
4363 ),
4364 ]));
4365
4366 if let Some(detail) = status_detail {
4367 let max_len = content_width.saturating_sub(10);
4368 let detail = truncate_view_text(detail, max_len);
4369 lines.push(Line::from(vec![
4370 Span::styled(" reason: ", Style::default().fg(palette::TEXT_MUTED)),
4371 Span::styled(detail, Style::default().fg(palette::WHALE_ERROR)),
4372 ]));
4373 }
4374
4375 if let Some(role) = agent.assignment.role.as_deref() {
4376 let max_len = content_width.saturating_sub(14);
4377 let role = truncate_view_text(role, max_len);
4378 lines.push(Line::from(vec![
4379 Span::styled(" role: ", Style::default().fg(palette::TEXT_MUTED)),
4380 Span::styled(role, Style::default().fg(palette::WHALE_INFO)),
4381 ]));
4382 }
4383
4384 if let Some(permissions) = agent.runtime_permissions.as_ref() {
4385 let posture = format!(
4386 "network={} · shell={} · write={}",
4387 if permissions.network { "on" } else { "off" },
4388 permissions.shell,
4389 if permissions.write { "on" } else { "off" },
4390 );
4391 let max_len = content_width.saturating_sub(18);
4392 let posture = truncate_view_text(&posture, max_len);
4393 lines.push(Line::from(vec![
4394 Span::styled(" posture: ", Style::default().fg(palette::TEXT_MUTED)),
4395 Span::styled(posture, Style::default().fg(palette::WHALE_INFO)),
4396 ]));
4397 }
4398
4399 if let Some(branch) = agent.git_branch.as_deref() {
4400 let workspace = agent
4401 .workspace
4402 .as_deref()
4403 .and_then(|path| path.file_name())
4404 .and_then(|name| name.to_str())
4405 .filter(|name| !name.is_empty());
4406 let mut branch_detail = format!("branch {branch}");
4407 if let Some(workspace) = workspace {
4408 branch_detail.push_str(&format!(" @ {workspace}"));
4409 }
4410 let max_len = content_width.saturating_sub(14);
4411 let branch_detail = truncate_view_text(&branch_detail, max_len);
4412 lines.push(Line::from(vec![
4413 Span::styled(" git: ", Style::default().fg(palette::TEXT_MUTED)),
4414 Span::styled(branch_detail, Style::default().fg(palette::WHALE_INFO)),
4415 ]));
4416 }
4417
4418 let max_len = content_width.saturating_sub(18);
4419 let objective = truncate_view_text(&agent.assignment.objective, max_len);
4420 lines.push(Line::from(vec![
4421 Span::styled(" objective: ", Style::default().fg(palette::TEXT_MUTED)),
4422 Span::styled(objective, Style::default().fg(palette::TEXT_DIM)),
4423 ]));
4424
4425 if let Some(result) = agent.result.as_ref() {
4426 let max_len = content_width.saturating_sub(16);
4427 let preview = truncate_view_text(result, max_len);
4428 lines.push(Line::from(vec![
4429 Span::styled(" result: ", Style::default().fg(palette::TEXT_MUTED)),
4430 Span::styled(preview, Style::default().fg(palette::TEXT_DIM)),
4431 ]));
4432 }
4433 }
4434
4435 lines.push(Line::from(""));
4436 }
4437
4438 fn agent_type_order(agent_type: &FleetRole) -> u8 {
4439 match agent_type {
4440 FleetRole::Worker => 0,
4441 FleetRole::Scout => 1,
4442 FleetRole::Planner => 2,
4443 FleetRole::Builder => 3,
4444 FleetRole::Verifier => 4,
4445 FleetRole::Reviewer => 5,
4446 FleetRole::Consultant => 6,
4447 FleetRole::Custom => 7,
4448 }
4449 }
4450
4451 fn format_agent_type(agent_type: &FleetRole) -> &'static str {
4452 // Source of truth lives on the enum so any new role lands in both
4453 // the user-visible label and the sort order via the as_str() helper.
4454 agent_type.as_str()
4455 }
4456
4457 fn format_agent_status(
4458 status: &SubAgentStatus,
4459 ) -> (&'static str, ratatui::style::Style, Option<&str>) {
4460 use ratatui::style::Style;
4461
4462 match status {
4463 SubAgentStatus::Running => ("running", Style::default().fg(palette::WHALE_INFO), None),
4464 SubAgentStatus::Completed => (
4465 "completed",
4466 Style::default().fg(palette::STATUS_SUCCESS),
4467 None,
4468 ),
4469 SubAgentStatus::Interrupted(reason) => (
4470 "interrupted",
4471 Style::default().fg(palette::STATUS_WARNING),
4472 Some(reason.as_str()),
4473 ),
4474 SubAgentStatus::Cancelled => ("cancelled", Style::default().fg(palette::TEXT_MUTED), None),
4475 SubAgentStatus::BudgetExhausted => (
4476 "budget_exhausted",
4477 Style::default().fg(palette::STATUS_WARNING),
4478 None,
4479 ),
4480 SubAgentStatus::Failed(reason) => (
4481 "failed",
4482 Style::default().fg(palette::WHALE_ERROR),
4483 Some(reason.as_str()),
4484 ),
4485 }
4486 }
4487
4488 fn truncate_view_text(text: &str, max_chars: usize) -> String {
4489 if max_chars == 0 {
4490 return String::new();
4491 }
4492 match text.char_indices().nth(max_chars) {
4493 Some((idx, _)) => text[..idx].to_string(),
4494 None => text.to_string(),
4495 }
4496 }
4497
4498 fn fit_config_column(text: &str, width: usize) -> String {
4499 let mut fitted = crate::tui::ui_text::truncate_line_to_width(text, width);
4500 let padding = width.saturating_sub(crate::tui::ui_text::text_display_width(&fitted));
4501 fitted.push_str(&" ".repeat(padding));
4502 fitted
4503 }
4504
4505 #[cfg(test)]
4506 mod tests {
4507 use super::{
4508 ActionHint, ConfigListItem, ConfigScope, ConfigTab, ConfigView, EmptyState,
4509 FocusTextureMode, HelpView, ListDetailLayout, ModalKind, ModalView, SettingKind,
4510 SettingsRegistry, ViewAction, ViewEvent, ViewStack, action_footer_lines,
4511 canonical_config_choice, centered_modal_area, config_choice_detail, config_choice_label,
4512 config_choice_values, config_label_for_key, config_label_for_key_for_locale,
4513 render_modal_footer_with_gutter, render_underwater_surface, subagent_view_agents,
4514 truncate_view_text,
4515 };
4516 use crate::config::Config;
4517 use crate::localization::{Locale, MessageId, tr};
4518 use crate::palette;
4519 use crate::settings::Settings;
4520 use crate::tools::subagent::{FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus};
4521 use crate::tui::app::{App, TuiOptions};
4522 use crate::tui::history::{HistoryCell, SubAgentCell};
4523 use crate::tui::views::{CommandPaletteAction, SubAgentsView};
4524 use crate::tui::widgets::agent_card::{AgentLifecycle, FanoutCard};
4525 use crossterm::event::{
4526 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
4527 };
4528 use ratatui::{
4529 buffer::Buffer,
4530 layout::Rect,
4531 style::{Color, Style},
4532 };
4533 use std::borrow::Cow;
4534 use std::ffi::OsString;
4535 use std::fs;
4536 use std::path::PathBuf;
4537 use tempfile::TempDir;
4538 use unicode_width::UnicodeWidthStr;
4539
4540 /// Terminal sizes the v0.8.66 modal blocker (#3732) requires every overlay
4541 /// to remain readable and fully operable at.
4542 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
4543
4544 /// Render a modal through the `ViewStack` (so the shared opaque backdrop is
4545 /// painted exactly as in production) over a sentinel-filled buffer, then
4546 /// assert: every `required_label` is visible, no sentinel `X` survives
4547 /// anywhere (fully opaque), the center cell carries the modal ink, and no
4548 /// row overflows the frame width.
4549 fn assert_modal_usable_and_opaque<V: ModalView + 'static>(
4550 make: impl Fn() -> V,
4551 required_labels: &[&str],
4552 ) {
4553 for (w, h) in BLOCKER_SIZES {
4554 let area = Rect::new(0, 0, w, h);
4555 let mut buf = Buffer::empty(area);
4556 let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green);
4557 for y in 0..h {
4558 for x in 0..w {
4559 buf[(x, y)].set_symbol("X").set_style(sentinel_style);
4560 }
4561 }
4562 let mut stack = ViewStack::new();
4563 stack.push(make());
4564 stack.render(area, &mut buf);
4565
4566 let rows: Vec<String> = (0..h)
4567 .map(|y| {
4568 (0..w)
4569 .map(|x| buf[(x, y)].symbol().to_string())
4570 .collect::<String>()
4571 })
4572 .collect();
4573 let text = rows.join("\n");
4574
4575 for label in required_labels {
4576 assert!(text.contains(label), "{w}x{h}: missing '{label}'");
4577 }
4578 let unpainted = (0..h).find_map(|y| {
4579 (0..w).find_map(|x| {
4580 let cell = &buf[(x, y)];
4581 (cell.symbol() == "X" && cell.fg == Color::Magenta && cell.bg == Color::Green)
4582 .then_some((x, y))
4583 })
4584 });
4585 assert!(
4586 unpainted.is_none(),
4587 "{w}x{h}: background bleed-through at {unpainted:?}"
4588 );
4589 assert_eq!(
4590 buf[(w / 2, h / 2)].bg,
4591 palette::WHALE_BG,
4592 "{w}x{h}: modal interior must be opaque"
4593 );
4594 for (y, row) in rows.iter().enumerate() {
4595 assert!(
4596 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
4597 "{w}x{h}: row {y} overflows width: {row:?}"
4598 );
4599 }
4600 }
4601 }
4602
4603 #[test]
4604 fn config_modal_is_usable_and_opaque_at_blocker_sizes() {
4605 let _lock = crate::test_support::lock_test_env();
4606 // "Search" is the hardcoded English search-row label; asserting it (plus
4607 // the opacity/overflow checks) proves the modal renders fully and its
4608 // footer wraps inside bounds rather than clipping.
4609 assert_modal_usable_and_opaque(|| create_config_view(Locale::En), &["Search"]);
4610 }
4611
4612 #[test]
4613 fn subagents_modal_is_usable_and_opaque_at_blocker_sizes() {
4614 assert_modal_usable_and_opaque(
4615 || SubAgentsView::new(Vec::new()),
4616 &["close", "refresh", "setup"],
4617 );
4618 }
4619
4620 /// Focus-texture prototype (#4823): with a mode forced on, a real
4621 /// full-screen modal must render exactly as before — the texture pass
4622 /// no-ops because the focus region covers (nearly) the whole frame.
4623 /// The default `Off` case is pinned by the existing
4624 /// `*_modal_is_usable_and_opaque_at_blocker_sizes` tests above: they run
4625 /// unmodified because `ViewStack::new()` defaults to `Off`, which leaves
4626 /// the buffer byte-identical to the pre-prototype render.
4627 #[test]
4628 fn focus_texture_modes_keep_fullscreen_modal_usable_and_opaque() {
4629 let _lock = crate::test_support::lock_test_env();
4630 let theme = crate::palette::ThemeId::Whale.ui_theme();
4631 for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] {
4632 for (w, h) in BLOCKER_SIZES {
4633 let area = Rect::new(0, 0, w, h);
4634 let mut buf = Buffer::empty(area);
4635 let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green);
4636 for y in 0..h {
4637 for x in 0..w {
4638 buf[(x, y)].set_symbol("X").set_style(sentinel_style);
4639 }
4640 }
4641 let mut stack = ViewStack::new();
4642 stack.push(create_config_view(Locale::En));
4643 stack.set_focus_texture(mode, theme);
4644 stack.render(area, &mut buf);
4645
4646 let rows: Vec<String> = (0..h)
4647 .map(|y| {
4648 (0..w)
4649 .map(|x| buf[(x, y)].symbol().to_string())
4650 .collect::<String>()
4651 })
4652 .collect();
4653 let text = rows.join("\n");
4654
4655 assert!(
4656 text.contains("Search"),
4657 "{mode:?} {w}x{h}: missing 'Search'"
4658 );
4659 let unpainted = (0..h).find_map(|y| {
4660 (0..w).find_map(|x| {
4661 let cell = &buf[(x, y)];
4662 (cell.symbol() == "X"
4663 && cell.fg == Color::Magenta
4664 && cell.bg == Color::Green)
4665 .then_some((x, y))
4666 })
4667 });
4668 assert!(
4669 unpainted.is_none(),
4670 "{mode:?} {w}x{h}: background bleed-through at {unpainted:?}"
4671 );
4672 assert_eq!(
4673 buf[(w / 2, h / 2)].bg,
4674 palette::WHALE_BG,
4675 "{mode:?} {w}x{h}: modal interior must be opaque"
4676 );
4677 }
4678 }
4679 }
4680
4681 /// The texture actually engages outside an *inline* modal's band: the
4682 /// approval prompt only occupies a bottom strip, so the sentinel field
4683 /// above it goes through the scrim/grain pass. The modal is painted
4684 /// after the texture, so its band stays fully opaque and its labels
4685 /// survive at every blocker size.
4686 #[test]
4687 fn focus_texture_modes_keep_inline_modal_usable() {
4688 let theme = crate::palette::ThemeId::Whale.ui_theme();
4689 for mode in [FocusTextureMode::Scrim, FocusTextureMode::Grain] {
4690 for (w, h) in BLOCKER_SIZES {
4691 let area = Rect::new(0, 0, w, h);
4692 let mut buf = Buffer::empty(area);
4693 let sentinel_style = Style::default().fg(Color::Magenta).bg(Color::Green);
4694 for y in 0..h {
4695 for x in 0..w {
4696 buf[(x, y)].set_symbol("X").set_style(sentinel_style);
4697 }
4698 }
4699 let request = crate::tui::approval::ApprovalRequest::new(
4700 "test-id",
4701 "read_file",
4702 "Read a file from disk",
4703 &serde_json::json!({"path": "src/main.rs"}),
4704 "tool:read_file",
4705 );
4706 let mut stack = ViewStack::new();
4707 stack.push(crate::tui::approval::ApprovalView::new(request));
4708 stack.set_focus_texture(mode, theme);
4709 let focus = stack
4710 .top_occupied_region(area)
4711 .expect("approval view on the stack");
4712 stack.render(area, &mut buf);
4713
4714 let rows: Vec<String> = (0..h)
4715 .map(|y| {
4716 (0..w)
4717 .map(|x| buf[(x, y)].symbol().to_string())
4718 .collect::<String>()
4719 })
4720 .collect();
4721 let text = rows.join("\n");
4722
4723 assert!(
4724 text.contains("Do you want to proceed?") && text.contains("read_file"),
4725 "{mode:?} {w}x{h}: approval prompt must survive the texture"
4726 );
4727 // Zero sentinel bleed INSIDE the focused band: the backdrop
4728 // and the modal own every cell there. Outside the band the
4729 // texture intentionally leaves the sentinel glyphs in place
4730 // (Scrim only re-colors; Grain never overwrites text).
4731 let mut whale_bg_cells = 0_u32;
4732 for y in focus.top()..focus.bottom() {
4733 for x in focus.left()..focus.right() {
4734 let cell = &buf[(x, y)];
4735 assert!(
4736 !(cell.symbol() == "X"
4737 && cell.fg == Color::Magenta
4738 && cell.bg == Color::Green),
4739 "{mode:?} {w}x{h}: sentinel bleed inside focus at ({x},{y})"
4740 );
4741 if cell.bg == palette::WHALE_BG {
4742 whale_bg_cells += 1;
4743 }
4744 }
4745 }
4746 // The band keeps the opaque modal ink. (Not every cell: the
4747 // selected option row carries its own highlight background.)
4748 assert!(
4749 whale_bg_cells > 0,
4750 "{mode:?} {w}x{h}: modal band lost its opaque WHALE_BG surface"
4751 );
4752 }
4753 }
4754 }
4755
4756 #[test]
4757 fn centered_modal_area_clamps_and_centers() {
4758 // Roomy frame: preferred size honoured, centered.
4759 let area = Rect::new(0, 0, 160, 40);
4760 let rect = centered_modal_area(area, 80, 20, 40, 10);
4761 assert_eq!((rect.width, rect.height), (80, 20));
4762 assert_eq!(rect.x, (160 - 80) / 2);
4763 assert_eq!(rect.y, (40 - 20) / 2);
4764
4765 // Tiny frame: never exceeds the frame even below the requested minimum.
4766 let tiny = Rect::new(0, 0, 30, 8);
4767 let rect = centered_modal_area(tiny, 80, 20, 40, 10);
4768 assert!(rect.width <= tiny.width, "width must fit frame");
4769 assert!(rect.height <= tiny.height, "height must fit frame");
4770 assert!(rect.x + rect.width <= tiny.width);
4771 assert!(rect.y + rect.height <= tiny.height);
4772 }
4773
4774 #[test]
4775 fn action_footer_wraps_instead_of_overflowing() {
4776 let hints = [
4777 ActionHint::new("↑↓", "move"),
4778 ActionHint::new("a-z", "jump"),
4779 ActionHint::new("Enter", "apply"),
4780 ActionHint::new("R", "edit key"),
4781 ActionHint::new("M", "models"),
4782 ActionHint::new("Esc", "cancel"),
4783 ];
4784
4785 // Wide enough for a single row.
4786 let wide = action_footer_lines(&hints, 120);
4787 assert_eq!(wide.len(), 1);
4788 assert!(wide[0].width() <= 120);
4789
4790 // Narrow forces wrapping but never truncates: every action survives and
4791 // no produced line exceeds the available width.
4792 let narrow = action_footer_lines(&hints, 28);
4793 assert!(narrow.len() >= 2, "narrow footer should wrap to >1 row");
4794 for line in &narrow {
4795 assert!(
4796 line.width() <= 28,
4797 "wrapped footer row overflows: {} cols",
4798 line.width()
4799 );
4800 }
4801 let joined: String = narrow
4802 .iter()
4803 .flat_map(|l| l.spans.iter())
4804 .map(|s| s.content.as_ref())
4805 .collect();
4806 for label in ["move", "jump", "apply", "edit key", "models", "cancel"] {
4807 assert!(joined.contains(label), "footer dropped action: {label}");
4808 }
4809 }
4810
4811 #[test]
4812 fn render_modal_footer_reserves_rows_and_returns_body() {
4813 let inner = Rect::new(2, 2, 40, 10);
4814 let mut buf = Buffer::empty(Rect::new(0, 0, 44, 14));
4815 let hints = [
4816 ActionHint::new("Enter", "save"),
4817 ActionHint::new("Esc", "cancel"),
4818 ];
4819 let body = render_modal_footer_with_gutter(inner, &mut buf, &hints);
4820 // Normal-height overlays reserve a single quiet gutter above the
4821 // one-row footer, so body prose never runs into the action rail.
4822 assert_eq!(body.y, inner.y);
4823 assert_eq!(body.height, inner.height - 2);
4824 assert_eq!(body.y + body.height, inner.y + inner.height - 2);
4825 let gutter_y = inner.y + inner.height - 2;
4826 assert!(
4827 (inner.x..inner.right()).all(|x| buf[(x, gutter_y)].symbol().trim().is_empty()),
4828 "modal footer gutter should stay visually quiet"
4829 );
4830 }
4831
4832 #[test]
4833 fn list_detail_layout_splits_wide_and_stacks_narrow() {
4834 let wide = ListDetailLayout::split(Rect::new(0, 0, 120, 24), 34);
4835 assert!(!wide.stacked);
4836 assert!(wide.list.width >= 30);
4837 assert!(wide.detail.width >= 34);
4838 assert_eq!(wide.list.height, 24);
4839 assert_eq!(wide.detail.height, 24);
4840 assert!(wide.list.right() < wide.detail.left());
4841
4842 let narrow = ListDetailLayout::split(Rect::new(0, 0, 80, 20), 34);
4843 assert!(narrow.stacked);
4844 assert_eq!(narrow.list.width, 80);
4845 assert_eq!(narrow.detail.width, 80);
4846 assert!(narrow.list.bottom() <= narrow.detail.top());
4847 assert!(narrow.list.height > 0);
4848 }
4849
4850 #[test]
4851 fn empty_state_renders_copy_and_actions() {
4852 let area = Rect::new(0, 0, 48, 8);
4853 let mut buf = Buffer::empty(area);
4854 EmptyState::new("Nothing here", "Use search or switch categories.")
4855 .primary_action("/", "filter")
4856 .secondary_action("Esc", "cancel")
4857 .render(area, &mut buf);
4858
4859 let text = (0..area.height)
4860 .map(|y| {
4861 (0..area.width)
4862 .map(|x| buf[(x, y)].symbol().to_string())
4863 .collect::<String>()
4864 })
4865 .collect::<Vec<_>>()
4866 .join("\n");
4867 for expected in ["Nothing here", "Use search", "filter", "cancel"] {
4868 assert!(
4869 text.contains(expected),
4870 "empty state missing {expected:?}: {text:?}"
4871 );
4872 }
4873 }
4874
4875 struct ConfigSettingsEnvGuard {
4876 _tmp: TempDir,
4877 previous_config_path: Option<OsString>,
4878 _lock: crate::test_support::TestEnvLock,
4879 }
4880
4881 impl ConfigSettingsEnvGuard {
4882 fn new(settings_toml: &str) -> Self {
4883 let lock = crate::test_support::lock_test_env();
4884 let tmp = TempDir::new().expect("settings tempdir");
4885 let config_path = tmp.path().join(".deepseek").join("config.toml");
4886 let settings_path = config_path
4887 .parent()
4888 .expect("settings parent")
4889 .join("settings.toml");
4890 std::fs::create_dir_all(config_path.parent().expect("config parent"))
4891 .expect("config dir");
4892 std::fs::write(&settings_path, settings_toml).expect("settings file");
4893 let previous_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH");
4894 unsafe {
4895 std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
4896 }
4897 Self {
4898 _tmp: tmp,
4899 previous_config_path,
4900 _lock: lock,
4901 }
4902 }
4903 }
4904
4905 impl Drop for ConfigSettingsEnvGuard {
4906 fn drop(&mut self) {
4907 unsafe {
4908 match self.previous_config_path.take() {
4909 Some(previous) => std::env::set_var("DEEPSEEK_CONFIG_PATH", previous),
4910 None => std::env::remove_var("DEEPSEEK_CONFIG_PATH"),
4911 }
4912 }
4913 }
4914 }
4915
4916 fn create_test_app() -> App {
4917 static NEXT_CONFIG_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
4918 let config_id = NEXT_CONFIG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4919 let isolated_config_path = std::env::temp_dir().join(format!(
4920 "codewhale-config-view-test-{}-{config_id}.toml",
4921 std::process::id()
4922 ));
4923 let options = TuiOptions {
4924 // ConfigView consults the app's persisted config. Point generic
4925 // tests at a unique absent file so developer or concurrent test
4926 // settings cannot silently change which controls are editable.
4927 config_path: Some(isolated_config_path),
4928 ..crate::test_support::test_tui_options(PathBuf::from("."))
4929 };
4930 let mut app = App::new(options, &Config::default());
4931 app.api_provider = crate::config::ApiProvider::Deepseek;
4932 app
4933 }
4934
4935 fn cost_currency_row_for_settings(
4936 settings_toml: &str,
4937 ) -> (String, String, crate::pricing::CostCurrency, Locale) {
4938 let _guard = ConfigSettingsEnvGuard::new(settings_toml);
4939 let app = create_test_app();
4940 let view = ConfigView::new_for_app(&app);
4941 let row = view
4942 .rows
4943 .iter()
4944 .find(|row| row.key == "cost_currency")
4945 .expect("cost_currency row");
4946
4947 (
4948 row.value.clone(),
4949 view.row_display_value(row),
4950 app.cost_currency,
4951 app.ui_locale,
4952 )
4953 }
4954
4955 fn type_filter(view: &mut ConfigView, text: &str) {
4956 for ch in text.chars() {
4957 let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
4958 assert!(matches!(action, ViewAction::None));
4959 }
4960 }
4961
4962 fn manager_agent(id: &str, status: SubAgentStatus) -> SubAgentResult {
4963 SubAgentResult {
4964 name: id.to_string(),
4965 agent_id: id.to_string(),
4966 context_mode: "fresh".to_string(),
4967 fork_context: false,
4968 workspace: None,
4969 git_branch: None,
4970 agent_type: FleetRole::Scout,
4971 assignment: SubAgentAssignment {
4972 objective: "read the docs".to_string(),
4973 role: None,
4974 },
4975 model: "deepseek-v4-flash".to_string(),
4976 nickname: None,
4977 status,
4978 worker_status: None,
4979 runtime_permissions: None,
4980 parent_run_id: None,
4981 spawn_depth: 0,
4982 result: None,
4983 steps_taken: 1,
4984 checkpoint: None,
4985 needs_input: None,
4986 duration_ms: 10,
4987 from_prior_session: false,
4988 }
4989 }
4990
4991 #[test]
4992 fn subagent_view_agents_includes_progress_only_running_agent() {
4993 let mut app = create_test_app();
4994 app.ensure_agent_label("agent_live");
4995 app.agent_progress
4996 .insert("agent_live".to_string(), "reading code".to_string());
4997
4998 let agents = subagent_view_agents(&app, &[]);
4999
5000 assert_eq!(agents.len(), 1);
5001 assert_eq!(agents[0].agent_id, "agent_live");
5002 assert!(matches!(agents[0].status, SubAgentStatus::Running));
5003 assert_eq!(agents[0].assignment.role.as_deref(), Some("live"));
5004 assert!(agents[0].assignment.objective.contains("reading code"));
5005 assert_eq!(agents[0].nickname.as_deref(), Some("Agent 1"));
5006 }
5007
5008 #[test]
5009 fn subagent_view_replaces_progress_placeholder_after_manager_snapshot() {
5010 let mut app = create_test_app();
5011 app.ui_locale = Locale::En;
5012 app.ensure_agent_label("agent_live");
5013 app.agent_progress
5014 .insert("agent_live".to_string(), "reading code".to_string());
5015
5016 let progress_only = subagent_view_agents(&app, &[]);
5017 assert_eq!(progress_only[0].nickname.as_deref(), Some("Agent 1"));
5018
5019 let mut manager = manager_agent("agent_live", SubAgentStatus::Running);
5020 manager.nickname = Some(crate::tools::subagent::whale_name_for_id_in_locale(
5021 "agent_live",
5022 "ja",
5023 ));
5024 let manager_backed = subagent_view_agents(&app, &[manager]);
5025 assert_eq!(
5026 manager_backed[0].nickname.as_deref(),
5027 Some(crate::tools::subagent::whale_name_for_id_in_locale("agent_live", "en").as_str())
5028 );
5029 }
5030
5031 #[test]
5032 fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() {
5033 let mut app = create_test_app();
5034 let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]);
5035 card.upsert_worker("chunk_1", AgentLifecycle::Completed);
5036 card.upsert_worker("chunk_2", AgentLifecycle::Running);
5037 app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
5038 app.last_fanout_card_index = Some(app.history.len().saturating_sub(1));
5039
5040 let agents = subagent_view_agents(&app, &[]);
5041
5042 assert_eq!(agents.len(), 2);
5043 assert_eq!(agents[0].agent_id, "chunk_1");
5044 assert!(matches!(agents[0].status, SubAgentStatus::Completed));
5045 assert_eq!(agents[1].agent_id, "chunk_2");
5046 assert!(matches!(agents[1].status, SubAgentStatus::Running));
5047 assert_eq!(agents[1].assignment.role.as_deref(), Some("rlm"));
5048 }
5049
5050 #[test]
5051 fn subagent_view_agents_deduplicates_manager_rows_over_live_rows() {
5052 let mut app = create_test_app();
5053 app.agent_progress
5054 .insert("agent_cached".to_string(), "live duplicate".to_string());
5055 let manager = vec![manager_agent("agent_cached", SubAgentStatus::Running)];
5056
5057 let agents = subagent_view_agents(&app, &manager);
5058
5059 assert_eq!(agents.len(), 1);
5060 assert_eq!(agents[0].agent_type, FleetRole::Scout);
5061 assert_eq!(agents[0].assignment.objective, "read the docs");
5062 }
5063
5064 #[test]
5065 fn fleet_worker_status_view_can_jump_to_fleet_setup() {
5066 let mut view = SubAgentsView::new(Vec::new());
5067
5068 let action = view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
5069
5070 match action {
5071 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
5072 action: CommandPaletteAction::ExecuteCommand { command },
5073 }) => assert_eq!(command, "/fleet"),
5074 other => panic!("expected /fleet jump action, got {other:?}"),
5075 }
5076 }
5077
5078 fn visible_section_labels(view: &ConfigView) -> Vec<Cow<'static, str>> {
5079 view.visible_items()
5080 .into_iter()
5081 .filter_map(|item| match item {
5082 ConfigListItem::Section(section) => Some(section.label(view.locale)),
5083 ConfigListItem::Row(_) => None,
5084 })
5085 .collect()
5086 }
5087
5088 fn create_config_view(locale: Locale) -> ConfigView {
5089 let mut app = create_test_app();
5090 app.ui_locale = locale;
5091 ConfigView::new_for_app(&app)
5092 }
5093
5094 fn visible_row_keys(view: &ConfigView) -> Vec<&str> {
5095 view.visible_items()
5096 .into_iter()
5097 .filter_map(|item| match item {
5098 ConfigListItem::Row(idx) => Some(view.rows[idx].key.as_str()),
5099 ConfigListItem::Section(_) => None,
5100 })
5101 .collect()
5102 }
5103
5104 #[test]
5105 fn truncate_view_text_handles_unicode() {
5106 let text = "abc😀é";
5107 assert_eq!(truncate_view_text(text, 0), "");
5108 assert_eq!(truncate_view_text(text, 1), "a");
5109 assert_eq!(truncate_view_text(text, 3), "abc");
5110 assert_eq!(truncate_view_text(text, 4), "abc😀");
5111 assert_eq!(truncate_view_text(text, 5), "abc😀é");
5112 }
5113
5114 #[test]
5115 fn underwater_surface_ellipsizes_narrow_titles() {
5116 let area = Rect::new(0, 0, 24, 8);
5117 let mut buf = Buffer::empty(area);
5118 render_underwater_surface(area, &mut buf, "Help — Concepts, commands, and keybindings");
5119 let top = (0..area.width)
5120 .map(|x| buf[(x, 0)].symbol())
5121 .collect::<String>();
5122 assert!(
5123 top.contains('…'),
5124 "narrow title should signal truncation: {top}"
5125 );
5126 }
5127
5128 #[test]
5129 fn config_view_groups_rows_by_expected_sections() {
5130 let view = create_config_view(Locale::En);
5131 assert_eq!(
5132 visible_section_labels(&view),
5133 vec!["Provider", "Network", "Composer", "Sidebar", "History"]
5134 );
5135 }
5136
5137 #[test]
5138 fn config_view_includes_expected_editable_rows() {
5139 let app = create_test_app();
5140 let view = ConfigView::new_for_app(&app);
5141 let keys = view
5142 .rows
5143 .iter()
5144 .map(|row| row.key.as_str())
5145 .collect::<Vec<_>>();
5146 assert!(keys.contains(&"provider"));
5147 assert!(keys.contains(&"model"));
5148 assert!(keys.contains(&"reasoning_effort"));
5149 assert!(keys.contains(&"base_url"));
5150 assert!(keys.contains(&"external_credentials.openai-codex"));
5151 assert!(keys.contains(&"external_credentials.xai"));
5152 assert!(keys.contains(&"approval_mode"));
5153 assert!(keys.contains(&"permission_posture"));
5154 assert!(keys.contains(&"allow_shell"));
5155 assert!(keys.contains(&"stream_chunk_timeout_secs"));
5156 assert!(keys.contains(&"theme"));
5157 assert!(keys.contains(&"locale"));
5158 assert!(keys.contains(&"background_color"));
5159 assert!(keys.contains(&"fancy_animations"));
5160 assert!(keys.contains(&"thinking_default_expanded"));
5161 assert!(keys.contains(&"status_indicator"));
5162 assert!(keys.contains(&"synchronized_output"));
5163 assert!(keys.contains(&"auto_compact"));
5164 assert!(keys.contains(&"tool_collapse"));
5165 assert!(keys.contains(&"composer_border"));
5166 assert!(keys.contains(&"composer_vim_mode"));
5167 assert!(keys.contains(&"bracketed_paste"));
5168 assert!(keys.contains(&"context_panel"));
5169 assert!(keys.contains(&"cost_currency"));
5170 assert!(keys.contains(&"mcp_config_path"));
5171 assert!(keys.contains(&"fleet.exec.max_spawn_depth"));
5172 assert!(keys.contains(&"features.vision_model"));
5173 assert!(keys.contains(&"goal_command"));
5174 assert!(keys.contains(&"workflow"));
5175 assert!(!keys.contains(&"features.subagents"));
5176 assert!(!keys.contains(&"features.web_search"));
5177 assert!(!keys.contains(&"features.apply_patch"));
5178 assert!(!keys.contains(&"features.mcp"));
5179 assert!(!keys.contains(&"features.exec_policy"));
5180 assert!(!keys.contains(&"whaleflow"));
5181 // Diagnostic-only model rows and managed permission rows are not
5182 // editable; everything else outside Experimental/Fleet should be.
5183 const DIAGNOSTIC_ONLY: &[&str] = &[
5184 "fast_model",
5185 "default_model",
5186 "context_window",
5187 "effective_context_window",
5188 "effective_auto_compact",
5189 "external_credentials.openai-codex",
5190 "external_credentials.xai",
5191 ];
5192 assert!(
5193 view.rows
5194 .iter()
5195 .filter(|row| {
5196 !matches!(
5197 row.section,
5198 super::ConfigSection::Experimental
5199 | super::ConfigSection::Fleet
5200 | super::ConfigSection::Workflow
5201 | super::ConfigSection::Session
5202 | super::ConfigSection::Legacy
5203 ) && !DIAGNOSTIC_ONLY.contains(&row.key.as_str())
5204 && !row.key.starts_with("managed_")
5205 })
5206 .all(|row| row.editable)
5207 );
5208 assert!(
5209 view.rows
5210 .iter()
5211 .filter(|row| {
5212 matches!(
5213 row.section,
5214 super::ConfigSection::Experimental
5215 | super::ConfigSection::Fleet
5216 | super::ConfigSection::Workflow
5217 | super::ConfigSection::Session
5218 | super::ConfigSection::Legacy
5219 )
5220 })
5221 .all(|row| !row.editable)
5222 );
5223 for key in DIAGNOSTIC_ONLY {
5224 assert!(
5225 view.rows.iter().any(|row| row.key == *key && !row.editable),
5226 "{key} must remain diagnostic-only"
5227 );
5228 }
5229 }
5230
5231 #[test]
5232 fn config_view_surfaces_structural_external_consent_without_io() {
5233 let _env = crate::test_support::lock_test_env();
5234 let temp = tempfile::tempdir().expect("config view fixture");
5235 let config_path = temp.path().join("config.toml");
5236 let auth_path = temp.path().join("codex-auth.json");
5237 fs::write(&auth_path, "external-secret-must-not-be-read").expect("auth trap");
5238 fs::write(
5239 &config_path,
5240 format!(
5241 r#"provider = "openai-codex"
5242 [providers.openai_codex]
5243 auth_mode = "oauth"
5244 [providers.openai_codex.external_credentials]
5245 access = "read_only"
5246 provider = "openai-codex"
5247 source = "codex_cli"
5248 path = {:?}
5249 consent_version = 1
5250 "#,
5251 auth_path.display().to_string()
5252 ),
5253 )
5254 .expect("config fixture");
5255 let ambient_path = temp.path().join("new-ambient-codex-auth.json");
5256 let _path = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &ambient_path);
5257 let mut app = create_test_app();
5258 app.config_path = Some(config_path);
5259 crate::external_credentials::reset_side_effect_trap();
5260 let view = ConfigView::new_for_app(&app);
5261 let row = view
5262 .rows
5263 .iter()
5264 .find(|row| row.key == "external_credentials.openai-codex")
5265 .expect("structural consent row");
5266 assert!(row.value.contains("access=read_only"), "{}", row.value);
5267 assert!(row.value.contains("source=codex_cli"), "{}", row.value);
5268 assert!(row.value.contains("version=1"), "{}", row.value);
5269 assert!(row.value.contains("active"), "{}", row.value);
5270 assert!(row.value.contains("remains pinned"), "{}", row.value);
5271 assert!(
5272 row.value
5273 .contains(&codewhale_config::quote_os_path(&auth_path)),
5274 "{}",
5275 row.value
5276 );
5277 assert!(
5278 !row.value.contains(&ambient_path.display().to_string()),
5279 "{}",
5280 row.value
5281 );
5282 assert!(
5283 row.value
5284 .contains("external-revoke --provider openai-codex")
5285 );
5286 assert_eq!(
5287 crate::external_credentials::complete_side_effect_trap_counts(),
5288 (0, 0, 0, 0, 0)
5289 );
5290 }
5291
5292 #[test]
5293 fn config_view_permission_row_tracks_the_controlling_saved_source() {
5294 let explicit_dir = TempDir::new().expect("explicit config tempdir");
5295 let explicit_path = explicit_dir.path().join("config.toml");
5296 fs::write(&explicit_path, "approval_policy = \"auto\"\n").expect("explicit config");
5297 let mut app = create_test_app();
5298 app.config_path = Some(explicit_path);
5299
5300 let mut explicit = ConfigView::new_for_app(&app);
5301 let row = explicit
5302 .rows
5303 .iter()
5304 .find(|row| row.key == "approval_policy")
5305 .expect("explicit approval policy row");
5306 assert_eq!(row.value, "auto");
5307 assert!(row.editable);
5308 assert_eq!(row.scope, ConfigScope::Saved);
5309 assert!(
5310 explicit
5311 .rows
5312 .iter()
5313 .all(|row| row.key != "permission_posture")
5314 );
5315 explicit.focus_key("approval_policy");
5316 explicit.start_edit();
5317 let choices = explicit
5318 .editing
5319 .as_ref()
5320 .and_then(|edit| edit.choices.as_ref())
5321 .expect("approval posture choices");
5322 assert_eq!(
5323 choices,
5324 &vec![
5325 "use-tui-default".to_string(),
5326 "ask".to_string(),
5327 "auto-review".to_string(),
5328 "full-access".to_string(),
5329 ]
5330 );
5331 let area = Rect::new(0, 0, 110, 30);
5332 let mut buf = Buffer::empty(area);
5333 explicit.render(area, &mut buf);
5334 let dump = buffer_text(&buf, area);
5335 assert!(
5336 dump.contains("4. Full Access"),
5337 "root permission chooser must expose the product posture:\n{dump}"
5338 );
5339 assert!(
5340 !dump.contains("4. Never"),
5341 "root permission chooser leaked the raw fail-closed policy token:\n{dump}"
5342 );
5343 let use_tui_default = explicit
5344 .editing
5345 .as_ref()
5346 .and_then(|edit| edit.choices.as_ref())
5347 .and_then(|choices| {
5348 choices
5349 .iter()
5350 .position(|choice| choice == "use-tui-default")
5351 })
5352 .expect("TUI default choice");
5353 explicit
5354 .editing
5355 .as_mut()
5356 .expect("choice editor")
5357 .selected_choice = use_tui_default;
5358 match explicit.handle_choice_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
5359 ViewAction::Emit(ViewEvent::ConfigUpdated {
5360 key,
5361 value,
5362 persist,
5363 }) => {
5364 assert_eq!(key, "approval_policy");
5365 assert_eq!(value, "use-tui-default");
5366 assert!(persist);
5367 }
5368 other => panic!("expected saved ConfigUpdated event, got {other:?}"),
5369 }
5370
5371 let managed_dir = TempDir::new().expect("managed config tempdir");
5372 let requirements_path = managed_dir.path().join("requirements.toml");
5373 fs::write(
5374 &requirements_path,
5375 "allowed_approval_policies = [\"never\"]\n",
5376 )
5377 .expect("requirements config");
5378 let config_path = managed_dir.path().join("config.toml");
5379 let requirements_value =
5380 toml::Value::String(requirements_path.to_string_lossy().into_owned()).to_string();
5381 fs::write(
5382 &config_path,
5383 format!("approval_policy = \"never\"\nrequirements_path = {requirements_value}\n"),
5384 )
5385 .expect("managed config");
5386 app.config_path = Some(config_path);
5387
5388 let managed = ConfigView::new_for_app(&app);
5389 let row = managed
5390 .rows
5391 .iter()
5392 .find(|row| row.key == "managed_approval_policy")
5393 .expect("managed approval policy row");
5394 assert!(!row.editable);
5395 assert_eq!(row.scope, ConfigScope::Saved);
5396 assert!(
5397 managed
5398 .rows
5399 .iter()
5400 .all(|row| row.key != "permission_posture" && row.key != "approval_policy")
5401 );
5402 }
5403
5404 #[test]
5405 fn config_view_provider_uses_full_picker_and_preserves_custom_provider_id() {
5406 let dir = TempDir::new().expect("custom provider tempdir");
5407 let config_path = dir.path().join("config.toml");
5408 fs::write(
5409 &config_path,
5410 r#"
5411 provider = "acme_ai"
5412
5413 [providers.acme_ai]
5414 kind = "openai-compatible"
5415 base_url = "https://api.example.invalid/v1"
5416 model = "acme-model"
5417 api_key_env = "ACME_API_KEY"
5418 "#,
5419 )
5420 .expect("custom provider config");
5421 let mut app = create_test_app();
5422 app.config_path = Some(config_path);
5423 app.api_provider = crate::config::ApiProvider::Custom;
5424 let mut view = ConfigView::new_for_app(&app);
5425 view.selected = view
5426 .rows
5427 .iter()
5428 .position(|row| row.key == "provider")
5429 .expect("provider row");
5430
5431 let row = &view.rows[view.selected];
5432 assert_eq!(row.value, "acme_ai");
5433 assert_eq!(row.scope, ConfigScope::Saved);
5434 assert!(
5435 config_choice_values("provider", app.api_provider).is_none(),
5436 "provider must not be truncated to the generic enum chooser"
5437 );
5438
5439 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
5440 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
5441 action: CommandPaletteAction::ExecuteCommand { command },
5442 }) => assert_eq!(command, "/provider"),
5443 other => panic!("expected full provider picker command, got {other:?}"),
5444 }
5445 assert!(view.editing.is_none());
5446 }
5447
5448 #[test]
5449 fn config_view_active_model_uses_picker_and_fallback_is_diagnostic_only() {
5450 let app = create_test_app();
5451 let mut view = ConfigView::new_for_app(&app);
5452 view.focus_key("model");
5453
5454 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
5455 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
5456 action: CommandPaletteAction::ExecuteCommand { command },
5457 }) => assert_eq!(command, "/model"),
5458 other => panic!("expected full model picker, got {other:?}"),
5459 }
5460 assert!(view.editing.is_none());
5461
5462 for key in ["fast_model", "default_model"] {
5463 let row = view
5464 .rows
5465 .iter()
5466 .find(|row| row.key == key)
5467 .unwrap_or_else(|| panic!("{key} row"));
5468 assert!(!row.editable, "{key} must be diagnostic-only");
5469 }
5470 }
5471
5472 #[test]
5473 fn config_view_explains_zai_fast_sibling() {
5474 let _guard = ConfigSettingsEnvGuard::new("");
5475 let mut app = create_test_app();
5476 app.api_provider = crate::config::ApiProvider::Zai;
5477 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
5478
5479 let view = ConfigView::new_for_app(&app);
5480 let active = view
5481 .rows
5482 .iter()
5483 .find(|row| row.key == "model")
5484 .expect("active model row");
5485 let fast = view
5486 .rows
5487 .iter()
5488 .find(|row| row.key == "fast_model")
5489 .expect("fast model row");
5490
5491 assert_eq!(active.value, "zai / GLM-5.2");
5492 assert_eq!(fast.value, "GLM-5-Turbo");
5493 // #4717: DeepSeek-only fallback must not appear on non-DeepSeek providers.
5494 assert!(
5495 view.rows.iter().all(|row| row.key != "default_model"),
5496 "default_model row must be hidden for zai when unset"
5497 );
5498 }
5499
5500 #[test]
5501 fn config_view_hides_deepseek_fallback_on_non_deepseek_providers() {
5502 let _guard = ConfigSettingsEnvGuard::new("");
5503 let mut app = create_test_app();
5504 for provider in [
5505 crate::config::ApiProvider::Zai,
5506 crate::config::ApiProvider::Xai,
5507 crate::config::ApiProvider::Openrouter,
5508 crate::config::ApiProvider::Ollama,
5509 ] {
5510 app.api_provider = provider;
5511 let view = ConfigView::new_for_app(&app);
5512 assert!(
5513 view.rows.iter().all(|row| row.key != "default_model"),
5514 "default_model must stay hidden for {:?}",
5515 provider
5516 );
5517 }
5518
5519 // DeepSeek providers still show the diagnostic row.
5520 app.api_provider = crate::config::ApiProvider::Deepseek;
5521 let view = ConfigView::new_for_app(&app);
5522 assert!(
5523 view.rows
5524 .iter()
5525 .any(|row| row.key == "default_model" && !row.editable),
5526 "DeepSeek must keep the fallback diagnostic row"
5527 );
5528 }
5529
5530 #[test]
5531 fn config_view_marks_saved_deepseek_fallback_as_legacy_off_route() {
5532 let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n");
5533 let mut app = create_test_app();
5534 app.api_provider = crate::config::ApiProvider::Zai;
5535
5536 let view = ConfigView::new_for_app(&app);
5537 let row = view
5538 .rows
5539 .iter()
5540 .find(|row| row.key == "default_model")
5541 .expect("saved legacy fallback should remain visible for cleanup");
5542 assert!(!row.editable, "legacy fallback must remain diagnostic-only");
5543 assert_eq!(
5544 config_label_for_key(&row.key),
5545 "Legacy fallback model (DeepSeek routes only)"
5546 );
5547 // #4751: never a Fleet (or live Model) choice.
5548 assert_eq!(row.section, super::ConfigSection::Legacy);
5549 }
5550
5551 /// #4751: Fleet settings hold Fleet/member concerns only. The
5552 /// legacy DeepSeek fallback is Legacy, `/goal` is Session, and Workflow
5553 /// orchestration is Workflow — every persisted key is unchanged.
5554 #[test]
5555 fn config_view_settings_rows_land_in_truthful_sections() {
5556 let _guard = ConfigSettingsEnvGuard::new("default_model = \"deepseek-v4-pro\"\n");
5557 let mut app = create_test_app();
5558 app.api_provider = crate::config::ApiProvider::Zai;
5559 let view = ConfigView::new_for_app(&app);
5560
5561 let section_of = |key: &str| {
5562 view.rows
5563 .iter()
5564 .find(|row| row.key == key)
5565 .unwrap_or_else(|| panic!("{key} row"))
5566 .section
5567 };
5568 assert_eq!(section_of("default_model"), super::ConfigSection::Legacy);
5569 assert_eq!(section_of("goal_command"), super::ConfigSection::Session);
5570 assert_eq!(section_of("workflow"), super::ConfigSection::Workflow);
5571
5572 // Relabelling is presentation only: the persisted key, the persisted
5573 // value, the Saved scope, and the read-only posture all round-trip
5574 // unchanged, so existing config files keep loading identically.
5575 let legacy = view
5576 .rows
5577 .iter()
5578 .find(|row| row.section == super::ConfigSection::Legacy)
5579 .expect("legacy row");
5580 assert_eq!(legacy.key, "default_model");
5581 assert_eq!(legacy.value, "deepseek-v4-pro");
5582 assert_eq!(legacy.scope, ConfigScope::Saved);
5583 assert!(!legacy.editable);
5584
5585 // Fleet keeps Fleet/member concerns only.
5586 let fleet_keys: Vec<&str> = view
5587 .rows
5588 .iter()
5589 .filter(|row| row.section == super::ConfigSection::Fleet)
5590 .map(|row| row.key.as_str())
5591 .collect();
5592 assert!(
5593 fleet_keys.iter().all(|key| key.starts_with("fleet.")),
5594 "non-Fleet concerns leaked into Fleet settings: {fleet_keys:?}"
5595 );
5596 assert!(
5597 !fleet_keys.contains(&"default_model"),
5598 "the legacy fallback must not be presented as a Fleet choice"
5599 );
5600
5601 // Workflow keeps its own name and its `/workflow` wording.
5602 let workflow = view
5603 .rows
5604 .iter()
5605 .find(|row| row.section == super::ConfigSection::Workflow)
5606 .expect("workflow row");
5607 assert_eq!(workflow.key, "workflow");
5608 assert!(workflow.value.starts_with("/workflow "), "{workflow:?}");
5609 assert_eq!(config_label_for_key("workflow"), "Workflow");
5610 }
5611
5612 #[test]
5613 fn config_view_experimental_features_show_effective_state_and_overrides() {
5614 let temp_root = std::env::temp_dir().join(format!(
5615 "codewhale-experimental-config-view-test-{}",
5616 std::process::id()
5617 ));
5618 fs::create_dir_all(&temp_root).unwrap();
5619 let config_path = temp_root.join("config.toml");
5620 fs::write(
5621 &config_path,
5622 r#"
5623 [features]
5624 web_search = false
5625 vision_model = true
5626 "#,
5627 )
5628 .unwrap();
5629
5630 let mut app = create_test_app();
5631 app.config_path = Some(config_path);
5632 let view = ConfigView::new_for_app(&app);
5633
5634 let web_search = view
5635 .rows
5636 .iter()
5637 .find(|row| row.key == "features.web_search");
5638 assert!(web_search.is_none());
5639
5640 let vision = view
5641 .rows
5642 .iter()
5643 .find(|row| row.key == "features.vision_model")
5644 .expect("vision feature row");
5645 assert_eq!(vision.value, "enabled (configured; default disabled)");
5646 assert!(!vision.editable);
5647
5648 let subagents = view.rows.iter().find(|row| row.key == "features.subagents");
5649 assert!(subagents.is_none());
5650 }
5651
5652 #[test]
5653 fn config_view_shows_fleet_max_spawn_depth_from_config() {
5654 let temp_root = std::env::temp_dir().join(format!(
5655 "codewhale-fleet-config-view-test-{}",
5656 std::process::id()
5657 ));
5658 fs::create_dir_all(&temp_root).unwrap();
5659 let config_path = temp_root.join("config.toml");
5660 fs::write(
5661 &config_path,
5662 r#"
5663 [fleet.exec]
5664 max_spawn_depth = 2
5665 "#,
5666 )
5667 .unwrap();
5668
5669 let mut app = create_test_app();
5670 app.config_path = Some(config_path);
5671 let view = ConfigView::new_for_app(&app);
5672
5673 let row = view
5674 .rows
5675 .iter()
5676 .find(|row| row.key == "fleet.exec.max_spawn_depth")
5677 .expect("fleet spawn depth row");
5678 assert_eq!(row.value, "2");
5679 assert!(!row.editable);
5680 }
5681
5682 #[test]
5683 fn config_view_experimental_section_is_searchable() {
5684 let mut view = create_config_view(Locale::En);
5685
5686 view.update_filter(|filter| filter.push_str("experimental"));
5687 assert_eq!(visible_section_labels(&view), vec!["Experimental"]);
5688 assert_eq!(visible_row_keys(&view), vec!["features.vision_model"]);
5689
5690 view.clear_filter();
5691 type_filter(&mut view, "feature vision");
5692 assert_eq!(visible_section_labels(&view), vec!["Experimental"]);
5693 assert_eq!(visible_row_keys(&view), vec!["features.vision_model"]);
5694
5695 view.clear_filter();
5696 type_filter(&mut view, "goal");
5697 assert_eq!(visible_section_labels(&view), vec!["Session"]);
5698 assert_eq!(visible_row_keys(&view), vec!["goal_command"]);
5699
5700 // The `workflow` row keeps its key and its name; #4751 only moved it
5701 // out of Fleet into its own Workflow section.
5702 view.clear_filter();
5703 type_filter(&mut view, "workflow");
5704 assert_eq!(visible_section_labels(&view), vec!["Workflow"]);
5705 assert_eq!(visible_row_keys(&view), vec!["workflow"]);
5706
5707 view.clear_filter();
5708 type_filter(&mut view, "whaleflow");
5709 assert!(visible_row_keys(&view).is_empty());
5710 }
5711
5712 #[test]
5713 fn config_view_base_url_reflects_app_config_path() {
5714 let temp_root = std::env::temp_dir().join(format!(
5715 "deepseek-tui-base-url-view-test-{}",
5716 std::process::id()
5717 ));
5718 fs::create_dir_all(&temp_root).unwrap();
5719 let config_path = temp_root.join("config.toml");
5720 fs::write(
5721 &config_path,
5722 "base_url = \"https://ui-config-view.local/v1\"\n",
5723 )
5724 .unwrap();
5725
5726 let mut app = create_test_app();
5727 app.config_path = Some(config_path.clone());
5728 let view = ConfigView::new_for_app(&app);
5729
5730 let row = view
5731 .rows
5732 .iter()
5733 .find(|row| row.key == "base_url")
5734 .expect("base_url row missing");
5735 assert_eq!(
5736 config_label_for_key(&row.key),
5737 "Provider API URL (DeepSeek route)"
5738 );
5739 assert_eq!(row.value, "https://ui-config-view.local/v1");
5740 }
5741
5742 #[test]
5743 fn config_view_uses_provider_url_for_non_deepseek_provider() {
5744 let temp_root = std::env::temp_dir().join(format!(
5745 "codewhale-provider-url-view-test-{}",
5746 std::process::id()
5747 ));
5748 fs::create_dir_all(&temp_root).unwrap();
5749 let config_path = temp_root.join("config.toml");
5750 fs::write(
5751 &config_path,
5752 r#"
5753 provider = "xiaomi-mimo"
5754
5755 [providers.xiaomi_mimo]
5756 api_key = "tp-test-token-plan-key"
5757 base_url = "https://api.xiaomimimo.com/v1"
5758 "#,
5759 )
5760 .unwrap();
5761
5762 let mut app = create_test_app();
5763 app.api_provider = crate::config::ApiProvider::XiaomiMimo;
5764 app.config_path = Some(config_path.clone());
5765 let view = ConfigView::new_for_app(&app);
5766
5767 let row = view
5768 .rows
5769 .iter()
5770 .find(|row| row.key == "provider_url")
5771 .expect("provider_url row missing");
5772 assert_eq!(row.value, crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL);
5773 assert!(!view.rows.iter().any(|row| row.key == "base_url"));
5774 }
5775
5776 #[test]
5777 fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() {
5778 let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n");
5779 let app = create_test_app();
5780 assert_eq!(app.ui_locale, Locale::ZhHans);
5781 assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
5782
5783 let view = ConfigView::new_for_app(&app);
5784 let row = view
5785 .rows
5786 .iter()
5787 .find(|row| row.key == "cost_currency")
5788 .expect("cost_currency row");
5789
5790 assert_eq!(row.value, "usd");
5791 assert_eq!(view.row_display_value(row), "usd (实际 cny)");
5792 assert_eq!(Settings::load().expect("settings").cost_currency, "usd");
5793 }
5794
5795 #[test]
5796 fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() {
5797 for alias in ["rmb", "yuan", "¥"] {
5798 let (saved_value, display_value, effective_currency, locale) =
5799 cost_currency_row_for_settings(&format!(
5800 "locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n"
5801 ));
5802
5803 assert_eq!(locale, Locale::ZhHans);
5804 assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
5805 assert_eq!(saved_value, alias);
5806 assert_eq!(display_value, alias);
5807 }
5808 }
5809
5810 #[test]
5811 fn config_view_cost_currency_matching_cny_setting_is_silent() {
5812 let (saved_value, display_value, effective_currency, locale) =
5813 cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n");
5814
5815 assert_eq!(locale, Locale::ZhHans);
5816 assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
5817 assert_eq!(saved_value, "cny");
5818 assert_eq!(display_value, "cny");
5819 }
5820
5821 #[test]
5822 fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() {
5823 let (saved_value, display_value, effective_currency, locale) =
5824 cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n");
5825
5826 assert_eq!(locale, Locale::En);
5827 assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
5828 assert_eq!(saved_value, "cny");
5829 assert_eq!(display_value, "cny");
5830 }
5831
5832 #[test]
5833 fn config_view_exposes_all_available_saved_settings() {
5834 let app = create_test_app();
5835 let view = ConfigView::new_for_app(&app);
5836 let keys: std::collections::HashSet<&str> =
5837 view.rows.iter().map(|row| row.key.as_str()).collect();
5838
5839 for (key, _) in Settings::available_settings() {
5840 assert!(keys.contains(key), "missing native config row for {key}");
5841 }
5842 }
5843
5844 #[test]
5845 fn config_view_exposes_effective_auto_compaction_policy() {
5846 let mut app = create_test_app();
5847 app.auto_compact = true;
5848 app.auto_compact_threshold_percent = 65.0;
5849 app.compact_threshold = 123_456;
5850
5851 let view = ConfigView::new_for_app(&app);
5852 let row = view
5853 .rows
5854 .iter()
5855 .find(|row| row.key == "effective_auto_compact")
5856 .expect("effective auto-compaction row");
5857
5858 assert_eq!(row.value, "on · 65% · 123456 tokens");
5859 assert!(!row.editable);
5860 assert_eq!(row.scope, ConfigScope::Session);
5861 }
5862
5863 #[test]
5864 fn config_view_exposes_configured_and_effective_context_window() {
5865 let temp = tempfile::tempdir().expect("config fixture");
5866 let config_path = temp.path().join("config.toml");
5867 std::fs::write(
5868 &config_path,
5869 r#"
5870 provider = "moonshot"
5871 [providers.moonshot]
5872 model = "kimi-k3"
5873 context_window = 262144
5874 "#,
5875 )
5876 .expect("config");
5877 let mut app = create_test_app();
5878 app.config_path = Some(config_path);
5879 app.api_provider = crate::config::ApiProvider::Moonshot;
5880 app.model = "kimi-k3".to_string();
5881 app.active_route_limits = Some(codewhale_config::route::RouteLimits {
5882 context_tokens: Some(262_144),
5883 ..Default::default()
5884 });
5885 app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
5886
5887 let view = ConfigView::new_for_app(&app);
5888 let configured = view
5889 .rows
5890 .iter()
5891 .find(|row| row.key == "context_window")
5892 .expect("configured context row");
5893 let effective = view
5894 .rows
5895 .iter()
5896 .find(|row| row.key == "effective_context_window")
5897 .expect("effective context row");
5898
5899 assert_eq!(configured.value, "262144");
5900 assert_eq!(effective.value, "262144 tokens · configured");
5901 }
5902
5903 #[test]
5904 fn config_view_displays_saved_codex_reasoning_effort_label() {
5905 let _guard = ConfigSettingsEnvGuard::new("reasoning_effort = \"max\"\n");
5906 let mut app = create_test_app();
5907 app.api_provider = crate::config::ApiProvider::OpenaiCodex;
5908
5909 let view = ConfigView::new_for_app(&app);
5910 let row = view
5911 .rows
5912 .iter()
5913 .find(|row| row.key == "reasoning_effort")
5914 .expect("reasoning_effort row");
5915
5916 assert_eq!(row.value, "xhigh");
5917 }
5918
5919 #[test]
5920 fn config_view_editing_localized_default_placeholders_starts_blank() {
5921 let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\n");
5922 let app = create_test_app();
5923 let mut view = ConfigView::new_for_app(&app);
5924
5925 for (key, message_id) in [
5926 ("reasoning_effort", MessageId::ConfigDefaultReasoning),
5927 ("background_color", MessageId::ConfigDefaultValue),
5928 ] {
5929 view.focus_key(key);
5930 view.start_edit();
5931
5932 let edit = view.editing.as_ref().expect("editing should start");
5933 assert_eq!(edit.original_value, tr(Locale::ZhHans, message_id));
5934 assert!(
5935 edit.buffer.is_empty(),
5936 "localized default placeholder should not become edit text for {key}"
5937 );
5938
5939 view.editing = None;
5940 }
5941 }
5942
5943 #[test]
5944 fn config_view_filter_matches_group_and_rows() {
5945 let mut view = create_config_view(Locale::En);
5946
5947 type_filter(&mut view, "side");
5948
5949 assert_eq!(view.filter, "side");
5950 assert_eq!(visible_section_labels(&view), vec!["Sidebar"]);
5951 assert_eq!(
5952 visible_row_keys(&view),
5953 vec![
5954 "work_surface_placement",
5955 "work_surface_top_height",
5956 "work_surface_side_width",
5957 "rail_panel",
5958 "context_panel",
5959 "sessions_rail",
5960 "session_auto_resume",
5961 ]
5962 );
5963 assert_eq!(view.rows[view.selected].key, "work_surface_placement");
5964 }
5965
5966 #[test]
5967 fn localized_config_view_filter_matches_english_section_and_scope_labels() {
5968 let mut view = create_config_view(Locale::PtBr);
5969
5970 type_filter(&mut view, "sidebar saved");
5971
5972 assert_eq!(view.filter, "sidebar saved");
5973 assert_eq!(visible_section_labels(&view), vec!["Barra lateral"]);
5974 assert_eq!(
5975 visible_row_keys(&view),
5976 vec![
5977 "work_surface_placement",
5978 "work_surface_top_height",
5979 "work_surface_side_width",
5980 "rail_panel",
5981 "context_panel",
5982 "sessions_rail",
5983 "session_auto_resume",
5984 ]
5985 );
5986 }
5987
5988 #[test]
5989 fn config_view_filter_accepts_j_k_and_unicode_case() {
5990 let app = create_test_app();
5991 let mut view = ConfigView::new_for_app(&app);
5992
5993 type_filter(&mut view, "thinking");
5994 assert_eq!(
5995 visible_row_keys(&view),
5996 vec![
5997 "show_thinking",
5998 "thinking_default_expanded",
5999 "thinking_highlight"
6000 ]
6001 );
6002
6003 view.clear_filter();
6004 view.rows[0].value = "CAFÉ".to_string();
6005 type_filter(&mut view, "café");
6006 assert_eq!(visible_row_keys(&view), vec!["provider"]);
6007 }
6008
6009 #[test]
6010 fn config_view_filter_matches_friendly_labels_and_hints() {
6011 let mut view = create_config_view(Locale::En);
6012
6013 type_filter(&mut view, "shell access");
6014 assert_eq!(visible_row_keys(&view), vec!["allow_shell"]);
6015
6016 view.clear_filter();
6017 type_filter(&mut view, "reasoning level");
6018 assert_eq!(visible_row_keys(&view), vec!["reasoning_effort"]);
6019
6020 view.clear_filter();
6021 type_filter(&mut view, "fan-out/fan-in");
6022 assert_eq!(visible_row_keys(&view), vec!["workflow"]);
6023 }
6024
6025 /// #5134 filed an issue to ask how to raise the context window, because
6026 /// the rows that answer it are keyed `context_window` and only findable by
6027 /// someone who already knows that name. The filter has to answer the words
6028 /// a user actually types.
6029 #[test]
6030 fn config_view_filter_finds_context_window_by_user_vocabulary() {
6031 let mut view = create_config_view(Locale::En);
6032
6033 for phrase in ["context length", "context size", "max context length"] {
6034 view.clear_filter();
6035 type_filter(&mut view, phrase);
6036 let keys = visible_row_keys(&view);
6037 assert!(
6038 keys.contains(&"context_window"),
6039 "`{phrase}` must surface the context_window row: {keys:?}"
6040 );
6041 assert!(
6042 keys.contains(&"effective_context_window"),
6043 "`{phrase}` must surface the resolved window row: {keys:?}"
6044 );
6045 }
6046
6047 // The adjacent knob the same user reaches for next.
6048 view.clear_filter();
6049 type_filter(&mut view, "compaction threshold");
6050 let keys = visible_row_keys(&view);
6051 assert!(
6052 keys.contains(&"auto_compact_threshold_percent"),
6053 "`compaction threshold` must surface the auto-compaction trigger: {keys:?}"
6054 );
6055 }
6056
6057 #[test]
6058 fn config_view_renders_friendly_setting_labels() {
6059 let mut view = create_config_view(Locale::En);
6060 assert_ne!(
6061 config_label_for_key("show_thinking"),
6062 config_label_for_key("thinking_highlight"),
6063 "reasoning visibility and background controls need distinct labels"
6064 );
6065 let area = Rect::new(0, 0, 100, 40);
6066 let mut buf = Buffer::empty(area);
6067
6068 view.render(area, &mut buf);
6069
6070 let dump = buffer_text(&buf, area);
6071 assert!(
6072 dump.contains("Active provider"),
6073 "missing provider label:\n{dump}"
6074 );
6075 assert!(dump.contains("General"), "missing settings tabs:\n{dump}");
6076
6077 view.active_tab = ConfigTab::Permissions;
6078 view.select_first_visible_row();
6079 let mut permission_buf = Buffer::empty(area);
6080 view.render(area, &mut permission_buf);
6081 let permission_dump = buffer_text(&permission_buf, area);
6082 assert!(
6083 permission_dump.contains("Shell access"),
6084 "missing shell label:\n{permission_dump}"
6085 );
6086 }
6087
6088 #[test]
6089 fn localized_config_view_renders_at_narrow_width() {
6090 let mut app = create_test_app();
6091 app.ui_locale = Locale::PtBr;
6092 let view = ConfigView::new_for_app(&app);
6093 let area = Rect::new(0, 0, 60, 18);
6094 let mut buf = Buffer::empty(area);
6095
6096 view.render(area, &mut buf);
6097
6098 let dump = buffer_text(&buf, area);
6099 assert!(dump.contains("Provedor"), "missing localized rows:\n{dump}");
6100 assert!(
6101 !dump.contains("MISSING"),
6102 "missing-key marker leaked:\n{dump}"
6103 );
6104 }
6105
6106 #[test]
6107 fn config_view_selected_row_uses_muted_selection_highlight() {
6108 let mut view = create_config_view(Locale::En);
6109 view.selected = view
6110 .rows
6111 .iter()
6112 .position(|row| row.key == "theme")
6113 .expect("theme row");
6114 view.active_tab = ConfigTab::Display;
6115 view.adjust_scroll(8);
6116 let area = Rect::new(0, 0, 100, 24);
6117 let mut buf = Buffer::empty(area);
6118
6119 view.render(area, &mut buf);
6120
6121 let y = view
6122 .last_row_hitboxes
6123 .borrow()
6124 .iter()
6125 .find_map(|(y, idx)| (*idx == view.selected).then_some(*y))
6126 .expect("selected config row should have a hitbox");
6127 let highlighted_cells = (area.x..area.x.saturating_add(area.width))
6128 .filter(|&x| {
6129 let cell = &buf[(x, y)];
6130 !cell.symbol().trim().is_empty()
6131 && cell.bg == palette::SELECTION_BG
6132 && cell.fg == palette::SELECTION_TEXT
6133 })
6134 .count();
6135
6136 assert!(
6137 highlighted_cells >= 4,
6138 "selected config row should render readable selection text"
6139 );
6140 assert!(
6141 !(area.x..area.x.saturating_add(area.width))
6142 .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION),
6143 "selected config row should not use the bright accent background"
6144 );
6145 }
6146
6147 #[test]
6148 fn config_view_keeps_scope_column_aligned_for_long_keys() {
6149 let mut view = create_config_view(Locale::ZhHans);
6150 type_filter(&mut view, "composer");
6151 let area = Rect::new(0, 0, 100, 24);
6152 let mut buf = Buffer::empty(area);
6153
6154 view.render(area, &mut buf);
6155
6156 let dump = buffer_text(&buf, area);
6157 assert!(
6158 dump.contains("粘 贴 检 测"),
6159 "localized config labels should stay readable:\n{dump}"
6160 );
6161 let scope_columns = (area.y..area.y.saturating_add(area.height))
6162 .filter(|y| {
6163 let line = buffer_row_text(&buf, area, *y);
6164 line.contains("comfortable") || line.contains("normal") || line.contains("fuzzy")
6165 })
6166 .filter_map(|y| {
6167 let line = buffer_row_text(&buf, area, y);
6168 line.find("saved")
6169 .map(|byte| crate::tui::ui_text::text_display_width(&line[..byte]))
6170 })
6171 .collect::<Vec<_>>();
6172 assert!(
6173 scope_columns.len() >= 2,
6174 "expected composer config rows with scopes:\n{dump}"
6175 );
6176 assert!(
6177 scope_columns
6178 .iter()
6179 .all(|column| *column == scope_columns[0]),
6180 "scope column should stay aligned even for long keys ({scope_columns:?}):\n{dump}"
6181 );
6182 }
6183
6184 #[test]
6185 fn config_view_filter_no_match_does_not_edit_hidden_row() {
6186 let app = create_test_app();
6187 let mut view = ConfigView::new_for_app(&app);
6188
6189 type_filter(&mut view, "zzzz");
6190 assert!(visible_row_keys(&view).is_empty());
6191
6192 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6193 assert!(matches!(action, ViewAction::None));
6194 assert!(view.editing.is_none());
6195
6196 let clear = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6197 assert!(matches!(clear, ViewAction::None));
6198 assert!(view.filter.is_empty());
6199 assert!(!visible_row_keys(&view).is_empty());
6200 }
6201
6202 #[test]
6203 fn config_view_can_edit_filtered_row() {
6204 let app = create_test_app();
6205 let mut view = ConfigView::new_for_app(&app);
6206
6207 type_filter(&mut view, "mcp_config");
6208 assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]);
6209
6210 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6211 assert!(matches!(start, ViewAction::None));
6212 assert!(view.editing.is_some());
6213
6214 let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
6215 assert!(matches!(clear, ViewAction::None));
6216 type_filter(&mut view, "servers.json");
6217
6218 let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6219 match submit {
6220 ViewAction::Emit(ViewEvent::ConfigUpdated {
6221 key,
6222 value,
6223 persist,
6224 }) => {
6225 assert_eq!(key, "mcp_config_path");
6226 assert_eq!(value, "servers.json");
6227 assert!(persist);
6228 }
6229 other => panic!("expected config update emit, got {other:?}"),
6230 }
6231 }
6232
6233 #[test]
6234 fn config_view_enter_and_ctrl_u_emit_config_updated() {
6235 let app = create_test_app();
6236 let mut view = ConfigView::new_for_app(&app);
6237 view.selected = view
6238 .rows
6239 .iter()
6240 .position(|row| row.key == "stream_chunk_timeout_secs")
6241 .expect("stream timeout row");
6242
6243 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6244 assert!(matches!(start, ViewAction::None));
6245 assert!(view.editing.is_some());
6246
6247 let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
6248 assert!(matches!(clear, ViewAction::None));
6249 let cleared = view
6250 .editing
6251 .as_ref()
6252 .expect("editing should remain active after Ctrl+U");
6253 assert!(cleared.buffer.is_empty());
6254
6255 for ch in "55".chars() {
6256 let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
6257 assert!(matches!(action, ViewAction::None));
6258 }
6259
6260 let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6261 match submit {
6262 ViewAction::Emit(ViewEvent::ConfigUpdated {
6263 key,
6264 value,
6265 persist,
6266 }) => {
6267 assert_eq!(key, "stream_chunk_timeout_secs");
6268 assert_eq!(value, "55");
6269 assert!(!persist);
6270 }
6271 other => panic!("expected config update emit, got {other:?}"),
6272 }
6273 assert!(view.editing.is_none());
6274 }
6275
6276 #[test]
6277 fn config_view_boolean_rows_toggle_without_text_editing() {
6278 let app = create_test_app();
6279 let mut view = ConfigView::new_for_app(&app);
6280 view.focus_key("low_motion");
6281 let expected =
6282 if canonical_config_choice("low_motion", &view.rows[view.selected].value) == "true" {
6283 "false"
6284 } else {
6285 "true"
6286 };
6287
6288 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6289
6290 match action {
6291 ViewAction::Emit(ViewEvent::ConfigUpdated {
6292 key,
6293 value,
6294 persist,
6295 }) => {
6296 assert_eq!(key, "low_motion");
6297 assert_eq!(value, expected);
6298 assert!(persist);
6299 }
6300 other => panic!("expected direct boolean update, got {other:?}"),
6301 }
6302 assert!(view.editing.is_none());
6303 }
6304
6305 #[test]
6306 fn config_view_enum_rows_use_a_bounded_choice_list() {
6307 let app = create_test_app();
6308 let mut view = ConfigView::new_for_app(&app);
6309 view.focus_key("default_mode");
6310
6311 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6312 assert!(matches!(start, ViewAction::None));
6313 let edit = view.editing.as_ref().expect("choice editor");
6314 assert_eq!(
6315 edit.choices.as_deref(),
6316 Some(
6317 &[
6318 "agent".to_string(),
6319 "plan".to_string(),
6320 "operate".to_string(),
6321 ][..]
6322 )
6323 );
6324 assert!(
6325 edit.choices
6326 .as_ref()
6327 .expect("startup choices")
6328 .iter()
6329 .all(|choice| choice != "yolo")
6330 );
6331
6332 let _ = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
6333 let apply = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6334 match apply {
6335 ViewAction::Emit(ViewEvent::ConfigUpdated {
6336 key,
6337 value,
6338 persist,
6339 }) => {
6340 assert_eq!(key, "default_mode");
6341 assert_eq!(value, "operate");
6342 assert!(persist);
6343 }
6344 other => panic!("expected startup choice update, got {other:?}"),
6345 }
6346
6347 assert_eq!(
6348 canonical_config_choice("default_mode", "Operate"),
6349 "operate"
6350 );
6351 assert_eq!(
6352 config_choice_label(Locale::En, "default_mode", "operate"),
6353 "Operate"
6354 );
6355 assert!(!config_choice_detail(Locale::En, "default_mode", "operate").is_empty());
6356 }
6357
6358 #[test]
6359 fn locale_choices_cover_shipped_registry_and_mark_partial_packs() {
6360 let choices = config_choice_values("locale", crate::config::ApiProvider::Deepseek)
6361 .expect("locale choices");
6362 let expected = std::iter::once("auto".to_string())
6363 .chain(
6364 Locale::shipped()
6365 .iter()
6366 .map(|locale| locale.tag().to_string()),
6367 )
6368 .collect::<Vec<_>>();
6369 assert_eq!(
6370 choices, expected,
6371 "native locale choices must match Locale::shipped()"
6372 );
6373
6374 let partial_badge = tr(Locale::En, MessageId::ConfigLocalePartialBadge);
6375 let partial_detail = tr(Locale::En, MessageId::ConfigLocalePartialDetail);
6376 for locale in Locale::shipped() {
6377 let canonical = canonical_config_choice("locale", locale.tag());
6378 assert_eq!(canonical, locale.tag());
6379
6380 let label = config_choice_label(Locale::En, "locale", &canonical);
6381 assert_eq!(
6382 label.contains(partial_badge.as_ref()),
6383 locale.is_partial_pack(),
6384 "{} partial-pack badge drifted",
6385 locale.tag()
6386 );
6387
6388 let detail = config_choice_detail(Locale::En, "locale", &canonical);
6389 assert_eq!(
6390 !detail.is_empty(),
6391 locale.is_partial_pack(),
6392 "{} partial-pack detail drifted",
6393 locale.tag()
6394 );
6395 if locale.is_partial_pack() {
6396 assert_eq!(detail, partial_detail);
6397 }
6398 }
6399 }
6400
6401 #[test]
6402 fn locale_choice_editor_submits_newly_admitted_locales() {
6403 for tag in ["ko", "vi", "zh-Hant"] {
6404 let mut view = create_config_view(Locale::En);
6405 view.focus_key("locale");
6406 view.start_edit();
6407 let edit = view.editing.as_mut().expect("locale choice editor");
6408 edit.selected_choice = edit
6409 .choices
6410 .as_ref()
6411 .and_then(|choices| choices.iter().position(|choice| choice == tag))
6412 .unwrap_or_else(|| panic!("locale choices must include {tag}"));
6413
6414 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
6415 ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, .. }) => {
6416 assert_eq!(key, "locale");
6417 assert_eq!(value, tag);
6418 }
6419 other => panic!("selecting locale {tag} must submit ConfigUpdated, got {other:?}"),
6420 }
6421 }
6422 }
6423
6424 #[test]
6425 fn complete_locale_shows_no_partial_badge_at_minimum_terminal_layout() {
6426 // zh-Hant reached full en.json parity in #5143 and no shipped pack is
6427 // partial anymore, so the picker must not render the partial badge.
6428 let mut view = create_config_view(Locale::En);
6429 view.focus_key("locale");
6430 view.start_edit();
6431 let edit = view.editing.as_mut().expect("locale choice editor");
6432 edit.selected_choice = edit
6433 .choices
6434 .as_ref()
6435 .and_then(|choices| choices.iter().position(|choice| choice == "zh-Hant"))
6436 .expect("zh-Hant choice");
6437
6438 let area = Rect::new(0, 0, 40, 12);
6439 let mut buf = Buffer::empty(area);
6440 view.render(area, &mut buf);
6441 let dump = buffer_text(&buf, area);
6442 assert!(
6443 dump.contains("zh-Hant"),
6444 "zh-Hant choice must render at minimum layout: {dump:?}"
6445 );
6446 assert!(
6447 !dump.contains("zh-Hant (partial)"),
6448 "zh-Hant is a complete pack and must not show the partial badge: {dump:?}"
6449 );
6450 }
6451
6452 #[test]
6453 fn settings_registry_types_every_config_row() {
6454 let app = create_test_app();
6455 let view = ConfigView::new_for_app(&app);
6456 let registry = SettingsRegistry::new(app.api_provider);
6457
6458 let kind_for = |key: &str| {
6459 let row = view
6460 .rows
6461 .iter()
6462 .find(|row| row.key == key)
6463 .unwrap_or_else(|| panic!("missing config row {key}"));
6464 registry.meta(row).kind
6465 };
6466
6467 assert_eq!(kind_for("provider"), SettingKind::Action);
6468 assert_eq!(kind_for("model"), SettingKind::Action);
6469 assert_eq!(kind_for("low_motion"), SettingKind::Boolean);
6470 assert_eq!(kind_for("default_mode"), SettingKind::Choice);
6471 assert_eq!(kind_for("mention_menu_limit"), SettingKind::Integer);
6472 assert_eq!(kind_for("mcp_config_path"), SettingKind::Text);
6473 assert_eq!(kind_for("fast_model"), SettingKind::ReadOnly);
6474
6475 for row in &view.rows {
6476 let meta = registry.meta(row);
6477 assert_eq!(meta.category, row.section);
6478 assert_eq!(
6479 meta.kind == SettingKind::Choice || meta.kind == SettingKind::Boolean,
6480 meta.choices.is_some(),
6481 "choice metadata drifted for {}",
6482 row.key
6483 );
6484 }
6485 }
6486
6487 #[test]
6488 fn config_labels_are_consumed_from_complete_locale_packs() {
6489 for locale in Locale::shipped_complete() {
6490 assert_eq!(
6491 config_label_for_key_for_locale(*locale, "provider"),
6492 tr(*locale, MessageId::ConfigLabelProvider)
6493 );
6494 assert_eq!(
6495 config_label_for_key_for_locale(*locale, "features.mcp"),
6496 tr(*locale, MessageId::ConfigLabelFeaturePrefix).replace("{name}", "Mcp")
6497 );
6498 }
6499 assert_ne!(
6500 config_label_for_key_for_locale(Locale::Ja, "provider"),
6501 config_label_for_key_for_locale(Locale::En, "provider")
6502 );
6503 }
6504
6505 #[test]
6506 fn model_row_hint_names_the_model_picker() {
6507 let app = create_test_app();
6508 let mut view = ConfigView::new_for_app(&app);
6509 view.focus_key("model");
6510
6511 let hint = view.selected_row_hint().expect("model row hint");
6512 assert!(hint.contains("Enter opens model picker"), "{hint}");
6513 assert!(!hint.contains("Enter opens provider picker"), "{hint}");
6514 }
6515
6516 #[test]
6517 fn config_view_mouse_wheel_moves_rows_and_choice_selection() {
6518 let app = create_test_app();
6519 let mut view = ConfigView::new_for_app(&app);
6520 let first_row = view.selected;
6521
6522 let _ = view.handle_mouse(MouseEvent {
6523 kind: MouseEventKind::ScrollDown,
6524 column: 0,
6525 row: 0,
6526 modifiers: KeyModifiers::NONE,
6527 });
6528 assert!(
6529 view.selected > first_row,
6530 "wheel should move the settings list"
6531 );
6532
6533 view.focus_key("default_mode");
6534 view.start_edit();
6535 view.editing
6536 .as_mut()
6537 .expect("choice editor")
6538 .selected_choice = 0;
6539 let _ = view.handle_mouse(MouseEvent {
6540 kind: MouseEventKind::ScrollDown,
6541 column: 0,
6542 row: 0,
6543 modifiers: KeyModifiers::NONE,
6544 });
6545 assert_eq!(
6546 view.editing
6547 .as_ref()
6548 .expect("choice editor")
6549 .selected_choice,
6550 1
6551 );
6552 }
6553
6554 #[test]
6555 fn config_view_mouse_click_selects_row() {
6556 let app = create_test_app();
6557 let mut view = ConfigView::new_for_app(&app);
6558 view.active_tab = ConfigTab::Models;
6559 view.select_first_visible_row();
6560 let area = Rect::new(0, 0, 100, 30);
6561 let mut buf = Buffer::empty(area);
6562 view.render(area, &mut buf);
6563
6564 let hitboxes = view.last_row_hitboxes.borrow().clone();
6565 let (_, row_idx) = hitboxes
6566 .iter()
6567 .find(|(_, idx)| view.rows.get(*idx).is_some_and(|row| row.key == "model"))
6568 .copied()
6569 .expect("model row should have a hitbox");
6570 let y = hitboxes
6571 .iter()
6572 .find_map(|(y, idx)| (*idx == row_idx).then_some(*y))
6573 .expect("selected row should have a y coordinate");
6574
6575 let action = view.handle_mouse(MouseEvent {
6576 kind: MouseEventKind::Down(MouseButton::Left),
6577 column: 20,
6578 row: y,
6579 modifiers: KeyModifiers::NONE,
6580 });
6581
6582 assert!(matches!(action, ViewAction::None));
6583 assert_eq!(view.selected, row_idx);
6584
6585 let second = view.handle_mouse(MouseEvent {
6586 kind: MouseEventKind::Down(MouseButton::Left),
6587 column: 20,
6588 row: y,
6589 modifiers: KeyModifiers::NONE,
6590 });
6591 match second {
6592 ViewAction::Emit(ViewEvent::CommandPaletteSelected {
6593 action: CommandPaletteAction::ExecuteCommand { command },
6594 }) => assert_eq!(command, "/model"),
6595 other => panic!("second click should open the model picker, got {other:?}"),
6596 }
6597 assert!(view.editing.is_none());
6598 }
6599
6600 #[test]
6601 fn config_view_bottom_hint_semantically_truncates_at_narrow_width() {
6602 // The dense bottom status line must truncate on a word boundary with an
6603 // ellipsis instead of leaving a mid-word fragment clipped by the
6604 // terminal (#3987).
6605 let mut app = create_test_app();
6606 app.ui_locale = Locale::En;
6607 let mut view = ConfigView::new_for_app(&app);
6608 view.status = Some(
6609 "CFGSTATUS persisted the configuration override to disk successfully \
6610 without clipping the trailing MARKEREND status text"
6611 .to_string(),
6612 );
6613
6614 let area = Rect::new(0, 0, 100, 40);
6615 let mut buf = Buffer::empty(area);
6616 view.render(area, &mut buf);
6617
6618 let rows: Vec<String> = (0..area.height)
6619 .map(|y| {
6620 (0..area.width)
6621 .map(|x| buf[(x, y)].symbol())
6622 .collect::<String>()
6623 })
6624 .collect();
6625
6626 // No rendered row may overflow the available columns.
6627 for (idx, row) in rows.iter().enumerate() {
6628 assert!(
6629 crate::tui::ui_text::text_display_width(row) <= usize::from(area.width),
6630 "line {idx} overflows: {row:?}"
6631 );
6632 }
6633
6634 let status_line = rows
6635 .iter()
6636 .find(|row| row.contains("CFGSTATUS"))
6637 .expect("bottom status hint should be rendered");
6638 assert!(
6639 status_line.contains('…'),
6640 "status should be truncated with an ellipsis: {status_line:?}"
6641 );
6642 assert!(
6643 !status_line.contains("MARKEREND"),
6644 "truncated status must drop trailing text: {status_line:?}"
6645 );
6646 }
6647
6648 #[test]
6649 fn config_view_typing_replaces_on_first_char() {
6650 let app = create_test_app();
6651 let mut view = ConfigView::new_for_app(&app);
6652 view.selected = view
6653 .rows
6654 .iter()
6655 .position(|row| row.key == "base_url")
6656 .expect("base_url row");
6657
6658 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6659 let edit = view.editing.as_ref().expect("editing should be active");
6660 assert!(edit.select_all, "editor should start with select-all");
6661
6662 let _ = view.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
6663 let edit = view.editing.as_ref().expect("editing should remain active");
6664 assert_eq!(edit.buffer.iter().collect::<String>(), "x");
6665 }
6666
6667 #[test]
6668 fn config_view_escape_cancels_editing() {
6669 let mut app = create_test_app();
6670 app.ui_locale = Locale::En;
6671 let mut view = ConfigView::new_for_app(&app);
6672 view.selected = view
6673 .rows
6674 .iter()
6675 .position(|row| row.key == "base_url")
6676 .expect("base_url row");
6677 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6678 assert!(view.editing.is_some());
6679
6680 let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6681 assert!(matches!(cancel, ViewAction::None));
6682 assert!(view.editing.is_none());
6683 assert_eq!(
6684 view.status.as_deref(),
6685 Some(&*tr(Locale::En, MessageId::ConfigEditCancelled))
6686 );
6687 }
6688
6689 /// A modal that doesn't override `handle_paste` must report
6690 /// "not consumed" so the host can fall through to the composer.
6691 /// Regression: views/mod.rs previously inverted the boolean, swallowing
6692 /// every Cmd-V while any modal was on top.
6693 #[test]
6694 fn default_modal_does_not_consume_paste() {
6695 let mut stack = ViewStack::new();
6696 stack.push(HelpView::new_for_locale(crate::localization::Locale::En));
6697 assert!(!stack.handle_paste("hello"));
6698 assert_eq!(stack.top_kind(), Some(ModalKind::Help));
6699 }
6700
6701 struct BareModal;
6702
6703 impl ModalView for BareModal {
6704 fn kind(&self) -> ModalKind {
6705 ModalKind::ContextMenu
6706 }
6707
6708 fn handle_key(&mut self, _key: KeyEvent) -> ViewAction {
6709 ViewAction::None
6710 }
6711
6712 fn render(&self, area: Rect, buf: &mut Buffer) {
6713 let x = area.x + area.width / 2;
6714 let y = area.y + area.height / 2;
6715 buf[(x, y)]
6716 .set_symbol("M")
6717 .set_style(Style::default().fg(Color::White).bg(Color::Red));
6718 }
6719
6720 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
6721 self
6722 }
6723 }
6724
6725 #[test]
6726 fn view_stack_paints_opaque_backdrop_before_modal() {
6727 let area = Rect::new(0, 0, 24, 8);
6728 let modal_x = area.x + area.width / 2;
6729 let modal_y = area.y + area.height / 2;
6730 let mut buf = Buffer::empty(area);
6731 for y in area.top()..area.bottom() {
6732 for x in area.left()..area.right() {
6733 buf[(x, y)]
6734 .set_symbol("X")
6735 .set_style(Style::default().fg(Color::Red).bg(Color::Blue));
6736 }
6737 }
6738
6739 let mut stack = ViewStack::new();
6740 stack.push(BareModal);
6741 stack.render(area, &mut buf);
6742
6743 assert_eq!(buf[(modal_x, modal_y)].symbol(), "M");
6744 for y in area.top()..area.bottom() {
6745 for x in area.left()..area.right() {
6746 if x == modal_x && y == modal_y {
6747 continue;
6748 }
6749 let cell = &buf[(x, y)];
6750 assert_eq!(
6751 cell.symbol(),
6752 " ",
6753 "stale glyph at ({x},{y}) must be cleared"
6754 );
6755 assert_eq!(
6756 cell.bg,
6757 palette::WHALE_BG,
6758 "backdrop at ({x},{y}) must be opaque"
6759 );
6760 }
6761 }
6762 }
6763
6764 #[test]
6765 fn view_stack_masks_links_behind_opaque_modals() {
6766 let area = Rect::new(0, 0, 24, 8);
6767 crate::tui::osc8::set_frame_links(vec![crate::tui::osc8::LinkRegion {
6768 row: 3,
6769 col_start: 2,
6770 col_end: 18,
6771 target: "https://example.invalid/under-modal".to_string(),
6772 }]);
6773 let mut stack = ViewStack::new();
6774 stack.push(BareModal);
6775 stack.render(area, &mut Buffer::empty(area));
6776 assert!(crate::tui::osc8::take_frame_links().is_empty());
6777 }
6778
6779 fn buffer_text(buf: &Buffer, area: Rect) -> String {
6780 let mut out = String::new();
6781 for y in area.top()..area.bottom() {
6782 for x in area.left()..area.right() {
6783 out.push_str(buf[(x, y)].symbol());
6784 }
6785 out.push('\n');
6786 }
6787 out
6788 }
6789
6790 fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String {
6791 (area.left()..area.right())
6792 .map(|x| buf[(x, y)].symbol())
6793 .collect()
6794 }
6795
6796 /// 40x12 regression: the compact tier must surrender secondary chrome
6797 /// (in-body title, column captions, separator) before it surrenders the
6798 /// settings rows, and the wrapped footer height must come out of the
6799 /// table budget instead of silently clipping rows.
6800 #[test]
6801 fn config_view_compact_heights_always_show_a_selectable_setting() {
6802 let mut view = create_config_view(Locale::En);
6803 for (width, height, label) in [(40u16, 12u16, "40x12"), (60, 16, "60x16")] {
6804 let area = Rect::new(0, 0, width, height);
6805 let mut buf = Buffer::empty(area);
6806
6807 view.render(area, &mut buf);
6808
6809 let dump = buffer_text(&buf, area);
6810 let (selected_y, selected_idx) = {
6811 let hitboxes = view.last_row_hitboxes.borrow();
6812 assert!(
6813 !hitboxes.is_empty(),
6814 "{label} should register selectable setting hitboxes:\n{dump}"
6815 );
6816 hitboxes
6817 .iter()
6818 .find(|(_, idx)| *idx == view.selected)
6819 .copied()
6820 .unwrap_or_else(|| {
6821 panic!("{label} selected setting should be rendered:\n{dump}")
6822 })
6823 };
6824 let row = buffer_row_text(&buf, area, selected_y);
6825 let row_label = config_label_for_key(&view.rows[selected_idx].key);
6826 let prefix: String = row_label.chars().take(8).collect();
6827 assert!(
6828 row.contains(&prefix),
6829 "{label} hitbox row should contain the selected setting ({row_label:?}); got {row:?}"
6830 );
6831 assert!(
6832 dump.contains("Search:"),
6833 "{label} should keep the search affordance:\n{dump}"
6834 );
6835 }
6836
6837 // The selection anchor must hold while navigating across sections at
6838 // the smallest supported size.
6839 let area = Rect::new(0, 0, 40, 12);
6840 for step in 0..12 {
6841 view.move_selection(1);
6842 let mut buf = Buffer::empty(area);
6843 view.render(area, &mut buf);
6844 let rendered = view
6845 .last_row_hitboxes
6846 .borrow()
6847 .iter()
6848 .any(|(_, idx)| *idx == view.selected);
6849 assert!(
6850 rendered,
6851 "selected setting fell out of the 40x12 window after {} moves",
6852 step + 1
6853 );
6854 }
6855 }
6856
6857 /// 40x12 regression: the edit surface must keep the editable value line
6858 /// (and its hint) above the wrapped footer.
6859 #[test]
6860 fn config_view_compact_edit_surface_keeps_value_line_visible() {
6861 let mut view = create_config_view(Locale::En);
6862 view.focus_key("approval_mode");
6863 view.start_edit();
6864 assert!(view.editing.is_some(), "approval_mode should be editable");
6865 assert_eq!(
6866 view.editing
6867 .as_ref()
6868 .and_then(|edit| edit.choices.as_ref())
6869 .expect("session permission choices"),
6870 &vec![
6871 "ask".to_string(),
6872 "auto-review".to_string(),
6873 "full-access".to_string(),
6874 ]
6875 );
6876 let area = Rect::new(0, 0, 40, 12);
6877 let mut buf = Buffer::empty(area);
6878
6879 view.render(area, &mut buf);
6880
6881 let dump = buffer_text(&buf, area);
6882 assert!(
6883 dump.contains("Choose:") && dump.contains("Full Access"),
6884 "the choice list must stay visible at 40x12:\n{dump}"
6885 );
6886 }
6887 }
6888
6888 lines RUST