Skip to main content

netget/tui/modal/
form.rs

1//! Generic field form behind `[+ add]` and `e` (edit).
2//!
3//! Fields are derived from `ServerForm` / `ClientForm` plus the protocol's own
4//! declared startup parameters (`management::server_declared_params` /
5//! `client_declared_params`). A parameter's declared `default` is pre-filled as
6//! its value and left out of the submission unless edited; a parameter without
7//! one shows its `example` as a dim `e.g.` placeholder.
8//!
9//! Applying routes through the same `management` APIs the LLM and MCP use, so
10//! validation, the hot-apply/restart split, and validation-before-mutation all
11//! behave identically to every other path.
12
13use anyhow::Result;
14use tokio::sync::mpsc;
15
16use crate::cli::management::{self, ClientForm, ServerForm};
17use crate::llm::actions::ParameterDefinition;
18use crate::state::app_state::AppState;
19use crate::tui::app::{Section, UiKey};
20
21/// What a field edits.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum FieldTarget {
24    Port,
25    Host,
26    Interface,
27    MacAddress,
28    RemoteAddr,
29    SendFirst,
30    Instruction,
31    InitialMemory,
32    FeedbackInstructions,
33    /// A protocol-declared startup parameter, by name.
34    StartupParam(String),
35    /// Raw JSON of the whole event-handler list (the routing editor is the
36    /// friendlier path; this is the escape hatch).
37    EventHandlersJson,
38    /// The routing draft's script code (text-editor return path).
39    DraftCode,
40    /// The routing draft's LLM instruction (text-editor return path).
41    DraftInstruction,
42    /// The routing draft's static action list, as a JSON array.
43    DraftActions,
44}
45
46#[derive(Debug, Clone)]
47pub struct Field {
48    pub target: FieldTarget,
49    pub label: String,
50    pub value: String,
51    /// The value this field was opened with. `update_server`/`update_client`
52    /// take a **partial** form — supplying an unchanged binding field would
53    /// read as a change and force a needless restart (new id, dropped
54    /// connections), so unchanged fields are omitted on apply.
55    pub original: String,
56    /// Shown dimmed when the value is empty.
57    pub placeholder: String,
58    /// The protocol's declared default for a startup parameter, as the text the field holds.
59    /// A field is pre-filled with it and `original` is set to match, so leaving it alone
60    /// submits nothing and the protocol's own fallback applies — the value shown *is* that
61    /// fallback, because the declaration points at the constant the code uses.
62    pub default: Option<String>,
63    pub help: String,
64    pub required: bool,
65    /// Multi-line fields open the text editor rather than editing inline.
66    pub multiline: bool,
67}
68
69impl Field {
70    fn simple(target: FieldTarget, label: &str, help: &str) -> Self {
71        Self {
72            target,
73            label: label.to_string(),
74            value: String::new(),
75            original: String::new(),
76            placeholder: String::new(),
77            default: None,
78            help: help.to_string(),
79            required: false,
80            multiline: false,
81        }
82    }
83
84    pub fn changed(&self) -> bool {
85        self.value.trim() != self.original.trim()
86    }
87
88    /// The field holds the protocol's declared default, untouched.
89    pub fn is_default(&self) -> bool {
90        self.default
91            .as_deref()
92            .is_some_and(|d| d.trim() == self.value.trim())
93    }
94}
95
96/// Whether the form creates a new instance or edits an existing one.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum FormMode {
99    Create(Section),
100    Edit(UiKey),
101}
102
103/// The editable form state held by the modal.
104#[derive(Debug, Clone)]
105pub struct FormModel {
106    pub mode: FormMode,
107    pub protocol: String,
108    pub fields: Vec<Field>,
109    pub selected: usize,
110    /// Inline edit buffer for the selected single-line field; `None` when not
111    /// editing.
112    pub editing: Option<String>,
113    pub error: Option<String>,
114    /// An apply is in flight. The work is spawned (it does network I/O), so
115    /// the form stays on screen, refuses a second submit, and says so.
116    pub busy: bool,
117    /// `Some(i)` when focus is on the i-th button rather than a field.
118    pub focused_button: Option<usize>,
119}
120
121impl FormModel {
122    /// Build a create-form for a freshly picked protocol.
123    pub fn for_create(section: Section, protocol: &str, default_port: Option<u16>) -> Self {
124        let mut fields = Vec::new();
125        match section {
126            Section::Servers => {
127                let mut port = Field::simple(
128                    FieldTarget::Port,
129                    "port",
130                    "TCP/UDP port to bind. 0 asks the OS for a free one.",
131                );
132                // A protocol with no declared default still has a good one:
133                // port 0 asks the OS for a free port. That keeps "pick a
134                // protocol and go" working for TCP and friends, and the port
135                // remains editable afterwards like everything else.
136                port.value = default_port
137                    .map(|p| p.to_string())
138                    .unwrap_or_else(|| "0".to_string());
139                port.placeholder = if default_port.is_none() {
140                    "0 = let the OS choose a free port".to_string()
141                } else {
142                    String::new()
143                };
144                fields.push(port);
145                fields.push(Field::simple(
146                    FieldTarget::Host,
147                    "host",
148                    "Address to bind. Defaults to the protocol's own default.",
149                ));
150                fields.push(Field::simple(
151                    FieldTarget::Interface,
152                    "interface",
153                    "Network interface, for layer-2/raw protocols (arp, datalink…).",
154                ));
155                fields.push(Field::simple(
156                    FieldTarget::MacAddress,
157                    "mac_address",
158                    "Source MAC, only meaningful together with an interface.",
159                ));
160                let mut send_first = Field::simple(
161                    FieldTarget::SendFirst,
162                    "send_first",
163                    "Speak first on connect, before the peer sends anything. Honoured by the \
164                     protocols that declare a `send_first` startup parameter (tcp, telnet, \
165                     redis, ldap, mysql, postgresql, …); any other protocol ignores it with a \
166                     warning.",
167                );
168                send_first.value = "false".to_string();
169                fields.push(send_first);
170            }
171            Section::Clients => {
172                let mut remote = Field::simple(
173                    FieldTarget::RemoteAddr,
174                    "remote_addr",
175                    "Server to connect to, host:port.",
176                );
177                remote.required = true;
178                remote.placeholder = "required — e.g. 127.0.0.1:2323".to_string();
179                fields.push(remote);
180            }
181        }
182        let mut model = Self::finish(
183            FormMode::Create(section),
184            protocol,
185            fields,
186            declared_params(section, protocol),
187        );
188        // An instance created interactively defaults to MANUAL routing: every
189        // event parks at the dashboard for YOU to answer. That is the whole
190        // point of creating one by hand — you are here, driving. The rules are
191        // ordinary handlers, so the routing editor can retarget them to
192        // static/script/LLM or delete them outright; instances the model
193        // creates through its own tools get no such default and keep LLM
194        // behavior.
195        //
196        // Clients get one extra rule ahead of the wildcard: their connect
197        // event(s) are answered with a zero-action static handler ("answer
198        // with nothing"). Without it, `<proto>_connected` parked like any
199        // other event — and several clients handle that event inline in
200        // `connect()` before returning, so creating a client through the
201        // dashboard stalled on a question ("you connected — say anything?")
202        // that nobody needed answered before the loop could even start.
203        // Matching is first-match-wins on exact event ids (`EventPattern`
204        // knows only literal ids plus the `*`/`all` wildcard — there is no
205        // globbing), so the ids are enumerated from the protocol's own event
206        // declarations rather than written as a `*_connected` pattern.
207        model.set_field_value(
208            &FieldTarget::EventHandlersJson,
209            default_event_handlers(section, protocol).to_string(),
210        );
211        model
212    }
213
214    /// Say on the port field where its pre-filled value came from: the well-known port, or
215    /// why it is not the well-known port ("well-known port 53 needs root; …").
216    pub fn note_default_port(&mut self, default: &crate::protocol::default_port::DefaultPort) {
217        if let Some(port) = self
218            .fields
219            .iter_mut()
220            .find(|f| f.target == FieldTarget::Port)
221        {
222            port.help = format!(
223                "TCP/UDP port to bind. 0 asks the OS for a free one. Default: {}.",
224                default.describe()
225            );
226        }
227    }
228
229    /// Build an edit-form pre-filled from a live instance.
230    pub fn for_edit_server(row: &crate::tui::projection::ServerRow) -> Self {
231        let mut fields = Vec::new();
232        let mut port = Field::simple(
233            FieldTarget::Port,
234            "port",
235            "Changing this RESTARTS the server: it gets a new id and drops connections.",
236        );
237        // The port it is actually bound to, not the one that was requested. They differ
238        // exactly when the request was `port: 0`, and this field sat next to a `host` field
239        // that already read `local_addr` — so the form showed the real host beside a literal
240        // `0`. An edit form is a snapshot the operator changes in place: showing `0` invites
241        // submitting `0`, which is a request for *a different* random port, and this field's
242        // own description says changing it drops every connection.
243        port.value = row
244            .local_addr
245            .as_ref()
246            .and_then(|a| a.rsplit_once(':'))
247            .and_then(|(_, p)| p.parse::<u16>().ok())
248            .filter(|p| *p != 0)
249            .unwrap_or(row.port)
250            .to_string();
251        // `original` matches, so an untouched field is still omitted from the update and
252        // still does not restart the server.
253        port.original = port.value.clone();
254        fields.push(port);
255        let mut host = Field::simple(
256            FieldTarget::Host,
257            "host",
258            "Changing this RESTARTS the server.",
259        );
260        host.value = row
261            .local_addr
262            .as_ref()
263            .and_then(|a| a.rsplit_once(':').map(|(h, _)| h.to_string()))
264            .unwrap_or_default();
265        host.original = host.value.clone();
266        fields.push(host);
267        Self::finish_edit(
268            UiKey::Server(row.id),
269            &row.protocol,
270            fields,
271            declared_params(Section::Servers, &row.protocol),
272            &row.startup_params,
273            &row.instruction,
274            row.routing.as_ref(),
275        )
276    }
277
278    pub fn for_edit_client(row: &crate::tui::projection::ClientRow) -> Self {
279        let mut fields = Vec::new();
280        let mut remote = Field::simple(
281            FieldTarget::RemoteAddr,
282            "remote_addr",
283            "Changing this RECONNECTS the client: it gets a new id.",
284        );
285        remote.value = row.remote_addr.clone();
286        remote.original = remote.value.clone();
287        fields.push(remote);
288        Self::finish_edit(
289            UiKey::Client(row.id),
290            &row.protocol,
291            fields,
292            declared_params(Section::Clients, &row.protocol),
293            &row.startup_params,
294            &row.instruction,
295            row.routing.as_ref(),
296        )
297    }
298
299    fn finish(
300        mode: FormMode,
301        protocol: &str,
302        mut fields: Vec<Field>,
303        params: Vec<ParameterDefinition>,
304    ) -> Self {
305        push_param_fields(&mut fields, &params, None);
306        push_text_fields(&mut fields, "", None);
307        Self {
308            mode,
309            protocol: protocol.to_string(),
310            fields,
311            selected: 0,
312            editing: None,
313            error: None,
314            busy: false,
315            focused_button: None,
316        }
317    }
318
319    #[allow(clippy::too_many_arguments)]
320    fn finish_edit(
321        key: UiKey,
322        protocol: &str,
323        mut fields: Vec<Field>,
324        params: Vec<ParameterDefinition>,
325        startup_params: &Option<serde_json::Value>,
326        instruction: &str,
327        routing: Option<&crate::scripting::EventHandlerConfig>,
328    ) -> Self {
329        push_param_fields(&mut fields, &params, startup_params.as_ref());
330        push_text_fields(&mut fields, instruction, routing);
331        Self {
332            mode: FormMode::Edit(key),
333            protocol: protocol.to_string(),
334            fields,
335            selected: 0,
336            editing: None,
337            error: None,
338            busy: false,
339            focused_button: None,
340        }
341    }
342
343    /// Buttons, in Tab order after the fields.
344    pub fn buttons(&self) -> Vec<crate::tui::hit::ModalAction> {
345        use crate::tui::hit::ModalAction::*;
346        vec![FormApply, FormWireshark, FormCancel]
347    }
348
349    /// What Wireshark would need to watch the instance this form describes,
350    /// read from the fields **as they currently are** (an in-progress edit
351    /// included) — not from the changed-only view `apply` uses, because the
352    /// capture needs the whole address whether or not it changed.
353    pub fn capture_target(&self) -> crate::tui::wireshark::CaptureTarget {
354        use crate::tui::wireshark::{CaptureTarget, Role};
355        let current = |target: &FieldTarget| -> Option<String> {
356            let field = self.fields.iter().find(|f| &f.target == target)?;
357            let value = match (&self.editing, self.selected == self.position_of(target)) {
358                (Some(buffer), true) => buffer.trim().to_string(),
359                _ => field.value.trim().to_string(),
360            };
361            (!value.is_empty()).then_some(value)
362        };
363        let section = match &self.mode {
364            FormMode::Create(section) => *section,
365            FormMode::Edit(UiKey::Server(_)) => Section::Servers,
366            FormMode::Edit(UiKey::Client(_)) => Section::Clients,
367        };
368        match section {
369            Section::Servers => CaptureTarget {
370                protocol: self.protocol.clone(),
371                role: Role::Server,
372                host: current(&FieldTarget::Host),
373                port: current(&FieldTarget::Port).and_then(|p| p.parse().ok()),
374                interface: current(&FieldTarget::Interface),
375            },
376            Section::Clients => {
377                CaptureTarget::client(&self.protocol, current(&FieldTarget::RemoteAddr).as_deref())
378            }
379        }
380    }
381
382    fn position_of(&self, target: &FieldTarget) -> usize {
383        self.fields
384            .iter()
385            .position(|f| &f.target == target)
386            .unwrap_or(usize::MAX)
387    }
388
389    pub fn focused_action(&self) -> Option<crate::tui::hit::ModalAction> {
390        self.focused_button
391            .and_then(|index| self.buttons().get(index).copied())
392    }
393
394    /// Tab through fields, then buttons, then back to the first field.
395    pub fn cycle_focus(&mut self, backward: bool) {
396        let fields = self.fields.len();
397        let buttons = self.buttons().len();
398        let total = fields + buttons;
399        if total == 0 {
400            return;
401        }
402        let current = match self.focused_button {
403            None => self.selected.min(fields.saturating_sub(1)),
404            Some(index) => fields + index,
405        };
406        let next = if backward {
407            (current + total - 1) % total
408        } else {
409            (current + 1) % total
410        };
411        if next < fields {
412            self.selected = next;
413            self.focused_button = None;
414        } else {
415            self.focused_button = Some(next - fields);
416        }
417    }
418
419    pub fn selected_field(&self) -> Option<&Field> {
420        self.fields.get(self.selected)
421    }
422
423    /// The first required value, including protocols with alternative inputs.
424    fn missing_required_index(&self) -> Option<usize> {
425        if let Some(index) = self
426            .fields
427            .iter()
428            .position(|f| f.required && f.value.trim().is_empty())
429        {
430            return Some(index);
431        }
432        // OpenAPI's two spec inputs are individually optional, but a new client needs
433        // one of them. The server accepts inline spec only. Existing instances already
434        // have their spec, so partial edits must not demand it again.
435        if matches!(self.mode, FormMode::Create(_))
436            && self.protocol.eq_ignore_ascii_case("openapi")
437            && !self.fields.iter().any(|field| {
438                matches!(&field.target, FieldTarget::StartupParam(name)
439                    if name == "spec" || name == "spec_file")
440                    && !field.value.trim().is_empty()
441            })
442        {
443            return self
444                .fields
445                .iter()
446                .position(|field| field.target == FieldTarget::StartupParam("spec".into()));
447        }
448        None
449    }
450
451    /// When this is `None`, the instance can start without asking for more input.
452    pub fn missing_required(&self) -> Option<String> {
453        let index = self.missing_required_index()?;
454        let field = &self.fields[index];
455        if field.label == "spec"
456            && self.protocol.eq_ignore_ascii_case("openapi")
457            && self
458                .fields
459                .iter()
460                .any(|field| field.target == FieldTarget::StartupParam("spec_file".into()))
461        {
462            Some("spec or spec_file".to_string())
463        } else {
464            Some(field.label.clone())
465        }
466    }
467
468    /// Put the cursor on the missing value (the inline spec for alternative spec inputs).
469    pub fn focus_first_missing_required(&mut self) {
470        if let Some(index) = self.missing_required_index() {
471            self.selected = index;
472            self.focused_button = None;
473        }
474    }
475
476    pub fn move_selection(&mut self, delta: isize) {
477        if self.fields.is_empty() {
478            return;
479        }
480        let len = self.fields.len() as isize;
481        let next = (self.selected as isize + delta).rem_euclid(len);
482        self.selected = next as usize;
483    }
484
485    /// Begin editing the selected field inline (single-line fields only).
486    pub fn begin_edit(&mut self) {
487        if let Some(field) = self.fields.get(self.selected) {
488            if !field.multiline {
489                self.editing = Some(field.value.clone());
490            }
491        }
492    }
493
494    pub fn commit_edit(&mut self) {
495        if let Some(buffer) = self.editing.take() {
496            if let Some(field) = self.fields.get_mut(self.selected) {
497                field.value = buffer;
498            }
499        }
500    }
501
502    pub fn cancel_edit(&mut self) {
503        self.editing = None;
504    }
505
506    pub fn set_field_value(&mut self, target: &FieldTarget, value: String) {
507        if let Some(field) = self.fields.iter_mut().find(|f| &f.target == target) {
508            field.value = value;
509        }
510    }
511
512    /// The value to submit for a field: its text when non-empty, and — when
513    /// editing — only if the user actually changed it. On create, every
514    /// non-empty field is submitted.
515    fn value_of(&self, target: &FieldTarget) -> Option<String> {
516        let field = self.fields.iter().find(|f| &f.target == target)?;
517        let editing = matches!(self.mode, FormMode::Edit(_));
518        if editing && !field.changed() {
519            return None;
520        }
521        let value = field.value.trim().to_string();
522        (!value.is_empty()).then_some(value)
523    }
524
525    /// Assemble the startup-params object from the per-parameter fields.
526    ///
527    /// An untouched parameter is omitted, in both modes. On edit, `update_server` merges the
528    /// supplied params over the stored ones, and re-sending them all would trigger a restart
529    /// for no reason. On create, the only untouched parameter with a value is one pre-filled
530    /// with its declared default, and omitting it leaves the protocol's fallback — the same
531    /// value — in charge, so the new instance's `startup_params` records only what the
532    /// operator chose.
533    fn startup_params(&self) -> Option<serde_json::Value> {
534        let section = match &self.mode {
535            FormMode::Create(section) => *section,
536            FormMode::Edit(UiKey::Server(_)) => Section::Servers,
537            FormMode::Edit(UiKey::Client(_)) => Section::Clients,
538        };
539        let params = declared_params(section, &self.protocol);
540        let mut map = serde_json::Map::new();
541        for field in &self.fields {
542            if let FieldTarget::StartupParam(name) = &field.target {
543                if !field.changed() {
544                    continue;
545                }
546                let raw = field.value.trim();
547                if raw.is_empty() {
548                    continue;
549                }
550                // Declared strings stay strings: a JSON OpenAPI spec is still inline
551                // text, and a numeric-looking OAuth client ID is still an identifier.
552                let value = if params
553                    .iter()
554                    .any(|p| p.name == *name && p.type_hint == "string")
555                {
556                    serde_json::Value::String(raw.to_string())
557                } else {
558                    serde_json::from_str::<serde_json::Value>(raw)
559                        .unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
560                };
561                map.insert(name.clone(), value);
562            }
563        }
564        (!map.is_empty()).then(|| serde_json::Value::Object(map))
565    }
566
567    fn event_handlers(&self) -> Result<Option<Vec<serde_json::Value>>> {
568        let Some(raw) = self.value_of(&FieldTarget::EventHandlersJson) else {
569            return Ok(None);
570        };
571        let parsed: serde_json::Value = serde_json::from_str(&raw)
572            .map_err(|e| anyhow::anyhow!("event_handlers is not valid JSON: {e}"))?;
573        match parsed {
574            serde_json::Value::Array(items) => Ok(Some(items)),
575            _ => anyhow::bail!("event_handlers must be a JSON array of handler objects"),
576        }
577    }
578
579    pub fn to_server_form(&self) -> Result<ServerForm> {
580        let port = match self.value_of(&FieldTarget::Port) {
581            Some(text) => Some(
582                text.parse::<u16>()
583                    .map_err(|_| anyhow::anyhow!("port must be a number 0-65535"))?,
584            ),
585            None => None,
586        };
587        Ok(ServerForm {
588            protocol: self.protocol.clone(),
589            port,
590            host: self.value_of(&FieldTarget::Host),
591            interface: self.value_of(&FieldTarget::Interface),
592            mac_address: self.value_of(&FieldTarget::MacAddress),
593            send_first: self
594                .value_of(&FieldTarget::SendFirst)
595                .map(|v| v == "true")
596                .unwrap_or(false),
597            instruction: self.value_of(&FieldTarget::Instruction),
598            initial_memory: self.value_of(&FieldTarget::InitialMemory),
599            startup_params: self.startup_params(),
600            event_handlers: self.event_handlers()?,
601            scheduled_tasks: None,
602            feedback_instructions: self.value_of(&FieldTarget::FeedbackInstructions),
603        })
604    }
605
606    pub fn to_client_form(&self) -> Result<ClientForm> {
607        Ok(ClientForm {
608            protocol: self.protocol.clone(),
609            remote_addr: self.value_of(&FieldTarget::RemoteAddr),
610            instruction: self.value_of(&FieldTarget::Instruction),
611            initial_memory: self.value_of(&FieldTarget::InitialMemory),
612            startup_params: self.startup_params(),
613            event_handlers: self.event_handlers()?,
614            scheduled_tasks: None,
615            feedback_instructions: self.value_of(&FieldTarget::FeedbackInstructions),
616        })
617    }
618
619    /// Apply the form: create or update, returning a line for the chat log.
620    pub async fn apply(
621        &self,
622        state: &AppState,
623        llm_client: crate::llm::OllamaClient,
624        status_tx: &mpsc::UnboundedSender<String>,
625    ) -> Result<String> {
626        if let Some(missing) = self.missing_required() {
627            anyhow::bail!("{} requires {missing}", self.protocol);
628        }
629        match &self.mode {
630            FormMode::Create(Section::Servers) => {
631                let form = self.to_server_form()?;
632                let id = form.create(state, status_tx.clone()).await?;
633                Ok(format!(
634                    "Started server #{} ({})",
635                    id.as_u32(),
636                    self.protocol
637                ))
638            }
639            FormMode::Create(Section::Clients) => {
640                let form = self.to_client_form()?;
641                let id = form.create(state, llm_client, status_tx.clone()).await?;
642                Ok(format!(
643                    "Connected client #{} ({})",
644                    id.as_u32(),
645                    self.protocol
646                ))
647            }
648            FormMode::Edit(UiKey::Server(server_id)) => {
649                let form = self.to_server_form()?;
650                let outcome =
651                    management::update_server(state, *server_id, form, status_tx.clone()).await?;
652                Ok(outcome.summary)
653            }
654            FormMode::Edit(UiKey::Client(client_id)) => {
655                let form = self.to_client_form()?;
656                let outcome = management::update_client(
657                    state,
658                    *client_id,
659                    form,
660                    llm_client,
661                    status_tx.clone(),
662                )
663                .await?;
664                Ok(outcome.summary)
665            }
666        }
667    }
668}
669
670/// The default routing an interactively created instance starts with.
671///
672/// Servers: one `*` → manual rule — every event waits for the human driving
673/// the dashboard — preceded by one zero-action static rule per event the
674/// protocol raises on every connection (`EventType::on_every_connection`, e.g.
675/// `tcp_connection_opened`). Those fire for each peer before it has said
676/// anything, and parking each one would put a question in front of the human
677/// for every connection; answered with nothing ahead of time they cost no model
678/// call and no attention, and the peer's first message still parks.
679///
680/// Clients: the same `*` → manual rule, preceded by one zero-action static
681/// rule per connect event (`<proto>_connected`), so establishing the
682/// connection itself never parks. The connect event is bookkeeping — the
683/// interesting questions (data arrived, what do we send?) still park for the
684/// human. `EventHandlerConfig::find_handler` is first-match-wins over exact
685/// event ids ('*'/'all' are the only wildcards), which is why the ids come
686/// from the client protocol's own `get_event_types()` instead of a
687/// `*_connected` glob that nothing would ever match. A client whose connect
688/// event does not follow the `_connected` naming keeps the plain manual
689/// default, except OIDC discovery, which also completes inline during connect.
690pub fn default_event_handlers(section: Section, protocol: &str) -> serde_json::Value {
691    let manual_fallback = serde_json::json!({
692        "event_pattern": "*",
693        "handler": {"type": "manual"}
694    });
695    let mut rules = Vec::new();
696    if section == Section::Servers {
697        if let Ok(server) = crate::protocol::server_registry::registry().resolve(protocol) {
698            for event_type in server.get_event_types() {
699                if event_type.on_every_connection {
700                    rules.push(serde_json::json!({
701                        "event_pattern": event_type.id,
702                        "handler": {"type": "static", "actions": []}
703                    }));
704                }
705            }
706        }
707    }
708    if section == Section::Clients {
709        // `resolve` rather than `get`: case-insensitive, same lookup the
710        // management paths use for a protocol the user named.
711        if let Ok(client) = crate::protocol::CLIENT_REGISTRY.resolve(protocol) {
712            for event_type in client.get_event_types() {
713                // OIDC completes discovery inline during connect; parking that lifecycle
714                // event would leave the create form busy before the user can send a verb.
715                if event_type.id.ends_with("_connected") || event_type.id == "oidc_discovered" {
716                    rules.push(serde_json::json!({
717                        "event_pattern": event_type.id,
718                        // Zero actions: acknowledge the connect, say nothing —
719                        // the same answer as the intercept modal's
720                        // [ Answer with nothing ], made ahead of time.
721                        "handler": {"type": "static", "actions": []}
722                    }));
723                }
724            }
725        }
726    }
727    rules.push(manual_fallback);
728    serde_json::Value::Array(rules)
729}
730
731fn declared_params(section: Section, protocol: &str) -> Vec<ParameterDefinition> {
732    match section {
733        Section::Servers => management::server_declared_params(protocol),
734        Section::Clients => management::client_declared_params(protocol),
735    }
736    .unwrap_or_default()
737}
738
739fn push_param_fields(
740    fields: &mut Vec<Field>,
741    params: &[ParameterDefinition],
742    current: Option<&serde_json::Value>,
743) {
744    for param in params {
745        // Several protocols declare a startup parameter that the form already
746        // has a first-class field for (`send_first`, `port`, `host`). Showing
747        // both would offer two controls for one value.
748        if fields.iter().any(|f| f.label == param.name) {
749            continue;
750        }
751        let existing = current
752            .and_then(|v| v.get(&param.name))
753            .map(|v| match v {
754                serde_json::Value::String(s) => s.clone(),
755                other => other.to_string(),
756            })
757            .unwrap_or_default();
758        let as_text = |v: &serde_json::Value| match v {
759            serde_json::Value::Null => String::new(),
760            serde_json::Value::String(s) => s.clone(),
761            other => other.to_string(),
762        };
763        let example = as_text(&param.example);
764        let default = param.default.as_ref().map(as_text);
765        // A declared default fills an otherwise empty field, with `original` matching so an
766        // untouched default is never submitted (see `startup_params`).
767        let value = match (&existing, &default) {
768            (e, Some(d)) if e.is_empty() => d.clone(),
769            _ => existing,
770        };
771        let help = match &default {
772            Some(d) => format!("{} ({}) Default: {d}.", param.description, param.type_hint),
773            None => format!("{} ({})", param.description, param.type_hint),
774        };
775        fields.push(Field {
776            target: FieldTarget::StartupParam(param.name.clone()),
777            label: param.name.clone(),
778            original: value.clone(),
779            value,
780            placeholder: if example.is_empty() {
781                String::new()
782            } else {
783                format!("e.g. {example}")
784            },
785            default,
786            help,
787            required: param.required,
788            multiline: param.name == "spec",
789        });
790    }
791}
792
793fn push_text_fields(
794    fields: &mut Vec<Field>,
795    instruction: &str,
796    routing: Option<&crate::scripting::EventHandlerConfig>,
797) {
798    let mut instruction_field = Field::simple(
799        FieldTarget::Instruction,
800        "instruction",
801        "Natural-language instruction for the LLM fallback path.",
802    );
803    instruction_field.value = instruction.to_string();
804    instruction_field.original = instruction_field.value.clone();
805    instruction_field.multiline = true;
806    fields.push(instruction_field);
807
808    let mut memory = Field::simple(
809        FieldTarget::InitialMemory,
810        "initial_memory",
811        "Seed the instance's LLM memory.",
812    );
813    memory.multiline = true;
814    fields.push(memory);
815
816    let mut feedback = Field::simple(
817        FieldTarget::FeedbackInstructions,
818        "feedback_instructions",
819        "Instructions for the automatic feedback loop.",
820    );
821    feedback.multiline = true;
822    fields.push(feedback);
823
824    let mut handlers = Field::simple(
825        FieldTarget::EventHandlersJson,
826        "event_handlers",
827        "Routing as raw JSON. Press r on the band for the guided editor.",
828    );
829    handlers.value = routing
830        .map(|r| serde_json::to_string_pretty(&r.handlers).unwrap_or_default())
831        .unwrap_or_default();
832    handlers.original = handlers.value.clone();
833    handlers.multiline = true;
834    fields.push(handlers);
835}