use anyhow::Result;
use tokio::sync::mpsc;
use crate::cli::management::{self, ClientForm, ServerForm};
use crate::llm::actions::ActionDefinition;
use crate::scripting::event_handler::{EventHandler, EventPattern};
use crate::scripting::{EventHandlerConfig, EventHandlerType};
use crate::state::app_state::AppState;
use crate::tui::app::UiKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandlerKind {
Static,
Script,
Llm,
Manual,
}
impl HandlerKind {
pub const ALL: [HandlerKind; 4] = [
HandlerKind::Static,
HandlerKind::Script,
HandlerKind::Llm,
HandlerKind::Manual,
];
pub fn label(&self) -> &'static str {
match self {
HandlerKind::Static => "STATIC",
HandlerKind::Script => "SCRIPT",
HandlerKind::Llm => "LLM",
HandlerKind::Manual => "MANUAL",
}
}
pub fn blurb(&self) -> &'static str {
match self {
HandlerKind::Static => {
"Fixed actions, sent as-is. Cheapest and fully deterministic; \
{{event.field}} echoes values from the event."
}
HandlerKind::Script => {
"Your code decides. Runs an interpreter per event (or resident), no model call."
}
HandlerKind::Llm => "The model decides, using an instruction written for this event.",
HandlerKind::Manual => {
"YOU decide. Each matched event waits at the dashboard for you to compose \
the answer; no answer within the timeout fails closed."
}
}
}
pub fn next(&self) -> Self {
let index = Self::ALL.iter().position(|k| k == self).unwrap_or(0);
Self::ALL[(index + 1) % Self::ALL.len()]
}
pub fn previous(&self) -> Self {
let index = Self::ALL.iter().position(|k| k == self).unwrap_or(0);
Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftFocus {
Kind,
Pattern,
Language,
Code,
Resident,
Instruction,
Actions,
Timeout,
Button(usize),
}
pub const SCRIPT_LANGUAGES: [&str; 4] = ["python", "javascript", "perl", "go"];
#[derive(Debug, Clone)]
pub struct HandlerDraft {
pub pattern: String,
pub kind: HandlerKind,
pub instruction: String,
pub language: String,
pub code: String,
pub resident: bool,
pub actions: Vec<serde_json::Value>,
pub timeout_secs: String,
pub focus: DraftFocus,
pub selected_action: usize,
pub editing: Option<String>,
pub error: Option<String>,
}
impl HandlerDraft {
pub fn buttons(&self) -> Vec<crate::tui::hit::ModalAction> {
use crate::tui::hit::ModalAction::*;
vec![DraftSave, DraftCancel]
}
pub fn focused_action(&self) -> Option<crate::tui::hit::ModalAction> {
match self.focus {
DraftFocus::Button(index) => self.buttons().get(index).copied(),
_ => None,
}
}
pub fn focus_stops(&self) -> Vec<DraftFocus> {
let mut stops = vec![DraftFocus::Kind, DraftFocus::Pattern];
match self.kind {
HandlerKind::Static => stops.push(DraftFocus::Actions),
HandlerKind::Script => {
stops.push(DraftFocus::Language);
stops.push(DraftFocus::Code);
stops.push(DraftFocus::Resident);
}
HandlerKind::Llm => stops.push(DraftFocus::Instruction),
HandlerKind::Manual => stops.push(DraftFocus::Timeout),
}
for index in 0..self.buttons().len() {
stops.push(DraftFocus::Button(index));
}
stops
}
pub fn cycle_focus(&mut self, backward: bool) {
let stops = self.focus_stops();
let current = stops.iter().position(|s| *s == self.focus).unwrap_or(0);
let total = stops.len();
let next = if backward {
(current + total - 1) % total
} else {
(current + 1) % total
};
self.focus = stops[next];
}
pub fn set_kind(&mut self, kind: HandlerKind) {
self.kind = kind;
if !self.focus_stops().contains(&self.focus) {
self.focus = DraftFocus::Kind;
}
}
pub fn pattern_choices(event_ids: &[(String, String)]) -> Vec<String> {
let mut choices = vec!["*".to_string()];
choices.extend(event_ids.iter().map(|(id, _)| id.clone()));
choices
}
pub fn cycle_pattern(&mut self, event_ids: &[(String, String)], backward: bool) {
let choices = Self::pattern_choices(event_ids);
let current = choices.iter().position(|c| *c == self.pattern).unwrap_or(0);
let total = choices.len();
let next = if backward {
(current + total - 1) % total
} else {
(current + 1) % total
};
self.pattern = choices[next].clone();
}
pub fn cycle_language(&mut self, backward: bool) {
let current = SCRIPT_LANGUAGES
.iter()
.position(|l| *l == self.language)
.unwrap_or(0);
let total = SCRIPT_LANGUAGES.len();
let next = if backward {
(current + total - 1) % total
} else {
(current + 1) % total
};
self.language = SCRIPT_LANGUAGES[next].to_string();
}
pub fn new() -> Self {
Self {
pattern: "*".to_string(),
kind: HandlerKind::Static,
instruction: String::new(),
language: "python".to_string(),
code: String::new(),
resident: false,
actions: Vec::new(),
timeout_secs: crate::scripting::DEFAULT_MANUAL_TIMEOUT_SECS.to_string(),
focus: DraftFocus::Kind,
selected_action: 0,
editing: None,
error: None,
}
}
pub fn from_handler(handler: &EventHandler) -> Self {
let mut draft = Self::new();
draft.pattern = match &handler.event_pattern {
EventPattern::Specific(s) => s.clone(),
EventPattern::Wildcard => "*".to_string(),
};
match &handler.handler {
EventHandlerType::Llm { instruction } => {
draft.kind = HandlerKind::Llm;
draft.instruction = instruction.clone();
}
EventHandlerType::Script {
language,
code,
resident,
..
} => {
draft.kind = HandlerKind::Script;
draft.language = language.clone();
draft.code = code.clone();
draft.resident = *resident;
}
EventHandlerType::Static { actions } => {
draft.kind = HandlerKind::Static;
draft.actions = actions.clone();
}
EventHandlerType::Manual { timeout_secs } => {
draft.kind = HandlerKind::Manual;
draft.timeout_secs = timeout_secs.to_string();
}
}
draft
}
pub fn to_handler(&self) -> Result<EventHandler> {
let pattern = if self.pattern.trim().is_empty() || self.pattern.trim() == "*" {
EventPattern::wildcard()
} else {
EventPattern::from(self.pattern.trim())
};
let handler = match self.kind {
HandlerKind::Llm => {
if self.instruction.trim().is_empty() {
anyhow::bail!("an LLM handler needs an instruction");
}
EventHandlerType::llm(self.instruction.trim())
}
HandlerKind::Script => {
if self.code.trim().is_empty() {
anyhow::bail!("a script handler needs code");
}
EventHandlerType::Script {
language: self.language.clone(),
code: self.code.clone(),
resident: self.resident,
scope: None,
}
}
HandlerKind::Static => EventHandlerType::Static {
actions: self.actions.clone(),
},
HandlerKind::Manual => {
let timeout_secs = self
.timeout_secs
.trim()
.parse::<u64>()
.ok()
.filter(|t| *t > 0)
.ok_or_else(|| {
anyhow::anyhow!(
"the timeout must be a positive number of seconds (got '{}')",
self.timeout_secs
)
})?;
EventHandlerType::Manual { timeout_secs }
}
};
handler.validate()?;
Ok(EventHandler {
event_pattern: pattern,
handler,
})
}
}
impl Default for HandlerDraft {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutingFocus {
List,
Button(usize),
}
#[derive(Debug, Clone)]
pub struct RoutingModel {
pub key: UiKey,
pub protocol: String,
pub handlers: Vec<EventHandler>,
pub selected: usize,
pub event_ids: Vec<(String, String)>,
pub actions: Vec<ActionDefinition>,
pub draft: Option<Box<HandlerDraft>>,
draft_is_new: bool,
pub error: Option<String>,
pub dirty: bool,
pub busy: bool,
pub focus: RoutingFocus,
}
impl RoutingModel {
pub fn new(
key: UiKey,
protocol: &str,
routing: Option<&EventHandlerConfig>,
state: &AppState,
) -> Self {
let (event_ids, actions) = vocabulary(key, protocol, state);
Self {
key,
protocol: protocol.to_string(),
handlers: routing.map(|r| r.handlers.clone()).unwrap_or_default(),
selected: 0,
event_ids,
actions,
draft: None,
draft_is_new: false,
error: None,
dirty: false,
busy: false,
focus: RoutingFocus::List,
}
}
pub fn buttons(&self) -> Vec<crate::tui::hit::ModalAction> {
use crate::tui::hit::ModalAction::*;
let mut buttons = vec![RoutingAdd];
if !self.handlers.is_empty() {
buttons.push(RoutingEdit);
buttons.push(RoutingDelete);
if self.handlers.len() > 1 {
buttons.push(RoutingMoveUp);
buttons.push(RoutingMoveDown);
}
}
buttons.push(RoutingSave);
buttons.push(RoutingCancel);
buttons
}
pub fn cycle_focus(&mut self, backward: bool) {
let count = self.buttons().len();
let current = match self.focus {
RoutingFocus::List => 0,
RoutingFocus::Button(index) => index + 1,
};
let total = count + 1;
let next = if backward {
(current + total - 1) % total
} else {
(current + 1) % total
};
self.focus = if next == 0 {
RoutingFocus::List
} else {
RoutingFocus::Button(next - 1)
};
}
pub fn focused_button(&self) -> Option<crate::tui::hit::ModalAction> {
match self.focus {
RoutingFocus::Button(index) => self.buttons().get(index).copied(),
RoutingFocus::List => None,
}
}
pub fn move_selection(&mut self, delta: isize) {
if self.handlers.is_empty() {
return;
}
let len = self.handlers.len() as isize;
self.selected = ((self.selected as isize + delta).rem_euclid(len)) as usize;
}
pub fn add(&mut self) {
self.draft = Some(Box::new(HandlerDraft::new()));
self.draft_is_new = true;
}
pub fn edit_selected(&mut self) {
if let Some(handler) = self.handlers.get(self.selected) {
self.draft = Some(Box::new(HandlerDraft::from_handler(handler)));
self.draft_is_new = false;
}
}
pub fn delete_selected(&mut self) {
if self.selected < self.handlers.len() {
self.handlers.remove(self.selected);
if self.selected > 0 && self.selected >= self.handlers.len() {
self.selected -= 1;
}
self.dirty = true;
}
}
pub fn reorder(&mut self, delta: isize) {
if self.handlers.len() < 2 {
return;
}
let target = self.selected as isize + delta;
if target < 0 || target >= self.handlers.len() as isize {
return;
}
self.handlers.swap(self.selected, target as usize);
self.selected = target as usize;
self.dirty = true;
}
pub fn commit_draft(&mut self) -> Result<()> {
let Some(draft) = &self.draft else {
return Ok(());
};
let handler = draft.to_handler()?;
if self.draft_is_new || self.selected >= self.handlers.len() {
self.handlers.push(handler);
self.selected = self.handlers.len() - 1;
} else {
self.handlers[self.selected] = handler;
}
self.draft = None;
self.draft_is_new = false;
self.dirty = true;
Ok(())
}
pub async fn apply(
&self,
state: &AppState,
llm_client: crate::llm::OllamaClient,
status_tx: &mpsc::UnboundedSender<String>,
) -> Result<String> {
let handlers_json: Vec<serde_json::Value> = self
.handlers
.iter()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
match self.key {
UiKey::Server(id) => {
let form = ServerForm {
protocol: self.protocol.clone(),
event_handlers: Some(handlers_json),
..Default::default()
};
let outcome = management::update_server(state, id, form, status_tx.clone()).await?;
Ok(outcome.summary)
}
UiKey::Client(id) => {
let form = ClientForm {
protocol: self.protocol.clone(),
event_handlers: Some(handlers_json),
..Default::default()
};
let outcome =
management::update_client(state, id, form, llm_client, status_tx.clone())
.await?;
Ok(outcome.summary)
}
}
}
pub fn rows(&self) -> Vec<String> {
self.handlers
.iter()
.map(|h| {
let pattern = match &h.event_pattern {
EventPattern::Specific(s) => s.clone(),
EventPattern::Wildcard => "*".to_string(),
};
let body = match &h.handler {
EventHandlerType::Llm { instruction } => {
format!("LLM — {}", crate::utils::truncate_for_log(instruction, 50))
}
EventHandlerType::Script {
language, resident, ..
} => format!(
"SCRIPT ({language}{})",
if *resident { ", resident" } else { "" }
),
EventHandlerType::Static { actions } => {
let names: Vec<&str> = actions
.iter()
.filter_map(|a| a.get("type").and_then(|t| t.as_str()))
.collect();
format!("STATIC — {}", names.join(", "))
}
EventHandlerType::Manual { timeout_secs } => {
format!("MANUAL — you answer each one at the dashboard ({timeout_secs}s)")
}
};
format!("{pattern:<26} {body}")
})
.collect()
}
pub fn fallback_note(&self) -> &'static str {
"Anything not matched above goes to the LLM, using the instance instruction."
}
}
pub(crate) fn vocabulary(
key: UiKey,
protocol: &str,
state: &AppState,
) -> (Vec<(String, String)>, Vec<ActionDefinition>) {
match key {
UiKey::Server(_) => {
let Ok(impl_) = crate::protocol::server_registry::registry().resolve(protocol) else {
return (Vec::new(), Vec::new());
};
let events = impl_
.get_event_types()
.into_iter()
.map(|e| (e.id.clone(), e.description.clone()))
.collect();
let mut actions = impl_.get_sync_actions();
for action in impl_.get_async_actions(state) {
if !actions.iter().any(|a| a.name == action.name) {
actions.push(action);
}
}
(events, actions)
}
UiKey::Client(_) => {
let Some(impl_) = crate::protocol::CLIENT_REGISTRY.get(protocol) else {
return (Vec::new(), Vec::new());
};
let events = impl_
.get_event_types()
.into_iter()
.map(|e| (e.id.clone(), e.description.clone()))
.collect();
let mut actions = impl_.get_sync_actions();
for action in impl_.get_async_actions(state) {
if !actions.iter().any(|a| a.name == action.name) {
actions.push(action);
}
}
(events, actions)
}
}
}