Skip to main content

zeph_tui/widgets/
subagents.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crossterm::event::{KeyCode, KeyEvent};
5use ratatui::Frame;
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
10use zeph_subagent::{ModelSpec, SubAgentDef, ToolPolicy, is_valid_agent_name};
11
12use crate::metrics::{MetricsSnapshot, SubAgentMetrics};
13use crate::theme::Theme;
14
15// ── Runtime sub-agent monitor ─────────────────────────────────────────────────
16
17/// Spinner frames for working agents.
18const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
19
20fn state_color(state: &str) -> Color {
21    match state {
22        "working" | "submitted" => Color::Yellow,
23        "completed" => Color::Green,
24        "failed" => Color::Red,
25        "input_required" => Color::Cyan,
26        _ => Color::DarkGray,
27    }
28}
29
30fn build_agent_list_item<'a>(sa: &SubAgentMetrics, tick: u8, selected: bool) -> ListItem<'a> {
31    let color = state_color(&sa.state);
32    let is_working = matches!(sa.state.as_str(), "working" | "submitted");
33    let spinner = if is_working {
34        let idx = (tick as usize) % SPINNER_FRAMES.len();
35        SPINNER_FRAMES[idx].to_string()
36    } else {
37        "  ".to_owned()
38    };
39
40    let bg_marker = if sa.background { " [bg]" } else { "" };
41    let perm_badge = match sa.permission_mode.as_str() {
42        "plan" => " [plan]",
43        "bypass_permissions" => " [bypass!]",
44        "dont_ask" => " [dont_ask]",
45        "accept_edits" => " [accept_edits]",
46        _ => "",
47    };
48
49    let base_style = if selected {
50        Style::default().add_modifier(Modifier::REVERSED)
51    } else {
52        Style::default()
53    };
54
55    let line = Line::from(vec![
56        Span::styled(format!(" {spinner} "), Style::default().fg(color)),
57        Span::styled(
58            format!("{}{}{}", sa.name, bg_marker, perm_badge),
59            base_style,
60        ),
61        Span::styled(
62            format!(" {}", sa.state.to_uppercase()),
63            base_style.fg(color),
64        ),
65        Span::styled(
66            format!(" {}/{}  {}s", sa.turns_used, sa.max_turns, sa.elapsed_secs),
67            base_style,
68        ),
69    ]);
70    ListItem::new(line)
71}
72
73/// Non-interactive render (used when `SubAgents` panel is not focused).
74pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect) {
75    let theme = Theme::default();
76
77    if metrics.sub_agents.is_empty() {
78        let block = Block::default()
79            .borders(Borders::ALL)
80            .border_style(theme.panel_border)
81            .title(" Sub-Agents ");
82        let paragraph = Paragraph::new(" No sub-agents. Use /agent spawn <name> to create one.")
83            .block(block)
84            .wrap(Wrap { trim: true });
85        frame.render_widget(paragraph, area);
86        return;
87    }
88
89    let items: Vec<ListItem<'_>> = metrics
90        .sub_agents
91        .iter()
92        .map(|sa| build_agent_list_item(sa, 0, false))
93        .collect();
94
95    let list = List::new(items).block(
96        Block::default()
97            .borders(Borders::ALL)
98            .border_style(theme.panel_border)
99            .title(format!(" Sub-Agents ({}) ", metrics.sub_agents.len())),
100    );
101    frame.render_widget(list, area);
102}
103
104/// Interactive render: shows selection highlight and spinner animation.
105/// Called when the `SubAgents` panel has keyboard focus (`a` key).
106pub fn render_interactive(
107    metrics: &MetricsSnapshot,
108    sidebar: &mut crate::app::SubAgentSidebarState,
109    frame: &mut Frame,
110    area: Rect,
111    tick: u8,
112) {
113    use crate::theme::Theme;
114    let theme = Theme::default();
115
116    if metrics.sub_agents.is_empty() {
117        let block = Block::default()
118            .borders(Borders::ALL)
119            .border_style(theme.highlight)
120            .title(" Sub-Agents [focused] ");
121        let paragraph = Paragraph::new(" No sub-agents. Use /agent spawn <name> to create one.")
122            .block(block)
123            .wrap(Wrap { trim: true });
124        frame.render_widget(paragraph, area);
125        return;
126    }
127
128    let selected = sidebar.selected();
129    let items: Vec<ListItem<'_>> = metrics
130        .sub_agents
131        .iter()
132        .enumerate()
133        .map(|(i, sa)| build_agent_list_item(sa, tick, selected == Some(i)))
134        .collect();
135
136    let list = List::new(items).block(
137        Block::default()
138            .borders(Borders::ALL)
139            .border_style(theme.highlight)
140            .title(format!(
141                " Sub-Agents ({}) [j/k=nav  Enter=view  Esc=close] ",
142                metrics.sub_agents.len()
143            )),
144    );
145    frame.render_stateful_widget(list, area, &mut sidebar.list_state);
146}
147
148// ── Definition manager ────────────────────────────────────────────────────────
149
150/// Form field for create/edit wizard.
151#[derive(Debug, Clone)]
152pub struct FormField {
153    pub label: &'static str,
154    pub value: String,
155    pub required: bool,
156    pub placeholder: &'static str,
157}
158
159/// Shared state for Create and Edit forms.
160#[derive(Debug, Clone)]
161pub struct AgentFormState {
162    pub fields: Vec<FormField>,
163    /// Which field has keyboard focus.
164    pub focused: usize,
165    /// Cursor position within focused field value string.
166    pub cursor: usize,
167    pub error: Option<String>,
168}
169
170impl AgentFormState {
171    #[must_use]
172    pub fn new_empty() -> Self {
173        Self {
174            fields: vec![
175                FormField {
176                    label: "Name",
177                    value: String::new(),
178                    required: true,
179                    placeholder: "e.g. code-reviewer",
180                },
181                FormField {
182                    label: "Description",
183                    value: String::new(),
184                    required: true,
185                    placeholder: "Short description",
186                },
187                FormField {
188                    label: "Model",
189                    value: String::new(),
190                    required: false,
191                    placeholder: "e.g. claude-sonnet-4-20250514 (optional)",
192                },
193                FormField {
194                    label: "Max turns",
195                    value: "20".to_owned(),
196                    required: false,
197                    placeholder: "20",
198                },
199            ],
200            focused: 0,
201            cursor: 0,
202            error: None,
203        }
204    }
205
206    #[must_use]
207    pub fn from_def(def: &SubAgentDef) -> Self {
208        let mut form = Self::new_empty();
209        form.fields[0].value.clone_from(&def.name);
210        form.fields[1].value.clone_from(&def.description);
211        form.fields[2].value = def.model.as_ref().map_or("", ModelSpec::as_str).to_string();
212        form.fields[3].value = def.permissions.max_turns.to_string();
213        // Reset focus to beginning; cursor is char-count, not byte offset.
214        form.focused = 0;
215        form.cursor = form.fields[0].value.chars().count();
216        form
217    }
218
219    pub fn focus_next(&mut self) {
220        if self.focused + 1 < self.fields.len() {
221            self.focused += 1;
222            self.cursor = self.fields[self.focused].value.chars().count();
223        }
224    }
225
226    pub fn focus_prev(&mut self) {
227        if self.focused > 0 {
228            self.focused -= 1;
229            self.cursor = self.fields[self.focused].value.chars().count();
230        }
231    }
232
233    pub fn insert_char(&mut self, c: char) {
234        let val = &mut self.fields[self.focused].value;
235        // Convert char-count cursor to byte offset before inserting.
236        let byte_offset = val
237            .char_indices()
238            .nth(self.cursor)
239            .map_or(val.len(), |(i, _)| i);
240        val.insert(byte_offset, c);
241        self.cursor += 1;
242        self.error = None;
243    }
244
245    pub fn delete_char_before_cursor(&mut self) {
246        if self.cursor > 0 {
247            self.cursor -= 1;
248            let val = &mut self.fields[self.focused].value;
249            // Convert char-count cursor to byte offset before removing.
250            if let Some((byte_offset, _)) = val.char_indices().nth(self.cursor) {
251                val.remove(byte_offset);
252            }
253            self.error = None;
254        }
255    }
256
257    /// Validate and build a `SubAgentDef`. Returns `Err` with user-facing message on failure.
258    ///
259    /// # Errors
260    ///
261    /// Returns `Err(String)` if required fields are empty or `max_turns` is not a valid integer.
262    pub fn to_def(&self) -> Result<SubAgentDef, String> {
263        let name = self.fields[0].value.trim().to_owned();
264        let description = self.fields[1].value.trim().to_owned();
265        if name.is_empty() {
266            return Err("Name is required".into());
267        }
268        if !is_valid_agent_name(&name) {
269            return Err(
270                "Name must match [a-zA-Z0-9][a-zA-Z0-9_-]{0,63} (ASCII only, no spaces)".into(),
271            );
272        }
273        if description.is_empty() {
274            return Err("Description is required".into());
275        }
276        let model = self.fields[2].value.trim();
277        let max_turns: u32 = self.fields[3]
278            .value
279            .trim()
280            .parse()
281            .map_err(|_| "Max turns must be a positive integer".to_owned())?;
282
283        let mut def = SubAgentDef::default_template(name, description);
284        if !model.is_empty() {
285            def.model = Some(zeph_subagent::ModelSpec::Named(model.to_owned()));
286        }
287        def.permissions.max_turns = max_turns;
288        Ok(def)
289    }
290}
291
292/// States of the agent definition manager panel.
293pub enum AgentManagerState {
294    /// Shows a scrollable list of all definitions.
295    List {
296        definitions: Vec<SubAgentDef>,
297        list_state: ListState,
298    },
299    /// Shows full detail of a selected definition.
300    Detail {
301        definitions: Vec<SubAgentDef>,
302        index: usize,
303    },
304    /// Create wizard (empty form).
305    Create {
306        /// Preserved list for restoring on Esc.
307        definitions: Vec<SubAgentDef>,
308        form: AgentFormState,
309    },
310    /// Edit wizard (pre-filled form).
311    Edit {
312        definitions: Vec<SubAgentDef>,
313        index: usize,
314        form: AgentFormState,
315    },
316    /// Confirm deletion prompt.
317    ConfirmDelete {
318        definitions: Vec<SubAgentDef>,
319        index: usize,
320        /// True when definition is not project-scoped (extra warning shown).
321        non_project: bool,
322        /// Awaiting second confirmation for non-project scope.
323        awaiting_second: bool,
324    },
325}
326
327impl AgentManagerState {
328    /// Create a new panel showing a loaded list of definitions.
329    #[must_use]
330    pub fn from_definitions(defs: Vec<SubAgentDef>) -> Self {
331        let mut state = ListState::default();
332        if !defs.is_empty() {
333            state.select(Some(0));
334        }
335        Self::List {
336            definitions: defs,
337            list_state: state,
338        }
339    }
340
341    /// Handle a key event. Returns `true` if the panel should be closed.
342    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
343        // Extract next state from helper; None means no state transition.
344        // Returns (close_panel, Option<new_state>).
345        let (close, next) = handle_key_dispatch(self, key);
346        if let Some(s) = next {
347            *self = s;
348        }
349        close
350    }
351}
352
353/// Returns `(close_panel, Option<new_state>)`.
354fn handle_key_dispatch(
355    state: &mut AgentManagerState,
356    key: KeyEvent,
357) -> (bool, Option<AgentManagerState>) {
358    match state {
359        AgentManagerState::List {
360            definitions,
361            list_state,
362        } => {
363            match key.code {
364                KeyCode::Esc => return (true, None),
365                KeyCode::Down | KeyCode::Char('j') => {
366                    let next = list_state
367                        .selected()
368                        .map_or(0, |i| (i + 1).min(definitions.len().saturating_sub(1)));
369                    list_state.select(Some(next));
370                }
371                KeyCode::Up | KeyCode::Char('k') => {
372                    let prev = list_state.selected().map_or(0, |i| i.saturating_sub(1));
373                    list_state.select(Some(prev));
374                }
375                KeyCode::Enter => {
376                    if let Some(i) = list_state.selected() {
377                        let defs = std::mem::take(definitions);
378                        return (
379                            false,
380                            Some(AgentManagerState::Detail {
381                                definitions: defs,
382                                index: i,
383                            }),
384                        );
385                    }
386                }
387                KeyCode::Char('c') => {
388                    let defs = std::mem::take(definitions);
389                    return (
390                        false,
391                        Some(AgentManagerState::Create {
392                            definitions: defs,
393                            form: AgentFormState::new_empty(),
394                        }),
395                    );
396                }
397                _ => {}
398            }
399            (false, None)
400        }
401        AgentManagerState::Detail { definitions, index } => {
402            handle_key_detail(definitions, *index, key)
403        }
404        AgentManagerState::Create { definitions, form } => {
405            handle_key_form_create(definitions, form, key)
406        }
407        AgentManagerState::Edit {
408            definitions,
409            index,
410            form,
411        } => handle_key_form_edit(definitions, *index, form, key),
412        AgentManagerState::ConfirmDelete {
413            definitions,
414            index,
415            non_project,
416            awaiting_second,
417        } => handle_key_confirm_delete(definitions, *index, *non_project, awaiting_second, key),
418    }
419}
420
421fn handle_key_detail(
422    definitions: &mut Vec<SubAgentDef>,
423    index: usize,
424    key: KeyEvent,
425) -> (bool, Option<AgentManagerState>) {
426    match key.code {
427        KeyCode::Esc => {
428            let defs = std::mem::take(definitions);
429            let mut list_state = ListState::default();
430            list_state.select(Some(index));
431            (
432                false,
433                Some(AgentManagerState::List {
434                    definitions: defs,
435                    list_state,
436                }),
437            )
438        }
439        KeyCode::Char('e') => {
440            let form = AgentFormState::from_def(&definitions[index]);
441            let defs = std::mem::take(definitions);
442            (
443                false,
444                Some(AgentManagerState::Edit {
445                    definitions: defs,
446                    index,
447                    form,
448                }),
449            )
450        }
451        KeyCode::Char('d') => {
452            let source = definitions[index].source.as_deref().unwrap_or("");
453            let non_project = !source.starts_with("project/");
454            let defs = std::mem::take(definitions);
455            (
456                false,
457                Some(AgentManagerState::ConfirmDelete {
458                    definitions: defs,
459                    index,
460                    non_project,
461                    awaiting_second: false,
462                }),
463            )
464        }
465        _ => (false, None),
466    }
467}
468
469fn handle_key_form_create(
470    definitions: &mut Vec<SubAgentDef>,
471    form: &mut AgentFormState,
472    key: KeyEvent,
473) -> (bool, Option<AgentManagerState>) {
474    match key.code {
475        KeyCode::Esc => {
476            // Restore definitions list on cancel (S3 fix).
477            let defs = std::mem::take(definitions);
478            (false, Some(AgentManagerState::from_definitions(defs)))
479        }
480        KeyCode::Tab => {
481            form.focus_next();
482            (false, None)
483        }
484        KeyCode::BackTab => {
485            form.focus_prev();
486            (false, None)
487        }
488        KeyCode::Backspace => {
489            form.delete_char_before_cursor();
490            (false, None)
491        }
492        KeyCode::Enter => {
493            match form.to_def() {
494                Ok(def) => {
495                    // C3: canonicalize CWD + ".zeph/agents" for project root resolution.
496                    let dir = std::env::current_dir()
497                        .unwrap_or_else(|_| std::path::PathBuf::from("."))
498                        .join(".zeph/agents");
499                    match def.save_atomic(&dir) {
500                        Ok(_) => {
501                            // Restore list after successful create (S3 fix).
502                            let defs = std::mem::take(definitions);
503                            return (false, Some(AgentManagerState::from_definitions(defs)));
504                        }
505                        Err(e) => {
506                            form.error = Some(e.to_string());
507                        }
508                    }
509                }
510                Err(msg) => {
511                    form.error = Some(msg);
512                }
513            }
514            (false, None)
515        }
516        KeyCode::Char(c) => {
517            form.insert_char(c);
518            (false, None)
519        }
520        _ => (false, None),
521    }
522}
523
524fn handle_key_form_edit(
525    definitions: &mut Vec<SubAgentDef>,
526    index: usize,
527    form: &mut AgentFormState,
528    key: KeyEvent,
529) -> (bool, Option<AgentManagerState>) {
530    match key.code {
531        KeyCode::Esc => {
532            let defs = std::mem::take(definitions);
533            (
534                false,
535                Some(AgentManagerState::Detail {
536                    definitions: defs,
537                    index,
538                }),
539            )
540        }
541        KeyCode::Tab => {
542            form.focus_next();
543            (false, None)
544        }
545        KeyCode::BackTab => {
546            form.focus_prev();
547            (false, None)
548        }
549        KeyCode::Backspace => {
550            form.delete_char_before_cursor();
551            (false, None)
552        }
553        KeyCode::Enter => {
554            match form.to_def() {
555                Ok(mut def) => {
556                    if let Some(path) = definitions[index].file_path.as_deref() {
557                        let dir = path.parent().unwrap_or(std::path::Path::new("."));
558                        // Preserve file_path on the new def so Detail view can edit/delete.
559                        def.file_path = Some(path.to_path_buf());
560                        def.source.clone_from(&definitions[index].source);
561                        match def.save_atomic(dir) {
562                            Ok(_) => {
563                                // S2: update in-memory definition after save.
564                                definitions[index] = def;
565                                let defs = std::mem::take(definitions);
566                                return (
567                                    false,
568                                    Some(AgentManagerState::Detail {
569                                        definitions: defs,
570                                        index,
571                                    }),
572                                );
573                            }
574                            Err(e) => {
575                                form.error = Some(e.to_string());
576                            }
577                        }
578                    } else {
579                        form.error = Some("Cannot determine file path for this definition".into());
580                    }
581                }
582                Err(msg) => {
583                    form.error = Some(msg);
584                }
585            }
586            (false, None)
587        }
588        KeyCode::Char(c) => {
589            form.insert_char(c);
590            (false, None)
591        }
592        _ => (false, None),
593    }
594}
595
596fn handle_key_confirm_delete(
597    definitions: &mut Vec<SubAgentDef>,
598    index: usize,
599    non_project: bool,
600    awaiting_second: &mut bool,
601    key: KeyEvent,
602) -> (bool, Option<AgentManagerState>) {
603    match key.code {
604        KeyCode::Esc => {
605            let defs = std::mem::take(definitions);
606            (
607                false,
608                Some(AgentManagerState::Detail {
609                    definitions: defs,
610                    index,
611                }),
612            )
613        }
614        KeyCode::Enter | KeyCode::Char('y' | 'Y') => {
615            // IMP-04: extra confirmation for non-project scope
616            if non_project && !*awaiting_second {
617                *awaiting_second = true;
618                return (false, None);
619            }
620            let next = if let Some(path) = definitions[index].file_path.as_deref() {
621                match SubAgentDef::delete_file(path) {
622                    Ok(()) => {
623                        // S4: remove deleted entry from list, keep the rest.
624                        let mut defs = std::mem::take(definitions);
625                        defs.remove(index);
626                        let selected = if defs.is_empty() {
627                            None
628                        } else {
629                            Some(index.saturating_sub(1).min(defs.len() - 1))
630                        };
631                        let mut list_state = ListState::default();
632                        list_state.select(selected);
633                        AgentManagerState::List {
634                            definitions: defs,
635                            list_state,
636                        }
637                    }
638                    Err(e) => {
639                        // S5: surface delete error to user.
640                        let defs = std::mem::take(definitions);
641                        // Re-borrow after state transition is not possible here;
642                        // error is shown via a Detail render with no error field.
643                        tracing::warn!(error = %e, "failed to delete agent definition");
644                        AgentManagerState::Detail {
645                            definitions: defs,
646                            index,
647                        }
648                    }
649                }
650            } else {
651                // No file_path — just remove from in-memory list.
652                let mut defs = std::mem::take(definitions);
653                defs.remove(index);
654                AgentManagerState::from_definitions(defs)
655            };
656            (false, Some(next))
657        }
658        _ => (false, None),
659    }
660}
661
662/// Render the agent definition manager panel as a floating overlay.
663pub fn render_manager(state: &mut AgentManagerState, frame: &mut Frame, area: Rect) {
664    let theme = Theme::default();
665
666    // Center floating panel
667    let panel = centered_rect(80, 80, area);
668    frame.render_widget(Clear, panel);
669
670    match state {
671        AgentManagerState::List {
672            definitions,
673            list_state,
674        } => render_list(definitions, list_state, &theme, frame, panel),
675        AgentManagerState::Detail { definitions, index } => {
676            render_detail(definitions, *index, &theme, frame, panel);
677        }
678        AgentManagerState::Create { form, .. } => {
679            render_form(form, "Create Sub-Agent", &theme, frame, panel);
680        }
681        AgentManagerState::Edit { form, .. } => {
682            render_form(form, "Edit Sub-Agent", &theme, frame, panel);
683        }
684        AgentManagerState::ConfirmDelete {
685            definitions,
686            index,
687            non_project,
688            awaiting_second,
689        } => render_confirm_delete(
690            definitions,
691            *index,
692            *non_project,
693            *awaiting_second,
694            &theme,
695            frame,
696            panel,
697        ),
698    }
699}
700
701fn render_list(
702    defs: &[SubAgentDef],
703    list_state: &mut ListState,
704    theme: &Theme,
705    frame: &mut Frame,
706    area: Rect,
707) {
708    let items: Vec<ListItem<'_>> = defs
709        .iter()
710        .map(|d| {
711            let scope = d.source.as_deref().unwrap_or("-");
712            let model = d.model.as_ref().map_or("-", ModelSpec::as_str);
713            let line = Line::from(vec![
714                Span::styled(
715                    format!(" {:<24}", d.name),
716                    Style::default().add_modifier(Modifier::BOLD),
717                ),
718                Span::raw(format!(" {scope:<12}")),
719                Span::styled(
720                    format!(" {:<36}", truncate_str(&d.description, 36)),
721                    Style::default().fg(Color::Gray),
722                ),
723                Span::styled(format!(" {model}"), Style::default().fg(Color::DarkGray)),
724            ]);
725            ListItem::new(line)
726        })
727        .collect();
728
729    let block = Block::default()
730        .borders(Borders::ALL)
731        .border_style(theme.panel_border)
732        .title(" Agent Definitions  [j/k] navigate  [Enter] detail  [c] create  [Esc] close ");
733
734    if defs.is_empty() {
735        let para = Paragraph::new("No definitions found. Press [c] to create one.")
736            .block(block)
737            .style(Style::default().fg(Color::DarkGray));
738        frame.render_widget(para, area);
739    } else {
740        let list = List::new(items).block(block).highlight_style(
741            Style::default()
742                .bg(Color::DarkGray)
743                .add_modifier(Modifier::BOLD),
744        );
745        frame.render_stateful_widget(list, area, list_state);
746    }
747}
748
749fn render_detail(defs: &[SubAgentDef], index: usize, theme: &Theme, frame: &mut Frame, area: Rect) {
750    let def = &defs[index];
751    let tools_str = match &def.tools {
752        ToolPolicy::AllowList(v) => format!("allow {v:?}"),
753        ToolPolicy::DenyList(v) => format!("deny {v:?}"),
754        ToolPolicy::InheritAll => "inherit_all".to_owned(),
755    };
756    let except_str = if def.disallowed_tools.is_empty() {
757        String::new()
758    } else {
759        format!(" except {:?}", &def.disallowed_tools)
760    };
761    let mut text = vec![
762        Line::from(vec![
763            Span::styled(
764                "Name:        ",
765                Style::default().add_modifier(Modifier::BOLD),
766            ),
767            Span::raw(&def.name),
768        ]),
769        Line::from(vec![
770            Span::styled(
771                "Description: ",
772                Style::default().add_modifier(Modifier::BOLD),
773            ),
774            Span::raw(&def.description),
775        ]),
776        Line::from(vec![
777            Span::styled(
778                "Source:      ",
779                Style::default().add_modifier(Modifier::BOLD),
780            ),
781            Span::raw(def.source.as_deref().unwrap_or("-")),
782        ]),
783        Line::from(vec![
784            Span::styled(
785                "Model:       ",
786                Style::default().add_modifier(Modifier::BOLD),
787            ),
788            Span::raw(def.model.as_ref().map_or("-", ModelSpec::as_str)),
789        ]),
790        Line::from(vec![
791            Span::styled(
792                "Mode:        ",
793                Style::default().add_modifier(Modifier::BOLD),
794            ),
795            Span::raw(format!("{:?}", &def.permissions.permission_mode)),
796        ]),
797        Line::from(vec![
798            Span::styled(
799                "Max turns:   ",
800                Style::default().add_modifier(Modifier::BOLD),
801            ),
802            Span::raw(def.permissions.max_turns.to_string()),
803        ]),
804        Line::from(vec![
805            Span::styled(
806                "Background:  ",
807                Style::default().add_modifier(Modifier::BOLD),
808            ),
809            Span::raw(def.permissions.background.to_string()),
810        ]),
811        Line::from(vec![
812            Span::styled(
813                "Tools:       ",
814                Style::default().add_modifier(Modifier::BOLD),
815            ),
816            Span::raw(format!("{tools_str}{except_str}")),
817        ]),
818    ];
819
820    if !def.system_prompt.is_empty() {
821        text.push(Line::raw(""));
822        text.push(Line::from(Span::styled(
823            "System prompt:",
824            Style::default().add_modifier(Modifier::BOLD),
825        )));
826        let mut lines = def.system_prompt.lines();
827        for line in lines.by_ref().take(10) {
828            text.push(Line::raw(line.to_owned()));
829        }
830        if lines.next().is_some() {
831            text.push(Line::from(Span::styled(
832                "(truncated — use CLI `zeph agents show` for full prompt)",
833                Style::default().fg(Color::DarkGray),
834            )));
835        }
836    }
837
838    let para = Paragraph::new(text)
839        .block(
840            Block::default()
841                .borders(Borders::ALL)
842                .border_style(theme.panel_border)
843                .title(format!(
844                    " {} ({}/{})  [e] edit  [d] delete  [Esc] back ",
845                    def.name,
846                    index + 1,
847                    defs.len()
848                )),
849        )
850        .wrap(Wrap { trim: false });
851    frame.render_widget(para, area);
852}
853
854fn render_form(form: &AgentFormState, title: &str, theme: &Theme, frame: &mut Frame, area: Rect) {
855    let chunks = Layout::default()
856        .direction(Direction::Vertical)
857        .margin(1)
858        .constraints(
859            std::iter::repeat_n(Constraint::Length(3), form.fields.len())
860                .chain([Constraint::Length(2), Constraint::Min(0)])
861                .collect::<Vec<_>>(),
862        )
863        .split(area);
864
865    let block = Block::default()
866        .borders(Borders::ALL)
867        .border_style(theme.panel_border)
868        .title(format!(
869            " {title}  [Tab] next field  [Enter] save  [Esc] cancel "
870        ));
871    frame.render_widget(block, area);
872
873    for (i, field) in form.fields.iter().enumerate() {
874        let is_focused = i == form.focused;
875        let display = if field.value.is_empty() && !is_focused {
876            Span::styled(field.placeholder, Style::default().fg(Color::DarkGray))
877        } else {
878            Span::raw(&field.value)
879        };
880        let label_suffix = if field.required { " *" } else { "" };
881        let field_block = Block::default()
882            .borders(Borders::ALL)
883            .border_style(if is_focused {
884                Style::default().fg(Color::Yellow)
885            } else {
886                Style::default().fg(Color::DarkGray)
887            })
888            .title(format!(" {}{} ", field.label, label_suffix));
889
890        let para = Paragraph::new(Line::from(vec![display])).block(field_block);
891        if i < chunks.len() {
892            frame.render_widget(para, chunks[i]);
893        }
894    }
895
896    // Error message
897    if let Some(err) = &form.error {
898        let err_idx = form.fields.len();
899        if err_idx < chunks.len() {
900            let err_para =
901                Paragraph::new(format!("  {err}")).style(Style::default().fg(Color::Red));
902            frame.render_widget(err_para, chunks[err_idx]);
903        }
904    }
905}
906
907fn render_confirm_delete(
908    defs: &[SubAgentDef],
909    index: usize,
910    non_project: bool,
911    awaiting_second: bool,
912    _theme: &Theme,
913    frame: &mut Frame,
914    area: Rect,
915) {
916    let def = &defs[index];
917    let path_str = def
918        .file_path
919        .as_ref()
920        .map_or_else(|| def.name.clone(), |p| p.display().to_string());
921
922    let mut lines = vec![
923        Line::raw(""),
924        Line::from(Span::styled(
925            format!("  Delete: {path_str}"),
926            Style::default().add_modifier(Modifier::BOLD),
927        )),
928        Line::raw(""),
929    ];
930
931    if non_project && !awaiting_second {
932        lines.push(Line::from(Span::styled(
933            "  WARNING: This is a USER-level definition shared across all projects.",
934            Style::default()
935                .fg(Color::Yellow)
936                .add_modifier(Modifier::BOLD),
937        )));
938        lines.push(Line::raw(""));
939        lines.push(Line::raw(
940            "  Press [Enter/y] again to confirm, [Esc] to cancel.",
941        ));
942    } else if awaiting_second {
943        lines.push(Line::from(Span::styled(
944            "  Are you absolutely sure? This cannot be undone.",
945            Style::default().fg(Color::Red),
946        )));
947        lines.push(Line::raw(""));
948        lines.push(Line::raw("  Press [Enter/y] to DELETE, [Esc] to cancel."));
949    } else {
950        lines.push(Line::raw("  Press [Enter/y] to confirm, [Esc] to cancel."));
951    }
952
953    let para = Paragraph::new(lines).block(
954        Block::default()
955            .borders(Borders::ALL)
956            .border_style(Style::default().fg(Color::Red))
957            .title(" Confirm Delete "),
958    );
959    frame.render_widget(para, area);
960}
961
962fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
963    let popup_layout = Layout::default()
964        .direction(Direction::Vertical)
965        .constraints([
966            Constraint::Percentage((100 - percent_y) / 2),
967            Constraint::Percentage(percent_y),
968            Constraint::Percentage((100 - percent_y) / 2),
969        ])
970        .split(r);
971
972    Layout::default()
973        .direction(Direction::Horizontal)
974        .constraints([
975            Constraint::Percentage((100 - percent_x) / 2),
976            Constraint::Percentage(percent_x),
977            Constraint::Percentage((100 - percent_x) / 2),
978        ])
979        .split(popup_layout[1])[1]
980}
981
982fn truncate_str(s: &str, max: usize) -> String {
983    if s.chars().count() <= max {
984        s.to_owned()
985    } else {
986        let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
987        format!("{truncated}…")
988    }
989}
990
991// ── Tests ─────────────────────────────────────────────────────────────────────
992
993#[cfg(test)]
994mod tests {
995    use zeph_core::metrics::SubAgentMetrics;
996
997    use crate::metrics::MetricsSnapshot;
998    use crate::test_utils::render_to_string;
999
1000    use super::*;
1001
1002    // ── Runtime monitor tests ─────────────────────────────────────────────────
1003
1004    #[test]
1005    fn subagents_widget_renders_placeholder_when_empty() {
1006        let metrics = MetricsSnapshot::default();
1007        let output = render_to_string(60, 5, |frame, area| {
1008            super::render(&metrics, frame, area);
1009        });
1010        assert!(
1011            output.contains("Sub-Agents") && output.contains("No sub-agents"),
1012            "expected placeholder text, got: {output:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn subagents_widget_renders_entries() {
1018        let metrics = MetricsSnapshot {
1019            sub_agents: vec![
1020                SubAgentMetrics {
1021                    id: "abc123".into(),
1022                    name: "code-reviewer".into(),
1023                    state: "working".into(),
1024                    turns_used: 3,
1025                    max_turns: 20,
1026                    background: false,
1027                    elapsed_secs: 42,
1028                    permission_mode: String::new(),
1029                    transcript_dir: None,
1030                },
1031                SubAgentMetrics {
1032                    id: "def456".into(),
1033                    name: "test-writer".into(),
1034                    state: "completed".into(),
1035                    turns_used: 10,
1036                    max_turns: 20,
1037                    background: true,
1038                    elapsed_secs: 100,
1039                    permission_mode: "dont_ask".into(),
1040                    transcript_dir: None,
1041                },
1042            ],
1043            ..MetricsSnapshot::default()
1044        };
1045        let output = render_to_string(50, 10, |frame, area| {
1046            super::render(&metrics, frame, area);
1047        });
1048        assert!(output.contains("Sub-Agents"));
1049        assert!(output.contains("code-reviewer"));
1050        assert!(output.contains("test-writer"));
1051        assert!(output.contains("[dont_ask]"));
1052    }
1053
1054    #[test]
1055    fn subagents_widget_renders_permission_badges() {
1056        let metrics = MetricsSnapshot {
1057            sub_agents: vec![
1058                SubAgentMetrics {
1059                    id: "a".into(),
1060                    name: "planner".into(),
1061                    state: "working".into(),
1062                    turns_used: 1,
1063                    max_turns: 5,
1064                    background: false,
1065                    elapsed_secs: 1,
1066                    permission_mode: "plan".into(),
1067                    transcript_dir: None,
1068                },
1069                SubAgentMetrics {
1070                    id: "b".into(),
1071                    name: "bypasser".into(),
1072                    state: "working".into(),
1073                    turns_used: 1,
1074                    max_turns: 5,
1075                    background: false,
1076                    elapsed_secs: 1,
1077                    permission_mode: "bypass_permissions".into(),
1078                    transcript_dir: None,
1079                },
1080            ],
1081            ..MetricsSnapshot::default()
1082        };
1083        let output = render_to_string(60, 10, |frame, area| {
1084            super::render(&metrics, frame, area);
1085        });
1086        assert!(output.contains("[plan]"));
1087        assert!(output.contains("[bypass!]"));
1088    }
1089
1090    // ── AgentManagerState tests ───────────────────────────────────────────────
1091
1092    fn make_def(name: &str, description: &str) -> SubAgentDef {
1093        SubAgentDef::default_template(name, description)
1094    }
1095
1096    #[test]
1097    fn agent_manager_list_renders_definitions() {
1098        let defs = vec![
1099            make_def("reviewer", "Reviews code"),
1100            make_def("writer", "Writes tests"),
1101        ];
1102        let mut state = AgentManagerState::from_definitions(defs);
1103        let output = render_to_string(80, 20, |frame, area| {
1104            render_manager(&mut state, frame, area);
1105        });
1106        assert!(output.contains("reviewer"));
1107        assert!(output.contains("writer"));
1108    }
1109
1110    #[test]
1111    fn agent_manager_form_field_navigation() {
1112        let mut form = AgentFormState::new_empty();
1113        assert_eq!(form.focused, 0);
1114        form.focus_next();
1115        assert_eq!(form.focused, 1);
1116        form.focus_next();
1117        assert_eq!(form.focused, 2);
1118        form.focus_prev();
1119        assert_eq!(form.focused, 1);
1120    }
1121
1122    #[test]
1123    fn agent_manager_form_char_input() {
1124        let mut form = AgentFormState::new_empty();
1125        form.insert_char('h');
1126        form.insert_char('i');
1127        assert_eq!(form.fields[0].value, "hi");
1128        assert_eq!(form.cursor, 2);
1129    }
1130
1131    #[test]
1132    fn agent_manager_form_backspace() {
1133        let mut form = AgentFormState::new_empty();
1134        form.insert_char('a');
1135        form.insert_char('b');
1136        form.delete_char_before_cursor();
1137        assert_eq!(form.fields[0].value, "a");
1138        assert_eq!(form.cursor, 1);
1139    }
1140
1141    #[test]
1142    fn agent_manager_form_submit_empty_name_fails() {
1143        let form = AgentFormState::new_empty();
1144        let result = form.to_def();
1145        assert!(result.is_err());
1146        assert!(result.unwrap_err().contains("Name"));
1147    }
1148
1149    #[test]
1150    fn agent_manager_form_submit_valid() {
1151        let mut form = AgentFormState::new_empty();
1152        for c in "reviewer".chars() {
1153            form.insert_char(c);
1154        }
1155        form.focus_next();
1156        for c in "Reviews code".chars() {
1157            form.insert_char(c);
1158        }
1159        let result = form.to_def();
1160        assert!(result.is_ok());
1161        let def = result.unwrap();
1162        assert_eq!(def.name, "reviewer");
1163        assert_eq!(def.description, "Reviews code");
1164    }
1165
1166    #[test]
1167    fn agent_panel_list_to_detail_transition() {
1168        use crossterm::event::{KeyEvent, KeyModifiers};
1169        let defs = vec![make_def("reviewer", "Reviews code")];
1170        let mut state = AgentManagerState::from_definitions(defs);
1171        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1172        let closed = state.handle_key(enter);
1173        assert!(!closed);
1174        assert!(matches!(state, AgentManagerState::Detail { index: 0, .. }));
1175    }
1176
1177    #[test]
1178    fn agent_panel_detail_esc_returns_to_list() {
1179        use crossterm::event::{KeyEvent, KeyModifiers};
1180        let defs = vec![make_def("reviewer", "Reviews code")];
1181        let mut state = AgentManagerState::Detail {
1182            definitions: defs,
1183            index: 0,
1184        };
1185        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1186        let closed = state.handle_key(esc);
1187        assert!(!closed);
1188        assert!(matches!(state, AgentManagerState::List { .. }));
1189    }
1190
1191    #[test]
1192    fn agent_panel_list_esc_closes_panel() {
1193        use crossterm::event::{KeyEvent, KeyModifiers};
1194        let mut state = AgentManagerState::from_definitions(Vec::new());
1195        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1196        let closed = state.handle_key(esc);
1197        assert!(closed);
1198    }
1199
1200    #[test]
1201    fn agent_panel_detail_to_create_transition() {
1202        use crossterm::event::{KeyEvent, KeyModifiers};
1203        let defs = vec![make_def("reviewer", "Reviews code")];
1204        let mut state = AgentManagerState::from_definitions(defs);
1205        let c_key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
1206        state.handle_key(c_key);
1207        assert!(matches!(state, AgentManagerState::Create { .. }));
1208    }
1209
1210    #[test]
1211    fn agent_command_entries_present() {
1212        use crate::command::extra_command_registry;
1213        let all = extra_command_registry();
1214        assert!(all.iter().any(|e| e.id == "agents:show"));
1215        assert!(all.iter().any(|e| e.id == "agents:create"));
1216        assert!(all.iter().any(|e| e.id == "agents:edit"));
1217        assert!(all.iter().any(|e| e.id == "agents:delete"));
1218    }
1219
1220    // ── New tests for review findings ─────────────────────────────────────────
1221
1222    #[test]
1223    fn agent_manager_form_submit_invalid_name_fails() {
1224        let mut form = AgentFormState::new_empty();
1225        for c in "my agent".chars() {
1226            form.insert_char(c);
1227        }
1228        form.focus_next();
1229        for c in "desc".chars() {
1230            form.insert_char(c);
1231        }
1232        let result = form.to_def();
1233        assert!(result.is_err());
1234        assert!(result.unwrap_err().contains("Name must match"));
1235    }
1236
1237    #[test]
1238    fn agent_manager_form_submit_empty_description_fails() {
1239        let mut form = AgentFormState::new_empty();
1240        for c in "reviewer".chars() {
1241            form.insert_char(c);
1242        }
1243        let result = form.to_def();
1244        assert!(result.is_err());
1245        assert!(result.unwrap_err().contains("Description"));
1246    }
1247
1248    #[test]
1249    fn agent_manager_form_submit_invalid_max_turns_fails() {
1250        let mut form = AgentFormState::new_empty();
1251        for c in "reviewer".chars() {
1252            form.insert_char(c);
1253        }
1254        form.focus_next();
1255        for c in "Reviews code".chars() {
1256            form.insert_char(c);
1257        }
1258        // Override max_turns field with invalid value
1259        form.fields[3].value = "not-a-number".to_owned();
1260        let result = form.to_def();
1261        assert!(result.is_err());
1262        assert!(result.unwrap_err().contains("integer"));
1263    }
1264
1265    #[test]
1266    fn agent_manager_form_from_def_populates_fields() {
1267        let mut def = SubAgentDef::default_template("reviewer", "Reviews code");
1268        def.model = Some(zeph_subagent::ModelSpec::Named(
1269            "claude-sonnet-4-20250514".to_owned(),
1270        ));
1271        def.permissions.max_turns = 5;
1272        let form = AgentFormState::from_def(&def);
1273        assert_eq!(form.fields[0].value, "reviewer");
1274        assert_eq!(form.fields[1].value, "Reviews code");
1275        assert_eq!(form.fields[2].value, "claude-sonnet-4-20250514");
1276        assert_eq!(form.fields[3].value, "5");
1277    }
1278
1279    #[test]
1280    fn agent_panel_detail_to_edit_transition() {
1281        use crossterm::event::{KeyEvent, KeyModifiers};
1282        let defs = vec![make_def("reviewer", "Reviews code")];
1283        let mut state = AgentManagerState::Detail {
1284            definitions: defs,
1285            index: 0,
1286        };
1287        let e_key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE);
1288        let closed = state.handle_key(e_key);
1289        assert!(!closed);
1290        assert!(matches!(state, AgentManagerState::Edit { index: 0, .. }));
1291    }
1292
1293    #[test]
1294    fn agent_panel_edit_esc_returns_to_detail() {
1295        use crossterm::event::{KeyEvent, KeyModifiers};
1296        let defs = vec![make_def("reviewer", "Reviews code")];
1297        let form = AgentFormState::from_def(&defs[0]);
1298        let mut state = AgentManagerState::Edit {
1299            definitions: defs,
1300            index: 0,
1301            form,
1302        };
1303        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1304        let closed = state.handle_key(esc);
1305        assert!(!closed);
1306        assert!(matches!(state, AgentManagerState::Detail { index: 0, .. }));
1307    }
1308
1309    #[test]
1310    fn agent_panel_detail_to_confirm_delete_transition() {
1311        use crossterm::event::{KeyEvent, KeyModifiers};
1312        let defs = vec![make_def("reviewer", "Reviews code")];
1313        let mut state = AgentManagerState::Detail {
1314            definitions: defs,
1315            index: 0,
1316        };
1317        let d_key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
1318        let closed = state.handle_key(d_key);
1319        assert!(!closed);
1320        assert!(matches!(state, AgentManagerState::ConfirmDelete { .. }));
1321    }
1322
1323    #[test]
1324    fn agent_panel_confirm_delete_esc_returns_to_detail() {
1325        use crossterm::event::{KeyEvent, KeyModifiers};
1326        let defs = vec![make_def("reviewer", "Reviews code")];
1327        let mut state = AgentManagerState::ConfirmDelete {
1328            definitions: defs,
1329            index: 0,
1330            non_project: false,
1331            awaiting_second: false,
1332        };
1333        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1334        let closed = state.handle_key(esc);
1335        assert!(!closed);
1336        assert!(matches!(state, AgentManagerState::Detail { index: 0, .. }));
1337    }
1338
1339    #[test]
1340    fn agent_panel_confirm_delete_non_project_two_step() {
1341        use crossterm::event::{KeyEvent, KeyModifiers};
1342        let defs = vec![make_def("reviewer", "Reviews code")];
1343        let mut state = AgentManagerState::ConfirmDelete {
1344            definitions: defs,
1345            index: 0,
1346            non_project: true,
1347            awaiting_second: false,
1348        };
1349        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1350        // First Enter: sets awaiting_second = true, does NOT delete.
1351        state.handle_key(enter);
1352        assert!(matches!(
1353            state,
1354            AgentManagerState::ConfirmDelete {
1355                awaiting_second: true,
1356                ..
1357            }
1358        ));
1359    }
1360
1361    #[test]
1362    fn agent_panel_create_esc_restores_definitions() {
1363        use crossterm::event::{KeyEvent, KeyModifiers};
1364        let defs = vec![
1365            make_def("reviewer", "Reviews code"),
1366            make_def("writer", "Writes tests"),
1367        ];
1368        let mut state = AgentManagerState::from_definitions(defs);
1369        // Press 'c' to enter Create, then Esc to cancel.
1370        let c_key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
1371        state.handle_key(c_key);
1372        assert!(matches!(state, AgentManagerState::Create { .. }));
1373
1374        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1375        state.handle_key(esc);
1376        // Should be back to List with 2 definitions.
1377        if let AgentManagerState::List { definitions, .. } = &state {
1378            assert_eq!(definitions.len(), 2);
1379        } else {
1380            panic!("expected List state");
1381        }
1382    }
1383
1384    #[test]
1385    fn agent_form_multibyte_char_insert_and_delete() {
1386        let mut form = AgentFormState::new_empty();
1387        // Insert ASCII chars normally.
1388        form.insert_char('a');
1389        form.insert_char('b');
1390        assert_eq!(form.fields[0].value, "ab");
1391        assert_eq!(form.cursor, 2);
1392        // Delete one char.
1393        form.delete_char_before_cursor();
1394        assert_eq!(form.fields[0].value, "a");
1395        assert_eq!(form.cursor, 1);
1396    }
1397
1398    #[test]
1399    fn truncate_str_unicode_safe() {
1400        // String with 3 multi-byte chars.
1401        let s = "αβγδε";
1402        let truncated = truncate_str(s, 3);
1403        // Should be "αβ…" — 2 chars + ellipsis, all valid Unicode.
1404        assert_eq!(truncated.chars().count(), 3);
1405        assert!(truncated.ends_with('…'));
1406    }
1407
1408    #[test]
1409    fn truncate_str_ascii_unchanged() {
1410        assert_eq!(truncate_str("hello", 10), "hello");
1411        assert_eq!(truncate_str("hello", 5), "hello");
1412    }
1413}