1use crate::state::app_state::AppState;
10use crate::tui::app::{DashboardApp, Section, UiKey};
11use crate::tui::cards::InstanceAction;
12use crate::tui::modal::{confirm, Modal, PendingAction};
13use crate::tui::uimsg::{ActionOrigin, UiMsg};
14
15pub async fn run(app: &mut DashboardApp, key: UiKey, action: InstanceAction, state: &AppState) {
17 app.dirty = true;
18 match action {
19 InstanceAction::Stop => stop_instance(app, key, state).await,
20 InstanceAction::Edit => open_editor(app, key),
21 InstanceAction::Rules => open_routing_at(app, key, state, RouteTarget::List),
22 InstanceAction::AddRule => open_routing_at(app, key, state, RouteTarget::New),
23 InstanceAction::EditRule(index) => {
24 open_routing_at(app, key, state, RouteTarget::Edit(index))
25 }
26 InstanceAction::DeleteRule(index) => edit_rules_headless(app, key, state, |model| {
27 if index < model.handlers.len() {
28 model.selected = index;
29 model.delete_selected();
30 Some(format!("deleted rule {}", index + 1))
31 } else {
32 None
33 }
34 }),
35 InstanceAction::MoveRule(index, delta) => edit_rules_headless(app, key, state, |model| {
36 if index < model.handlers.len() {
37 model.selected = index;
38 let before = model.selected;
39 model.reorder(delta as isize);
40 if model.selected != before {
41 Some(format!(
42 "moved rule {} to position {}",
43 index + 1,
44 model.selected + 1
45 ))
46 } else {
47 None
48 }
49 } else {
50 None
51 }
52 }),
53 InstanceAction::CycleDriver => cycle_driver(app, key, state),
54 InstanceAction::ConnectClient => {
55 if let UiKey::Server(id) = key {
56 open_client_for_server(app, id, state).await;
57 }
58 }
59 InstanceAction::Send => {
60 if let UiKey::Client(id) = key {
61 open_composer(app, id, None, state).await;
62 }
63 }
64 InstanceAction::SendAction(index) => {
65 if let UiKey::Client(id) = key {
66 open_composer(app, id, Some(index), state).await;
67 }
68 }
69 InstanceAction::MessagePeer(conn) => {
70 if let UiKey::Server(id) = key {
71 open_peer_composer(app, id, conn);
72 }
73 }
74 InstanceAction::DisconnectPeer(conn) => {
75 if let UiKey::Server(id) = key {
76 disconnect_peer(app, id, conn, state);
77 }
78 }
79 InstanceAction::Disconnect => {
80 if let UiKey::Client(id) = key {
81 disconnect_client(app, id, state).await;
82 }
83 }
84 InstanceAction::Connect => {
85 if let UiKey::Client(id) = key {
86 connect_client(app, id, state);
87 }
88 }
89 InstanceAction::Wireshark => open_wireshark(app, key),
90 InstanceAction::Docs => show_docs(app, key),
91 InstanceAction::Answer(id) => open_intercept(app, key, id, state),
92 InstanceAction::OpenRequest(id) => open_request(app, key, id),
93 }
94}
95
96async fn stop_instance(app: &mut DashboardApp, key: UiKey, state: &AppState) {
100 let action = match key {
101 UiKey::Server(id) => PendingAction::StopServer(id),
102 UiKey::Client(id) => PendingAction::StopClient(id),
103 };
104 let line = confirm::execute(&action, state).await;
105 app.push_system(line);
106}
107
108async fn disconnect_client(app: &mut DashboardApp, id: crate::state::ClientId, state: &AppState) {
110 if state.disconnect_client(id).await {
111 app.push_system(format!(
112 "client #{} disconnected — [ connect ] re-establishes it",
113 id.as_u32()
114 ));
115 } else {
116 app.push_system(format!("client #{} is already gone", id.as_u32()));
117 }
118}
119
120fn connect_client(app: &mut DashboardApp, id: crate::state::ClientId, state: &AppState) {
123 app.push_system(format!("connecting client #{}…", id.as_u32()));
124 let llm = app.llm_client.clone();
125 let status_tx = app.status_tx.clone();
126 let ui_tx = app.ui_tx.clone();
127 let state = state.clone();
128 tokio::spawn(async move {
129 let message = match crate::cli::client_startup::start_client_by_id(
130 &state, id, &llm, &status_tx,
131 )
132 .await
133 {
134 Ok(_) => format!("client #{} connected", id.as_u32()),
135 Err(e) => format!("client #{} failed to connect: {e}", id.as_u32()),
136 };
137 let _ = ui_tx.send(UiMsg::Chat(message));
138 });
139}
140
141fn show_docs(app: &mut DashboardApp, key: UiKey) {
142 let Some(instance) = app.instance(key) else {
143 return;
144 };
145 let protocol = instance.protocol().to_string();
146 let text = match key {
147 UiKey::Server(_) => crate::protocol::server_registry::registry()
148 .resolve(&protocol)
149 .map(|p| {
150 format!(
151 "{} — {}\n{}",
152 p.protocol_name(),
153 p.description(),
154 p.metadata().summary()
155 )
156 })
157 .unwrap_or_else(|e| e.to_string()),
158 UiKey::Client(_) => crate::protocol::CLIENT_REGISTRY
159 .resolve(&protocol)
160 .map(|p| {
161 format!(
162 "{} client — {}\n{}",
163 p.protocol_name(),
164 p.description(),
165 p.metadata().summary()
166 )
167 })
168 .unwrap_or_else(|e| e.to_string()),
169 };
170 app.push_system(text);
171}
172
173fn open_request(app: &mut DashboardApp, key: UiKey, id: u64) {
175 let entry = app
176 .instance(key)
177 .and_then(|i| i.requests().iter().find(|r| r.id == id).cloned());
178 match entry {
179 Some(entry) => app.modals.push(Modal::RequestDetail {
180 entry: Box::new(entry),
181 scroll: 0,
182 }),
183 None => app.push_system(format!(
184 "request #{id} is no longer in the log (the per-instance log keeps the latest 100)"
185 )),
186 }
187}
188
189pub fn open_intercept(app: &mut DashboardApp, key: UiKey, intercept_id: u64, state: &AppState) {
191 use crate::tui::modal::intercept::InterceptModel;
192
193 let Some(instance) = app.instance(key) else {
194 return;
195 };
196 let protocol = instance.protocol().to_string();
197 let view = instance
198 .intercepts()
199 .iter()
200 .find(|v| v.id == intercept_id)
201 .cloned();
202 let Some(view) = view else {
203 app.push_system(format!(
204 "request #{intercept_id} is no longer waiting (answered or timed out)"
205 ));
206 return;
207 };
208 let (_events, vocabulary) = crate::tui::modal::routing::vocabulary(key, &protocol, state);
209 app.modals.push(Modal::Intercept(Box::new(InterceptModel {
210 id: view.id,
211 owner: key,
212 protocol,
213 event_type: view.event_type,
214 description: view.description,
215 event_data: view.event_data,
216 vocabulary,
217 error: None,
218 focused: 0,
219 })));
220}
221
222pub enum RouteTarget {
224 List,
226 Edit(usize),
228 New,
230}
231
232fn open_editor(app: &mut DashboardApp, key: UiKey) {
233 use crate::tui::modal::form::FormModel;
234 let model = match key {
235 UiKey::Server(id) => app.server_row(id).map(FormModel::for_edit_server),
236 UiKey::Client(id) => app.client_row(id).map(FormModel::for_edit_client),
237 };
238 if let Some(model) = model {
239 app.modals.push(Modal::Form(Box::new(model)));
240 }
241}
242
243pub fn open_routing_at(app: &mut DashboardApp, key: UiKey, state: &AppState, target: RouteTarget) {
245 use crate::tui::modal::routing::RoutingModel;
246 let model = app
247 .instance(key)
248 .map(|i| RoutingModel::new(key, i.protocol(), i.routing(), state));
249 if let Some(mut model) = model {
250 match target {
251 RouteTarget::List => {}
252 RouteTarget::New => model.add(),
253 RouteTarget::Edit(index) => {
254 if index < model.handlers.len() {
255 model.selected = index;
256 model.edit_selected();
257 }
258 }
259 }
260 app.modals.push(Modal::Routing(Box::new(model)));
261 }
262}
263
264fn edit_rules_headless(
268 app: &mut DashboardApp,
269 key: UiKey,
270 state: &AppState,
271 change: impl FnOnce(&mut crate::tui::modal::routing::RoutingModel) -> Option<String>,
272) {
273 use crate::tui::modal::routing::RoutingModel;
274 let Some(mut model) = app
275 .instance(key)
276 .map(|i| RoutingModel::new(key, i.protocol(), i.routing(), state))
277 else {
278 return;
279 };
280 let Some(description) = change(&mut model) else {
281 return;
282 };
283 app.push_system(format!("{}: {description}…", key.describe()));
284 spawn_routing_apply(app, model, state);
285}
286
287fn spawn_routing_apply(
288 app: &mut DashboardApp,
289 model: crate::tui::modal::routing::RoutingModel,
290 state: &AppState,
291) {
292 let llm = app.llm_client.clone();
293 let status_tx = app.status_tx.clone();
294 let ui_tx = app.ui_tx.clone();
295 let state = state.clone();
296 tokio::spawn(async move {
297 let message = match model.apply(&state, llm, &status_tx).await {
298 Ok(summary) => summary,
299 Err(e) => format!("✗ {e}"),
300 };
301 let _ = ui_tx.send(UiMsg::Chat(message));
302 });
303}
304
305fn cycle_driver(app: &mut DashboardApp, key: UiKey, state: &AppState) {
307 use crate::cli::management::{self, ClientForm, ServerForm};
308 use crate::tui::driver::{driver_of, handlers_with_driver};
309
310 let Some(instance) = app.instance(key) else {
311 return;
312 };
313 let current = driver_of(instance.routing());
314 let next = current.next();
315 let handlers = handlers_with_driver(instance.routing(), next);
316 let protocol = instance.protocol().to_string();
317 app.push_system(format!(
318 "{}: driver {} → {} — {}",
319 key.describe(),
320 current.label(),
321 next.label(),
322 next.describe()
323 ));
324
325 let llm = app.llm_client.clone();
326 let status_tx = app.status_tx.clone();
327 let ui_tx = app.ui_tx.clone();
328 let state = state.clone();
329 tokio::spawn(async move {
330 let result = match key {
331 UiKey::Server(id) => management::update_server(
332 &state,
333 id,
334 ServerForm {
335 protocol,
336 event_handlers: Some(handlers),
337 ..Default::default()
338 },
339 status_tx,
340 )
341 .await
342 .map(|o| o.summary),
343 UiKey::Client(id) => management::update_client(
344 &state,
345 id,
346 ClientForm {
347 protocol,
348 event_handlers: Some(handlers),
349 ..Default::default()
350 },
351 llm,
352 status_tx,
353 )
354 .await
355 .map(|o| o.summary),
356 };
357 let message = match result {
358 Ok(summary) => summary,
359 Err(e) => format!("✗ could not change the driver: {e}"),
360 };
361 let _ = ui_tx.send(UiMsg::Chat(message));
362 });
363}
364
365pub fn client_form_for_server(
372 server_id: crate::state::ServerId,
373 server_protocol: &str,
374 local_addr: Option<&str>,
375 port: u16,
376 server_startup_params: Option<&serde_json::Value>,
377) -> Result<crate::tui::modal::form::FormModel, String> {
378 use crate::tui::modal::form::{FieldTarget, FormModel};
379 let Some(client_protocol) =
380 crate::protocol::compiled_client_protocol_for_server(server_protocol)
381 else {
382 return Err(format!(
383 "{server_protocol} has no client implementation compiled into this build"
384 ));
385 };
386 let address = loopback_target(local_addr, port);
387 let http_url = format!("http://{address}");
388 let remote = match client_protocol.to_ascii_lowercase().as_str() {
391 "npm" | "pypi" | "maven" | "openidconnect" => http_url.clone(),
392 _ => address,
393 };
394
395 let mut model = FormModel::for_create(Section::Clients, &client_protocol, None);
396 model.set_field_value(&FieldTarget::RemoteAddr, remote.clone());
397 match client_protocol.to_ascii_lowercase().as_str() {
398 "openapi" => {
399 if let Some(spec) = server_startup_params
400 .and_then(|params| params.get("spec"))
401 .and_then(|spec| spec.as_str())
402 {
403 model.set_field_value(&FieldTarget::StartupParam("spec".into()), spec.into());
404 }
405 model.set_field_value(&FieldTarget::StartupParam("base_url".into()), http_url);
406 }
407 "oauth2" => {
408 model.set_field_value(
409 &FieldTarget::StartupParam("auth_url".into()),
410 format!("{http_url}/authorize"),
411 );
412 model.set_field_value(
413 &FieldTarget::StartupParam("token_url".into()),
414 format!("{http_url}/token"),
415 );
416 }
417 _ => {}
418 }
419 model.set_field_value(
420 &FieldTarget::Instruction,
421 format!(
422 "You are a {client_protocol} client connected to our own server #{} at {remote}.",
423 server_id.as_u32()
424 ),
425 );
426 Ok(model)
427}
428
429fn loopback_target(local_addr: Option<&str>, port: u16) -> String {
432 let port = local_addr
433 .and_then(|a| a.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()))
434 .unwrap_or(port);
435 format!("127.0.0.1:{port}")
436}
437
438async fn open_client_for_server(
441 app: &mut DashboardApp,
442 server_id: crate::state::ServerId,
443 state: &AppState,
444) {
445 let Some(row) = app.server_row(server_id) else {
446 return;
447 };
448 let mut model = match client_form_for_server(
449 server_id,
450 &row.protocol,
451 row.local_addr.as_deref(),
452 row.port,
453 row.startup_params.as_ref(),
454 ) {
455 Ok(model) => model,
456 Err(why) => {
457 app.push_system(why);
458 return;
459 }
460 };
461 let client_protocol = model.protocol.clone();
462 let remote = loopback_target(row.local_addr.as_deref(), row.port);
463
464 if let Some(missing) = model.missing_required() {
469 model.focus_first_missing_required();
470 model.error = Some(format!(
471 "{client_protocol} needs {missing} before it can connect — fill it in and press [ Apply ]"
472 ));
473 app.modals.push(Modal::Form(Box::new(model)));
474 return;
475 }
476 app.push_system(format!(
477 "Connecting a {client_protocol} client to {remote}…"
478 ));
479 spawn_form_apply(app, model, state);
480}
481
482pub fn spawn_form_apply(
485 app: &mut DashboardApp,
486 model: crate::tui::modal::form::FormModel,
487 state: &AppState,
488) {
489 let llm = app.llm_client.clone();
490 let status_tx = app.status_tx.clone();
491 let ui_tx = app.ui_tx.clone();
492 let state = state.clone();
493 tokio::spawn(async move {
494 let result = model
495 .apply(&state, llm, &status_tx)
496 .await
497 .map_err(|e| e.to_string());
498 let _ = ui_tx.send(UiMsg::ActionDone {
499 origin: ActionOrigin::Form,
500 result,
501 });
502 });
503}
504
505fn open_peer_composer(
509 app: &mut DashboardApp,
510 server_id: crate::state::ServerId,
511 connection_id: u32,
512) {
513 use crate::tui::modal::composer::ComposerModel;
514 let Some(row) = app.server_row(server_id) else {
515 return;
516 };
517 let Ok(protocol) = crate::protocol::server_registry::registry().resolve(&row.protocol) else {
518 app.push_system(format!("{} is not a registered protocol", row.protocol));
519 return;
520 };
521 let actions = protocol.get_sync_actions();
522 if actions.is_empty() {
523 app.push_system(format!("{} declares no wire actions to send", row.protocol));
524 return;
525 }
526 let protocol_name = row.protocol.clone();
527 app.modals
528 .push(Modal::Composer(Box::new(ComposerModel::for_peer(
529 server_id,
530 connection_id,
531 &protocol_name,
532 actions,
533 ))));
534}
535
536fn disconnect_peer(
539 app: &mut DashboardApp,
540 server_id: crate::state::ServerId,
541 connection_id: u32,
542 state: &AppState,
543) {
544 use crate::tui::modal::composer::{ComposerModel, ComposerTarget};
545 let target = ComposerTarget::Peer {
546 server: server_id,
547 connection: connection_id,
548 };
549 app.push_system(format!("{}: disconnecting…", target.describe()));
550 let ui_tx = app.ui_tx.clone();
551 let state = state.clone();
552 tokio::spawn(async move {
553 let message = match ComposerModel::deliver(
554 target,
555 &state,
556 serde_json::json!({"type": "close_connection"}),
557 )
558 .await
559 {
560 Ok(outcome) => format!(
561 "{}: {}",
562 target.describe(),
563 crate::tui::modal::composer::describe(&outcome)
564 ),
565 Err(e) => e.to_string(),
566 };
567 let _ = ui_tx.send(UiMsg::Chat(message));
568 });
569}
570
571async fn open_composer(
573 app: &mut DashboardApp,
574 client_id: crate::state::ClientId,
575 action_index: Option<usize>,
576 state: &AppState,
577) {
578 let Some(row) = app.client_row(client_id) else {
579 return;
580 };
581 match row.send_state {
582 crate::tui::projection::SendState::Ready => {}
583 crate::tui::projection::SendState::NotConnected => {
584 app.push_system(format!(
585 "client #{} is not connected — nothing to send through",
586 client_id.as_u32()
587 ));
588 return;
589 }
590 crate::tui::projection::SendState::ProtocolUnsupported => {
591 app.push_system(format!(
592 "the {} client cannot take injected actions yet: its connection loop has not \
593 adopted the command channel (see src/client/command_support.rs)",
594 row.protocol
595 ));
596 return;
597 }
598 }
599 use crate::tui::modal::composer::ComposerModel;
600 let protocol = row.protocol.clone();
601 let wanted = action_index.and_then(|i| row.send_actions.get(i).map(|v| v.name.clone()));
602 let actions = ComposerModel::vocabulary(&protocol, state);
603 if actions.is_empty() {
604 app.push_system(format!("{protocol} declares no client actions"));
605 return;
606 }
607 let mut model = ComposerModel::new(client_id, &protocol, actions);
608 if let Some(name) = wanted {
609 if let Some(index) = model.actions.iter().position(|a| a.name == name) {
612 model.selected = index;
613 model.choose();
614 }
615 }
616 app.modals.push(Modal::Composer(Box::new(model)));
617}
618
619fn open_wireshark(app: &mut DashboardApp, key: UiKey) {
621 use crate::tui::wireshark::{CapturePlan, CaptureTarget, Platform, Role};
622
623 let target = match key {
624 UiKey::Server(id) => {
625 let Some(row) = app.server_row(id) else {
626 return;
627 };
628 let bound: Option<std::net::SocketAddr> =
629 row.local_addr.as_deref().and_then(|a| a.parse().ok());
630 let param = |name: &str| -> Option<String> {
631 row.startup_params
632 .as_ref()
633 .and_then(|p| p.get(name))
634 .and_then(|v| v.as_str())
635 .map(str::to_string)
636 };
637 CaptureTarget {
638 protocol: row.protocol.clone(),
639 role: Role::Server,
640 host: bound.map(|a| a.ip().to_string()).or_else(|| param("host")),
641 port: bound.map(|a| a.port()).or(Some(row.port)),
642 interface: param("interface"),
643 }
644 }
645 UiKey::Client(id) => {
646 let Some(row) = app.client_row(id) else {
647 return;
648 };
649 CaptureTarget::client(&row.protocol, Some(&row.remote_addr))
650 }
651 };
652 let plan = CapturePlan::build(target, Platform::current());
653 app.modals.push(Modal::Wireshark {
654 plan: Box::new(plan),
655 scroll: 0,
656 });
657}
658
659pub async fn open_protocol_picker(
662 app: &mut DashboardApp,
663 prefill_remote: Option<String>,
664 state: &AppState,
665) {
666 let caps = state.get_system_capabilities().await;
667 let entries = crate::tui::modal::protocol_picker::all_entries(&caps);
668 if entries.is_empty() {
669 app.push_system("No protocols compiled into this build.");
670 return;
671 }
672 app.modals.push(Modal::ProtocolPicker {
673 entries,
674 filter: String::new(),
675 selected: 0,
676 prefill_remote,
677 });
678}
679
680pub fn answer_next_waiting(app: &mut DashboardApp, state: &AppState) {
682 let mut oldest: Option<(u64, UiKey, u64)> = None;
683 for s in &app.snapshot.servers {
684 for v in &s.intercepts {
685 if oldest
686 .map(|(t, _, _)| v.created_unix_ms < t)
687 .unwrap_or(true)
688 {
689 oldest = Some((v.created_unix_ms, UiKey::Server(s.id), v.id));
690 }
691 }
692 }
693 for c in &app.snapshot.clients {
694 for v in &c.intercepts {
695 if oldest
696 .map(|(t, _, _)| v.created_unix_ms < t)
697 .unwrap_or(true)
698 {
699 oldest = Some((v.created_unix_ms, UiKey::Client(c.id), v.id));
700 }
701 }
702 }
703 match oldest {
704 Some((_, key, id)) => {
705 app.focus_card(key);
706 open_intercept(app, key, id, state);
707 }
708 None => app.push_system("nothing is waiting for you"),
709 }
710}