Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): Schedules editor — the Cron screen comes alive

Replaces the "Coming Soon" placeholder on the Cron screen with a
working schedules editor. Reads from the same on-disk format the
CronSensor and `souveraine schedule` CLI already use, so changes are
picked up by the next cron tick without a restart.

Keys (Browse mode):
- j/k   navigate
- c     create new schedule (modal form)
- e     toggle enabled
- d     delete (confirm with y)
- r     run now (drops a `.trigger-<name>` file)
- R     reload from disk
- q/Esc back to menu

Create form fields: name, kind (interval/cron/once), schedule expr,
prompt, urgency. Tab/Shift-Tab between fields; Enter saves; Esc cancels.
Saves are written with the YAML-frontmatter format the parser expects.

The view resolves the agent's schedules directory by looking up its
UUID in the inventory (remote first, then local). Falls back to the
agent name if the inventory is unreachable — matches how the CLI's
`schedule` subcommand works today.

TOML config editor (the other half of the Settings task) is still
pending.
This commit is contained in:
Fimeg 2026-05-12 09:40:51 -04:00
commit b9b286293f
3 changed files with 591 additions and 1 deletions

View file

@ -47,6 +47,8 @@ pub struct App {
chat: Option<ChatState>,
/// Set when chat connect fails so we can surface the error in the menu.
chat_error: Option<String>,
/// Schedules editor (Cron screen). Lazily constructed on first entry.
schedules: Option<crate::ui::schedules::SchedulesView>,
/// Agent name preference (from `--agent` CLI flag).
agent_pref: String,
/// Companion buddy for visual agent representation (WIP).
@ -121,6 +123,7 @@ impl App {
config,
chat: None,
chat_error: None,
schedules: None,
agent_pref: agent_pref.clone(),
buddy: BuddyState::new(&agent_pref),
available_agents: Vec::new(),
@ -295,6 +298,7 @@ impl App {
}
}
Screen::Chat => self.handle_chat_key(key).await,
Screen::Cron => self.handle_schedules_key(key),
_ => {
match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
@ -306,6 +310,95 @@ impl App {
}
}
fn handle_schedules_key(&mut self, key: crossterm::event::KeyEvent) {
use crate::ui::schedules::{CreateField, Mode};
let Some(view) = self.schedules.as_mut() else {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.current_screen = Screen::Welcome;
}
return;
};
// Status banners absorb the next key — clear and continue.
if matches!(view.mode, Mode::Saved(_) | Mode::Error(_)) {
view.clear_status();
return;
}
match &mut view.mode {
Mode::Browse => match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
self.current_screen = Screen::Welcome;
}
KeyCode::Char('j') | KeyCode::Down => view.move_down(),
KeyCode::Char('k') | KeyCode::Up => view.move_up(),
KeyCode::Char('e') => view.toggle_enabled(),
KeyCode::Char('d') => view.confirm_delete(),
KeyCode::Char('r') => view.trigger_run(),
KeyCode::Char('c') => view.open_create(),
KeyCode::Char('R') => view.reload(),
_ => {}
},
Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => view.execute_delete(),
_ => view.cancel_delete(),
},
Mode::Create(form) => match key.code {
KeyCode::Esc => view.cancel_create(),
KeyCode::Enter => view.save_create(),
KeyCode::Tab | KeyCode::Down => form.next_field(),
KeyCode::BackTab | KeyCode::Up => form.prev_field(),
KeyCode::Backspace => match form.field {
CreateField::Name => { form.name.pop(); }
CreateField::Schedule => { form.schedule.pop(); }
CreateField::Prompt => { form.prompt.pop(); }
_ => {}
},
KeyCode::Left => match form.field {
CreateField::Kind => {
form.kind = match form.kind {
crate::core::nervous::cron::ScheduleKind::Once =>
crate::core::nervous::cron::ScheduleKind::Cron,
crate::core::nervous::cron::ScheduleKind::Interval =>
crate::core::nervous::cron::ScheduleKind::Once,
crate::core::nervous::cron::ScheduleKind::Cron =>
crate::core::nervous::cron::ScheduleKind::Interval,
};
}
CreateField::Urgency => {
form.urgency = (form.urgency - 0.1).max(0.0);
}
_ => {}
},
KeyCode::Right => match form.field {
CreateField::Kind => {
form.kind = match form.kind {
crate::core::nervous::cron::ScheduleKind::Once =>
crate::core::nervous::cron::ScheduleKind::Interval,
crate::core::nervous::cron::ScheduleKind::Interval =>
crate::core::nervous::cron::ScheduleKind::Cron,
crate::core::nervous::cron::ScheduleKind::Cron =>
crate::core::nervous::cron::ScheduleKind::Once,
};
}
CreateField::Urgency => {
form.urgency = (form.urgency + 0.1).min(1.0);
}
_ => {}
},
KeyCode::Char(c) => match form.field {
CreateField::Name => form.name.push(c),
CreateField::Schedule => form.schedule.push(c),
CreateField::Prompt => form.prompt.push(c),
_ => {}
},
_ => {}
},
Mode::Saved(_) | Mode::Error(_) => {}
}
}
async fn handle_chat_key(&mut self, key: crossterm::event::KeyEvent) {
use crate::ui::chat::Overlay;
@ -455,12 +548,63 @@ impl App {
}
3 => self.current_screen = Screen::Therapy,
4 => self.current_screen = Screen::AgentTime,
5 => self.current_screen = Screen::Cron,
5 => {
if self.schedules.is_none() {
self.schedules = Some(self.build_schedules_view().await);
} else if let Some(view) = self.schedules.as_mut() {
view.reload();
}
self.current_screen = Screen::Cron;
}
6 => self.current_screen = Screen::Settings,
_ => self.current_screen = Screen::Welcome,
}
}
/// Resolve the agent's schedules directory, preferring its UUID under
/// `~/.souveraine/agents/{id}/schedules/`. Falls back to the agent
/// name if the inventory isn't reachable — the CLI uses the same
/// path pattern, so a hand-managed dir keyed by name still works.
async fn build_schedules_view(&self) -> crate::ui::schedules::SchedulesView {
use crate::backend::Backend;
let base = dirs::home_dir()
.unwrap_or_default()
.join(".souveraine")
.join("agents");
let cfg = self.config.read().await;
let url = cfg.server.effective_url();
drop(cfg);
let mut resolved_id: Option<String> = None;
let remote = crate::backend::RemoteBackend::new(&url);
if remote.health().await {
if let Ok(list) = remote.list_agents().await {
resolved_id = list
.iter()
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
.map(|a| a.id.clone());
}
}
if resolved_id.is_none() {
let cfg = self.config.read().await.clone();
if let Ok(local) = crate::backend::LocalBackend::new(cfg).await {
if let Ok(list) = local.list_agents().await {
resolved_id = list
.iter()
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
.map(|a| a.id.clone());
}
}
}
let dir_key = resolved_id.unwrap_or_else(|| self.agent_pref.clone());
let dir = base.join(&dir_key).join("schedules");
let _ = std::fs::create_dir_all(&dir);
crate::ui::schedules::SchedulesView::new(self.agent_pref.clone(), dir)
}
/// Best-effort fetch of dashboard data from whichever backend is reachable.
/// Local mode also pulls recent git commits from the agent's memory repo.
async fn refresh_dashboard(&mut self) {
@ -580,6 +724,13 @@ impl App {
self.draw_placeholder(frame);
}
}
Screen::Cron => {
if let Some(view) = self.schedules.as_ref() {
crate::ui::schedules::draw(frame, view);
} else {
self.draw_placeholder(frame);
}
}
_ => self.draw_placeholder(frame),
}

View file

@ -7,6 +7,7 @@ pub mod cockpit_panel;
pub mod color_support;
pub mod component;
pub mod markdown;
pub mod schedules;
pub use app::App;
pub use buddy::{BuddyState, CompanionSprite, BuddyPosition};

438
src/ui/schedules.rs Normal file
View file

@ -0,0 +1,438 @@
//! Schedules editor — the Cron screen in the TUI.
//!
//! Reads the agent's schedules directory (`~/.souveraine/agents/{id}/schedules/`)
//! and lets the user list, create, delete, enable/disable, and run-now.
//! Each schedule is a markdown file with YAML frontmatter — the same format
//! `CronSensor` and the `souveraine schedule` CLI use, so what we write here
//! is picked up by the next cron tick.
use std::path::{Path, PathBuf};
use chrono::Utc;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Frame,
};
use crate::core::nervous::cron::{parse_schedule_file, ScheduleEntry, ScheduleKind};
pub enum Mode {
Browse,
ConfirmDelete,
Create(CreateForm),
Saved(String),
Error(String),
}
pub struct CreateForm {
pub field: CreateField,
pub name: String,
pub kind: ScheduleKind,
pub schedule: String,
pub prompt: String,
pub urgency: f32,
}
#[derive(Clone, Copy)]
pub enum CreateField {
Name,
Kind,
Schedule,
Prompt,
Urgency,
}
impl CreateForm {
pub fn new() -> Self {
Self {
field: CreateField::Name,
name: String::new(),
kind: ScheduleKind::Interval,
schedule: "3600".to_string(),
prompt: String::new(),
urgency: 0.3,
}
}
pub fn next_field(&mut self) {
self.field = match self.field {
CreateField::Name => CreateField::Kind,
CreateField::Kind => CreateField::Schedule,
CreateField::Schedule => CreateField::Prompt,
CreateField::Prompt => CreateField::Urgency,
CreateField::Urgency => CreateField::Name,
};
}
pub fn prev_field(&mut self) {
self.field = match self.field {
CreateField::Name => CreateField::Urgency,
CreateField::Kind => CreateField::Name,
CreateField::Schedule => CreateField::Kind,
CreateField::Prompt => CreateField::Schedule,
CreateField::Urgency => CreateField::Prompt,
};
}
}
pub struct SchedulesView {
pub agent_label: String,
pub schedules_dir: PathBuf,
pub entries: Vec<ScheduleEntry>,
pub selected: usize,
pub mode: Mode,
}
impl SchedulesView {
pub fn new(agent_label: String, schedules_dir: PathBuf) -> Self {
let mut s = Self {
agent_label,
schedules_dir,
entries: Vec::new(),
selected: 0,
mode: Mode::Browse,
};
s.reload();
s
}
pub fn reload(&mut self) {
self.entries = scan(&self.schedules_dir);
if self.selected >= self.entries.len() && !self.entries.is_empty() {
self.selected = self.entries.len() - 1;
}
}
pub fn current(&self) -> Option<&ScheduleEntry> {
self.entries.get(self.selected)
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.entries.len() {
self.selected += 1;
}
}
pub fn toggle_enabled(&mut self) {
let Some(entry) = self.entries.get_mut(self.selected) else { return };
entry.enabled = !entry.enabled;
let path = self.schedules_dir.join(format!("{}.md", entry.name));
match write_schedule_file(&path, entry) {
Ok(()) => self.mode = Mode::Saved(format!("{}: {}", entry.name, if entry.enabled { "enabled" } else { "disabled" })),
Err(e) => self.mode = Mode::Error(format!("save failed: {e}")),
}
}
pub fn confirm_delete(&mut self) {
if self.entries.get(self.selected).is_some() {
self.mode = Mode::ConfirmDelete;
}
}
pub fn cancel_delete(&mut self) {
self.mode = Mode::Browse;
}
pub fn execute_delete(&mut self) {
let Some(entry) = self.entries.get(self.selected) else {
self.mode = Mode::Browse;
return;
};
let path = self.schedules_dir.join(format!("{}.md", entry.name));
match std::fs::remove_file(&path) {
Ok(()) => {
let name = entry.name.clone();
self.reload();
self.mode = Mode::Saved(format!("deleted '{name}'"));
}
Err(e) => self.mode = Mode::Error(format!("delete failed: {e}")),
}
}
pub fn trigger_run(&mut self) {
let Some(entry) = self.entries.get(self.selected) else { return };
let trigger = self.schedules_dir.join(format!(".trigger-{}", entry.name));
match std::fs::write(&trigger, "") {
Ok(()) => self.mode = Mode::Saved(format!("'{}' will fire on next tick", entry.name)),
Err(e) => self.mode = Mode::Error(format!("trigger failed: {e}")),
}
}
pub fn open_create(&mut self) {
self.mode = Mode::Create(CreateForm::new());
}
pub fn cancel_create(&mut self) {
self.mode = Mode::Browse;
}
pub fn save_create(&mut self) {
let Mode::Create(form) = &self.mode else { return };
if form.name.trim().is_empty() {
self.mode = Mode::Error("name is required".to_string());
return;
}
if form.schedule.trim().is_empty() {
self.mode = Mode::Error("schedule expression is required".to_string());
return;
}
if form.prompt.trim().is_empty() {
self.mode = Mode::Error("prompt is required".to_string());
return;
}
let entry = ScheduleEntry {
name: form.name.trim().to_string(),
kind: form.kind.clone(),
schedule: form.schedule.trim().to_string(),
source: "user".to_string(),
enabled: true,
urgency: form.urgency,
created_at: Utc::now(),
prompt: form.prompt.trim().to_string(),
};
let path = self.schedules_dir.join(format!("{}.md", entry.name));
if path.exists() {
self.mode = Mode::Error(format!("'{}' already exists", entry.name));
return;
}
if let Err(e) = std::fs::create_dir_all(&self.schedules_dir) {
self.mode = Mode::Error(format!("mkdir failed: {e}"));
return;
}
match write_schedule_file(&path, &entry) {
Ok(()) => {
let name = entry.name.clone();
self.reload();
self.mode = Mode::Saved(format!("created '{name}'"));
}
Err(e) => self.mode = Mode::Error(format!("save failed: {e}")),
}
}
pub fn clear_status(&mut self) {
if matches!(self.mode, Mode::Saved(_) | Mode::Error(_)) {
self.mode = Mode::Browse;
}
}
}
fn scan(dir: &Path) -> Vec<ScheduleEntry> {
let mut entries = Vec::new();
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Ok(e) = parse_schedule_file(&path) {
entries.push(e);
}
}
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
fn write_schedule_file(path: &Path, entry: &ScheduleEntry) -> anyhow::Result<()> {
let kind = match entry.kind {
ScheduleKind::Once => "once",
ScheduleKind::Interval => "interval",
ScheduleKind::Cron => "cron",
};
let content = format!(
"---\nname: {name}\nkind: {kind}\nschedule: \"{schedule}\"\nsource: {source}\nenabled: {enabled}\nurgency: {urgency}\ncreated_at: {created}\n---\n\n{prompt}\n",
name = entry.name,
kind = kind,
schedule = entry.schedule,
source = entry.source,
enabled = entry.enabled,
urgency = entry.urgency,
created = entry.created_at.to_rfc3339(),
prompt = entry.prompt,
);
std::fs::write(path, content)?;
Ok(())
}
pub fn draw(frame: &mut Frame, view: &SchedulesView) {
let area = frame.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
])
.split(area);
let header = Paragraph::new(format!(" Schedules — {} ", view.agent_label))
.style(Style::default().fg(Color::Rgb(255, 140, 66)).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded));
frame.render_widget(header, chunks[0]);
match &view.mode {
Mode::Create(form) => draw_create_form(frame, chunks[1], form),
Mode::ConfirmDelete => draw_confirm_delete(frame, chunks[1], view),
_ => draw_list(frame, chunks[1], view),
}
let footer = footer_for(&view.mode);
let footer = Paragraph::new(footer)
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded));
frame.render_widget(footer, chunks[2]);
}
fn draw_list(frame: &mut Frame, area: Rect, view: &SchedulesView) {
let mut lines: Vec<Line> = Vec::new();
if view.entries.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" No schedules yet. Press `c` to create one.",
Style::default().fg(Color::DarkGray),
)));
} else {
for (idx, entry) in view.entries.iter().enumerate() {
let selected = idx == view.selected;
let marker = if selected { ">" } else { " " };
let status_color = if entry.enabled { Color::Rgb(120, 200, 120) } else { Color::DarkGray };
let status_label = if entry.enabled { "●" } else { "○" };
let kind = match entry.kind {
ScheduleKind::Once => "once",
ScheduleKind::Interval => "interval",
ScheduleKind::Cron => "cron",
};
let style = if selected {
Style::default().fg(Color::Rgb(255, 200, 130)).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::raw(format!("{marker} ")),
Span::styled(status_label.to_string(), Style::default().fg(status_color)),
Span::raw(" "),
Span::styled(format!("{:<20}", entry.name), style),
Span::styled(format!("{:<10}", kind), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:<24}", entry.schedule), Style::default().fg(Color::Rgb(180, 180, 220))),
Span::styled(format!("urg {:.1}", entry.urgency), Style::default().fg(Color::DarkGray)),
]));
if selected {
let prompt_preview: String = entry.prompt.chars().take(120).collect();
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(prompt_preview, Style::default().fg(Color::Rgb(160, 160, 180)).add_modifier(Modifier::ITALIC)),
]));
}
}
}
if let Mode::Saved(msg) = &view.mode {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Rgb(120, 200, 120)))));
} else if let Mode::Error(msg) = &view.mode {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(format!(" ✗ {msg}"), Style::default().fg(Color::Rgb(220, 100, 100)))));
}
let list = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded));
frame.render_widget(list, area);
}
fn draw_confirm_delete(frame: &mut Frame, area: Rect, view: &SchedulesView) {
let name = view.current().map(|e| e.name.as_str()).unwrap_or("?");
let lines = vec![
Line::from(""),
Line::from(Span::styled(
format!(" Delete '{name}'?"),
Style::default().fg(Color::Rgb(255, 180, 80)).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
" y — yes, delete n / Esc — cancel",
Style::default().fg(Color::DarkGray),
)),
];
let body = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded));
frame.render_widget(body, area);
}
fn draw_create_form(frame: &mut Frame, area: Rect, form: &CreateForm) {
let kind_str = match form.kind {
ScheduleKind::Once => "once",
ScheduleKind::Interval => "interval",
ScheduleKind::Cron => "cron",
};
let is_field = |f: CreateField| {
std::mem::discriminant(&form.field) == std::mem::discriminant(&f)
};
let cursor = |f: CreateField| if is_field(f) { "▌" } else { " " };
let field_style = |f: CreateField| {
if is_field(f) {
Style::default().fg(Color::Rgb(255, 200, 130)).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
}
};
let mut lines = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" name: ", Style::default().fg(Color::DarkGray)),
Span::styled(form.name.clone(), field_style(CreateField::Name)),
Span::raw(cursor(CreateField::Name)),
]));
lines.push(Line::from(vec![
Span::styled(" kind: ", Style::default().fg(Color::DarkGray)),
Span::styled(kind_str.to_string(), field_style(CreateField::Kind)),
Span::styled(" (←/→ to cycle: interval, cron, once)", Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(vec![
Span::styled(" schedule: ", Style::default().fg(Color::DarkGray)),
Span::styled(form.schedule.clone(), field_style(CreateField::Schedule)),
Span::raw(cursor(CreateField::Schedule)),
Span::styled(" (seconds for interval, cron expr for cron)", Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(vec![
Span::styled(" prompt: ", Style::default().fg(Color::DarkGray)),
Span::styled(form.prompt.clone(), field_style(CreateField::Prompt)),
Span::raw(cursor(CreateField::Prompt)),
]));
lines.push(Line::from(vec![
Span::styled(" urgency: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:.1}", form.urgency), field_style(CreateField::Urgency)),
Span::styled(" (←/→ to adjust, 0.0–1.0)", Style::default().fg(Color::DarkGray)),
]));
let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(
Block::default()
.title(" New schedule ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(255, 200, 130))),
);
frame.render_widget(body, area);
}
fn footer_for(mode: &Mode) -> String {
match mode {
Mode::Browse => "j/k navigate • c create • e toggle • r run-now • d delete • q back".to_string(),
Mode::ConfirmDelete => "y confirm • n/Esc cancel".to_string(),
Mode::Create(_) => "Tab next field • Enter save • Esc cancel".to_string(),
Mode::Saved(_) | Mode::Error(_) => "any key to continue".to_string(),
}
}