feat(tui/agents): interactive /agents UI with list + editor; remove param-based flow\n\n- New bottom pane views: AgentsSettingsView (list) and SubagentEditorView (form)\n- Shows built-in /plan, /solve, /code plus custom and Add New…\n- Editor provides Name, Mode (read-only/write), Agents multi-select summary,\n Orchestrator and Agent instruction fields, with [ Save ] [ Cancel ]\n- Persist via config_edit::upsert_subagent_command and update in-memory config\n- AppEvent::ShowSubagentEditor to open editor from list\n- Remove old /agents param parsing; keep zero warnings build

This commit is contained in:
James Peter
2025-09-14 20:35:03 +10:00
parent 14b6753c87
commit eb024bee76
5 changed files with 333 additions and 146 deletions
+10
View File
@@ -1027,6 +1027,16 @@ impl App<'_> {
widget.toggle_mcp_server(&name, enable); widget.toggle_mcp_server(&name, enable);
} }
} }
AppEvent::UpdateSubagentCommand(cmd) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_subagent_update(cmd);
}
}
AppEvent::ShowSubagentEditor { name, available_agents, existing, is_new } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_subagent_editor_ui(name, available_agents, existing, is_new);
}
}
AppEvent::PrefillComposer(text) => { AppEvent::PrefillComposer(text) => {
if let AppState::Chat { widget } = &mut self.app_state { if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str(&text); widget.insert_str(&text);
+9
View File
@@ -88,6 +88,15 @@ pub(crate) enum AppEvent {
/// Update the theme (with history event) /// Update the theme (with history event)
UpdateTheme(ThemeName), UpdateTheme(ThemeName),
/// Add or update a subagent command in memory (UI already persisted to config.toml)
UpdateSubagentCommand(codex_core::config_types::SubagentCommandConfig),
/// Open the subagent editor UI for the given command name
ShowSubagentEditor {
name: String,
available_agents: Vec<String>,
existing: Vec<codex_core::config_types::SubagentCommandConfig>,
is_new: bool,
},
/// Preview theme (no history event) /// Preview theme (no history event)
PreviewTheme(ThemeName), PreviewTheme(ThemeName),
@@ -0,0 +1,242 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::list_selection_view::{ListSelectionView, SelectionAction, SelectionItem};
use super::BottomPane;
#[derive(Clone, Debug)]
pub struct AgentsSettingsView {
builtins: Vec<String>,
custom: Vec<String>,
existing: Vec<codex_core::config_types::SubagentCommandConfig>,
available_agents: Vec<String>,
app_event_tx: AppEventSender,
}
impl AgentsSettingsView {
pub fn new(
builtins: Vec<String>,
custom: Vec<String>,
existing: Vec<codex_core::config_types::SubagentCommandConfig>,
available_agents: Vec<String>,
app_event_tx: AppEventSender,
) -> Self {
Self { builtins, custom, existing, available_agents, app_event_tx }
}
pub fn into_list_view(self) -> ListSelectionView {
// Build items: built-ins first, then custom, then Add New…
let mut items: Vec<SelectionItem> = Vec::new();
let make_actions = |name: String| -> Vec<SelectionAction> {
let name_clone = name.clone();
let view_clone = self.clone();
vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::ShowSubagentEditor { name: name_clone.clone(), available_agents: view_clone.available_agents.clone(), existing: view_clone.existing.clone(), is_new: false });
})]
};
// Built-ins
for name in &self.builtins {
let desc = Some("(press Enter to configure)".to_string());
items.push(SelectionItem { name: format!("/{}", name), description: desc, is_current: false, actions: make_actions(name.clone()) });
}
// Custom
for name in &self.custom {
let desc = Some("(press Enter to configure)".to_string());
items.push(SelectionItem { name: format!("/{}", name), description: desc, is_current: false, actions: make_actions(name.clone()) });
}
// Add New…
{
let view_clone = self.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::ShowSubagentEditor { name: String::new(), available_agents: view_clone.available_agents.clone(), existing: view_clone.existing.clone(), is_new: true });
})];
items.push(SelectionItem { name: "Add new…".to_string(), description: None, is_current: false, actions });
}
ListSelectionView::new(
" Agent Commands ".to_string(),
Some("Press Enter to configure".to_string()),
Some("Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
)
}
}
#[derive(Clone, Debug)]
pub struct SubagentEditorView {
name: String,
read_only: bool,
selected_agent_indices: Vec<usize>,
orchestrator: String,
agent: String,
available_agents: Vec<String>,
is_new: bool,
field: usize, // 0 name, 1 mode, 2 agents, 3 orch, 4 agent, 5 save, 6 cancel
app_event_tx: AppEventSender,
}
impl SubagentEditorView {
pub fn new(root: &AgentsSettingsView, name: &str) -> Self {
let mut me = Self {
name: name.to_string(),
read_only: matches!(name, "plan" | "solve"),
selected_agent_indices: Vec::new(),
orchestrator: String::new(),
agent: String::new(),
available_agents: root.available_agents.clone(),
is_new: name.is_empty(),
field: 0,
app_event_tx: root.app_event_tx.clone(),
};
// Seed from existing config if present
if let Some(cfg) = root.existing.iter().find(|c| c.name.eq_ignore_ascii_case(name)) {
me.name = cfg.name.clone();
me.read_only = cfg.read_only;
me.orchestrator = cfg.orchestrator_instructions.clone().unwrap_or_default();
me.agent = cfg.agent_instructions.clone().unwrap_or_default();
let set: std::collections::HashSet<String> = cfg.agents.iter().cloned().collect();
for (idx, a) in me.available_agents.iter().enumerate() {
if set.contains(a) { me.selected_agent_indices.push(idx); }
}
}
me
}
pub fn new_with_data(
name: String,
available_agents: Vec<String>,
existing: Vec<codex_core::config_types::SubagentCommandConfig>,
is_new: bool,
app_event_tx: AppEventSender,
) -> Self {
let root = AgentsSettingsView { builtins: vec![], custom: vec![], existing, available_agents, app_event_tx };
let mut s = Self::new(&root, &name);
s.is_new = is_new;
s
}
fn toggle_agent_at(&mut self, idx: usize) {
if let Some(pos) = self.selected_agent_indices.iter().position(|i| *i == idx) {
self.selected_agent_indices.remove(pos);
} else {
self.selected_agent_indices.push(idx);
}
}
fn save(&mut self) {
let agents: Vec<String> = if self.selected_agent_indices.is_empty() {
Vec::new()
} else {
self.selected_agent_indices.iter().filter_map(|i| self.available_agents.get(*i).cloned()).collect()
};
let cfg = codex_core::config_types::SubagentCommandConfig {
name: self.name.clone(),
read_only: self.read_only,
agents,
orchestrator_instructions: if self.orchestrator.trim().is_empty() { None } else { Some(self.orchestrator.clone()) },
agent_instructions: if self.agent.trim().is_empty() { None } else { Some(self.agent.clone()) },
};
// Persist to disk
if let Ok(home) = codex_core::config::find_codex_home() {
let rt = tokio::runtime::Handle::current();
let _ = rt.block_on(codex_core::config_edit::upsert_subagent_command(&home, &cfg));
}
// Update in-memory config
self.app_event_tx.send(AppEvent::UpdateSubagentCommand(cfg));
}
}
impl<'a> BottomPaneView<'a> for SubagentEditorView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event {
KeyEvent { code: KeyCode::Esc, .. } => { /* close handled by parent */ }
KeyEvent { code: KeyCode::Tab, .. } => { self.field = (self.field + 1).min(6); }
KeyEvent { code: KeyCode::BackTab, .. } => { if self.field > 0 { self.field -= 1; } }
KeyEvent { code: KeyCode::Left | KeyCode::Right, .. } if self.field == 1 => { self.read_only = !self.read_only; }
KeyEvent { code: KeyCode::Char(' '), .. } if self.field == 2 => {
// toggle currently highlighted agent (first one for simplicity)
self.toggle_agent_at(0);
}
KeyEvent { code: KeyCode::Enter, .. } if self.field == 5 => { self.save(); }
KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, .. } => {
match self.field {
0 => self.name.push(c),
3 => self.orchestrator.push(c),
4 => self.agent.push(c),
_ => {}
}
}
KeyEvent { code: KeyCode::Backspace, .. } => {
match self.field {
0 => { self.name.pop(); },
3 => { self.orchestrator.pop(); },
4 => { self.agent.pop(); },
_ => {}
}
}
_ => {}
}
}
fn is_complete(&self) -> bool { false }
fn desired_height(&self, _width: u16) -> u16 { 20 }
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" Configure Agent Command ")
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line<'static>> = Vec::new();
let sel = |idx: usize| if self.field == idx { Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD) } else { Style::default() };
lines.push(Line::from(vec![Span::styled("Name: ", Style::default()), Span::styled(self.name.clone(), sel(0))]));
let mode_str = if self.read_only { "read-only" } else { "write" };
lines.push(Line::from(vec![Span::styled("Mode: ", Style::default()), Span::styled(mode_str.to_string(), sel(1))]));
// Agents selection (simple summary; toggle behavior simplified)
let mut sel_names: Vec<String> = Vec::new();
for (idx, a) in self.available_agents.iter().enumerate() {
let checked = if self.selected_agent_indices.contains(&idx) { "[x]" } else { "[ ]" };
sel_names.push(format!("{} {}", checked, a));
}
lines.push(Line::from("Agents:"));
lines.push(Line::from(vec![Span::styled(sel_names.join(", "), sel(2))]));
lines.push(Line::from(""));
lines.push(Line::from("Instructions to Code (orchestrator):"));
lines.push(Line::from(Span::styled(self.orchestrator.clone(), sel(3))));
lines.push(Line::from(""));
lines.push(Line::from("Instructions to each agent:"));
lines.push(Line::from(Span::styled(self.agent.clone(), sel(4))));
lines.push(Line::from(""));
let save_style = sel(5).fg(crate::colors::success());
let cancel_style = sel(6).fg(crate::colors::error());
lines.push(Line::from(vec![
Span::styled("[ Save ] ", save_style),
Span::styled("[ Cancel ]", cancel_style),
]));
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(Rect { x: inner.x.saturating_add(1), y: inner.y, width: inner.width.saturating_sub(2), height: inner.height }, buf);
}
}
+16
View File
@@ -28,6 +28,7 @@ mod scroll_state;
mod selection_popup_common; mod selection_popup_common;
pub mod list_selection_view; pub mod list_selection_view;
pub mod resume_selection_view; pub mod resume_selection_view;
pub mod agents_settings_view;
mod github_settings_view; mod github_settings_view;
pub mod mcp_settings_view; pub mod mcp_settings_view;
// no direct use of list_selection_view or its items here // no direct use of list_selection_view or its items here
@@ -497,6 +498,21 @@ impl BottomPane<'_> {
self.request_redraw(); self.request_redraw();
} }
/// Show Subagent editor UI
pub fn show_subagent_editor(
&mut self,
name: String,
available_agents: Vec<String>,
existing: Vec<codex_core::config_types::SubagentCommandConfig>,
is_new: bool,
) {
use crate::bottom_pane::agents_settings_view::SubagentEditorView;
let view = SubagentEditorView::new_with_data(name, available_agents, existing, is_new, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Height (terminal rows) required by the current bottom pane. /// Height (terminal rows) required by the current bottom pane.
pub(crate) fn request_redraw(&self) { pub(crate) fn request_redraw(&self) {
self.app_event_tx.send(AppEvent::RequestRedraw) self.app_event_tx.send(AppEvent::RequestRedraw)
+56 -146
View File
@@ -4144,6 +4144,7 @@ impl ChatWidget<'_> {
self.history_push(history_cell::new_prompts_output()); self.history_push(history_cell::new_prompts_output());
} }
#[allow(dead_code)]
pub(crate) fn add_agents_output(&mut self) { pub(crate) fn add_agents_output(&mut self) {
use ratatui::text::Line; use ratatui::text::Line;
@@ -4299,158 +4300,67 @@ impl ChatWidget<'_> {
self.request_redraw(); self.request_redraw();
} }
pub(crate) fn handle_agents_command(&mut self, args: String) { pub(crate) fn handle_agents_command(&mut self, _args: String) {
let trimmed = args.trim(); // Open interactive Agents settings view (list + per-command editor)
if trimmed.is_empty() { self.show_agents_settings_ui();
self.add_agents_output(); }
return;
}
fn parse_kv(args: &str) -> std::collections::HashMap<String, String> { fn show_agents_settings_ui(&mut self) {
let mut map = std::collections::HashMap::new(); use crate::bottom_pane::agents_settings_view::AgentsSettingsView;
for tok in args.split_whitespace() { let available_agents: Vec<String> = if self.config.agents.is_empty() {
if let Some((k, v)) = tok.split_once('=') { vec!["claude".into(), "gemini".into(), "qwen".into(), "codex".into()]
map.insert(k.to_string(), v.trim_matches('"').to_string()); } else {
} self.config
} .agents
map .iter()
} .filter(|a| a.enabled)
.map(|a| a.name.clone())
.collect()
};
let builtins = vec!["plan".to_string(), "solve".to_string(), "code".to_string()];
let custom: Vec<String> = self
.config
.subagent_commands
.iter()
.map(|c| c.name.clone())
.filter(|n| !builtins.iter().any(|b| b.eq_ignore_ascii_case(n)))
.collect();
let view = AgentsSettingsView::new(
builtins,
custom,
self.config.subagent_commands.clone(),
available_agents,
self.app_event_tx.clone(),
);
self.bottom_pane.show_list_selection(" Agents ".to_string(), None, None, view.into_list_view());
}
let mut parts = trimmed.splitn(2, ' '); pub(crate) fn show_subagent_editor_ui(
let action = parts.next().unwrap_or(""); &mut self,
let rest = parts.next().unwrap_or(""); name: String,
let kv = parse_kv(rest); available_agents: Vec<String>,
existing: Vec<codex_core::config_types::SubagentCommandConfig>,
is_new: bool,
) {
self.bottom_pane
.show_subagent_editor(name, available_agents, existing, is_new);
}
match action { pub(crate) fn apply_subagent_update(&mut self, cmd: codex_core::config_types::SubagentCommandConfig) {
"add" | "edit" => { if let Some(slot) = self
let name = kv.get("name").cloned().unwrap_or_default(); .config
if name.is_empty() { .subagent_commands
self.history_push(crate::history_cell::new_error_event( .iter_mut()
"usage: /agents add name=<name> ...".to_string(), .find(|c| c.name.eq_ignore_ascii_case(&cmd.name))
)); {
return; *slot = cmd;
} } else {
self.config.subagent_commands.push(cmd);
let mut existing = self
.config
.subagent_commands
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&name))
.cloned()
.unwrap_or_default();
if existing.name.is_empty() {
existing.name = name.clone();
// default read-only for built-in names if not set during add
if existing.read_only == false && (name.eq_ignore_ascii_case("plan") || name.eq_ignore_ascii_case("solve")) {
existing.read_only = true;
}
}
if let Some(v) = kv.get("read-only") {
existing.read_only = matches!(v.as_str(), "true" | "1" | "yes");
}
if let Some(v) = kv.get("agents") {
let list = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
existing.agents = list;
}
if let Some(v) = kv.get("orchestrator").or_else(|| kv.get("orchestrator-instructions")) {
existing.orchestrator_instructions = Some(v.clone());
}
if let Some(v) = kv.get("agent").or_else(|| kv.get("agent-instructions")) {
existing.agent_instructions = Some(v.clone());
}
let codex_home = match codex_core::config::find_codex_home() {
Ok(p) => p,
Err(e) => {
self.history_push(crate::history_cell::new_error_event(format!(
"failed to locate CODEX_HOME: {}",
e
)));
return;
}
};
let fut = codex_core::config_edit::upsert_subagent_command(&codex_home, &existing);
let rt = tokio::runtime::Handle::current();
let res = rt.block_on(fut);
match res {
Ok(()) => {
if let Some(slot) = self
.config
.subagent_commands
.iter_mut()
.find(|c| c.name.eq_ignore_ascii_case(&existing.name))
{
*slot = existing.clone();
} else {
self.config.subagent_commands.push(existing.clone());
}
self.history_push(crate::history_cell::PlainHistoryCell {
lines: vec![ratatui::text::Line::from(format!(
"✅ saved subagent command '{}'",
name
))],
kind: crate::history_cell::HistoryCellType::Notice,
});
}
Err(e) => {
self.history_push(crate::history_cell::new_error_event(format!(
"failed to save subagent command: {}",
e
)));
}
}
}
"delete" => {
let name = kv.get("name").cloned().unwrap_or_default();
if name.is_empty() {
self.history_push(crate::history_cell::new_error_event(
"usage: /agents delete name=<name>".to_string(),
));
return;
}
let codex_home = match codex_core::config::find_codex_home() {
Ok(p) => p,
Err(e) => {
self.history_push(crate::history_cell::new_error_event(format!("failed to locate CODEX_HOME: {}", e)));
return;
}
};
let fut = codex_core::config_edit::delete_subagent_command(&codex_home, &name);
let rt = tokio::runtime::Handle::current();
let res = rt.block_on(fut);
match res {
Ok(true) => {
self.config
.subagent_commands
.retain(|c| !c.name.eq_ignore_ascii_case(&name));
self.history_push(crate::history_cell::PlainHistoryCell {
lines: vec![ratatui::text::Line::from(format!("🗑 removed '{}'", name))],
kind: crate::history_cell::HistoryCellType::Notice,
});
}
Ok(false) => self.history_push(crate::history_cell::new_error_event(format!(
"no subagent command named '{}'",
name
))),
Err(e) => self.history_push(crate::history_cell::new_error_event(format!(
"failed to delete subagent command: {}",
e
))),
}
}
_ => {
self.history_push(crate::history_cell::new_error_event(
"usage: /agents [add|edit|delete] ...".to_string(),
));
}
} }
} }
pub(crate) fn show_diffs_popup(&mut self) { pub(crate) fn show_diffs_popup(&mut self) {
use crate::diff_render::create_diff_details_only; use crate::diff_render::create_diff_details_only;
// Build a latest-first unique file list // Build a latest-first unique file list