1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ComposerTarget {
18 Client(ClientId),
20 Peer { server: ServerId, connection: u32 },
23 Intercept { id: u64, owner: UiKey },
27}
28
29impl ComposerTarget {
30 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum FieldKind {
48 Text,
49 Bool,
50 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
66pub 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 pub choices: Vec<String>,
85}
86
87impl ComposerField {
88 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 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 (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 pub chosen: Option<usize>,
140 pub selected: usize,
141 pub fields: Vec<ComposerField>,
142 pub editing: Option<String>,
143 pub raw_json: Option<String>,
145 pub error: Option<String>,
146 pub result: Option<String>,
147 pub busy: bool,
149 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 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 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 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 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 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 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(¶m.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(¶m.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 pub fn selected_field(&self) -> Option<&ComposerField> {
338 self.fields.get(self.selected)
339 }
340
341 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 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 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 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 pub async fn send(&self, state: &AppState) -> Result<ClientSendOutcome> {
418 Self::deliver(self.target, state, self.build_action()?).await
419 }
420
421 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 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 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
477pub 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}