1use anyhow::Result;
9use tokio::sync::mpsc;
10
11use crate::cli::management::{self, ClientForm, ServerForm};
12use crate::llm::actions::ActionDefinition;
13use crate::scripting::event_handler::{EventHandler, EventPattern};
14use crate::scripting::{EventHandlerConfig, EventHandlerType};
15use crate::state::app_state::AppState;
16use crate::tui::app::UiKey;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HandlerKind {
21 Static,
22 Script,
23 Llm,
24 Manual,
25}
26
27impl HandlerKind {
28 pub const ALL: [HandlerKind; 4] = [
30 HandlerKind::Static,
31 HandlerKind::Script,
32 HandlerKind::Llm,
33 HandlerKind::Manual,
34 ];
35
36 pub fn label(&self) -> &'static str {
38 match self {
39 HandlerKind::Static => "STATIC",
40 HandlerKind::Script => "SCRIPT",
41 HandlerKind::Llm => "LLM",
42 HandlerKind::Manual => "MANUAL",
43 }
44 }
45
46 pub fn blurb(&self) -> &'static str {
48 match self {
49 HandlerKind::Static => {
50 "Fixed actions, sent as-is. Cheapest and fully deterministic; \
51 {{event.field}} echoes values from the event."
52 }
53 HandlerKind::Script => {
54 "Your code decides. Runs an interpreter per event (or resident), no model call."
55 }
56 HandlerKind::Llm => "The model decides, using an instruction written for this event.",
57 HandlerKind::Manual => {
58 "YOU decide. Each matched event waits at the dashboard for you to compose \
59 the answer; no answer within the timeout fails closed."
60 }
61 }
62 }
63
64 pub fn next(&self) -> Self {
65 let index = Self::ALL.iter().position(|k| k == self).unwrap_or(0);
66 Self::ALL[(index + 1) % Self::ALL.len()]
67 }
68
69 pub fn previous(&self) -> Self {
70 let index = Self::ALL.iter().position(|k| k == self).unwrap_or(0);
71 Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()]
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum DraftFocus {
79 Kind,
81 Pattern,
83 Language,
85 Code,
87 Resident,
89 Instruction,
91 Actions,
93 Timeout,
95 Button(usize),
97}
98
99pub const SCRIPT_LANGUAGES: [&str; 4] = ["python", "javascript", "perl", "go"];
101
102#[derive(Debug, Clone)]
104pub struct HandlerDraft {
105 pub pattern: String,
106 pub kind: HandlerKind,
107 pub instruction: String,
109 pub language: String,
110 pub code: String,
111 pub resident: bool,
112 pub actions: Vec<serde_json::Value>,
114 pub timeout_secs: String,
116 pub focus: DraftFocus,
117 pub selected_action: usize,
118 pub editing: Option<String>,
119 pub error: Option<String>,
120}
121
122impl HandlerDraft {
123 pub fn buttons(&self) -> Vec<crate::tui::hit::ModalAction> {
125 use crate::tui::hit::ModalAction::*;
126 vec![DraftSave, DraftCancel]
127 }
128
129 pub fn focused_action(&self) -> Option<crate::tui::hit::ModalAction> {
130 match self.focus {
131 DraftFocus::Button(index) => self.buttons().get(index).copied(),
132 _ => None,
133 }
134 }
135
136 pub fn focus_stops(&self) -> Vec<DraftFocus> {
140 let mut stops = vec![DraftFocus::Kind, DraftFocus::Pattern];
141 match self.kind {
142 HandlerKind::Static => stops.push(DraftFocus::Actions),
143 HandlerKind::Script => {
144 stops.push(DraftFocus::Language);
145 stops.push(DraftFocus::Code);
146 stops.push(DraftFocus::Resident);
147 }
148 HandlerKind::Llm => stops.push(DraftFocus::Instruction),
149 HandlerKind::Manual => stops.push(DraftFocus::Timeout),
150 }
151 for index in 0..self.buttons().len() {
152 stops.push(DraftFocus::Button(index));
153 }
154 stops
155 }
156
157 pub fn cycle_focus(&mut self, backward: bool) {
159 let stops = self.focus_stops();
160 let current = stops.iter().position(|s| *s == self.focus).unwrap_or(0);
161 let total = stops.len();
162 let next = if backward {
163 (current + total - 1) % total
164 } else {
165 (current + 1) % total
166 };
167 self.focus = stops[next];
168 }
169
170 pub fn set_kind(&mut self, kind: HandlerKind) {
172 self.kind = kind;
173 if !self.focus_stops().contains(&self.focus) {
174 self.focus = DraftFocus::Kind;
175 }
176 }
177
178 pub fn pattern_choices(event_ids: &[(String, String)]) -> Vec<String> {
180 let mut choices = vec!["*".to_string()];
181 choices.extend(event_ids.iter().map(|(id, _)| id.clone()));
182 choices
183 }
184
185 pub fn cycle_pattern(&mut self, event_ids: &[(String, String)], backward: bool) {
188 let choices = Self::pattern_choices(event_ids);
189 let current = choices.iter().position(|c| *c == self.pattern).unwrap_or(0);
190 let total = choices.len();
191 let next = if backward {
192 (current + total - 1) % total
193 } else {
194 (current + 1) % total
195 };
196 self.pattern = choices[next].clone();
197 }
198
199 pub fn cycle_language(&mut self, backward: bool) {
201 let current = SCRIPT_LANGUAGES
202 .iter()
203 .position(|l| *l == self.language)
204 .unwrap_or(0);
205 let total = SCRIPT_LANGUAGES.len();
206 let next = if backward {
207 (current + total - 1) % total
208 } else {
209 (current + 1) % total
210 };
211 self.language = SCRIPT_LANGUAGES[next].to_string();
212 }
213
214 pub fn new() -> Self {
215 Self {
216 pattern: "*".to_string(),
217 kind: HandlerKind::Static,
218 instruction: String::new(),
219 language: "python".to_string(),
220 code: String::new(),
221 resident: false,
222 actions: Vec::new(),
223 timeout_secs: crate::scripting::DEFAULT_MANUAL_TIMEOUT_SECS.to_string(),
224 focus: DraftFocus::Kind,
225 selected_action: 0,
226 editing: None,
227 error: None,
228 }
229 }
230
231 pub fn from_handler(handler: &EventHandler) -> Self {
232 let mut draft = Self::new();
233 draft.pattern = match &handler.event_pattern {
234 EventPattern::Specific(s) => s.clone(),
235 EventPattern::Wildcard => "*".to_string(),
236 };
237 match &handler.handler {
238 EventHandlerType::Llm { instruction } => {
239 draft.kind = HandlerKind::Llm;
240 draft.instruction = instruction.clone();
241 }
242 EventHandlerType::Script {
243 language,
244 code,
245 resident,
246 ..
247 } => {
248 draft.kind = HandlerKind::Script;
249 draft.language = language.clone();
250 draft.code = code.clone();
251 draft.resident = *resident;
252 }
253 EventHandlerType::Static { actions } => {
254 draft.kind = HandlerKind::Static;
255 draft.actions = actions.clone();
256 }
257 EventHandlerType::Manual { timeout_secs } => {
258 draft.kind = HandlerKind::Manual;
259 draft.timeout_secs = timeout_secs.to_string();
260 }
261 }
262 draft
263 }
264
265 pub fn to_handler(&self) -> Result<EventHandler> {
267 let pattern = if self.pattern.trim().is_empty() || self.pattern.trim() == "*" {
268 EventPattern::wildcard()
269 } else {
270 EventPattern::from(self.pattern.trim())
271 };
272 let handler = match self.kind {
273 HandlerKind::Llm => {
274 if self.instruction.trim().is_empty() {
275 anyhow::bail!("an LLM handler needs an instruction");
276 }
277 EventHandlerType::llm(self.instruction.trim())
278 }
279 HandlerKind::Script => {
280 if self.code.trim().is_empty() {
281 anyhow::bail!("a script handler needs code");
282 }
283 EventHandlerType::Script {
284 language: self.language.clone(),
285 code: self.code.clone(),
286 resident: self.resident,
287 scope: None,
288 }
289 }
290 HandlerKind::Static => EventHandlerType::Static {
295 actions: self.actions.clone(),
296 },
297 HandlerKind::Manual => {
298 let timeout_secs = self
299 .timeout_secs
300 .trim()
301 .parse::<u64>()
302 .ok()
303 .filter(|t| *t > 0)
304 .ok_or_else(|| {
305 anyhow::anyhow!(
306 "the timeout must be a positive number of seconds (got '{}')",
307 self.timeout_secs
308 )
309 })?;
310 EventHandlerType::Manual { timeout_secs }
311 }
312 };
313 handler.validate()?;
315 Ok(EventHandler {
316 event_pattern: pattern,
317 handler,
318 })
319 }
320}
321
322impl Default for HandlerDraft {
323 fn default() -> Self {
324 Self::new()
325 }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum RoutingFocus {
331 List,
333 Button(usize),
335}
336
337#[derive(Debug, Clone)]
339pub struct RoutingModel {
340 pub key: UiKey,
341 pub protocol: String,
342 pub handlers: Vec<EventHandler>,
343 pub selected: usize,
344 pub event_ids: Vec<(String, String)>,
346 pub actions: Vec<ActionDefinition>,
348 pub draft: Option<Box<HandlerDraft>>,
349 draft_is_new: bool,
352 pub error: Option<String>,
353 pub dirty: bool,
354 pub busy: bool,
356 pub focus: RoutingFocus,
357}
358
359impl RoutingModel {
360 pub fn new(
361 key: UiKey,
362 protocol: &str,
363 routing: Option<&EventHandlerConfig>,
364 state: &AppState,
365 ) -> Self {
366 let (event_ids, actions) = vocabulary(key, protocol, state);
367 Self {
368 key,
369 protocol: protocol.to_string(),
370 handlers: routing.map(|r| r.handlers.clone()).unwrap_or_default(),
371 selected: 0,
372 event_ids,
373 actions,
374 draft: None,
375 draft_is_new: false,
376 error: None,
377 dirty: false,
378 busy: false,
379 focus: RoutingFocus::List,
380 }
381 }
382
383 pub fn buttons(&self) -> Vec<crate::tui::hit::ModalAction> {
386 use crate::tui::hit::ModalAction::*;
387 let mut buttons = vec![RoutingAdd];
388 if !self.handlers.is_empty() {
389 buttons.push(RoutingEdit);
390 buttons.push(RoutingDelete);
391 if self.handlers.len() > 1 {
392 buttons.push(RoutingMoveUp);
393 buttons.push(RoutingMoveDown);
394 }
395 }
396 buttons.push(RoutingSave);
397 buttons.push(RoutingCancel);
398 buttons
399 }
400
401 pub fn cycle_focus(&mut self, backward: bool) {
403 let count = self.buttons().len();
404 let current = match self.focus {
406 RoutingFocus::List => 0,
407 RoutingFocus::Button(index) => index + 1,
408 };
409 let total = count + 1;
410 let next = if backward {
411 (current + total - 1) % total
412 } else {
413 (current + 1) % total
414 };
415 self.focus = if next == 0 {
416 RoutingFocus::List
417 } else {
418 RoutingFocus::Button(next - 1)
419 };
420 }
421
422 pub fn focused_button(&self) -> Option<crate::tui::hit::ModalAction> {
423 match self.focus {
424 RoutingFocus::Button(index) => self.buttons().get(index).copied(),
425 RoutingFocus::List => None,
426 }
427 }
428
429 pub fn move_selection(&mut self, delta: isize) {
430 if self.handlers.is_empty() {
431 return;
432 }
433 let len = self.handlers.len() as isize;
434 self.selected = ((self.selected as isize + delta).rem_euclid(len)) as usize;
435 }
436
437 pub fn add(&mut self) {
438 self.draft = Some(Box::new(HandlerDraft::new()));
439 self.draft_is_new = true;
440 }
441
442 pub fn edit_selected(&mut self) {
443 if let Some(handler) = self.handlers.get(self.selected) {
444 self.draft = Some(Box::new(HandlerDraft::from_handler(handler)));
445 self.draft_is_new = false;
446 }
447 }
448
449 pub fn delete_selected(&mut self) {
450 if self.selected < self.handlers.len() {
451 self.handlers.remove(self.selected);
452 if self.selected > 0 && self.selected >= self.handlers.len() {
453 self.selected -= 1;
454 }
455 self.dirty = true;
456 }
457 }
458
459 pub fn reorder(&mut self, delta: isize) {
461 if self.handlers.len() < 2 {
462 return;
463 }
464 let target = self.selected as isize + delta;
465 if target < 0 || target >= self.handlers.len() as isize {
466 return;
467 }
468 self.handlers.swap(self.selected, target as usize);
469 self.selected = target as usize;
470 self.dirty = true;
471 }
472
473 pub fn commit_draft(&mut self) -> Result<()> {
475 let Some(draft) = &self.draft else {
476 return Ok(());
477 };
478 let handler = draft.to_handler()?;
479 if self.draft_is_new || self.selected >= self.handlers.len() {
480 self.handlers.push(handler);
481 self.selected = self.handlers.len() - 1;
482 } else {
483 self.handlers[self.selected] = handler;
484 }
485 self.draft = None;
486 self.draft_is_new = false;
487 self.dirty = true;
488 Ok(())
489 }
490
491 pub async fn apply(
493 &self,
494 state: &AppState,
495 llm_client: crate::llm::OllamaClient,
496 status_tx: &mpsc::UnboundedSender<String>,
497 ) -> Result<String> {
498 let handlers_json: Vec<serde_json::Value> = self
499 .handlers
500 .iter()
501 .map(serde_json::to_value)
502 .collect::<Result<_, _>>()?;
503 match self.key {
504 UiKey::Server(id) => {
505 let form = ServerForm {
506 protocol: self.protocol.clone(),
507 event_handlers: Some(handlers_json),
508 ..Default::default()
509 };
510 let outcome = management::update_server(state, id, form, status_tx.clone()).await?;
511 Ok(outcome.summary)
512 }
513 UiKey::Client(id) => {
514 let form = ClientForm {
515 protocol: self.protocol.clone(),
516 event_handlers: Some(handlers_json),
517 ..Default::default()
518 };
519 let outcome =
520 management::update_client(state, id, form, llm_client, status_tx.clone())
521 .await?;
522 Ok(outcome.summary)
523 }
524 }
525 }
526
527 pub fn rows(&self) -> Vec<String> {
534 self.handlers
535 .iter()
536 .map(|h| {
537 let pattern = match &h.event_pattern {
538 EventPattern::Specific(s) => s.clone(),
539 EventPattern::Wildcard => "*".to_string(),
540 };
541 let body = match &h.handler {
542 EventHandlerType::Llm { instruction } => {
543 format!("LLM — {}", crate::utils::truncate_for_log(instruction, 50))
544 }
545 EventHandlerType::Script {
546 language, resident, ..
547 } => format!(
548 "SCRIPT ({language}{})",
549 if *resident { ", resident" } else { "" }
550 ),
551 EventHandlerType::Static { actions } => {
552 let names: Vec<&str> = actions
553 .iter()
554 .filter_map(|a| a.get("type").and_then(|t| t.as_str()))
555 .collect();
556 format!("STATIC — {}", names.join(", "))
557 }
558 EventHandlerType::Manual { timeout_secs } => {
559 format!("MANUAL — you answer each one at the dashboard ({timeout_secs}s)")
560 }
561 };
562 format!("{pattern:<26} {body}")
563 })
564 .collect()
565 }
566
567 pub fn fallback_note(&self) -> &'static str {
569 "Anything not matched above goes to the LLM, using the instance instruction."
570 }
571}
572
573pub(crate) fn vocabulary(
575 key: UiKey,
576 protocol: &str,
577 state: &AppState,
578) -> (Vec<(String, String)>, Vec<ActionDefinition>) {
579 match key {
580 UiKey::Server(_) => {
581 let Ok(impl_) = crate::protocol::server_registry::registry().resolve(protocol) else {
582 return (Vec::new(), Vec::new());
583 };
584 let events = impl_
585 .get_event_types()
586 .into_iter()
587 .map(|e| (e.id.clone(), e.description.clone()))
588 .collect();
589 let mut actions = impl_.get_sync_actions();
590 for action in impl_.get_async_actions(state) {
591 if !actions.iter().any(|a| a.name == action.name) {
592 actions.push(action);
593 }
594 }
595 (events, actions)
596 }
597 UiKey::Client(_) => {
598 let Some(impl_) = crate::protocol::CLIENT_REGISTRY.get(protocol) else {
599 return (Vec::new(), Vec::new());
600 };
601 let events = impl_
602 .get_event_types()
603 .into_iter()
604 .map(|e| (e.id.clone(), e.description.clone()))
605 .collect();
606 let mut actions = impl_.get_sync_actions();
607 for action in impl_.get_async_actions(state) {
608 if !actions.iter().any(|a| a.name == action.name) {
609 actions.push(action);
610 }
611 }
612 (events, actions)
613 }
614 }
615}