Skip to main content

netget/tui/modal/
routing.rs

1//! Routing editor: the per-instance handler table that decides whether an
2//! event is answered by a static response, a script, or the LLM.
3//!
4//! Handlers match first-wins in declared order, so order is editable. Applying
5//! goes through `management::update_server` / `update_client`, which hot-swaps
6//! the config without restarting or dropping connections.
7
8use 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/// Handler kinds, in the order the segmented control shows them.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HandlerKind {
21    Static,
22    Script,
23    Llm,
24    Manual,
25}
26
27impl HandlerKind {
28    /// Segmented-control order.
29    pub const ALL: [HandlerKind; 4] = [
30        HandlerKind::Static,
31        HandlerKind::Script,
32        HandlerKind::Llm,
33        HandlerKind::Manual,
34    ];
35
36    /// The segment label.
37    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    /// One line under the control explaining the selected kind.
47    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/// One focus stop inside the handler editor. Tab visits every stop in order,
76/// so nothing in the editor is reachable only by shortcut.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum DraftFocus {
79    /// The segmented kind control (←/→ changes it).
80    Kind,
81    /// The event pattern (←/→ walks the protocol's events; Enter types one).
82    Pattern,
83    /// Script: the language (←/→ cycles).
84    Language,
85    /// Script: the code (Enter opens the editor).
86    Code,
87    /// Script: the resident toggle (Enter/Space flips).
88    Resident,
89    /// LLM: the per-event instruction (Enter opens the editor).
90    Instruction,
91    /// Static: the action list (Enter opens the JSON editor).
92    Actions,
93    /// Manual: seconds to wait for the operator (Enter edits).
94    Timeout,
95    /// One of the buttons at the bottom, by index into [`HandlerDraft::buttons`].
96    Button(usize),
97}
98
99/// Script languages the editor offers, in cycle order.
100pub const SCRIPT_LANGUAGES: [&str; 4] = ["python", "javascript", "perl", "go"];
101
102/// One handler being edited.
103#[derive(Debug, Clone)]
104pub struct HandlerDraft {
105    pub pattern: String,
106    pub kind: HandlerKind,
107    /// LLM handler instruction.
108    pub instruction: String,
109    pub language: String,
110    pub code: String,
111    pub resident: bool,
112    /// Static actions as JSON values.
113    pub actions: Vec<serde_json::Value>,
114    /// Manual handler: seconds to wait for the operator, as typed.
115    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    /// Buttons, in Tab order after the fields.
124    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    /// Every focus stop the current kind offers, in Tab order. The body stops
137    /// change with the kind, so switching kinds re-plans the tour rather than
138    /// leaving Tab landing on fields that no longer exist.
139    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    /// Tab through kind → pattern → the kind's own fields → Save → Cancel.
158    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    /// Change the kind, keeping focus valid for the new kind's fields.
171    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    /// The pattern choices ←/→ walks: `*` first, then the protocol's events.
179    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    /// Step the pattern through the choices (wrapping); free text that matches
186    /// no choice starts from `*`.
187    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    /// Step the script language through [`SCRIPT_LANGUAGES`].
200    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    /// Build the handler, validating it the same way the startup path does.
266    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            // An empty action list is deliberate and useful: it answers the
291            // event with nothing, deterministically, instead of falling
292            // through to the LLM. That is how you silence an event you do not
293            // want the model spending a call on.
294            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        // Catches malformed {{event.…}} references before anything is applied.
314        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/// What has keyboard focus in the routing editor.
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum RoutingFocus {
331    /// The handler list; Enter edits the selected handler.
332    List,
333    /// One of the buttons, by index into [`RoutingModel::buttons`].
334    Button(usize),
335}
336
337/// The routing editor's state for one instance.
338#[derive(Debug, Clone)]
339pub struct RoutingModel {
340    pub key: UiKey,
341    pub protocol: String,
342    pub handlers: Vec<EventHandler>,
343    pub selected: usize,
344    /// The event ids this protocol can raise, offered when choosing a pattern.
345    pub event_ids: Vec<(String, String)>,
346    /// Action vocabulary, for building static responses.
347    pub actions: Vec<ActionDefinition>,
348    pub draft: Option<Box<HandlerDraft>>,
349    /// True when the open draft is a new handler rather than an edit of the
350    /// selected one — decides whether committing appends or replaces.
351    draft_is_new: bool,
352    pub error: Option<String>,
353    pub dirty: bool,
354    /// An apply is in flight (spawned; see `crate::tui::uimsg`).
355    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    /// The buttons offered, in Tab order. Delete/Edit/Move only appear when
384    /// there is a handler to act on, so Tab never lands on a dead control.
385    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    /// Move focus forward (or back) through the list and the buttons.
402    pub fn cycle_focus(&mut self, backward: bool) {
403        let count = self.buttons().len();
404        // Focus order: the list, then each button.
405        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    /// Move the selected handler earlier/later — order is match priority.
460    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    /// Commit the open draft into the handler list.
474    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    /// Apply the handler table to the running instance (a hot update).
492    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    /// Display rows: one per configured handler.
528    ///
529    /// The implicit "anything else goes to the LLM" fallback is deliberately
530    /// NOT here. It is not a handler — there is nothing to select, reorder or
531    /// delete — and listing it alongside real ones invited exactly that.
532    /// `fallback_note` states it instead.
533    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    /// The always-present fallback, stated as prose rather than a fake row.
568    pub fn fallback_note(&self) -> &'static str {
569        "Anything not matched above goes to the LLM, using the instance instruction."
570    }
571}
572
573/// The event ids and action definitions of an instance's protocol.
574pub(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}