Skip to main content

netget/tui/modal/
composer.rs

1//! The `[send]` composer: pick an action from the client protocol's own
2//! vocabulary, fill its parameters, and push it through the running client
3//! with `AppState::send_to_client` — no LLM involved.
4
5use std::time::Duration;
6
7use anyhow::Result;
8
9use crate::llm::actions::ActionDefinition;
10use crate::state::app_state::AppState;
11use crate::state::client_handles::ClientSendOutcome;
12use crate::state::{ClientId, ServerId};
13use crate::tui::app::UiKey;
14
15/// What the composed action is delivered to.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ComposerTarget {
18    /// A running client's connection loop (`AppState::send_to_client`).
19    Client(ClientId),
20    /// One live connection of a server (`AppState::send_to_peer`) — offered
21    /// only where the protocol registered a peer handle.
22    Peer { server: ServerId, connection: u32 },
23    /// A request a `manual` rule parked (`AppState::resolve_intercept`): the
24    /// composed action *is* the answer, and the waiting dispatcher — not the
25    /// composer — puts it on the wire.
26    Intercept { id: u64, owner: UiKey },
27}
28
29impl ComposerTarget {
30    /// Short label for titles and chat lines.
31    pub fn describe(&self) -> String {
32        match self {
33            ComposerTarget::Client(id) => format!("client #{}", id.as_u32()),
34            ComposerTarget::Peer { server, connection } => format!(
35                "peer (connection #{connection} of server #{})",
36                server.as_u32()
37            ),
38            ComposerTarget::Intercept { id, .. } => format!("request #{id}"),
39        }
40    }
41}
42
43/// How a parameter is edited. Everything is text on the wire; this only
44/// decides the control — a boolean is a toggle, not a word you have to spell,
45/// and a closed value set is a selector, not a spelling test either.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum FieldKind {
48    Text,
49    Bool,
50    /// The parameter declares a closed set of valid values
51    /// (`Parameter::choices`, e.g. tcp's `encoding`: utf8 | hex).
52    /// Enter/Space/←/→ cycle through them; the pick submits as a plain string.
53    Choice,
54}
55
56impl FieldKind {
57    fn from_type_hint(hint: &str) -> Self {
58        if hint.to_ascii_lowercase().contains("bool") {
59            FieldKind::Bool
60        } else {
61            FieldKind::Text
62        }
63    }
64}
65
66/// How long a composed send waits for the loop to report back before giving
67/// up and saying so.
68///
69/// Generous because the send is fire-and-forget: nothing is blocked on this
70/// expiring. It matters most when the loop is legitimately busy — a client
71/// whose event is parked for a MANUAL answer does not drain its command
72/// channel until the human answers, so the send sits there until then.
73pub const SEND_TIMEOUT: Duration = Duration::from_secs(30);
74
75#[derive(Debug, Clone)]
76pub struct ComposerField {
77    pub name: String,
78    pub value: String,
79    pub placeholder: String,
80    pub help: String,
81    pub required: bool,
82    pub kind: FieldKind,
83    /// The valid values of a [`FieldKind::Choice`] field; empty otherwise.
84    pub choices: Vec<String>,
85}
86
87impl ComposerField {
88    /// Flip a boolean field. Empty (unset) reads as false, so the first press
89    /// turns it on.
90    pub fn toggle(&mut self) {
91        self.value = if self.value.trim() == "true" {
92            "false".to_string()
93        } else {
94            "true".to_string()
95        };
96    }
97
98    /// Step a choice field to the next (or previous) valid value.
99    ///
100    /// An optional field cycles through "unset" too, so the parameter can be
101    /// omitted again after picking; a required one only ever moves between the
102    /// valid values — there is nothing meaningful for it to go back to.
103    pub fn cycle_choice(&mut self, backward: bool) {
104        if self.choices.is_empty() {
105            return;
106        }
107        let len = self.choices.len();
108        let current = self.choices.iter().position(|c| c == self.value.trim());
109        self.value = match (current, backward) {
110            (None, false) => self.choices[0].clone(),
111            (None, true) => self.choices[len - 1].clone(),
112            (Some(i), false) if i + 1 < len => self.choices[i + 1].clone(),
113            (Some(i), true) if i > 0 => self.choices[i - 1].clone(),
114            // Off either end: a required field wraps, an optional one unsets.
115            (Some(_), false) => {
116                if self.required {
117                    self.choices[0].clone()
118                } else {
119                    String::new()
120                }
121            }
122            (Some(_), true) => {
123                if self.required {
124                    self.choices[len - 1].clone()
125                } else {
126                    String::new()
127                }
128            }
129        };
130    }
131}
132
133#[derive(Debug, Clone)]
134pub struct ComposerModel {
135    pub target: ComposerTarget,
136    pub protocol: String,
137    pub actions: Vec<ActionDefinition>,
138    /// `None` while choosing an action; `Some` once one is picked.
139    pub chosen: Option<usize>,
140    pub selected: usize,
141    pub fields: Vec<ComposerField>,
142    pub editing: Option<String>,
143    /// Raw-JSON mode for actions whose shape the field form cannot express.
144    pub raw_json: Option<String>,
145    pub error: Option<String>,
146    pub result: Option<String>,
147    /// A send is in flight (spawned; see `crate::tui::uimsg`).
148    pub busy: bool,
149    /// `Some(i)` when focus sits on the i-th button rather than in the list
150    /// or the fields.
151    pub focused_button: Option<usize>,
152}
153
154impl ComposerModel {
155    pub fn new(client_id: ClientId, protocol: &str, actions: Vec<ActionDefinition>) -> Self {
156        Self::for_target(ComposerTarget::Client(client_id), protocol, actions)
157    }
158
159    /// Compose for one live connection of a server.
160    pub fn for_peer(
161        server: ServerId,
162        connection: u32,
163        protocol: &str,
164        actions: Vec<ActionDefinition>,
165    ) -> Self {
166        Self::for_target(
167            ComposerTarget::Peer { server, connection },
168            protocol,
169            actions,
170        )
171    }
172
173    /// Compose the answer to a parked request. The vocabulary is whatever the
174    /// owning protocol can execute for that event.
175    pub fn for_intercept(
176        id: u64,
177        owner: UiKey,
178        protocol: &str,
179        actions: Vec<ActionDefinition>,
180    ) -> Self {
181        Self::for_target(ComposerTarget::Intercept { id, owner }, protocol, actions)
182    }
183
184    fn for_target(target: ComposerTarget, protocol: &str, actions: Vec<ActionDefinition>) -> Self {
185        Self {
186            target,
187            protocol: protocol.to_string(),
188            actions,
189            chosen: None,
190            selected: 0,
191            fields: Vec::new(),
192            editing: None,
193            raw_json: None,
194            error: None,
195            result: None,
196            busy: false,
197            focused_button: None,
198        }
199    }
200
201    /// The buttons for the current phase, in Tab order.
202    pub fn buttons(&self) -> Vec<crate::tui::hit::ModalAction> {
203        use crate::tui::hit::ModalAction::*;
204        if self.chosen.is_some() {
205            vec![ComposerSend, ComposerRaw, ComposerBack]
206        } else {
207            Vec::new()
208        }
209    }
210
211    pub fn focused_action(&self) -> Option<crate::tui::hit::ModalAction> {
212        self.focused_button
213            .and_then(|index| self.buttons().get(index).copied())
214    }
215
216    /// Tab through the fields (or the action list) and then the buttons.
217    pub fn cycle_focus(&mut self, backward: bool) {
218        let rows = if self.chosen.is_some() {
219            self.fields.len()
220        } else {
221            self.actions.len()
222        };
223        let buttons = self.buttons().len();
224        let total = rows + buttons;
225        if total == 0 {
226            return;
227        }
228        let current = match self.focused_button {
229            None => self.selected.min(rows.saturating_sub(1)),
230            Some(index) => rows + index,
231        };
232        let next = if backward {
233            (current + total - 1) % total
234        } else {
235            (current + 1) % total
236        };
237        if next < rows {
238            self.focused_button = None;
239            self.selected = next;
240        } else {
241            self.focused_button = Some(next - rows);
242        }
243    }
244
245    /// The vocabulary a client's loop can actually execute: the same union
246    /// `call_llm_for_client` advertises (async ∪ sync), so the composer offers
247    /// exactly what the protocol implements.
248    pub fn vocabulary(protocol_name: &str, state: &AppState) -> Vec<ActionDefinition> {
249        let Some(protocol) = crate::protocol::CLIENT_REGISTRY.get(protocol_name) else {
250            return Vec::new();
251        };
252        let mut actions = protocol.get_async_actions(state);
253        for action in protocol.get_sync_actions() {
254            if !actions.iter().any(|a| a.name == action.name) {
255                actions.push(action);
256            }
257        }
258        actions
259    }
260
261    pub fn move_selection(&mut self, delta: isize) {
262        let len = if self.chosen.is_some() {
263            self.fields.len()
264        } else {
265            self.actions.len()
266        };
267        if len == 0 {
268            return;
269        }
270        self.selected = ((self.selected as isize + delta).rem_euclid(len as isize)) as usize;
271    }
272
273    /// Pick the highlighted action and build its parameter fields.
274    pub fn choose(&mut self) {
275        let Some(action) = self.actions.get(self.selected) else {
276            return;
277        };
278        let example = action.example.clone();
279        self.fields = action
280            .parameters
281            .iter()
282            .map(|param| {
283                let example_value = example
284                    .get(&param.name)
285                    .map(|v| match v {
286                        serde_json::Value::String(s) => s.clone(),
287                        other => other.to_string(),
288                    })
289                    .unwrap_or_default();
290                let choices = param.choices().unwrap_or_default();
291                let kind = if !choices.is_empty() {
292                    FieldKind::Choice
293                } else {
294                    FieldKind::from_type_hint(&param.type_hint)
295                };
296                ComposerField {
297                    name: param.name.clone(),
298                    value: String::new(),
299                    placeholder: if example_value.is_empty() {
300                        String::new()
301                    } else {
302                        format!("e.g. {example_value}")
303                    },
304                    help: format!("{} ({})", param.description, param.type_hint),
305                    required: param.required,
306                    kind,
307                    choices,
308                }
309            })
310            .collect();
311        self.chosen = Some(self.selected);
312        self.selected = 0;
313        self.error = None;
314        self.focused_button = None;
315    }
316
317    pub fn back_to_actions(&mut self) {
318        self.chosen = None;
319        self.fields.clear();
320        self.raw_json = None;
321        self.selected = 0;
322        self.focused_button = None;
323    }
324
325    pub fn toggle_raw_json(&mut self) {
326        if self.raw_json.is_some() {
327            self.raw_json = None;
328        } else {
329            self.raw_json = Some(
330                serde_json::to_string_pretty(&self.build_action().unwrap_or(serde_json::json!({})))
331                    .unwrap_or_default(),
332            );
333        }
334    }
335
336    /// The selected field, if any.
337    pub fn selected_field(&self) -> Option<&ComposerField> {
338        self.fields.get(self.selected)
339    }
340
341    /// Start editing the selected field — or, for a boolean, just flip it,
342    /// and for a choice field, step to the next valid value.
343    pub fn begin_edit(&mut self) {
344        if let Some(field) = self.fields.get_mut(self.selected) {
345            match field.kind {
346                FieldKind::Bool => field.toggle(),
347                FieldKind::Choice => field.cycle_choice(false),
348                FieldKind::Text => self.editing = Some(field.value.clone()),
349            }
350        }
351    }
352
353    /// Everything the raw-JSON view can hold. For an intercept the answer is
354    /// a *list* of actions, so a JSON array is accepted there; elsewhere the
355    /// composer sends exactly one action.
356    pub fn build_answer(&self) -> Result<Vec<serde_json::Value>> {
357        if let Some(raw) = &self.raw_json {
358            let value: serde_json::Value = serde_json::from_str(raw)
359                .map_err(|e| anyhow::anyhow!("action JSON is invalid: {e}"))?;
360            return Ok(match value {
361                serde_json::Value::Array(items) => items,
362                other => vec![other],
363            });
364        }
365        Ok(vec![self.build_action()?])
366    }
367
368    pub fn commit_edit(&mut self) {
369        if let Some(buffer) = self.editing.take() {
370            if let Some(field) = self.fields.get_mut(self.selected) {
371                field.value = buffer;
372            }
373        }
374    }
375
376    pub fn cancel_edit(&mut self) {
377        self.editing = None;
378    }
379
380    /// Assemble the action JSON: `{"type": <name>, ...params}`.
381    pub fn build_action(&self) -> Result<serde_json::Value> {
382        if let Some(raw) = &self.raw_json {
383            return serde_json::from_str(raw)
384                .map_err(|e| anyhow::anyhow!("action JSON is invalid: {e}"));
385        }
386        let Some(index) = self.chosen else {
387            anyhow::bail!("no action chosen");
388        };
389        let action = &self.actions[index];
390        let mut map = serde_json::Map::new();
391        map.insert(
392            "type".to_string(),
393            serde_json::Value::String(action.name.clone()),
394        );
395        for field in &self.fields {
396            let raw = field.value.trim();
397            if raw.is_empty() {
398                if field.required {
399                    anyhow::bail!("'{}' is required", field.name);
400                }
401                continue;
402            }
403            // A picked choice always submits as the plain string it displays;
404            // everything else keeps JSON types where the text parses as JSON.
405            let value = if field.kind == FieldKind::Choice {
406                serde_json::Value::String(raw.to_string())
407            } else {
408                serde_json::from_str::<serde_json::Value>(raw)
409                    .unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
410            };
411            map.insert(field.name.clone(), value);
412        }
413        Ok(serde_json::Value::Object(map))
414    }
415
416    /// Deliver the composed action to the target.
417    pub async fn send(&self, state: &AppState) -> Result<ClientSendOutcome> {
418        Self::deliver(self.target, state, self.build_action()?).await
419    }
420
421    /// Deliver an already-built action.
422    ///
423    /// Split from [`Self::send`] so the UI can do the *validation* half
424    /// synchronously — a missing required field must keep the composer open —
425    /// and then fire the *network* half without holding the modal, which
426    /// otherwise sat on "sending…" until this returned.
427    pub async fn deliver(
428        target: ComposerTarget,
429        state: &AppState,
430        action: serde_json::Value,
431    ) -> Result<ClientSendOutcome> {
432        match target {
433            ComposerTarget::Client(id) => state.send_to_client(id, action, SEND_TIMEOUT).await,
434            ComposerTarget::Peer { server, connection } => {
435                state
436                    .send_to_peer(server, connection, action, SEND_TIMEOUT)
437                    .await
438            }
439            // Resolving is a channel send under a short lock; the waiting
440            // dispatcher does the wire work and reports in the access log.
441            ComposerTarget::Intercept { id, .. } => state
442                .resolve_intercept(id, vec![action])
443                .await
444                .map(|()| ClientSendOutcome::Executed {
445                    detail: "answered".to_string(),
446                })
447                .map_err(|e| anyhow::anyhow!(e)),
448        }
449    }
450
451    /// Why a send might be sitting in the queue rather than going out.
452    ///
453    /// A client parks its events for a MANUAL handler *inside* its connection
454    /// loop, so while a question is waiting for the human nothing drains the
455    /// command channel. Without this the failure reads as a dead client.
456    pub async fn queue_hint(target: ComposerTarget, state: &AppState) -> Option<String> {
457        let owner = match target {
458            ComposerTarget::Client(id) => crate::state::intercepts::InterceptOwner::Client(id),
459            ComposerTarget::Peer { server, .. } => {
460                crate::state::intercepts::InterceptOwner::Server(server)
461            }
462            ComposerTarget::Intercept { .. } => return None,
463        };
464        let waiting = state
465            .list_intercepts()
466            .await
467            .into_iter()
468            .find(|view| view.owner == owner)?;
469        Some(format!(
470            "it is still waiting for your answer to the parked '{}' request — answer that \
471             and the send goes out",
472            waiting.event_type
473        ))
474    }
475}
476
477/// Human-readable outcome for the chat log.
478pub fn describe(outcome: &ClientSendOutcome) -> String {
479    match outcome {
480        ClientSendOutcome::Sent { bytes_sent } => format!("sent {bytes_sent} byte(s)"),
481        ClientSendOutcome::Executed { detail } => format!("executed ({detail})"),
482        ClientSendOutcome::Rejected { error } => format!("rejected: {error}"),
483        ClientSendOutcome::Disconnected => "disconnected".to_string(),
484    }
485}