1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
//! Settings screen — rendering: the section menu, the subsection tab strip, the field
//! list, the Choice popup, and the search overlay. Part of the [super] module; split
//! out of the settings.rs monolith (see docs/history/refactoring-god-objects.md).
use super::helpers::*;
use super::*;
impl SettingsScreen {
// ---------- rendering ----------
/// The palette from the working config copy: theme + terminal compatibility mode.
pub(super) fn palette(&self) -> Palette {
Palette::for_theme(self.config.interface.theme)
.with_compat(self.config.interface.terminal_compat)
}
/// The interface locale from the working config copy (axis B, docs/i18n-ui.md).
pub(super) fn loc(&self) -> &'static crate::shared::i18n::Locale {
crate::shared::i18n::locale(self.config.interface.language)
}
pub fn render(&mut self, frame: &mut Frame) {
let palette = self.palette();
let loc = self.loc();
// The fields built from the **default** config, once per frame: the
// `•` "modified" marker needs them, and so does the footer, which hides
// `Del reset` on a row already at its default. Building them costs a
// whole throwaway screen (`default_fields`), so the two consumers share
// one build rather than taking one each.
let defaults = self.default_fields();
let hints = self.footer_hints(&defaults);
// The hotkey line — below the panel (outside the border), right-aligned
// and wrapping to as many rows as it needs, through the one hint grid
// every screen's footer uses (`shared::ui::screen_chrome`).
let chrome = screen_chrome(
frame,
&palette,
format!(
"{}{}",
palette.glyphs().settings_icon,
loc.t("ui.settings.ui.title")
),
None,
&hints,
);
let (inner, status_area, hotkeys) = (chrome.inner, chrome.status, chrome.hotkeys);
let area = frame.area();
frame.render_widget(Paragraph::new(hotkeys), status_area);
let [menu_area, fields_area] =
Layout::horizontal([Constraint::Length(24), Constraint::Min(20)]).areas(inner);
// Section counters are tied to the selected modes (from the search index) —
// the sum matches the number of fields in search.
let counts = self.section_counts();
self.render_menu(frame, menu_area, &counts);
self.render_fields(frame, fields_area, &defaults);
// The editor on top — with a real cursor (`InputBox::render` requires `&mut`).
self.render_editor_popup(frame, area, &palette);
// The Choice-field picker popup — on top (the editor/choice are closed while
// searching).
if self.choice.is_some() {
self.render_choice(frame, area, &palette);
}
// The model picker — above the fields, below the search overlay, like the
// Choice popup it sits next to.
if self.picker.is_some() {
self.render_picker(frame, area, &palette);
}
// The field-search overlay — on top of everything (the editor is closed while
// searching).
if self.search.is_some() {
self.render_search(frame, area, &palette);
}
}
/// The contextual footer's hotkey hints for the current focus, section and
/// selected field.
///
/// Contextual footer. The hints differ **by focus** — that's what teaches the
/// navigation model, which is otherwise undiscoverable: on the sections only
/// Enter goes in, and inside the pane the arrows only change a value while Esc
/// steps back out. Section-specific extras (Profiles: create/delete) are
/// appended in both states. See docs/history/settings-navigation.md §5.1.
///
/// Inside the pane the three value keys are also **per field**, because
/// `handle_fields_key` is: `←→` only cycles a `Choice`, `Space` only flips a
/// `Toggle`, and `Del` resets only where `reset_field` has something to do
/// (never on profile/user data, and not on a row already at its default).
/// A key that would be a no-op is not advertised — spec §11.2,
/// docs/history/status-hints-unified.md §2.2.
fn footer_hints(&self, defaults: &[FieldRow]) -> Vec<(&'static str, &'static str, bool)> {
let loc = self.loc();
let on_menu = self.focus == Focus::Menu;
let mut hints: Vec<(&'static str, &'static str, bool)> = if on_menu {
vec![
("Tab/↑↓", loc.t("ui.settings.hint.section"), false),
("Enter", loc.t("ui.settings.hint.enter_fields"), false),
("/", loc.t("ui.settings.hint.search"), false),
("Ctrl+Z/Y", loc.t("ui.settings.hint.undo"), false),
]
} else {
let fields = self.fields();
let field = fields.get(self.field_idx);
let mut hints = vec![("↑↓", loc.t("ui.settings.hint.fields"), false)];
if matches!(field.map(|f| &f.kind), Some(FieldKind::Choice(_))) {
hints.push(("←→", loc.t("ui.settings.hint.choose"), false));
}
hints.push(("Enter", loc.t("ui.settings.hint.edit"), false));
if matches!(field.map(|f| &f.kind), Some(FieldKind::Toggle(_))) {
hints.push(("Space", loc.t("ui.settings.hint.toggle"), false));
}
if field.is_some_and(|f| self.reset_changes_something(f, defaults)) {
hints.push(("Del", loc.t("ui.settings.hint.reset"), false));
}
hints.extend([
("Tab", loc.t("ui.settings.hint.section"), false),
("/", loc.t("ui.settings.hint.search"), false),
("Ctrl+Z/Y", loc.t("ui.settings.hint.undo"), false),
]);
hints
};
if matches!(self.section(), Section::Profiles | Section::Plugins) {
hints.push(("Ctrl+N", loc.t("ui.settings.hint.new"), false));
hints.push(("Ctrl+D", loc.t("ui.settings.hint.delete"), true));
}
hints.push(("F1", loc.t("ui.settings.hint.help"), false));
hints.push((
"Esc",
if on_menu {
loc.t("ui.settings.hint.close")
} else {
loc.t("ui.settings.hint.to_sections")
},
false,
));
hints.push(("Ctrl+Q", loc.t("ui.settings.hint.quit"), false));
hints
}
/// Whether `Del` on this row would change anything — the read-only twin of
/// [`SettingsScreen::reset_field`], following the same three branches in the
/// same order: a secret row resets when a secret is stored, user data never
/// resets, and a config row resets only while its value differs from the
/// default one. `defaults` is the per-frame [`SettingsScreen::default_fields`]
/// build, shared with the `•` marker.
fn reset_changes_something(&self, f: &FieldRow, defaults: &[FieldRow]) -> bool {
if let Some(key) = self.secret_field_key(f.id) {
return self.secret_present(Some(&key));
}
if is_profile_field(f.id) {
return false;
}
let loc = self.loc();
defaults
.iter()
.find(|d| d.id == f.id)
.is_some_and(|d| value_text(&d.kind, loc) != value_text(&f.kind, loc))
}
/// Draws the field editor popup, when one is open (a no-op otherwise).
fn render_editor_popup(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
let loc = self.loc();
let Some(editor) = self.editor.as_mut() else {
return;
};
// System message/greeting — a large multiline popup with
// wrapping; other fields — a compact single-line strip. On a validation
// error the title carries a red message and the editor doesn't close.
let err = editor.error;
let base_title = if editor.multiline {
loc.tf(
"ui.editor.multiline_footer",
&[("newline", crate::shared::keys::newline_chord())],
)
} else {
loc.t("ui.settings.ui.editor_single").to_string()
};
let title = match err {
Some(e) => format!(
"{} {e} {}",
palette.glyphs().warn,
loc.t("ui.settings.ui.esc_cancel")
),
None => base_title,
};
let popup = if editor.multiline {
centered_rect(80, 40, multiline_popup_height(area), area)
} else {
centered_rect(60, 30, 3, area)
};
// A large multiline popup (system message/greeting)
// dims the background so it doesn't blend in; compact single-line strips —
// don't (an in-place edit).
if editor.multiline {
dim_background(frame, palette);
}
frame.render_widget(Clear, popup);
editor
.input
.render(frame, popup, RenderOpts::focused(&title), palette);
}
/// Draws the Choice-field picker popup: the option list, the current one marked.
pub(super) fn render_choice(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
let st = self.choice.as_ref().unwrap();
// Height = the number of options + a border, but no taller than the screen;
// width by the longest label (with margin), centered.
let want_h = (st.options.len() as u16 + 2).min(area.height.max(3));
let want_w = st
.options
.iter()
.map(|o| o.chars().count())
.max()
.unwrap_or(4) as u16
+ 8;
let popup = centered_rect_wh(want_w.max(24), want_h.max(3), area);
dim_background(frame, palette);
frame.render_widget(Clear, popup);
let items: Vec<ListItem> = st
.options
.iter()
.enumerate()
.map(|(i, o)| {
let mark = if i == st.selected { "› " } else { " " };
ListItem::new(Line::from(vec![
Span::styled(mark, Style::new().fg(palette.accent)),
Span::styled(o.clone(), Style::new().fg(palette.text)),
]))
})
.collect();
let block = palette
.panel(self.loc().t("ui.settings.ui.choice_title"), true)
.border_style(palette.border_style(true));
let list = List::new(items)
.block(block)
.highlight_style(Style::new().reversed());
// The popup is sized to its options but capped at the screen, so a long
// option set scrolls — and the position has to survive the frame.
let (len, selected) = (st.options.len(), st.selected);
self.choice.as_mut().unwrap().scroll.render(
frame,
list,
popup,
len,
popup.height.saturating_sub(2) as usize, // the panel's borders
Some(selected),
);
}
/// Draws the model picker: the filter line, then the provider's models —
/// with "type a name by hand" always the first row, so no failure of the
/// catalogue can trap the user in a list
/// ([docs/research/model-picker.md](../../../docs/research/model-picker.md) F1).
pub(super) fn render_picker(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
let loc = self.loc();
let popup = centered_rect(72, 50, (area.height * 3 / 4).max(8), area);
dim_background(frame, palette);
frame.render_widget(Clear, popup);
let [input_area, list_area] =
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(popup);
// The rows, and the note under the filter — built before the input takes
// a mutable borrow.
let st = self.picker.as_ref().unwrap();
let (status, selected, shown, total) = (
st.status.clone(),
st.selected,
st.results.len(),
st.all.len(),
);
let mut rows: Vec<String> = vec![loc.t("ui.settings.models.by_hand").to_string()];
rows.extend(
st.results
.iter()
.filter_map(|&i| st.all.get(i))
.map(|m| self.picker_label(m)),
);
let note = match &status {
super::picker::PickerStatus::Fetching => {
loc.t("ui.settings.models.fetching").to_string()
}
super::picker::PickerStatus::Failed(err) => match err {
crate::shared::api::catalogue::CatalogueError::Refused(status) => {
loc.tf(err.message_key(), &[("status", &status.to_string())])
}
_ => loc.t(err.message_key()).to_string(),
},
super::picker::PickerStatus::Listed if total == 0 => {
loc.t("ui.settings.models.none").to_string()
}
// The count is the honest answer to "is this everything?" — a filter
// that hides 120 of 132 should say so.
super::picker::PickerStatus::Listed => loc.tf(
"ui.settings.models.count",
&[("shown", &shown.to_string()), ("total", &total.to_string())],
),
};
let title = format!("{} · {}", loc.t("ui.settings.ui.models_title"), note);
self.picker.as_mut().unwrap().input.render(
frame,
input_area,
RenderOpts::focused(&title),
palette,
);
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let mark = if i == selected { "› " } else { " " };
// The "by hand" row is the way out, not a model — it reads as a
// command, in the accent the rest of the screen uses for those.
let colour = if i == 0 { palette.accent } else { palette.text };
ListItem::new(Line::from(vec![
Span::styled(mark, Style::new().fg(palette.accent)),
Span::styled(row.clone(), Style::new().fg(colour)),
]))
})
.collect();
let block = palette
.panel(loc.t("ui.settings.ui.models_list"), true)
.border_style(palette.border_style(true));
let list = List::new(items)
.block(block)
.highlight_style(Style::new().reversed());
let len = rows.len();
self.picker.as_mut().unwrap().scroll.render(
frame,
list,
list_area,
len,
list_area.height.saturating_sub(2) as usize,
Some(selected),
);
}
/// Draws the search overlay: the query line + the filtered results.
pub(super) fn render_search(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
let loc = self.loc();
let popup = centered_rect(72, 50, (area.height * 3 / 4).max(8), area);
dim_background(frame, palette);
frame.render_widget(Clear, popup);
let [input_area, list_area] =
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(popup);
// A snapshot for the list (select/results) before mutably borrowing input.
let (results, all_len, selected): (Vec<(String, String)>, usize, usize) = {
let s = self.search.as_ref().unwrap();
let rows = s
.results
.iter()
.map(|&ai| {
let h = &s.all[ai];
(h.crumb.clone(), h.value.clone())
})
.collect();
(rows, s.all.len(), s.selected)
};
let title = loc.tf(
"ui.settings.ui.search_title",
&[
("found", &results.len().to_string()),
("total", &all_len.to_string()),
],
);
self.search.as_mut().unwrap().input.render(
frame,
input_area,
RenderOpts::focused(&title),
palette,
);
// The results list: "breadcrumb value" (the value muted).
let inner_w = list_area.width.saturating_sub(2) as usize;
let items: Vec<ListItem> = if results.is_empty() {
vec![ListItem::new(Line::styled(
loc.t("ui.settings.ui.nothing_found"),
palette.muted_style(),
))]
} else {
results
.iter()
.map(|(crumb, value)| {
let vw = if value.is_empty() {
0
} else {
(value.chars().count() + 2).min(inner_w / 2)
};
let (crumb_s, cw) = truncate_to_width(crumb, inner_w.saturating_sub(vw + 1));
let mut spans = vec![Span::styled(crumb_s, Style::new().fg(palette.text))];
if vw > 0 {
let (vs, _) = truncate_to_width(value, inner_w.saturating_sub(cw + 2));
spans.push(Span::raw(" "));
spans.push(Span::styled(vs, palette.muted_style()));
}
ListItem::new(Line::from(spans))
})
.collect()
};
let block = palette
.panel(loc.t("ui.settings.ui.search_footer"), false)
.border_style(palette.border_style(true));
let rows = items.len();
let list = List::new(items)
.block(block)
.highlight_style(Style::new().reversed());
// The whole settings index can be in this list, so it scrolls in earnest.
let view_h = list_area.height.saturating_sub(2) as usize; // the panel's borders
let st = self.search.as_mut().unwrap();
st.scroll.render(
frame,
list,
list_area,
rows,
view_h,
(!results.is_empty()).then_some(selected),
);
let offset = st.scroll.offset();
// A scrollbar on the panel's right border, when there are more results than the visible height.
if list_area.height > 2 {
let bar = Rect {
x: list_area.x,
y: list_area.y + 1,
width: list_area.width,
height: list_area.height - 2,
};
render_scrollbar(
frame,
bar,
results.len(),
bar.height as usize,
offset,
true,
palette,
);
}
}
/// The number of editable parameters in a section (for the counter in the left menu).
///
/// Tied to the **currently selected mode** of each server's engine/provider
/// (managed/external/cloud), and taken from the same index as search
/// ([`Self::build_search_index`]) — so the sum of section counters matches the
/// number of fields in search. Fields of all subsections (tab strips) are
/// enumerated for their current modes; the subsection selector — a navigation
/// tab, not a parameter — doesn't count (`collect_hits` skips it).
///
/// Test-only — rendering uses [`Self::section_counts`] (one pass over the index
/// for all sections).
#[cfg(test)]
pub(super) fn section_field_count(&self, s: Section) -> usize {
let target = SECTIONS.iter().position(|&x| x == s).unwrap_or(0);
self.build_search_index()
.iter()
.filter(|h| h.section_idx == target)
.count()
}
/// Parameter counters for all sections in [`SECTIONS`] order, tied to the
/// selected modes. Derived from the search index ([`Self::build_search_index`])
/// in one pass — so the sum of section counters is identically equal to the
/// number of fields in search (the same source of truth).
pub(super) fn section_counts(&self) -> Vec<usize> {
let mut counts = vec![0usize; SECTIONS.len()];
for h in self.build_search_index() {
if let Some(c) = counts.get_mut(h.section_idx) {
*c += 1;
}
}
counts
}
pub(super) fn render_menu(&mut self, frame: &mut Frame, area: Rect, counts: &[usize]) {
let palette = self.palette();
let loc = self.loc();
let focused = self.focus == Focus::Menu;
// Width for the menu row's content (minus the right border) — for right-aligning
// the field counter.
let inner_w = area.width.saturating_sub(1) as usize;
// The active section is marked by a colored rail and a bold title regardless
// of focus; keyboard selection is highlighted by List's highlight.
let items: Vec<ListItem> = SECTIONS
.iter()
.enumerate()
.map(|(i, s)| {
let active = i == self.section_idx;
let bar = if active {
Span::styled("▌ ", Style::new().fg(palette.success))
} else {
Span::styled(" ", Style::new())
};
let title = if active {
Span::styled(s.title(loc), Style::new().fg(palette.text).bold())
} else {
Span::styled(s.title(loc), palette.muted_style())
};
// The section's field counter, right-aligned in the menu.
let count = counts.get(i).copied().unwrap_or(0).to_string();
let used = 2 + label_width(s.title(loc)) + count.chars().count();
let pad = inner_w.saturating_sub(used).max(1);
ListItem::new(Line::from(vec![
bar,
title,
Span::raw(" ".repeat(pad)),
Span::styled(count, palette.muted_style()),
]))
})
.collect();
let block = Block::default()
.borders(Borders::RIGHT)
.border_style(palette.border_style(false))
// The marker is always present — only its colour tracks the focus. It used
// to appear and disappear, which flickered and shifted the title text.
.title(Line::from(vec![
Span::styled(
format!(" {} ", palette.glyphs().collapsed),
focus_marker_style(focused, &palette),
),
Span::styled(
format!("{} ", loc.t("ui.settings.ui.sections")),
palette.muted_style(),
),
]));
// Selection — a soft backdrop (as in the chat list), not inverting the whole
// row: reverse video would swap fg↔bg per span independently, which would smear
// the green rail `▌` over ~1.5 columns (the glyph is a left half-block), and
// different spans would get different backgrounds (rail/title/counter each their
// own). A uniform `keycap_bg` + a green rail on top reads cleanly.
let hl = if focused {
Style::new().bg(palette.keycap_bg)
} else {
Style::new()
};
let list = List::new(items).block(block).highlight_style(hl);
// Only the right border is drawn, so the menu owns every row of `area`.
let (len, selected) = (SECTIONS.len(), self.section_idx);
self.menu_scroll
.render(frame, list, area, len, area.height as usize, Some(selected));
}
/// The server-status chip for the "Model" section's active subsection (assistant →
/// chat, impersonation → impersonation, embeddings → embeddings).
pub(super) fn model_server_chip(&self, palette: &Palette) -> Vec<Span<'static>> {
let loc = self.loc();
let (status, label) = match self.model_sub {
ModelTab::Assistant => (&self.statuses.chat, loc.t("ui.settings.chip.chat")),
ModelTab::Impersonation => (
&self.statuses.impersonation,
loc.t("ui.settings.chip.impersonation"),
),
ModelTab::Embeddings => (&self.statuses.embed, loc.t("ui.settings.chip.embeddings")),
// Speech has no server/probe: clients are stateless, built per call
// (docs/research/tts.md §8) — there's nothing for a chip to show.
ModelTab::Tts => return Vec::new(),
};
server_status_chip(status, label, loc, palette)
}
/// The subsection tab strip for the current section: (tab labels, active one).
/// `None` — a section with no subsections.
pub(super) fn subsection_tabs(&self) -> Option<(Vec<&'static str>, usize)> {
let loc = self.loc();
match self.section() {
Section::Model => Some((model_tab_labels(loc), self.model_sub as usize)),
Section::Sampling => Some((sub_tab_labels(loc), self.sampling_sub as usize)),
Section::Profiles => Some((sub_tab_labels(loc), self.profile_sub as usize)),
_ => None,
}
}
pub(super) fn render_fields(&mut self, frame: &mut Frame, area: Rect, defaults: &[FieldRow]) {
let fields = self.fields();
let focused = self.focus == Focus::Fields;
let focused_field = focused.then(|| fields.get(self.field_idx)).flatten();
let palette = self.palette();
// The subsection selector (if present in the current field set) is drawn not as
// a list row but as a tab strip above it. Its position is needed for "focus on tabs".
let sub_pos = fields.iter().position(|f| is_subsection(f.id));
let tabs = sub_pos.and(self.subsection_tabs());
// Header: the section title (always) + a tab strip (if there are subsections).
let head_h: u16 = 1 + if tabs.is_some() { 1 } else { 0 };
let desc_h = self.desc_panel_height(&fields, area, head_h);
let [head_area, list_area, desc_area] = Layout::vertical([
Constraint::Length(head_h),
Constraint::Min(1),
Constraint::Length(desc_h),
])
.areas(area);
let on_tabs = focused && sub_pos == Some(self.field_idx);
self.render_fields_header(frame, head_area, focused, on_tabs, tabs, &palette);
let inner_w = list_area.width as usize;
let (items, select) = self.build_field_items(&fields, defaults, focused, inner_w, &palette);
let total = items.len();
// Selection — a soft backdrop (as in the section menu and the chat list), not
// inverting the whole row; the selected row's green rail is added in
// `render_field_line`.
let hl = if focused {
Style::new().bg(palette.keycap_bg)
} else {
Style::new()
};
let list = List::new(items)
.block(Block::default().borders(Borders::NONE))
// A group header is a row the selection skips over, so without a row
// of context the header of the group you are standing in scrolls out
// — including at the very top of the pane, where the window would
// then stop one row short of the beginning.
.scroll_padding(1)
.highlight_style(hl);
// The pane's own list — no block, so it draws into every row of its area.
self.fields_scroll.render(
frame,
list,
list_area,
total,
list_area.height as usize,
select,
);
// A scrollbar when there are more elements than the visible height. Drawn over
// the settings screen's right border: `fields_area` reaches exactly to it (the
// panel's inner area), so the `list_area.right()` column IS the border line.
// The title/tab strip now live in a separate header (not in the list) → the bar
// spans the full height of `list_area`. Content length — the FULL element count
// (group headers are rows too).
if list_area.height > 0 {
let bar = Rect {
width: list_area.width + 1,
..list_area
};
render_scrollbar(
frame,
bar,
total,
list_area.height as usize,
self.fields_scroll.offset(),
true, // the settings screen's border is drawn in the focus color
&palette,
);
}
self.render_desc_panel(frame, desc_area, desc_h, focused_field, &palette);
}
/// The bottom description panel's height for this field set (`0` — no fields).
fn desc_panel_height(&self, fields: &[FieldRow], area: Rect, head_h: u16) -> u16 {
// The bottom panel (value+description) is always reserved when there are
// fields, and its height is the longest hint of EVERY field set the screen
// can show — not just this section's. A hint clipped mid-sentence is
// unreadable; a per-field height would shift the list on every step; and a
// per-section height (the previous rule) resized the panel on every Tab —
// measuring the whole catalog makes the height one constant for the
// terminal size and locale, so switching sections doesn't jerk the layout.
// The ceiling keeps the list from being squeezed out by a wall of text (an
// MCP tool's description is arbitrary server text).
if fields.is_empty() {
0
} else {
// The cap subtracts the tallest header (HEAD_MAX_ROWS), not this
// section's real one: a section-dependent cap would give tabbed and
// untabbed sections different heights in a small terminal — the very
// jump the catalog-wide measure exists to remove.
let cap = HINT_MAX_ROWS.min((area.height as usize).saturating_sub(HEAD_MAX_ROWS) / 3);
let rows = self.max_hint_rows(area.width as usize, cap) as u16;
// +1 for the top border; never take the last row away from the list.
(rows + 1).min(area.height.saturating_sub(head_h + 1))
}
}
/// The hint panel's content rows at this width: the longest hint across all
/// sections and subsections, within the shared floor/cap ([`hint_panel_rows`]).
fn max_hint_rows(&self, width: usize, cap: usize) -> usize {
let mut all: Vec<FieldRow> = Vec::new();
self.visit_field_sets(&mut |_, _, _, _, mut fields| all.append(&mut fields));
hint_panel_rows(&all, width, cap, self.loc())
}
/// Draws the field pane's header: the section title with its focus marker, the
/// "Model" section's server-status chip, and the subsection tab strip.
fn render_fields_header(
&self,
frame: &mut Frame,
head_area: Rect,
focused: bool,
on_tabs: bool,
tabs: Option<(Vec<&'static str>, usize)>,
palette: &Palette,
) {
let loc = self.loc();
let mut title_spans = vec![
// The counterpart of the section menu's `▸`: same rule, opposite pane.
Span::styled(
format!(" {} ", palette.glyphs().title_marker),
focus_marker_style(focused, palette),
),
Span::styled(
format!("{} ", self.section().title(loc)),
Style::new().fg(palette.text).bold(),
),
];
// The "Model/server" section: the active subsection's server-status chip on the
// right — edit the engine and see the effect (connecting → ready) without leaving to chat.
if self.section() == Section::Model {
let chip = self.model_server_chip(palette);
let used_left: usize = title_spans.iter().map(|s| span_width(s)).sum();
let used_right: usize = chip.iter().map(|s| span_width(s)).sum();
let head_w = head_area.width as usize;
if head_w > used_left + used_right + 1 {
title_spans.push(Span::raw(" ".repeat(head_w - used_left - used_right - 1)));
title_spans.extend(chip);
}
}
let mut head_lines = vec![Line::from(title_spans)];
if let Some((labels, active)) = tabs {
head_lines.push(tab_strip_line(&labels, active, on_tabs, palette));
}
frame.render_widget(Paragraph::new(head_lines), head_area);
}
/// Builds the field list's rows (group headers included) and the selected row's
/// position among them, for highlight/scroll.
fn build_field_items(
&self,
fields: &[FieldRow],
default_fields: &[FieldRow],
focused: bool,
inner_w: usize,
palette: &Palette,
) -> (Vec<ListItem<'static>>, Option<usize>) {
let loc = self.loc();
// A single value column across the WHOLE section (`section_label_col`): values
// and inline hints of all groups line up on one vertical (a per-group column
// "sawtoothed" — each group had its own stop). An overlong label raises the
// column no further than `LABEL_CAP` and is clipped there, so a dynamic label
// (an MCP tool id) cannot bend the vertical. This is also where we count the
// group's toggles (on/total) for the header counter.
let label_col = section_label_col(fields);
let group_toggles = group_toggle_counts(fields);
// Build the elements: a group header is inserted at the transition to a new
// non-empty group; `select` — the selected field's position among the elements
// (headers included) for highlight/scroll. We skip the subsection selector
// (it's a tab strip): while the cursor is on it, the list has no highlight.
let mut items: Vec<ListItem> = Vec::with_capacity(fields.len() + 8);
let mut select: Option<usize> = None;
let mut prev_group: Option<&str> = None;
for (i, f) in fields.iter().enumerate() {
if is_subsection(f.id) {
continue;
}
if !f.group.is_empty() && prev_group != Some(f.group) {
// The "on/total" counter — only for groups with ≥2 toggles (there it's
// informative; for a single toggle it would duplicate the visible [x]).
let count = group_toggles
.get(f.group)
.copied()
.filter(|&(_, total)| total >= 2);
items.push(ListItem::new(header_line(f.group, count, inner_w, palette)));
}
prev_group = Some(f.group);
if focused && i == self.field_idx {
select = Some(items.len());
}
// User data (profiles and impersonation personas) has no "default value"
// to deviate from — comparing it against a default config would mark, say,
// a chosen impersonation persona as "modified" simply because the default
// config has no personas at all.
let modified = !is_profile_field(f.id)
&& default_fields
.iter()
.find(|d| d.id == f.id)
.map(|d| value_text(&d.kind, loc) != value_text(&f.kind, loc))
.unwrap_or(false);
// Width for the value: minus the marker(2)+label+indent and the right margin.
// The label never runs past the column — a longer one is clipped there
// (`render_field_line`) — so every row's value gets the same width.
let value_w = inner_w.saturating_sub(label_col + 4);
items.push(ListItem::new(render_field_line(
f,
label_col,
value_w,
modified,
focused && i == self.field_idx,
palette,
)));
}
(items, select)
}
/// The bottom panel: the full value of the selected text field (whole paths,
/// truncated with "…" in the list) + a description hint.
fn render_desc_panel(
&self,
frame: &mut Frame,
desc_area: Rect,
desc_h: u16,
focused_field: Option<&FieldRow>,
palette: &Palette,
) {
if desc_h <= 1 {
return;
}
let content_h = desc_h as usize - 1;
let w = desc_area.width as usize;
// The hint has first claim on the panel — the height was reserved for it.
let mut hint: Vec<Line<'static>> = Vec::new();
if let Some(f) = focused_field {
if let Some(text) = f.description.as_deref() {
hint.extend(wrap_text(text, palette.muted_style(), w));
}
// An expanded explanation for a flagged row, when the row has one
// (a globally-disabled tool) — so the honest gate is
// understandable, not just "⊘". Driven by the note rather than by
// `warn`: that flag is raised for several unrelated reasons.
if let Some(note) = f.warn_note.as_deref() {
hint.extend(wrap_text(note, Style::new().fg(palette.warning), w));
}
}
// The cap can still cut a hint (arbitrary MCP text, a tiny terminal):
// end the last visible line with "…" instead of stopping mid-sentence.
if hint.len() > content_h {
hint.truncate(content_h);
ellipsize_last(&mut hint, w);
}
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(f) = focused_field
&& let FieldKind::Text(v) = &f.kind
{
let shown = v.trim();
// Show the full value only for "long" fields (paths, URLs, the
// system message) — in the list they're truncated with "…". Short
// values (numbers, host) are already fully visible in the list, no need to duplicate.
let long = crate::shared::wrap::display_width(&shown.chars().collect::<Vec<_>>()) > 32;
if !shown.is_empty() && shown != "—" && long {
// The preview fills what the hint leaves and never grows the
// panel — the value is also in the list row above, the hint is
// only here. A value that doesn't fit ends with a visible "…".
lines = value_preview(
shown,
content_h.saturating_sub(hint.len()),
w,
Style::new().fg(palette.text),
);
}
}
lines.extend(hint);
// Pre-wrapped above (`Wrap` can't be measured before layout), so every
// line already fits — no re-wrap here.
let para = Paragraph::new(lines).block(
Block::default()
.borders(Borders::TOP)
.border_style(palette.border_style(false)),
);
frame.render_widget(para, desc_area);
}
}
/// Counts each group's toggles as `(on, total)` — for the "N/M" counter in the
/// group header.
fn group_toggle_counts(fields: &[FieldRow]) -> HashMap<&'static str, (usize, usize)> {
let mut group_toggles: HashMap<&'static str, (usize, usize)> = HashMap::new();
for f in fields {
if is_subsection(f.id) {
continue;
}
if let FieldKind::Toggle(on) = f.kind {
let e = group_toggles.entry(f.group).or_insert((0, 0));
e.1 += 1;
if on {
e.0 += 1;
}
}
}
group_toggles
}