1use 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
15const 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
73pub 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
104pub 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#[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#[derive(Debug, Clone)]
161pub struct AgentFormState {
162 pub fields: Vec<FormField>,
163 pub focused: usize,
165 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 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 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 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 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
292pub enum AgentManagerState {
294 List {
296 definitions: Vec<SubAgentDef>,
297 list_state: ListState,
298 },
299 Detail {
301 definitions: Vec<SubAgentDef>,
302 index: usize,
303 },
304 Create {
306 definitions: Vec<SubAgentDef>,
308 form: AgentFormState,
309 },
310 Edit {
312 definitions: Vec<SubAgentDef>,
313 index: usize,
314 form: AgentFormState,
315 },
316 ConfirmDelete {
318 definitions: Vec<SubAgentDef>,
319 index: usize,
320 non_project: bool,
322 awaiting_second: bool,
324 },
325}
326
327impl AgentManagerState {
328 #[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 pub fn handle_key(&mut self, key: KeyEvent) -> bool {
343 let (close, next) = handle_key_dispatch(self, key);
346 if let Some(s) = next {
347 *self = s;
348 }
349 close
350 }
351}
352
353fn 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 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 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 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 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 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 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 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 let defs = std::mem::take(definitions);
641 tracing::warn!(error = %e, "failed to delete agent definition");
644 AgentManagerState::Detail {
645 definitions: defs,
646 index,
647 }
648 }
649 }
650 } else {
651 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
662pub fn render_manager(state: &mut AgentManagerState, frame: &mut Frame, area: Rect) {
664 let theme = Theme::default();
665
666 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 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#[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 #[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 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 #[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 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 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 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 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 form.insert_char('a');
1389 form.insert_char('b');
1390 assert_eq!(form.fields[0].value, "ab");
1391 assert_eq!(form.cursor, 2);
1392 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 let s = "αβγδε";
1402 let truncated = truncate_str(s, 3);
1403 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}