返回 DeepSeek-TUI-2026
mod.rs
根目录 / crates / tui / src / tui / views / mod.rs
1 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
2 use ratatui::{buffer::Buffer, layout::Rect};
3 use std::cell::{Cell, RefCell};
4 use std::fmt;
5
6 use crate::localization::{Locale, MessageId, tr};
7 use crate::palette;
8 use crate::settings::Settings;
9 use crate::tools::UserInputResponse;
10 use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType};
11 use crate::tui::app::App;
12 use crate::tui::approval::{ElevationOption, ReviewDecision};
13
14 pub mod status_picker;
15
16 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 pub enum ModalKind {
18 Approval,
19 Elevation,
20 UserInput,
21 PlanPrompt,
22 CommandPalette,
23 Help,
24 SubAgents,
25 Pager,
26 LiveTranscript,
27 SessionPicker,
28 Config,
29 ModelPicker,
30 ProviderPicker,
31 FilePicker,
32 StatusPicker,
33 ContextMenu,
34 ShellControl,
35 }
36
37 #[derive(Debug, Clone)]
38 pub enum CommandPaletteAction {
39 ExecuteCommand { command: String },
40 InsertText { text: String },
41 OpenTextPager { title: String, content: String },
42 }
43
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 pub enum ContextMenuAction {
46 CopySelection,
47 OpenSelection,
48 ClearSelection,
49 CopyCell {
50 cell_index: usize,
51 },
52 OpenDetails {
53 cell_index: usize,
54 },
55 Paste,
56 OpenCommandPalette,
57 OpenContextInspector,
58 OpenHelp,
59 /// Open the selected file:line in the user's editor.
60 OpenFileAtLine {
61 cell_index: usize,
62 },
63 /// Hide a transcript cell. Adds the cell's index to `collapsed_cells`.
64 HideCell {
65 cell_index: usize,
66 },
67 /// Show a previously hidden cell (when right-clicking near it).
68 ShowCell {
69 cell_index: usize,
70 },
71 /// Show all currently hidden cells.
72 ShowAllHidden,
73 }
74
75 #[derive(Debug, Clone)]
76 pub enum ViewEvent {
77 CommandPaletteSelected {
78 action: CommandPaletteAction,
79 },
80 OpenTextPager {
81 title: String,
82 content: String,
83 },
84 ApprovalDecision {
85 tool_id: String,
86 tool_name: String,
87 decision: ReviewDecision,
88 timed_out: bool,
89 /// Fingerprint key for per‑call approval caching (§5.A).
90 approval_key: String,
91 },
92 ElevationDecision {
93 tool_id: String,
94 tool_name: String,
95 option: ElevationOption,
96 },
97 UserInputSubmitted {
98 tool_id: String,
99 response: UserInputResponse,
100 },
101 UserInputCancelled {
102 tool_id: String,
103 },
104 ConfigUpdated {
105 key: String,
106 value: String,
107 persist: bool,
108 },
109 PlanPromptSelected {
110 option: usize,
111 },
112 PlanPromptDismissed,
113 SubAgentsRefresh,
114 /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a
115 /// candidate. The handler should insert `@<path>` at the composer's cursor
116 /// position.
117 FilePickerSelected {
118 path: String,
119 },
120 SessionSelected {
121 session_id: String,
122 },
123 SessionDeleted {
124 session_id: String,
125 title: String,
126 },
127 /// Emitted by the `/model` picker on Enter — carries both the chosen
128 /// model id and reasoning effort tier so the UI handler can update App
129 /// state, persist via `Settings`, and forward `Op::SetModel` to the
130 /// running engine. `previous_*` fields let the handler skip work when
131 /// nothing changed and craft a clear status message.
132 ModelPickerApplied {
133 model: String,
134 effort: crate::tui::app::ReasoningEffort,
135 previous_model: String,
136 previous_effort: crate::tui::app::ReasoningEffort,
137 },
138 /// Emitted by the `/provider` picker when the user selects a provider
139 /// that already has credentials — the handler should perform the same
140 /// switch as `AppAction::SwitchProvider`.
141 ProviderPickerApplied {
142 provider: crate::config::ApiProvider,
143 },
144 /// Emitted by the `/provider` picker after the user types an API key
145 /// inline for a provider that lacked one. The handler should persist
146 /// the key via `save_api_key_for` and then perform the provider switch.
147 ProviderPickerApiKeySubmitted {
148 provider: crate::config::ApiProvider,
149 api_key: String,
150 },
151 /// Emitted by the `/statusline` picker every time the user toggles an
152 /// item (live preview) and once more on Enter (final). The handler
153 /// updates `app.status_items` immediately and persists on `final_save`
154 /// so the footer animates without a write per keystroke.
155 StatusItemsUpdated {
156 items: Vec<crate::config::StatusItem>,
157 final_save: bool,
158 },
159 /// Emitted by the live-transcript overlay while in backtrack preview
160 /// mode (#133) when the user steps the highlighted user message with
161 /// Left or Right. The handler advances `app.backtrack`, refreshes the
162 /// overlay's `selected_idx`, and pins scroll near the new highlight.
163 BacktrackStep {
164 direction: crate::tui::backtrack::Direction,
165 },
166 /// Emitted by the live-transcript overlay when the user presses Enter
167 /// in backtrack preview mode (#133). The handler calls
168 /// `app.backtrack.confirm()`, trims `app.history`/`api_messages` to
169 /// the selected user message, populates the composer with the
170 /// dropped user text, and closes the overlay.
171 BacktrackConfirm,
172 /// Emitted by the live-transcript overlay when the user presses Esc
173 /// in backtrack preview mode (#133). The handler resets
174 /// `app.backtrack` and closes the overlay without trimming.
175 BacktrackCancel,
176 ContextMenuSelected {
177 action: ContextMenuAction,
178 },
179 ShellControlBackground,
180 ShellControlCancel,
181 }
182
183 #[derive(Debug, Clone)]
184 pub enum ViewAction {
185 None,
186 Close,
187 Emit(ViewEvent),
188 EmitAndClose(ViewEvent),
189 }
190
191 pub trait ModalView: std::any::Any {
192 fn kind(&self) -> ModalKind;
193 fn handle_key(&mut self, key: KeyEvent) -> ViewAction;
194 /// Returns `true` if the modal consumed the paste; `false` to let the
195 /// host route the text elsewhere (e.g. drop it because a modal is open,
196 /// or insert it into the composer when no modal wants it). The default
197 /// is `false` so modals that don't care about paste don't silently
198 /// swallow Cmd-V.
199 fn handle_paste(&mut self, _text: &str) -> bool {
200 false
201 }
202 fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction {
203 ViewAction::None
204 }
205 fn render(&self, area: Rect, buf: &mut Buffer);
206 fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool {
207 false
208 }
209 fn tick(&mut self) -> ViewAction {
210 ViewAction::None
211 }
212 /// Erased downcast hook for views that need a typed reference back from
213 /// the boxed trait object (e.g. the live transcript overlay needs `&mut`
214 /// access from outside the trait so it can refresh its snapshot of the
215 /// app's transcript state right before render).
216 fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
217 }
218
219 #[derive(Default)]
220 pub struct ViewStack {
221 views: Vec<Box<dyn ModalView>>,
222 }
223
224 impl ViewStack {
225 pub fn new() -> Self {
226 Self { views: Vec::new() }
227 }
228
229 pub fn is_empty(&self) -> bool {
230 self.views.is_empty()
231 }
232
233 pub fn top_kind(&self) -> Option<ModalKind> {
234 self.views.last().map(|view| view.kind())
235 }
236
237 pub fn push<V: ModalView + 'static>(&mut self, view: V) {
238 let kind = view.kind();
239 self.views.push(Box::new(view));
240 tracing::debug!(target: "deepseek_tui::view_stack", action = "push", kind = ?kind, depth = self.views.len(), "view pushed");
241 }
242
243 /// Push an already-boxed view back onto the stack. Used by call sites
244 /// that pop a view, mutate it externally, and need to restore it without
245 /// the generic `push` re-boxing dance.
246 pub fn push_boxed(&mut self, view: Box<dyn ModalView>) {
247 let kind = view.kind();
248 self.views.push(view);
249 tracing::debug!(target: "deepseek_tui::view_stack", action = "push_boxed", kind = ?kind, depth = self.views.len(), "view pushed");
250 }
251
252 pub fn pop(&mut self) -> Option<Box<dyn ModalView>> {
253 let popped = self.views.pop();
254 if let Some(view) = popped.as_ref() {
255 tracing::debug!(target: "deepseek_tui::view_stack", action = "pop", kind = ?view.kind(), depth = self.views.len(), "view popped");
256 }
257 popped
258 }
259
260 pub fn render(&self, area: Rect, buf: &mut Buffer) {
261 for view in &self.views {
262 view.render(area, buf);
263 }
264 }
265
266 pub fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool {
267 self.views
268 .last_mut()
269 .map(|view| view.update_subagents(agents))
270 .unwrap_or(false)
271 }
272
273 pub fn handle_key(&mut self, key: KeyEvent) -> Vec<ViewEvent> {
274 let action = self
275 .views
276 .last_mut()
277 .map(|view| view.handle_key(key))
278 .unwrap_or(ViewAction::None);
279 self.apply_action(action)
280 }
281
282 pub fn handle_paste(&mut self, text: &str) -> bool {
283 self.views
284 .last_mut()
285 .map(|view| view.handle_paste(text))
286 .unwrap_or(false)
287 }
288
289 pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec<ViewEvent> {
290 let action = self
291 .views
292 .last_mut()
293 .map(|view| view.handle_mouse(mouse))
294 .unwrap_or(ViewAction::None);
295 self.apply_action(action)
296 }
297
298 pub fn tick(&mut self) -> Vec<ViewEvent> {
299 let action = self
300 .views
301 .last_mut()
302 .map(|view| view.tick())
303 .unwrap_or(ViewAction::None);
304 self.apply_action(action)
305 }
306
307 fn apply_action(&mut self, action: ViewAction) -> Vec<ViewEvent> {
308 let mut events = Vec::new();
309 match action {
310 ViewAction::None => {}
311 ViewAction::Close => {
312 if let Some(view) = self.views.pop() {
313 tracing::debug!(target: "deepseek_tui::view_stack", action = "close", kind = ?view.kind(), depth = self.views.len(), "view closed via action");
314 }
315 }
316 ViewAction::Emit(event) => {
317 events.push(event);
318 }
319 ViewAction::EmitAndClose(event) => {
320 events.push(event);
321 if let Some(view) = self.views.pop() {
322 tracing::debug!(target: "deepseek_tui::view_stack", action = "emit_and_close", kind = ?view.kind(), depth = self.views.len(), "view closed via action");
323 }
324 }
325 }
326 events
327 }
328 }
329
330 impl fmt::Debug for ViewStack {
331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332 f.debug_struct("ViewStack")
333 .field("len", &self.views.len())
334 .field("top", &self.top_kind())
335 .finish()
336 }
337 }
338
339 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
340 enum ShellControlChoice {
341 Background,
342 Cancel,
343 }
344
345 impl ShellControlChoice {
346 fn event(self) -> ViewEvent {
347 match self {
348 ShellControlChoice::Background => ViewEvent::ShellControlBackground,
349 ShellControlChoice::Cancel => ViewEvent::ShellControlCancel,
350 }
351 }
352 }
353
354 pub struct ShellControlView {
355 selected: ShellControlChoice,
356 }
357
358 impl ShellControlView {
359 pub fn new() -> Self {
360 Self {
361 selected: ShellControlChoice::Background,
362 }
363 }
364
365 fn toggle(&mut self) {
366 self.selected = match self.selected {
367 ShellControlChoice::Background => ShellControlChoice::Cancel,
368 ShellControlChoice::Cancel => ShellControlChoice::Background,
369 };
370 }
371 }
372
373 impl ModalView for ShellControlView {
374 fn kind(&self) -> ModalKind {
375 ModalKind::ShellControl
376 }
377
378 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
379 self
380 }
381
382 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
383 match key.code {
384 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => ViewAction::Close,
385 KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
386 self.toggle();
387 ViewAction::None
388 }
389 KeyCode::Char('b') | KeyCode::Char('B') => {
390 ViewAction::EmitAndClose(ViewEvent::ShellControlBackground)
391 }
392 KeyCode::Char('c') | KeyCode::Char('C') => {
393 ViewAction::EmitAndClose(ViewEvent::ShellControlCancel)
394 }
395 KeyCode::Enter => ViewAction::EmitAndClose(self.selected.event()),
396 _ => ViewAction::None,
397 }
398 }
399
400 fn render(&self, area: Rect, buf: &mut Buffer) {
401 use ratatui::{
402 prelude::Stylize,
403 style::Style,
404 text::{Line, Span},
405 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
406 };
407
408 let popup_width = 62.min(area.width.saturating_sub(4));
409 let popup_height = 11.min(area.height.saturating_sub(2));
410
411 let popup_area = Rect {
412 x: (area.width - popup_width) / 2,
413 y: (area.height - popup_height) / 2,
414 width: popup_width,
415 height: popup_height,
416 };
417
418 Clear.render(popup_area, buf);
419
420 let option_line = |choice: ShellControlChoice, key: &'static str, label: &'static str| {
421 let selected = self.selected == choice;
422 let style = if selected {
423 Style::default()
424 .fg(palette::SELECTION_TEXT)
425 .bg(palette::SELECTION_BG)
426 } else {
427 Style::default().fg(palette::TEXT_PRIMARY)
428 };
429 Line::from(vec![
430 Span::styled(if selected { "> " } else { " " }, style),
431 Span::styled(format!("{key:<3}"), style.bold()),
432 Span::styled(label, style),
433 ])
434 };
435
436 let lines = vec![
437 Line::from(Span::styled(
438 "Foreground shell command is still running.",
439 Style::default().fg(palette::TEXT_PRIMARY),
440 )),
441 Line::from(""),
442 option_line(
443 ShellControlChoice::Background,
444 "B",
445 "Background - detach and keep the command running",
446 ),
447 option_line(
448 ShellControlChoice::Cancel,
449 "C",
450 "Cancel - stop the command and interrupt this turn",
451 ),
452 ];
453
454 let view = Paragraph::new(lines)
455 .block(
456 Block::default()
457 .title(Line::from(vec![Span::styled(
458 " Shell command ",
459 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
460 )]))
461 .title_bottom(Line::from(Span::styled(
462 " Enter select | Esc close ",
463 Style::default().fg(palette::TEXT_MUTED),
464 )))
465 .borders(Borders::ALL)
466 .border_style(Style::default().fg(palette::BORDER_COLOR))
467 .style(Style::default().bg(palette::DEEPSEEK_INK))
468 .padding(Padding::uniform(1)),
469 )
470 .style(Style::default().fg(palette::TEXT_PRIMARY));
471
472 view.render(popup_area, buf);
473 }
474 }
475
476 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
477 enum ConfigScope {
478 Session,
479 Saved,
480 }
481
482 impl ConfigScope {
483 fn label(self) -> &'static str {
484 match self {
485 ConfigScope::Session => "SESSION",
486 ConfigScope::Saved => "SAVED",
487 }
488 }
489
490 fn persist(self) -> bool {
491 matches!(self, ConfigScope::Saved)
492 }
493 }
494
495 #[derive(Debug, Clone)]
496 struct ConfigRow {
497 section: ConfigSection,
498 key: String,
499 value: String,
500 editable: bool,
501 scope: ConfigScope,
502 }
503
504 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
505 enum ConfigSection {
506 Model,
507 Permissions,
508 Display,
509 Composer,
510 Sidebar,
511 History,
512 Mcp,
513 }
514
515 impl ConfigSection {
516 fn label(self) -> &'static str {
517 match self {
518 ConfigSection::Model => "Model",
519 ConfigSection::Permissions => "Permissions",
520 ConfigSection::Display => "Display",
521 ConfigSection::Composer => "Composer",
522 ConfigSection::Sidebar => "Sidebar",
523 ConfigSection::History => "History",
524 ConfigSection::Mcp => "MCP",
525 }
526 }
527 }
528
529 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
530 enum ConfigListItem {
531 Section(ConfigSection),
532 Row(usize),
533 }
534
535 #[derive(Debug, Clone)]
536 struct ConfigEdit {
537 key: String,
538 original_value: String,
539 buffer: Vec<char>,
540 cursor: usize,
541 select_all: bool,
542 scope: ConfigScope,
543 }
544
545 pub struct ConfigView {
546 rows: Vec<ConfigRow>,
547 selected: usize,
548 scroll: usize,
549 editing: Option<ConfigEdit>,
550 filter: String,
551 status: Option<String>,
552 locale: Locale,
553 last_visible_rows: Cell<usize>,
554 last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
555 }
556
557 impl ConfigView {
558 pub fn new_for_app(app: &App) -> Self {
559 let settings = Settings::load().unwrap_or_else(|_| Settings::default());
560 let rows = vec![
561 ConfigRow {
562 section: ConfigSection::Model,
563 key: "model".to_string(),
564 value: app.model.clone(),
565 editable: true,
566 scope: ConfigScope::Session,
567 },
568 ConfigRow {
569 section: ConfigSection::Model,
570 key: "default_model".to_string(),
571 value: settings
572 .default_model
573 .as_deref()
574 .unwrap_or("(default)")
575 .to_string(),
576 editable: true,
577 scope: ConfigScope::Saved,
578 },
579 ConfigRow {
580 section: ConfigSection::Permissions,
581 key: "approval_mode".to_string(),
582 value: app.approval_mode.label().to_string(),
583 editable: true,
584 scope: ConfigScope::Session,
585 },
586 ConfigRow {
587 section: ConfigSection::Permissions,
588 key: "default_mode".to_string(),
589 value: settings.default_mode.clone(),
590 editable: true,
591 scope: ConfigScope::Saved,
592 },
593 ConfigRow {
594 section: ConfigSection::Display,
595 key: "locale".to_string(),
596 value: settings.locale.clone(),
597 editable: true,
598 scope: ConfigScope::Saved,
599 },
600 ConfigRow {
601 section: ConfigSection::Display,
602 key: "calm_mode".to_string(),
603 value: settings.calm_mode.to_string(),
604 editable: true,
605 scope: ConfigScope::Saved,
606 },
607 ConfigRow {
608 section: ConfigSection::Display,
609 key: "low_motion".to_string(),
610 value: settings.low_motion.to_string(),
611 editable: true,
612 scope: ConfigScope::Saved,
613 },
614 ConfigRow {
615 section: ConfigSection::Display,
616 key: "show_thinking".to_string(),
617 value: settings.show_thinking.to_string(),
618 editable: true,
619 scope: ConfigScope::Saved,
620 },
621 ConfigRow {
622 section: ConfigSection::Display,
623 key: "show_tool_details".to_string(),
624 value: settings.show_tool_details.to_string(),
625 editable: true,
626 scope: ConfigScope::Saved,
627 },
628 ConfigRow {
629 section: ConfigSection::Display,
630 key: "transcript_spacing".to_string(),
631 value: settings.transcript_spacing.clone(),
632 editable: true,
633 scope: ConfigScope::Saved,
634 },
635 ConfigRow {
636 section: ConfigSection::Composer,
637 key: "composer_density".to_string(),
638 value: settings.composer_density.clone(),
639 editable: true,
640 scope: ConfigScope::Saved,
641 },
642 ConfigRow {
643 section: ConfigSection::Composer,
644 key: "composer_border".to_string(),
645 value: settings.composer_border.to_string(),
646 editable: true,
647 scope: ConfigScope::Saved,
648 },
649 ConfigRow {
650 section: ConfigSection::Composer,
651 key: "paste_burst_detection".to_string(),
652 value: settings.paste_burst_detection.to_string(),
653 editable: true,
654 scope: ConfigScope::Saved,
655 },
656 ConfigRow {
657 section: ConfigSection::Sidebar,
658 key: "sidebar_width".to_string(),
659 value: settings.sidebar_width_percent.to_string(),
660 editable: true,
661 scope: ConfigScope::Saved,
662 },
663 ConfigRow {
664 section: ConfigSection::Sidebar,
665 key: "sidebar_focus".to_string(),
666 value: settings.sidebar_focus.clone(),
667 editable: true,
668 scope: ConfigScope::Saved,
669 },
670 ConfigRow {
671 section: ConfigSection::History,
672 key: "auto_compact".to_string(),
673 value: settings.auto_compact.to_string(),
674 editable: true,
675 scope: ConfigScope::Saved,
676 },
677 ConfigRow {
678 section: ConfigSection::History,
679 key: "max_history".to_string(),
680 value: settings.max_input_history.to_string(),
681 editable: true,
682 scope: ConfigScope::Saved,
683 },
684 ConfigRow {
685 section: ConfigSection::Mcp,
686 key: "mcp_config_path".to_string(),
687 value: app.mcp_config_path.display().to_string(),
688 editable: true,
689 scope: ConfigScope::Saved,
690 },
691 ];
692
693 Self {
694 rows,
695 selected: 0,
696 scroll: 0,
697 editing: None,
698 filter: String::new(),
699 status: None,
700 locale: app.ui_locale,
701 last_visible_rows: Cell::new(0),
702 last_row_hitboxes: RefCell::new(Vec::new()),
703 }
704 }
705
706 fn tr(&self, id: MessageId) -> &'static str {
707 tr(self.locale, id)
708 }
709
710 fn visible_rows_cached(&self) -> usize {
711 let cached = self.last_visible_rows.get();
712 if cached == 0 { 8 } else { cached }
713 }
714
715 fn row_matches_filter(&self, row: &ConfigRow) -> bool {
716 let filter = self.filter.trim().to_lowercase();
717 if filter.is_empty() {
718 return true;
719 }
720
721 let section = row.section.label().to_lowercase();
722 let key = row.key.to_lowercase();
723 let value = row.value.to_lowercase();
724 let scope = row.scope.label().to_lowercase();
725
726 filter.split_whitespace().all(|term| {
727 section.contains(term)
728 || key.contains(term)
729 || value.contains(term)
730 || scope.contains(term)
731 })
732 }
733
734 fn matching_row_indices(&self) -> Vec<usize> {
735 self.rows
736 .iter()
737 .enumerate()
738 .filter_map(|(idx, row)| self.row_matches_filter(row).then_some(idx))
739 .collect()
740 }
741
742 fn visible_items(&self) -> Vec<ConfigListItem> {
743 let mut items = Vec::new();
744 let mut current_section = None;
745
746 for (idx, row) in self.rows.iter().enumerate() {
747 if !self.row_matches_filter(row) {
748 continue;
749 }
750
751 if current_section != Some(row.section) {
752 current_section = Some(row.section);
753 items.push(ConfigListItem::Section(row.section));
754 }
755 items.push(ConfigListItem::Row(idx));
756 }
757
758 items
759 }
760
761 fn selected_row_index(&self) -> Option<usize> {
762 let selected = self.selected;
763 self.matching_row_indices()
764 .into_iter()
765 .any(|idx| idx == selected)
766 .then_some(selected)
767 }
768
769 fn selected_display_position(&self, items: &[ConfigListItem]) -> Option<usize> {
770 items
771 .iter()
772 .position(|item| matches!(item, ConfigListItem::Row(idx) if *idx == self.selected))
773 }
774
775 fn sync_selection_to_filter(&mut self) {
776 let matches = self.matching_row_indices();
777 if matches.is_empty() {
778 self.selected = 0;
779 self.scroll = 0;
780 return;
781 }
782
783 if !matches.contains(&self.selected) {
784 self.selected = matches[0];
785 }
786 }
787
788 fn update_filter(&mut self, update: impl FnOnce(&mut String)) {
789 update(&mut self.filter);
790 self.status = None;
791 self.sync_selection_to_filter();
792 self.adjust_scroll(self.visible_rows_cached());
793 }
794
795 fn adjust_scroll(&mut self, visible_rows: usize) {
796 self.sync_selection_to_filter();
797
798 let items = self.visible_items();
799 if items.is_empty() {
800 self.scroll = 0;
801 return;
802 }
803
804 let visible_rows = visible_rows.max(1);
805 let max_scroll = items.len().saturating_sub(visible_rows);
806 self.scroll = self.scroll.min(max_scroll);
807
808 let Some(selected_pos) = self.selected_display_position(&items) else {
809 self.scroll = 0;
810 return;
811 };
812
813 if selected_pos < self.scroll {
814 self.scroll = selected_pos;
815 }
816
817 if selected_pos >= self.scroll + visible_rows {
818 self.scroll = selected_pos.saturating_sub(visible_rows.saturating_sub(1));
819 }
820 }
821
822 fn move_selection(&mut self, delta: isize) {
823 let matches = self.matching_row_indices();
824 if matches.is_empty() {
825 return;
826 }
827
828 let current = matches
829 .iter()
830 .position(|idx| *idx == self.selected)
831 .unwrap_or(0);
832 let max = matches.len().saturating_sub(1);
833 let next = if delta.is_negative() {
834 current.saturating_sub(delta.unsigned_abs())
835 } else {
836 (current + delta as usize).min(max)
837 };
838
839 self.selected = matches[next];
840 let visible_rows = self.visible_rows_cached();
841 self.adjust_scroll(visible_rows);
842 }
843
844 fn handle_editing_key(&mut self, key: KeyEvent) -> ViewAction {
845 match key.code {
846 KeyCode::Esc => {
847 self.editing = None;
848 self.status = Some("Edit cancelled".to_string());
849 ViewAction::None
850 }
851 KeyCode::Enter => {
852 let Some(edit) = self.editing.take() else {
853 return ViewAction::None;
854 };
855 let submitted = edit.buffer.iter().collect::<String>();
856 let value = submitted.trim().to_string();
857 ViewAction::Emit(ViewEvent::ConfigUpdated {
858 key: edit.key,
859 value,
860 persist: edit.scope.persist(),
861 })
862 }
863 KeyCode::Backspace => {
864 if let Some(edit) = self.editing.as_mut() {
865 if edit.select_all {
866 edit.buffer.clear();
867 edit.cursor = 0;
868 edit.select_all = false;
869 } else if edit.cursor > 0 {
870 edit.cursor = edit.cursor.saturating_sub(1);
871 edit.buffer.remove(edit.cursor);
872 }
873 }
874 ViewAction::None
875 }
876 KeyCode::Delete => {
877 if let Some(edit) = self.editing.as_mut() {
878 if edit.select_all {
879 edit.buffer.clear();
880 edit.cursor = 0;
881 edit.select_all = false;
882 } else if edit.cursor < edit.buffer.len() {
883 edit.buffer.remove(edit.cursor);
884 }
885 }
886 ViewAction::None
887 }
888 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
889 if let Some(edit) = self.editing.as_mut() {
890 edit.buffer.clear();
891 edit.cursor = 0;
892 edit.select_all = false;
893 }
894 ViewAction::None
895 }
896 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
897 if let Some(edit) = self.editing.as_mut() {
898 edit.cursor = edit.buffer.len();
899 edit.select_all = true;
900 }
901 ViewAction::None
902 }
903 KeyCode::Left => {
904 if let Some(edit) = self.editing.as_mut() {
905 if edit.select_all {
906 edit.cursor = 0;
907 edit.select_all = false;
908 } else {
909 edit.cursor = edit.cursor.saturating_sub(1);
910 }
911 }
912 ViewAction::None
913 }
914 KeyCode::Right => {
915 if let Some(edit) = self.editing.as_mut() {
916 if edit.select_all {
917 edit.cursor = edit.buffer.len();
918 edit.select_all = false;
919 } else {
920 edit.cursor = (edit.cursor + 1).min(edit.buffer.len());
921 }
922 }
923 ViewAction::None
924 }
925 KeyCode::Home => {
926 if let Some(edit) = self.editing.as_mut() {
927 edit.cursor = 0;
928 edit.select_all = false;
929 }
930 ViewAction::None
931 }
932 KeyCode::End => {
933 if let Some(edit) = self.editing.as_mut() {
934 edit.cursor = edit.buffer.len();
935 edit.select_all = false;
936 }
937 ViewAction::None
938 }
939 KeyCode::Char(ch)
940 if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() =>
941 {
942 if let Some(edit) = self.editing.as_mut() {
943 if edit.select_all {
944 edit.buffer.clear();
945 edit.cursor = 0;
946 edit.select_all = false;
947 }
948 edit.buffer.insert(edit.cursor, ch);
949 edit.cursor += 1;
950 }
951 ViewAction::None
952 }
953 _ => ViewAction::None,
954 }
955 }
956
957 fn start_edit(&mut self) {
958 let Some(row_idx) = self.selected_row_index() else {
959 return;
960 };
961 let Some(row) = self.rows.get(row_idx) else {
962 return;
963 };
964 let key = row.key.clone();
965 let original_value = row.value.clone();
966 let initial_value = if key == "default_model" && original_value == "(default)" {
967 String::new()
968 } else {
969 original_value.clone()
970 };
971
972 let buffer: Vec<char> = initial_value.chars().collect();
973 self.editing = Some(ConfigEdit {
974 key,
975 original_value,
976 cursor: buffer.len(),
977 buffer,
978 select_all: true,
979 scope: row.scope,
980 });
981 self.status = None;
982 }
983
984 fn clear_filter(&mut self) {
985 if self.filter.is_empty() {
986 return;
987 }
988
989 self.update_filter(|filter| filter.clear());
990 }
991 }
992
993 fn config_hint_for_key(key: &str) -> &'static str {
994 match key {
995 "model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-*",
996 "approval_mode" => "auto | suggest | never",
997 "auto_compact"
998 | "calm_mode"
999 | "low_motion"
1000 | "show_thinking"
1001 | "show_tool_details"
1002 | "composer_border"
1003 | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0",
1004 "composer_density" | "transcript_spacing" => "compact | comfortable | spacious",
1005 "locale" => "auto | en | ja | zh-Hans | pt-BR",
1006 "default_mode" => "agent | plan | yolo",
1007 "sidebar_width" => "10..=50",
1008 "sidebar_focus" => "auto | plan | todos | tasks | agents",
1009 "max_history" => "integer (0 allowed)",
1010 "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default",
1011 "mcp_config_path" => "path to mcp.json",
1012 _ => "",
1013 }
1014 }
1015
1016 fn render_config_editor_value_line(edit: &ConfigEdit) -> ratatui::text::Line<'static> {
1017 use ratatui::{
1018 prelude::Stylize,
1019 style::Style,
1020 text::{Line, Span},
1021 };
1022
1023 let mut spans = Vec::new();
1024 spans.push(Span::styled(
1025 "New: ",
1026 Style::default().fg(palette::TEXT_MUTED),
1027 ));
1028
1029 let cursor_style = Style::default()
1030 .fg(palette::DEEPSEEK_INK)
1031 .bg(palette::DEEPSEEK_SKY)
1032 .bold();
1033 let selected_style = Style::default()
1034 .fg(palette::SELECTION_TEXT)
1035 .bg(palette::SELECTION_BG);
1036
1037 if edit.select_all && !edit.buffer.is_empty() {
1038 let text = edit.buffer.iter().collect::<String>();
1039 spans.push(Span::styled(text, selected_style));
1040 spans.push(Span::styled(" ", cursor_style));
1041 return Line::from(spans);
1042 }
1043
1044 let before = edit.buffer.iter().take(edit.cursor).collect::<String>();
1045 spans.push(Span::raw(before));
1046 if edit.cursor < edit.buffer.len() {
1047 let ch = edit.buffer[edit.cursor];
1048 spans.push(Span::styled(ch.to_string(), cursor_style));
1049 let after = edit
1050 .buffer
1051 .iter()
1052 .skip(edit.cursor.saturating_add(1))
1053 .collect::<String>();
1054 spans.push(Span::raw(after));
1055 } else {
1056 spans.push(Span::styled(" ", cursor_style));
1057 }
1058
1059 Line::from(spans)
1060 }
1061
1062 impl ModalView for ConfigView {
1063 fn kind(&self) -> ModalKind {
1064 ModalKind::Config
1065 }
1066
1067 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1068 self
1069 }
1070
1071 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
1072 if self.editing.is_some() {
1073 return self.handle_editing_key(key);
1074 }
1075
1076 match key.code {
1077 KeyCode::Esc => {
1078 if self.filter.is_empty() {
1079 ViewAction::Close
1080 } else {
1081 self.clear_filter();
1082 ViewAction::None
1083 }
1084 }
1085 KeyCode::Char('q') if self.filter.is_empty() => ViewAction::Close,
1086 KeyCode::Up => {
1087 self.move_selection(-1);
1088 ViewAction::None
1089 }
1090 KeyCode::Char('k') if self.filter.is_empty() => {
1091 self.move_selection(-1);
1092 ViewAction::None
1093 }
1094 KeyCode::Down => {
1095 self.move_selection(1);
1096 ViewAction::None
1097 }
1098 KeyCode::Char('j') if self.filter.is_empty() => {
1099 self.move_selection(1);
1100 ViewAction::None
1101 }
1102 KeyCode::PageUp => {
1103 self.move_selection(-5);
1104 ViewAction::None
1105 }
1106 KeyCode::PageDown => {
1107 self.move_selection(5);
1108 ViewAction::None
1109 }
1110 KeyCode::Backspace => {
1111 if !self.filter.is_empty() {
1112 self.update_filter(|filter| {
1113 filter.pop();
1114 });
1115 }
1116 ViewAction::None
1117 }
1118 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1119 self.clear_filter();
1120 ViewAction::None
1121 }
1122 KeyCode::Char('e') | KeyCode::Char('E') if self.filter.is_empty() => {
1123 if self
1124 .selected_row_index()
1125 .and_then(|idx| self.rows.get(idx))
1126 .is_some_and(|row| row.editable)
1127 {
1128 self.start_edit();
1129 }
1130 ViewAction::None
1131 }
1132 KeyCode::Enter => {
1133 if self
1134 .selected_row_index()
1135 .and_then(|idx| self.rows.get(idx))
1136 .is_some_and(|row| row.editable)
1137 {
1138 self.start_edit();
1139 }
1140 ViewAction::None
1141 }
1142 KeyCode::Char(ch)
1143 if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() =>
1144 {
1145 self.update_filter(|filter| filter.push(ch));
1146 ViewAction::None
1147 }
1148 _ => ViewAction::None,
1149 }
1150 }
1151
1152 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
1153 if self.editing.is_some() {
1154 return ViewAction::None;
1155 }
1156 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1157 return ViewAction::None;
1158 }
1159
1160 let selected = self
1161 .last_row_hitboxes
1162 .borrow()
1163 .iter()
1164 .find_map(|(y, row_idx)| (*y == mouse.row).then_some(*row_idx));
1165 if let Some(row_idx) = selected {
1166 self.selected = row_idx;
1167 self.status = None;
1168 self.adjust_scroll(self.visible_rows_cached());
1169 }
1170 ViewAction::None
1171 }
1172
1173 fn render(&self, area: Rect, buf: &mut Buffer) {
1174 use ratatui::{
1175 prelude::Stylize,
1176 style::Style,
1177 text::{Line, Span},
1178 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
1179 };
1180
1181 let popup_width = 84.min(area.width.saturating_sub(4));
1182 let popup_height = 22.min(area.height.saturating_sub(4));
1183
1184 let popup_area = Rect {
1185 x: (area.width - popup_width) / 2,
1186 y: (area.height - popup_height) / 2,
1187 width: popup_width,
1188 height: popup_height,
1189 };
1190
1191 Clear.render(popup_area, buf);
1192
1193 let base_block = Block::default()
1194 .borders(Borders::ALL)
1195 .border_style(Style::default().fg(palette::BORDER_COLOR))
1196 .style(Style::default().bg(palette::DEEPSEEK_INK))
1197 .padding(Padding::uniform(1));
1198
1199 let inner = base_block.inner(popup_area);
1200 let (lines, footer) = if let Some(edit) = self.editing.as_ref() {
1201 let mut lines: Vec<Line> = Vec::new();
1202 lines.push(Line::from(vec![Span::styled(
1203 format!("Edit {}", edit.key),
1204 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
1205 )]));
1206 lines.push(Line::from(""));
1207 lines.push(Line::from(vec![
1208 Span::styled("Scope: ", Style::default().fg(palette::TEXT_MUTED)),
1209 Span::raw(edit.scope.label()),
1210 ]));
1211 lines.push(Line::from(vec![
1212 Span::styled("Current: ", Style::default().fg(palette::TEXT_MUTED)),
1213 Span::raw(truncate_view_text(&edit.original_value, 60)),
1214 ]));
1215 lines.push(Line::from(""));
1216 lines.push(render_config_editor_value_line(edit));
1217 lines.push(Line::from(""));
1218 let hint = config_hint_for_key(&edit.key);
1219 if !hint.is_empty() {
1220 lines.push(Line::from(vec![
1221 Span::styled("Hint: ", Style::default().fg(palette::TEXT_MUTED)),
1222 Span::raw(hint),
1223 ]));
1224 }
1225 (
1226 lines,
1227 " Enter=apply, Esc=cancel, Ctrl+U=clear, Ctrl+A=all, \u{2190}/\u{2192}=move "
1228 .to_string(),
1229 )
1230 } else {
1231 let content_height = usize::from(inner.height);
1232 let header_lines = 5usize;
1233 let bottom_lines = 1usize;
1234 let visible_rows = content_height
1235 .saturating_sub(header_lines + bottom_lines)
1236 .max(1);
1237 self.last_visible_rows.set(visible_rows);
1238
1239 let items = self.visible_items();
1240 let match_count = self.matching_row_indices().len();
1241 let start = self.scroll.min(items.len());
1242 let end = (start + visible_rows).min(items.len());
1243 let scrollable = items.len() > visible_rows;
1244 let search_value = if self.filter.is_empty() {
1245 self.tr(MessageId::ConfigSearchPlaceholder).to_string()
1246 } else {
1247 self.filter.clone()
1248 };
1249
1250 let mut lines: Vec<Line> = vec![
1251 Line::from(vec![Span::styled(
1252 self.tr(MessageId::ConfigTitle),
1253 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
1254 )]),
1255 Line::from(vec![
1256 Span::styled(" Search: ", Style::default().fg(palette::TEXT_MUTED)),
1257 Span::raw(search_value),
1258 Span::styled(
1259 format!(" ({match_count}/{})", self.rows.len()),
1260 Style::default().fg(palette::TEXT_MUTED),
1261 ),
1262 ]),
1263 Line::from(""),
1264 Line::from(" Key Value Scope"),
1265 Line::from(" ----------------------------------------------------------------"),
1266 ];
1267 let mut row_hitboxes = Vec::new();
1268
1269 for item in items.iter().skip(start).take(visible_rows) {
1270 match item {
1271 ConfigListItem::Section(section) => {
1272 lines.push(Line::from(Span::styled(
1273 format!(" {}", section.label()),
1274 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
1275 )));
1276 }
1277 ConfigListItem::Row(idx) => {
1278 let Some(row) = self.rows.get(*idx) else {
1279 continue;
1280 };
1281 let line_y = inner.y.saturating_add(lines.len() as u16);
1282 row_hitboxes.push((line_y, *idx));
1283 let selected = *idx == self.selected;
1284 let style = if selected {
1285 Style::default()
1286 .fg(ratatui::style::Color::White)
1287 .bg(palette::DEEPSEEK_BLUE)
1288 .add_modifier(ratatui::style::Modifier::BOLD)
1289 } else {
1290 Style::default().fg(palette::TEXT_PRIMARY)
1291 };
1292 let value = truncate_view_text(&row.value, 44);
1293 let mut line = Line::from(format!(
1294 " {:<19} {:<44} {}",
1295 row.key,
1296 value,
1297 row.scope.label()
1298 ));
1299 line.style = style;
1300 lines.push(line);
1301 }
1302 }
1303 }
1304 *self.last_row_hitboxes.borrow_mut() = row_hitboxes;
1305
1306 if items.is_empty() {
1307 let message = if self.filter.is_empty() {
1308 self.tr(MessageId::ConfigNoSettings).to_string()
1309 } else {
1310 format!(
1311 "{}\"{}\".",
1312 self.tr(MessageId::ConfigNoMatchesPrefix),
1313 self.filter
1314 )
1315 };
1316 lines.push(Line::from(Span::styled(
1317 message,
1318 Style::default().fg(palette::TEXT_MUTED),
1319 )));
1320 }
1321
1322 let bottom_text = if let Some(status) = self.status.as_ref() {
1323 status.clone()
1324 } else if !self.filter.is_empty() {
1325 format!(
1326 "{}: {match_count}",
1327 self.tr(MessageId::ConfigFilteredSettings)
1328 )
1329 } else if scrollable && !items.is_empty() {
1330 format!(
1331 "{} {}-{} / {}",
1332 self.tr(MessageId::ConfigShowing),
1333 self.scroll.saturating_add(1),
1334 end,
1335 items.len()
1336 )
1337 } else {
1338 String::new()
1339 };
1340 lines.push(Line::from(Span::styled(
1341 bottom_text,
1342 Style::default().fg(palette::TEXT_MUTED),
1343 )));
1344
1345 let footer = if !self.filter.is_empty() {
1346 self.tr(MessageId::ConfigFooterFiltered)
1347 } else if scrollable {
1348 self.tr(MessageId::ConfigFooterScrollable)
1349 } else {
1350 self.tr(MessageId::ConfigFooterDefault)
1351 };
1352 (lines, footer.to_string())
1353 };
1354
1355 let block = Block::default()
1356 .title(Line::from(vec![Span::styled(
1357 self.tr(MessageId::ConfigModalTitle),
1358 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
1359 )]))
1360 .title_bottom(Line::from(Span::styled(
1361 footer,
1362 Style::default().fg(palette::TEXT_MUTED),
1363 )))
1364 .borders(Borders::ALL)
1365 .border_style(Style::default().fg(palette::BORDER_COLOR))
1366 .style(Style::default().bg(palette::DEEPSEEK_INK))
1367 .padding(Padding::uniform(1));
1368
1369 let inner = block.inner(popup_area);
1370 block.render(popup_area, buf);
1371 Paragraph::new(lines)
1372 .style(Style::default().fg(palette::TEXT_PRIMARY))
1373 .scroll((0, 0))
1374 .render(inner, buf);
1375 }
1376 }
1377
1378 pub mod help;
1379
1380 pub use help::HelpView;
1381
1382 pub struct SubAgentsView {
1383 agents: Vec<SubAgentResult>,
1384 scroll: usize,
1385 }
1386
1387 impl SubAgentsView {
1388 pub fn new(agents: Vec<SubAgentResult>) -> Self {
1389 Self { agents, scroll: 0 }
1390 }
1391 }
1392
1393 impl ModalView for SubAgentsView {
1394 fn kind(&self) -> ModalKind {
1395 ModalKind::SubAgents
1396 }
1397
1398 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1399 self
1400 }
1401
1402 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
1403 use crossterm::event::KeyCode;
1404
1405 match key.code {
1406 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
1407 KeyCode::Enter | KeyCode::Char('r') | KeyCode::Char('R') => {
1408 ViewAction::Emit(ViewEvent::SubAgentsRefresh)
1409 }
1410 KeyCode::Up | KeyCode::Char('k') => {
1411 self.scroll = self.scroll.saturating_sub(1);
1412 ViewAction::None
1413 }
1414 KeyCode::Down | KeyCode::Char('j') => {
1415 self.scroll = self.scroll.saturating_add(1);
1416 ViewAction::None
1417 }
1418 _ => ViewAction::None,
1419 }
1420 }
1421
1422 fn update_subagents(&mut self, agents: &[SubAgentResult]) -> bool {
1423 self.agents = agents.to_vec();
1424 self.scroll = self.scroll.min(self.agents.len().saturating_sub(1));
1425 true
1426 }
1427
1428 fn render(&self, area: Rect, buf: &mut Buffer) {
1429 use ratatui::{
1430 prelude::Stylize,
1431 style::Style,
1432 text::{Line, Span},
1433 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
1434 };
1435
1436 let popup_width = 78.min(area.width.saturating_sub(4));
1437 let popup_height = 20.min(area.height.saturating_sub(4));
1438
1439 let popup_area = Rect {
1440 x: (area.width - popup_width) / 2,
1441 y: (area.height - popup_height) / 2,
1442 width: popup_width,
1443 height: popup_height,
1444 };
1445
1446 Clear.render(popup_area, buf);
1447
1448 let mut lines: Vec<Line> = Vec::new();
1449 let content_width = popup_width.saturating_sub(4) as usize;
1450
1451 if self.agents.is_empty() {
1452 lines.push(Line::from(Span::styled(
1453 "No agents running.",
1454 Style::default().fg(palette::TEXT_MUTED),
1455 )));
1456 } else {
1457 let mut running = Vec::new();
1458 let mut completed = Vec::new();
1459 let mut interrupted = Vec::new();
1460 let mut failed = Vec::new();
1461 let mut cancelled = Vec::new();
1462
1463 for agent in &self.agents {
1464 match agent.status {
1465 SubAgentStatus::Running => running.push(agent),
1466 SubAgentStatus::Completed => completed.push(agent),
1467 SubAgentStatus::Interrupted(_) => interrupted.push(agent),
1468 SubAgentStatus::Failed(_) => failed.push(agent),
1469 SubAgentStatus::Cancelled => cancelled.push(agent),
1470 }
1471 }
1472
1473 let status_summary = [
1474 ("Running", running.len(), palette::STATUS_WARNING),
1475 ("Completed", completed.len(), palette::STATUS_SUCCESS),
1476 ("Interrupted", interrupted.len(), palette::STATUS_WARNING),
1477 ("Failed", failed.len(), palette::DEEPSEEK_RED),
1478 ("Cancelled", cancelled.len(), palette::TEXT_MUTED),
1479 ];
1480
1481 lines.push(Line::from(Span::styled(
1482 "Sub-agents",
1483 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
1484 )));
1485
1486 let mut summary_parts = Vec::new();
1487 for (label, count, color) in status_summary {
1488 summary_parts.push(Line::from(Span::styled(
1489 format!("{}: {}", label, count),
1490 Style::default().fg(color),
1491 )));
1492 }
1493
1494 let mut summary = vec![Span::styled(" ", Style::default().fg(palette::TEXT_DIM))];
1495 for (idx, part) in summary_parts.into_iter().enumerate() {
1496 if idx > 0 {
1497 summary.push(Span::raw(" · "));
1498 }
1499 summary.extend(part);
1500 }
1501 lines.push(Line::from(summary));
1502 lines.push(Line::from(Span::styled(
1503 "",
1504 Style::default().fg(palette::TEXT_DIM),
1505 )));
1506
1507 running.sort_by(|a, b| {
1508 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
1509 order.then_with(|| a.agent_id.cmp(&b.agent_id))
1510 });
1511 completed.sort_by(|a, b| {
1512 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
1513 order.then_with(|| a.agent_id.cmp(&b.agent_id))
1514 });
1515 interrupted.sort_by(|a, b| {
1516 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
1517 order.then_with(|| a.agent_id.cmp(&b.agent_id))
1518 });
1519 failed.sort_by(|a, b| {
1520 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
1521 order.then_with(|| a.agent_id.cmp(&b.agent_id))
1522 });
1523 cancelled.sort_by(|a, b| {
1524 let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type));
1525 order.then_with(|| a.agent_id.cmp(&b.agent_id))
1526 });
1527
1528 append_subagent_group(
1529 &mut lines,
1530 "Running",
1531 palette::STATUS_WARNING.into(),
1532 &running,
1533 content_width,
1534 );
1535 append_subagent_group(
1536 &mut lines,
1537 "Completed",
1538 palette::STATUS_SUCCESS.into(),
1539 &completed,
1540 content_width,
1541 );
1542 append_subagent_group(
1543 &mut lines,
1544 "Interrupted",
1545 palette::STATUS_WARNING.into(),
1546 &interrupted,
1547 content_width,
1548 );
1549 append_subagent_group(
1550 &mut lines,
1551 "Failed",
1552 palette::DEEPSEEK_RED.into(),
1553 &failed,
1554 content_width,
1555 );
1556 append_subagent_group(
1557 &mut lines,
1558 "Cancelled",
1559 palette::TEXT_MUTED.into(),
1560 &cancelled,
1561 content_width,
1562 );
1563 }
1564
1565 let total_lines = lines.len();
1566 let visible_lines = (popup_height as usize).saturating_sub(3);
1567 let max_scroll = total_lines.saturating_sub(visible_lines);
1568 let scroll = self.scroll.min(max_scroll);
1569
1570 let scroll_indicator = if total_lines > visible_lines {
1571 format!(" [{}/{} ↑↓] ", scroll + 1, max_scroll + 1)
1572 } else {
1573 String::new()
1574 };
1575
1576 let view = Paragraph::new(lines)
1577 .block(
1578 Block::default()
1579 .title(Line::from(vec![Span::styled(
1580 " Sub-agents ",
1581 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
1582 )]))
1583 .title_bottom(Line::from(vec![
1584 Span::styled(" Esc to close ", Style::default().fg(palette::TEXT_MUTED)),
1585 Span::styled(" R to refresh ", Style::default().fg(palette::TEXT_MUTED)),
1586 Span::styled(scroll_indicator, Style::default().fg(palette::DEEPSEEK_SKY)),
1587 ]))
1588 .borders(Borders::ALL)
1589 .border_style(Style::default().fg(palette::BORDER_COLOR))
1590 .style(Style::default().bg(palette::DEEPSEEK_INK))
1591 .padding(Padding::uniform(1)),
1592 )
1593 .scroll((scroll as u16, 0));
1594
1595 view.render(popup_area, buf);
1596 }
1597 }
1598
1599 fn append_subagent_group(
1600 lines: &mut Vec<ratatui::text::Line<'static>>,
1601 title: &str,
1602 section_style: ratatui::style::Style,
1603 agents: &[&SubAgentResult],
1604 content_width: usize,
1605 ) {
1606 use ratatui::{
1607 prelude::Stylize,
1608 style::Style,
1609 text::{Line, Span},
1610 };
1611 if agents.is_empty() {
1612 return;
1613 }
1614
1615 lines.push(Line::from(Span::styled(
1616 format!("{title} ({})", agents.len()),
1617 section_style.bold(),
1618 )));
1619
1620 for agent in agents {
1621 let id = truncate_view_text(&agent.agent_id, 11);
1622 let kind = format_agent_type(&agent.agent_type);
1623 let (status, status_style, status_detail) = format_agent_status(&agent.status);
1624
1625 lines.push(Line::from(vec![
1626 Span::raw(" "),
1627 Span::styled(
1628 format!("{id:<12}"),
1629 Style::default().fg(palette::TEXT_PRIMARY),
1630 ),
1631 Span::styled(
1632 format!("{kind:<9}"),
1633 Style::default().fg(palette::TEXT_MUTED),
1634 ),
1635 Span::raw(" "),
1636 Span::styled(format!("{status:<10}"), status_style),
1637 Span::raw(" "),
1638 Span::styled(
1639 format!("{:>4}✦", agent.steps_taken),
1640 Style::default().fg(palette::TEXT_DIM),
1641 ),
1642 Span::raw(" "),
1643 Span::styled(
1644 format!("{:>6}ms", agent.duration_ms),
1645 Style::default().fg(palette::TEXT_DIM),
1646 ),
1647 ]));
1648
1649 if let Some(detail) = status_detail {
1650 let max_len = content_width.saturating_sub(10);
1651 let detail = truncate_view_text(detail, max_len);
1652 lines.push(Line::from(vec![
1653 Span::styled(" reason: ", Style::default().fg(palette::TEXT_MUTED)),
1654 Span::styled(detail, Style::default().fg(palette::DEEPSEEK_RED)),
1655 ]));
1656 }
1657
1658 if let Some(role) = agent.assignment.role.as_deref() {
1659 let max_len = content_width.saturating_sub(14);
1660 let role = truncate_view_text(role, max_len);
1661 lines.push(Line::from(vec![
1662 Span::styled(" role: ", Style::default().fg(palette::TEXT_MUTED)),
1663 Span::styled(role, Style::default().fg(palette::DEEPSEEK_SKY)),
1664 ]));
1665 }
1666
1667 let max_len = content_width.saturating_sub(18);
1668 let objective = truncate_view_text(&agent.assignment.objective, max_len);
1669 lines.push(Line::from(vec![
1670 Span::styled(" objective: ", Style::default().fg(palette::TEXT_MUTED)),
1671 Span::styled(objective, Style::default().fg(palette::TEXT_DIM)),
1672 ]));
1673
1674 if let Some(result) = agent.result.as_ref() {
1675 let max_len = content_width.saturating_sub(16);
1676 let preview = truncate_view_text(result, max_len);
1677 lines.push(Line::from(vec![
1678 Span::styled(" result: ", Style::default().fg(palette::TEXT_MUTED)),
1679 Span::styled(preview, Style::default().fg(palette::TEXT_DIM)),
1680 ]));
1681 }
1682 }
1683
1684 lines.push(Line::from(""));
1685 }
1686
1687 fn agent_type_order(agent_type: &SubAgentType) -> u8 {
1688 match agent_type {
1689 SubAgentType::General => 0,
1690 SubAgentType::Explore => 1,
1691 SubAgentType::Plan => 2,
1692 SubAgentType::Implementer => 3,
1693 SubAgentType::Verifier => 4,
1694 SubAgentType::Review => 5,
1695 SubAgentType::Custom => 6,
1696 }
1697 }
1698
1699 fn format_agent_type(agent_type: &SubAgentType) -> &'static str {
1700 // Source of truth lives on the enum so any new role lands in both
1701 // the user-visible label and the sort order via the as_str() helper.
1702 agent_type.as_str()
1703 }
1704
1705 fn format_agent_status(
1706 status: &SubAgentStatus,
1707 ) -> (&'static str, ratatui::style::Style, Option<&str>) {
1708 use ratatui::style::Style;
1709
1710 match status {
1711 SubAgentStatus::Running => ("running", Style::default().fg(palette::DEEPSEEK_SKY), None),
1712 SubAgentStatus::Completed => (
1713 "completed",
1714 Style::default().fg(palette::DEEPSEEK_BLUE),
1715 None,
1716 ),
1717 SubAgentStatus::Interrupted(reason) => (
1718 "interrupted",
1719 Style::default().fg(palette::STATUS_WARNING),
1720 Some(reason.as_str()),
1721 ),
1722 SubAgentStatus::Cancelled => ("cancelled", Style::default().fg(palette::TEXT_MUTED), None),
1723 SubAgentStatus::Failed(reason) => (
1724 "failed",
1725 Style::default().fg(palette::DEEPSEEK_RED),
1726 Some(reason.as_str()),
1727 ),
1728 }
1729 }
1730
1731 fn truncate_view_text(text: &str, max_chars: usize) -> String {
1732 if max_chars == 0 {
1733 return String::new();
1734 }
1735 match text.char_indices().nth(max_chars) {
1736 Some((idx, _)) => text[..idx].to_string(),
1737 None => text.to_string(),
1738 }
1739 }
1740
1741 #[cfg(test)]
1742 mod tests {
1743 use super::{
1744 ConfigListItem, ConfigSection, ConfigView, ModalKind, ModalView, ShellControlView,
1745 ViewAction, ViewEvent, ViewStack, truncate_view_text,
1746 };
1747 use crate::config::Config;
1748 use crate::localization::Locale;
1749 use crate::tui::app::{App, TuiOptions};
1750 use crossterm::event::{
1751 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
1752 };
1753 use ratatui::{buffer::Buffer, layout::Rect};
1754 use std::path::PathBuf;
1755
1756 fn create_test_app() -> App {
1757 let options = TuiOptions {
1758 model: "deepseek-v4-pro".to_string(),
1759 workspace: PathBuf::from("."),
1760 config_path: None,
1761 config_profile: None,
1762 allow_shell: false,
1763 use_alt_screen: true,
1764 use_mouse_capture: false,
1765 use_bracketed_paste: true,
1766 max_subagents: 1,
1767 skills_dir: PathBuf::from("."),
1768 memory_path: PathBuf::from("memory.md"),
1769 notes_path: PathBuf::from("notes.txt"),
1770 mcp_config_path: PathBuf::from("mcp.json"),
1771 use_memory: false,
1772 start_in_agent_mode: false,
1773 skip_onboarding: true,
1774 yolo: false,
1775 resume_session_id: None,
1776 initial_input: None,
1777 };
1778 App::new(options, &Config::default())
1779 }
1780
1781 fn type_filter(view: &mut ConfigView, text: &str) {
1782 for ch in text.chars() {
1783 let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1784 assert!(matches!(action, ViewAction::None));
1785 }
1786 }
1787
1788 fn visible_section_labels(view: &ConfigView) -> Vec<&'static str> {
1789 view.visible_items()
1790 .into_iter()
1791 .filter_map(|item| match item {
1792 ConfigListItem::Section(section) => Some(section.label()),
1793 ConfigListItem::Row(_) => None,
1794 })
1795 .collect()
1796 }
1797
1798 fn visible_row_keys(view: &ConfigView) -> Vec<&str> {
1799 view.visible_items()
1800 .into_iter()
1801 .filter_map(|item| match item {
1802 ConfigListItem::Row(idx) => Some(view.rows[idx].key.as_str()),
1803 ConfigListItem::Section(_) => None,
1804 })
1805 .collect()
1806 }
1807
1808 #[test]
1809 fn truncate_view_text_handles_unicode() {
1810 let text = "abc😀é";
1811 assert_eq!(truncate_view_text(text, 0), "");
1812 assert_eq!(truncate_view_text(text, 1), "a");
1813 assert_eq!(truncate_view_text(text, 3), "abc");
1814 assert_eq!(truncate_view_text(text, 4), "abc😀");
1815 assert_eq!(truncate_view_text(text, 5), "abc😀é");
1816 }
1817
1818 #[test]
1819 fn config_view_groups_rows_by_expected_sections() {
1820 let app = create_test_app();
1821 let view = ConfigView::new_for_app(&app);
1822 assert_eq!(
1823 visible_section_labels(&view),
1824 vec![
1825 ConfigSection::Model.label(),
1826 ConfigSection::Permissions.label(),
1827 ConfigSection::Display.label(),
1828 ConfigSection::Composer.label(),
1829 ConfigSection::Sidebar.label(),
1830 ConfigSection::History.label(),
1831 ConfigSection::Mcp.label(),
1832 ]
1833 );
1834 }
1835
1836 #[test]
1837 fn config_view_includes_expected_editable_rows() {
1838 let app = create_test_app();
1839 let view = ConfigView::new_for_app(&app);
1840 let keys = view
1841 .rows
1842 .iter()
1843 .map(|row| row.key.as_str())
1844 .collect::<Vec<_>>();
1845 assert!(keys.contains(&"model"));
1846 assert!(keys.contains(&"approval_mode"));
1847 assert!(keys.contains(&"locale"));
1848 assert!(keys.contains(&"auto_compact"));
1849 assert!(keys.contains(&"composer_border"));
1850 assert!(keys.contains(&"mcp_config_path"));
1851 assert!(view.rows.iter().all(|row| row.editable));
1852 }
1853
1854 #[test]
1855 fn config_view_filter_matches_group_and_rows() {
1856 let app = create_test_app();
1857 let mut view = ConfigView::new_for_app(&app);
1858
1859 type_filter(&mut view, "side");
1860
1861 assert_eq!(view.filter, "side");
1862 assert_eq!(visible_section_labels(&view), vec!["Sidebar"]);
1863 assert_eq!(
1864 visible_row_keys(&view),
1865 vec!["sidebar_width", "sidebar_focus"]
1866 );
1867 assert_eq!(view.rows[view.selected].key, "sidebar_width");
1868 }
1869
1870 #[test]
1871 fn config_view_filter_accepts_j_k_and_unicode_case() {
1872 let app = create_test_app();
1873 let mut view = ConfigView::new_for_app(&app);
1874
1875 type_filter(&mut view, "thinking");
1876 assert_eq!(visible_row_keys(&view), vec!["show_thinking"]);
1877
1878 view.clear_filter();
1879 view.rows[0].value = "CAFÉ".to_string();
1880 type_filter(&mut view, "café");
1881 assert_eq!(visible_row_keys(&view), vec!["model"]);
1882 }
1883
1884 #[test]
1885 fn localized_config_view_renders_at_narrow_width() {
1886 let mut app = create_test_app();
1887 app.ui_locale = Locale::PtBr;
1888 let view = ConfigView::new_for_app(&app);
1889 let area = Rect::new(0, 0, 60, 18);
1890 let mut buf = Buffer::empty(area);
1891
1892 view.render(area, &mut buf);
1893
1894 let dump = buffer_text(&buf, area);
1895 assert!(
1896 dump.contains("Configuração") || dump.contains("Configura"),
1897 "missing localized config title:\n{dump}"
1898 );
1899 assert!(
1900 !dump.contains("MISSING"),
1901 "missing-key marker leaked:\n{dump}"
1902 );
1903 }
1904
1905 #[test]
1906 fn config_view_filter_no_match_does_not_edit_hidden_row() {
1907 let app = create_test_app();
1908 let mut view = ConfigView::new_for_app(&app);
1909
1910 type_filter(&mut view, "zzzz");
1911 assert!(visible_row_keys(&view).is_empty());
1912
1913 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1914 assert!(matches!(action, ViewAction::None));
1915 assert!(view.editing.is_none());
1916
1917 let clear = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1918 assert!(matches!(clear, ViewAction::None));
1919 assert!(view.filter.is_empty());
1920 assert!(!visible_row_keys(&view).is_empty());
1921 }
1922
1923 #[test]
1924 fn config_view_can_edit_filtered_row() {
1925 let app = create_test_app();
1926 let mut view = ConfigView::new_for_app(&app);
1927
1928 type_filter(&mut view, "mcp");
1929 assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]);
1930
1931 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1932 assert!(matches!(start, ViewAction::None));
1933 assert!(view.editing.is_some());
1934
1935 let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
1936 assert!(matches!(clear, ViewAction::None));
1937 type_filter(&mut view, "servers.json");
1938
1939 let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1940 match submit {
1941 ViewAction::Emit(ViewEvent::ConfigUpdated {
1942 key,
1943 value,
1944 persist,
1945 }) => {
1946 assert_eq!(key, "mcp_config_path");
1947 assert_eq!(value, "servers.json");
1948 assert!(persist);
1949 }
1950 other => panic!("expected config update emit, got {other:?}"),
1951 }
1952 }
1953
1954 #[test]
1955 fn config_view_enter_and_ctrl_u_emit_config_updated() {
1956 let app = create_test_app();
1957 let mut view = ConfigView::new_for_app(&app);
1958
1959 let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1960 assert!(matches!(start, ViewAction::None));
1961 assert!(view.editing.is_some());
1962
1963 let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
1964 assert!(matches!(clear, ViewAction::None));
1965 let cleared = view
1966 .editing
1967 .as_ref()
1968 .expect("editing should remain active after Ctrl+U");
1969 assert!(cleared.buffer.is_empty());
1970
1971 for ch in "deepseek-v4-flash".chars() {
1972 let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1973 assert!(matches!(action, ViewAction::None));
1974 }
1975
1976 let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1977 match submit {
1978 ViewAction::Emit(ViewEvent::ConfigUpdated {
1979 key,
1980 value,
1981 persist,
1982 }) => {
1983 assert_eq!(key, "model");
1984 assert_eq!(value, "deepseek-v4-flash");
1985 assert!(!persist);
1986 }
1987 other => panic!("expected config update emit, got {other:?}"),
1988 }
1989 assert!(view.editing.is_none());
1990 }
1991
1992 #[test]
1993 fn config_view_mouse_click_selects_row() {
1994 let app = create_test_app();
1995 let mut view = ConfigView::new_for_app(&app);
1996 let area = Rect::new(0, 0, 100, 30);
1997 let mut buf = Buffer::empty(area);
1998 view.render(area, &mut buf);
1999
2000 let hitboxes = view.last_row_hitboxes.borrow().clone();
2001 let (_, row_idx) = hitboxes
2002 .iter()
2003 .find(|(_, idx)| {
2004 view.rows
2005 .get(*idx)
2006 .is_some_and(|row| row.key == "default_model")
2007 })
2008 .copied()
2009 .expect("default_model row should have a hitbox");
2010 let y = hitboxes
2011 .iter()
2012 .find_map(|(y, idx)| (*idx == row_idx).then_some(*y))
2013 .expect("selected row should have a y coordinate");
2014
2015 let action = view.handle_mouse(MouseEvent {
2016 kind: MouseEventKind::Down(MouseButton::Left),
2017 column: 20,
2018 row: y,
2019 modifiers: KeyModifiers::NONE,
2020 });
2021
2022 assert!(matches!(action, ViewAction::None));
2023 assert_eq!(view.selected, row_idx);
2024 }
2025
2026 #[test]
2027 fn config_view_typing_replaces_on_first_char() {
2028 let app = create_test_app();
2029 let mut view = ConfigView::new_for_app(&app);
2030
2031 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2032 let edit = view.editing.as_ref().expect("editing should be active");
2033 assert!(edit.select_all, "editor should start with select-all");
2034
2035 let _ = view.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
2036 let edit = view.editing.as_ref().expect("editing should remain active");
2037 assert_eq!(edit.buffer.iter().collect::<String>(), "x");
2038 }
2039
2040 #[test]
2041 fn config_view_escape_cancels_editing() {
2042 let app = create_test_app();
2043 let mut view = ConfigView::new_for_app(&app);
2044 let _ = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2045 assert!(view.editing.is_some());
2046
2047 let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2048 assert!(matches!(cancel, ViewAction::None));
2049 assert!(view.editing.is_none());
2050 assert_eq!(view.status.as_deref(), Some("Edit cancelled"));
2051 }
2052
2053 #[test]
2054 fn shell_control_view_defaults_to_background() {
2055 let mut view = ShellControlView::new();
2056
2057 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2058
2059 assert!(matches!(
2060 action,
2061 ViewAction::EmitAndClose(ViewEvent::ShellControlBackground)
2062 ));
2063 }
2064
2065 #[test]
2066 fn shell_control_view_can_select_cancel() {
2067 let mut view = ShellControlView::new();
2068
2069 let action = view.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
2070
2071 assert!(matches!(
2072 action,
2073 ViewAction::EmitAndClose(ViewEvent::ShellControlCancel)
2074 ));
2075 }
2076
2077 /// A modal that doesn't override `handle_paste` must report
2078 /// "not consumed" so the host can fall through to the composer.
2079 /// Regression: views/mod.rs previously inverted the boolean, swallowing
2080 /// every Cmd-V while any modal was on top.
2081 #[test]
2082 fn default_modal_does_not_consume_paste() {
2083 let mut stack = ViewStack::new();
2084 stack.push(ShellControlView::new());
2085 assert!(!stack.handle_paste("hello"));
2086 assert_eq!(stack.top_kind(), Some(ModalKind::ShellControl));
2087 }
2088
2089 fn buffer_text(buf: &Buffer, area: Rect) -> String {
2090 let mut out = String::new();
2091 for y in area.top()..area.bottom() {
2092 for x in area.left()..area.right() {
2093 out.push_str(buf[(x, y)].symbol());
2094 }
2095 out.push('\n');
2096 }
2097 out
2098 }
2099 }
2100
2100 lines RUST