1use std::collections::{HashMap, HashSet};
14
15use crate::state::app_state::AccessLogEntry;
16use crate::state::llm_activity::LlmActivity;
17use crate::tui::app::{InstanceRef, UiKey};
18use crate::tui::driver::{driver_of, specific_rule_count};
19use crate::tui::llm_activity;
20use crate::tui::metrics::{clock, human_bytes, human_duration, human_rate, Throughput};
21use crate::tui::projection::{ClientRow, RailSnapshot, SendState, ServerRow};
22use crate::tui::rail::{client_line, fit, server_line, InstanceLine, Tone};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum InstanceAction {
27 Stop,
29 Edit,
31 Rules,
33 AddRule,
35 EditRule(usize),
37 DeleteRule(usize),
39 MoveRule(usize, i8),
41 CycleDriver,
43 ConnectClient,
45 Send,
47 SendAction(usize),
49 MessagePeer(u32),
51 DisconnectPeer(u32),
53 Disconnect,
55 Connect,
57 Wireshark,
58 Docs,
60 Answer(u64),
62 OpenRequest(u64),
64}
65
66pub fn shortcut(action: InstanceAction) -> Option<char> {
68 match action {
69 InstanceAction::Stop => Some('x'),
70 InstanceAction::Edit => Some('e'),
71 InstanceAction::Rules => Some('r'),
72 InstanceAction::CycleDriver => Some('m'),
73 InstanceAction::ConnectClient => Some('c'),
74 InstanceAction::Send => Some('n'),
75 InstanceAction::Wireshark => Some('w'),
76 InstanceAction::Docs => Some('d'),
77 _ => None,
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Button {
84 pub action: InstanceAction,
85 pub label: String,
86 pub key: Option<char>,
87 pub enabled: bool,
90 pub why_disabled: Option<String>,
91}
92
93impl Button {
94 pub fn on(action: InstanceAction, label: impl Into<String>) -> Self {
95 Self {
96 action,
97 label: label.into(),
98 key: shortcut(action),
99 enabled: true,
100 why_disabled: None,
101 }
102 }
103
104 pub fn off(action: InstanceAction, label: impl Into<String>, why: impl Into<String>) -> Self {
105 Self {
106 action,
107 label: label.into(),
108 key: shortcut(action),
109 enabled: false,
110 why_disabled: Some(why.into()),
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub enum Group {
118 Peers,
119 Connections,
120 Send,
121 Rules,
122 Config,
123}
124
125impl Group {
126 pub fn label(&self) -> &'static str {
127 match self {
128 Group::Peers => "peers",
129 Group::Connections => "connections",
130 Group::Send => "send",
131 Group::Rules => "rules",
132 Group::Config => "config",
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
139pub enum NodeId {
140 Card(UiKey),
141 Group(UiKey, Group),
142 Peer(UiKey, Option<u32>),
144 Attempt(UiKey, u64),
146}
147
148#[derive(Debug, Default, Clone)]
150pub struct CardState {
151 collapsed: HashSet<NodeId>,
153 opened: HashSet<NodeId>,
155 show_all: HashSet<NodeId>,
157}
158
159impl CardState {
160 pub fn defaults_closed(node: &NodeId) -> bool {
163 matches!(
164 node,
165 NodeId::Group(_, Group::Rules) | NodeId::Group(_, Group::Config)
166 )
167 }
168
169 pub fn is_open(&self, node: &NodeId) -> bool {
170 if Self::defaults_closed(node) {
171 self.opened.contains(node)
172 } else {
173 !self.collapsed.contains(node)
174 }
175 }
176
177 pub fn toggle(&mut self, node: &NodeId) {
178 let set = if Self::defaults_closed(node) {
179 &mut self.opened
180 } else {
181 &mut self.collapsed
182 };
183 if !set.remove(node) {
184 set.insert(node.clone());
185 }
186 }
187
188 pub fn open(&mut self, node: &NodeId) {
189 if Self::defaults_closed(node) {
190 self.opened.insert(node.clone());
191 } else {
192 self.collapsed.remove(node);
193 }
194 }
195
196 pub fn close(&mut self, node: &NodeId) {
197 if Self::defaults_closed(node) {
198 self.opened.remove(node);
199 } else {
200 self.collapsed.insert(node.clone());
201 }
202 }
203
204 pub fn show_all(&mut self, node: &NodeId) {
205 self.show_all.insert(node.clone());
206 }
207
208 pub fn retain_snapshot(&mut self, snapshot: &RailSnapshot) {
210 let mut valid = HashSet::new();
211 for server in &snapshot.servers {
212 let key = UiKey::Server(server.id);
213 valid.insert(NodeId::Card(key));
214 for group in [
215 Group::Peers,
216 Group::Connections,
217 Group::Send,
218 Group::Rules,
219 Group::Config,
220 ] {
221 valid.insert(NodeId::Group(key, group));
222 }
223 for id in server
224 .conns
225 .iter()
226 .map(|c| c.id)
227 .chain(server.recent.iter().map(|c| c.id))
228 {
229 valid.insert(NodeId::Peer(key, Some(id)));
230 }
231 if server.requests.iter().any(|r| r.connection_id.is_none())
232 || snapshot.llm_activity.iter().any(|work| {
233 llm_activity::owner(work) == Some(key)
234 && llm_activity::connection(work).is_none()
235 })
236 {
237 valid.insert(NodeId::Peer(key, None));
238 }
239 }
240 for client in &snapshot.clients {
241 let key = UiKey::Client(client.id);
242 valid.insert(NodeId::Card(key));
243 for group in [
244 Group::Peers,
245 Group::Connections,
246 Group::Send,
247 Group::Rules,
248 Group::Config,
249 ] {
250 valid.insert(NodeId::Group(key, group));
251 }
252 if client.history.is_empty() && client.connection.is_some() {
253 valid.insert(NodeId::Attempt(key, 0));
254 }
255 for attempt in &client.history {
256 valid.insert(NodeId::Attempt(key, attempt.started_unix_ms));
257 }
258 }
259 self.collapsed.retain(|node| valid.contains(node));
260 self.opened.retain(|node| valid.contains(node));
261 self.show_all.retain(|node| valid.contains(node));
262 }
263
264 fn limit_for(&self, node: &NodeId) -> usize {
265 if self.show_all.contains(node) {
266 usize::MAX
267 } else {
268 CHILD_LIMIT
269 }
270 }
271}
272
273pub const CHILD_LIMIT: usize = 5;
275
276pub const NO_PEER_HANDLE_REASON: &str =
281 "not implemented here yet: this protocol has no way to message a peer from the dashboard";
282
283#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum Activate {
286 None,
287 Toggle(NodeId),
288 ShowAll(NodeId),
289 Action(InstanceAction),
290 NewInstance,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct Row {
295 pub key: Option<UiKey>,
297 pub depth: u16,
298 pub header: Option<InstanceLine>,
300 pub spans: Vec<(String, Tone)>,
301 pub buttons: Vec<Button>,
302 pub button_width: usize,
305 pub on_enter: Activate,
306 pub expanded: Option<bool>,
308 pub busy: bool,
310}
311
312impl Row {
313 fn new(key: Option<UiKey>, depth: u16) -> Self {
314 Self {
315 key,
316 depth,
317 header: None,
318 spans: Vec::new(),
319 buttons: Vec::new(),
320 button_width: 0,
321 on_enter: Activate::None,
322 expanded: None,
323 busy: false,
324 }
325 }
326
327 fn note(key: UiKey, depth: u16, text: impl Into<String>, tone: Tone) -> Self {
328 let mut row = Row::new(Some(key), depth);
329 row.spans = vec![(text.into(), tone)];
330 row
331 }
332
333 pub fn has_label_stop(&self) -> bool {
335 self.on_enter != Activate::None
336 }
337
338 pub fn positions(&self) -> usize {
341 usize::from(self.has_label_stop()) + self.buttons.len()
342 }
343
344 pub fn button_at(&self, col: usize) -> Option<&Button> {
346 let offset = usize::from(self.has_label_stop());
347 col.checked_sub(offset).and_then(|i| self.buttons.get(i))
348 }
349
350 pub fn text(&self) -> String {
351 self.spans.iter().map(|(s, _)| s.as_str()).collect()
352 }
353}
354
355fn port_of(addr: &str) -> String {
357 addr.rsplit_once(':')
358 .map(|(_, p)| format!(":{p}"))
359 .unwrap_or_else(|| addr.to_string())
360}
361
362pub fn instance_buttons(instance: InstanceRef<'_>) -> Vec<Button> {
364 let driver = driver_of(instance.routing());
365 let driver_button = Button::on(
366 InstanceAction::CycleDriver,
367 format!("driver → {}", driver.next().label()),
368 );
369 match instance {
370 InstanceRef::Server(_) => {
371 let mut buttons = vec![
372 Button::on(InstanceAction::Stop, "stop"),
373 Button::on(InstanceAction::Edit, "edit"),
374 Button::on(InstanceAction::Rules, "rules"),
375 driver_button,
376 ];
377 buttons.push(Button::on(InstanceAction::Wireshark, "wireshark"));
378 buttons.push(Button::on(InstanceAction::Docs, "docs"));
379 buttons
380 }
381 InstanceRef::Client(row) => {
382 let mut buttons = Vec::new();
383 match row.send_state {
384 SendState::NotConnected => {
385 buttons.push(Button::on(InstanceAction::Connect, "connect"))
386 }
387 _ => buttons.push(Button::on(InstanceAction::Disconnect, "disconnect")),
388 }
389 buttons.push(match row.send_state {
390 SendState::Ready if !row.send_actions.is_empty() => {
391 Button::on(InstanceAction::Send, "send…")
392 }
393 SendState::Ready => Button::off(
394 InstanceAction::Send,
395 "send…",
396 "this protocol declares no client verbs",
397 ),
398 SendState::NotConnected => {
399 Button::off(InstanceAction::Send, "send…", "not connected")
400 }
401 SendState::ProtocolUnsupported => Button::off(
402 InstanceAction::Send,
403 "send…",
404 "this client's loop has no command channel yet",
405 ),
406 });
407 buttons.push(Button::on(InstanceAction::Stop, "remove"));
408 buttons.push(Button::on(InstanceAction::Edit, "edit"));
409 buttons.push(Button::on(InstanceAction::Rules, "rules"));
410 buttons.push(driver_button);
411 buttons.push(Button::on(InstanceAction::Wireshark, "wireshark"));
412 buttons.push(Button::on(InstanceAction::Docs, "docs"));
413 buttons
414 }
415 }
416}
417
418fn button_rows(key: UiKey, depth: u16, buttons: Vec<Button>, width: usize) -> Vec<Row> {
422 if buttons.is_empty() {
423 return Vec::new();
424 }
425 let cell = buttons
426 .iter()
427 .map(|b| b.label.chars().count() + 4)
428 .max()
429 .unwrap_or(4);
430 let indent = depth as usize * 2 + 2;
431 let per_row = (width.saturating_sub(indent) / (cell + 1)).max(1);
432 let mut rows = Vec::new();
433 for chunk in buttons.chunks(per_row) {
434 let mut row = Row::new(Some(key), depth);
435 row.buttons = chunk.to_vec();
436 row.button_width = cell;
437 rows.push(row);
438 }
439 rows
440}
441
442fn group_row(key: UiKey, group: Group, state: &CardState, detail: String) -> (Row, bool) {
443 let node = NodeId::Group(key, group);
444 let open = state.is_open(&node);
445 let mut row = Row::new(Some(key), 1);
446 row.spans = vec![
447 (group.label().to_string(), Tone::Title),
448 (format!(" {detail}"), Tone::Dim),
449 ];
450 row.on_enter = Activate::Toggle(node);
451 row.expanded = Some(open);
452 (row, open)
453}
454
455const PAYLOAD_KEYS: [&str; 14] = [
458 "data", "text", "command", "line", "message", "body", "payload", "query", "path", "url",
459 "domain", "name", "key", "value",
460];
461
462pub fn payload_summary(value: &serde_json::Value) -> String {
466 let text = match value {
467 serde_json::Value::String(s) => s.clone(),
468 serde_json::Value::Object(map) => {
469 let request_line = match (
471 map.get("method").and_then(|v| v.as_str()),
472 map.get("path")
473 .or_else(|| map.get("uri"))
474 .and_then(|v| v.as_str()),
475 ) {
476 (Some(method), Some(path)) => Some(format!("{method} {path}")),
477 _ => None,
478 };
479 let picked = PAYLOAD_KEYS.iter().find_map(|k| map.get(*k));
480 match (request_line, picked) {
481 (Some(line), _) => line,
482 (None, picked) => match picked {
483 Some(serde_json::Value::String(s)) => s.clone(),
484 Some(other) => other.to_string(),
485 None => {
486 let rest: Vec<String> = map
487 .iter()
488 .filter(|(k, _)| *k != "type" && *k != "connection_id")
489 .map(|(k, v)| match v {
490 serde_json::Value::String(s) => format!("{k}={s}"),
491 other => format!("{k}={other}"),
492 })
493 .collect();
494 rest.join(" ")
495 }
496 },
497 }
498 }
499 serde_json::Value::Null => String::new(),
500 other => other.to_string(),
501 };
502 crate::utils::sanitize::line_field(&text.replace('\r', "").replace('\n', "⏎"))
506}
507
508fn message_rows(key: UiKey, depth: u16, entry: &AccessLogEntry) -> Vec<Row> {
512 let time = format!("{} ", clock(entry.unix_ms));
513 let open = Activate::Action(InstanceAction::OpenRequest(entry.id));
514 let mut rows = Vec::new();
515 if entry.event_type == "injected_action" {
516 let kind = entry
517 .request
518 .get("type")
519 .and_then(|t| t.as_str())
520 .unwrap_or("action");
521 let mut row = Row::new(Some(key), depth);
522 row.spans = vec![
523 (time, Tone::Dim),
524 ("→ ".to_string(), Tone::Good),
525 (format!("{kind} "), Tone::Dim),
526 (payload_summary(&entry.request), Tone::Normal),
527 ];
528 row.on_enter = open;
529 rows.push(row);
530 return rows;
531 }
532 let mut incoming = Row::new(Some(key), depth);
533 incoming.spans = vec![
534 (time.clone(), Tone::Dim),
535 ("← ".to_string(), Tone::Accent),
536 (format!("{} ", entry.event_type), Tone::Dim),
537 (payload_summary(&entry.request), Tone::Normal),
538 ];
539 incoming.on_enter = open.clone();
540 rows.push(incoming);
541 for action in &entry.response {
542 let kind = action
543 .get("type")
544 .and_then(|t| t.as_str())
545 .map(|t| t.to_string())
546 .unwrap_or_else(|| {
547 action
549 .as_object()
550 .and_then(|o| o.keys().next().cloned())
551 .unwrap_or_else(|| "action".to_string())
552 });
553 let mut outgoing = Row::new(Some(key), depth);
554 outgoing.spans = vec![
555 (" ".to_string(), Tone::Dim),
556 ("→ ".to_string(), Tone::Good),
557 (format!("{kind} "), Tone::Dim),
558 (payload_summary(action), Tone::Normal),
559 ];
560 outgoing.on_enter = open.clone();
561 rows.push(outgoing);
562 }
563 rows
564}
565
566fn conversation(
569 rows: &mut Vec<Row>,
570 state: &CardState,
571 node: &NodeId,
572 key: UiKey,
573 depth: u16,
574 entries: &[&AccessLogEntry],
575) {
576 let limit = state.limit_for(node);
577 let total = entries.len();
578 let shown = total.min(limit);
579 if total > shown {
580 let mut more = Row::note(
581 key,
582 depth,
583 format!("… {} earlier", total - shown),
584 Tone::Dim,
585 );
586 more.on_enter = Activate::ShowAll(node.clone());
587 rows.push(more);
588 }
589 for entry in &entries[total - shown..] {
590 rows.extend(message_rows(key, depth, entry));
591 }
592 if total == 0 {
593 rows.push(Row::note(key, depth, "(nothing exchanged yet)", Tone::Dim));
594 }
595}
596
597fn waiting_rows(instance: InstanceRef<'_>, rows: &mut Vec<Row>) {
598 let key = instance.key();
599 for view in instance.intercepts() {
600 let peer = match instance {
601 InstanceRef::Server(row) => view
602 .connection_id
603 .and_then(|c| row.conns.iter().find(|x| x.id == c))
604 .map(|c| format!(" from {}", port_of(&c.remote_addr)))
605 .unwrap_or_default(),
606 InstanceRef::Client(_) => String::new(),
607 };
608 let mut row = Row::new(Some(key), 1);
609 row.spans = vec![
610 ("⚠ YOUR answer needed".to_string(), Tone::Bad),
611 (format!(" · {}{peer}", view.event_type), Tone::Normal),
612 ];
613 row.on_enter = Activate::Action(InstanceAction::Answer(view.id));
614 rows.push(row);
615 }
616}
617
618fn traffic_text(rx: u64, tx: u64, metrics: Option<&Throughput>) -> String {
619 let mut text = format!("↓{} ↑{}", human_bytes(rx), human_bytes(tx));
620 if let Some(m) = metrics {
621 let (rrx, rtx) = m.rate();
622 if rrx > 0 || rtx > 0 {
623 text.push_str(&format!(" · now ↓{} ↑{}", human_rate(rrx), human_rate(rtx)));
624 }
625 }
626 text
627}
628
629fn driver_row(instance: InstanceRef<'_>) -> Row {
630 let driver = driver_of(instance.routing());
631 let specific = specific_rule_count(instance.routing());
632 let suffix = match specific {
633 0 => String::new(),
634 1 => " · 1 specific rule first".to_string(),
635 n => format!(" · {n} specific rules first"),
636 };
637 let mut row = Row::new(Some(instance.key()), 1);
638 row.spans = vec![
639 ("driver ".to_string(), Tone::Dim),
640 (driver.label().to_string(), driver.tone()),
641 (format!(" — {}{suffix}", driver.describe()), Tone::Dim),
642 ];
643 row
644}
645
646fn rule_rows(instance: InstanceRef<'_>, state: &CardState, rows: &mut Vec<Row>) {
647 use crate::scripting::event_handler::EventPattern;
648 use crate::scripting::EventHandlerType;
649
650 let key = instance.key();
651 let count = instance.routing().map(|c| c.handlers.len()).unwrap_or(0);
652 let driver = driver_of(instance.routing());
653 let (row, open) = group_row(
654 key,
655 Group::Rules,
656 state,
657 format!(
658 "{count} rule{} · driver {}",
659 if count == 1 { "" } else { "s" },
660 driver.label()
661 ),
662 );
663 rows.push(row);
664 if !open {
665 return;
666 }
667 let mut has_wildcard = false;
668 if let Some(config) = instance.routing() {
669 for (index, handler) in config.handlers.iter().enumerate() {
670 let pattern = match &handler.event_pattern {
671 EventPattern::Specific(s) => s.clone(),
672 EventPattern::Wildcard => {
673 has_wildcard = true;
674 "*".to_string()
675 }
676 };
677 let (kind, tone, detail) = match &handler.handler {
678 EventHandlerType::Llm { instruction } => (
679 "LLM",
680 Tone::Reasoning,
681 crate::utils::truncate_for_log(instruction, 40),
682 ),
683 EventHandlerType::Script {
684 language, resident, ..
685 } => (
686 "SCRIPT",
687 Tone::Good,
688 format!("{language}{}", if *resident { ", resident" } else { "" }),
689 ),
690 EventHandlerType::Static { actions } => {
691 let names: Vec<&str> = actions
692 .iter()
693 .filter_map(|a| a.get("type").and_then(|t| t.as_str()))
694 .collect();
695 (
696 if actions.is_empty() {
697 "SILENT"
698 } else {
699 "STATIC"
700 },
701 if actions.is_empty() {
702 Tone::Dim
703 } else {
704 Tone::Good
705 },
706 if names.is_empty() {
707 "answer with nothing".to_string()
708 } else {
709 names.join(", ")
710 },
711 )
712 }
713 EventHandlerType::Manual { timeout_secs } => (
714 "MANUAL",
715 Tone::Warn,
716 format!("you answer · {timeout_secs}s"),
717 ),
718 };
719 let mut row = Row::new(Some(key), 2);
720 row.spans = vec![
721 (format!("{} ", index + 1), Tone::Dim),
722 (format!("{pattern} → "), Tone::Normal),
723 (kind.to_string(), tone),
724 (format!(" {detail}"), Tone::Dim),
725 ];
726 row.on_enter = Activate::Action(InstanceAction::EditRule(index));
727 row.buttons
728 .push(Button::on(InstanceAction::DeleteRule(index), "delete"));
729 if count > 1 {
730 row.buttons
731 .push(Button::on(InstanceAction::MoveRule(index, -1), "↑"));
732 row.buttons
733 .push(Button::on(InstanceAction::MoveRule(index, 1), "↓"));
734 }
735 rows.push(row);
736 }
737 }
738 if !has_wildcard {
739 rows.push(Row::note(
740 key,
741 2,
742 "otherwise → LLM, from the instance instruction",
743 Tone::Dim,
744 ));
745 }
746 let mut add = Row::new(Some(key), 2);
747 add.buttons
748 .push(Button::on(InstanceAction::AddRule, "+ add rule"));
749 rows.push(add);
750}
751
752fn config_rows(instance: InstanceRef<'_>, state: &CardState, width: usize, rows: &mut Vec<Row>) {
753 let key = instance.key();
754 let (params, instruction, memory_len, tasks) = match instance {
755 InstanceRef::Server(row) => (
756 row.startup_params.as_ref(),
757 &row.instruction,
758 row.memory_len,
759 row.task_count,
760 ),
761 InstanceRef::Client(row) => (
762 row.startup_params.as_ref(),
763 &row.instruction,
764 row.memory_len,
765 row.task_count,
766 ),
767 };
768 let param_count = params
769 .and_then(|p| p.as_object())
770 .map(|m| m.len())
771 .unwrap_or(0);
772 let (row, open) = group_row(
773 key,
774 Group::Config,
775 state,
776 format!(
777 "{} setting{}",
778 param_count + 2,
779 if param_count + 2 == 1 { "" } else { "s" }
780 ),
781 );
782 rows.push(row);
783 if !open {
784 return;
785 }
786 let value_width = width.saturating_sub(20);
787 let mut push = |name: &str, value: String, tone: Tone| {
788 let mut row = Row::new(Some(key), 2);
789 row.spans = vec![
790 (format!("{name:<12} "), Tone::Dim),
791 (fit(&value, value_width), tone),
792 ];
793 row.on_enter = Activate::Action(InstanceAction::Edit);
794 rows.push(row);
795 };
796 match instance {
797 InstanceRef::Server(row) => {
798 push(
799 "port",
800 row.local_addr
801 .as_deref()
802 .and_then(|a| a.rsplit_once(':').map(|(_, p)| p.to_string()))
803 .unwrap_or_else(|| row.port.to_string()),
804 Tone::Normal,
805 );
806 push(
807 "host",
808 row.local_addr
809 .as_deref()
810 .and_then(|a| a.rsplit_once(':').map(|(h, _)| h.to_string()))
811 .unwrap_or_else(|| "(default)".to_string()),
812 Tone::Normal,
813 );
814 }
815 InstanceRef::Client(row) => push("remote", row.remote_addr.clone(), Tone::Normal),
816 }
817 if let Some(map) = params.and_then(|p| p.as_object()) {
818 for (k, v) in map {
819 let shown = match v {
820 serde_json::Value::String(s) => s.clone(),
821 other => other.to_string(),
822 };
823 push(k, shown, Tone::Normal);
824 }
825 }
826 let brief = instruction.trim();
827 push(
828 "instruction",
829 if brief.is_empty() {
830 "(none)".to_string()
831 } else {
832 brief.lines().next().unwrap_or("").to_string()
833 },
834 if brief.is_empty() {
835 Tone::Dim
836 } else {
837 Tone::Normal
838 },
839 );
840 push("memory", format!("{memory_len} chars"), Tone::Dim);
841 if tasks > 0 {
842 push("tasks", format!("{tasks} scheduled"), Tone::Dim);
843 }
844}
845
846fn generating_rows(rows: &mut Vec<Row>, key: UiKey, depth: u16, activity: &[&LlmActivity]) {
847 for work in activity {
848 let mut row = Row::note(key, depth, llm_activity::label(work), Tone::Reasoning);
849 row.busy = true;
850 rows.push(row);
851 }
852}
853
854fn server_rows(
855 row: &ServerRow,
856 state: &CardState,
857 metrics: Option<&Throughput>,
858 width: usize,
859 activity: &[&LlmActivity],
860) -> Vec<Row> {
861 let key = UiKey::Server(row.id);
862 let instance = InstanceRef::Server(row);
863 let card = NodeId::Card(key);
864 let open = state.is_open(&card);
865 let mut rows = Vec::new();
866
867 let mut header = Row::new(Some(key), 0);
868 header.header = Some(server_line(row));
869 header.on_enter = Activate::Toggle(card.clone());
870 header.expanded = Some(open);
871 rows.push(header);
872 if !open {
873 return rows;
874 }
875
876 waiting_rows(instance, &mut rows);
877
878 use crate::state::server::ServerStatus;
880 let (status, tone) = match &row.status {
881 ServerStatus::Running => ("Running", Tone::Good),
882 ServerStatus::Starting => ("Starting", Tone::Warn),
883 ServerStatus::Stopped => ("Stopped", Tone::Dim),
884 ServerStatus::Error(_) => ("Error", Tone::Bad),
885 };
886 let (rx, tx) = row.conns.iter().fold((0u64, 0u64), |(a, b), c| {
887 (a + c.bytes_received, b + c.bytes_sent)
888 });
889 let mut facts = Row::new(Some(key), 1);
890 facts.spans = vec![
891 (status.to_string(), tone),
892 (
893 format!(
894 " · up {} · {} · {} req",
895 human_duration(row.uptime_secs),
896 traffic_text(rx, tx, metrics),
897 row.requests.len()
898 ),
899 Tone::Dim,
900 ),
901 ];
902 rows.push(facts);
903 if let ServerStatus::Error(e) = &row.status {
904 rows.push(Row::note(
905 key,
906 1,
907 fit(e, width.saturating_sub(4)),
908 Tone::Bad,
909 ));
910 }
911 rows.push(driver_row(instance));
912
913 rows.extend(button_rows(key, 1, instance_buttons(instance), width));
914
915 let live = row.conns.iter().filter(|c| c.active).count();
917 let (mut group, open) = group_row(
918 key,
919 Group::Peers,
920 state,
921 format!("{live} live · {} recent", row.recent.len()),
922 );
923 group.busy = !open && !activity.is_empty();
924 rows.push(group);
925 if open {
926 let mut any = false;
927 for conn in &row.conns {
928 any = true;
929 let node = NodeId::Peer(key, Some(conn.id));
930 let mine: Vec<&AccessLogEntry> = row
931 .requests
932 .iter()
933 .filter(|r| r.connection_id == Some(conn.id))
934 .collect();
935 let waiting = row
936 .intercepts
937 .iter()
938 .any(|v| v.connection_id == Some(conn.id));
939 let peer_open = state.is_open(&node);
940 let generating: Vec<_> = activity
941 .iter()
942 .copied()
943 .filter(|work| llm_activity::connection(work) == Some(conn.id))
944 .collect();
945 let mut peer = Row::new(Some(key), 2);
946 peer.busy = !peer_open && !generating.is_empty();
947 peer.spans = vec![
948 (
949 if conn.active { "● " } else { "○ " }.to_string(),
950 if conn.active { Tone::Good } else { Tone::Dim },
951 ),
952 (
953 conn.remote_addr.clone(),
954 if conn.active { Tone::Normal } else { Tone::Dim },
955 ),
956 (
957 format!(
958 " ↓{} ↑{} · {}{}",
959 human_bytes(conn.bytes_received),
960 human_bytes(conn.bytes_sent),
961 mine.len(),
962 if conn.active { "" } else { " (closed)" }
963 ),
964 Tone::Dim,
965 ),
966 ];
967 if waiting {
968 peer.spans.push((" ⚠ waiting".to_string(), Tone::Bad));
969 }
970 peer.on_enter = Activate::Toggle(node.clone());
971 peer.expanded = Some(peer_open);
972 if conn.active {
973 peer.buttons.push(if conn.can_message {
974 Button::on(InstanceAction::DisconnectPeer(conn.id), "disconnect")
975 } else {
976 Button::off(
977 InstanceAction::DisconnectPeer(conn.id),
978 "disconnect",
979 "this protocol cannot disconnect a peer from here yet",
980 )
981 });
982 }
983 rows.push(peer);
984 if peer_open {
985 conversation(&mut rows, state, &node, key, 3, &mine);
986 generating_rows(&mut rows, key, 3, &generating);
987 if conn.active {
988 let mut send = Row::new(Some(key), 3);
989 send.buttons.push(if conn.can_message {
990 Button::on(InstanceAction::MessagePeer(conn.id), "send message")
991 } else {
992 Button::off(
993 InstanceAction::MessagePeer(conn.id),
994 "send message",
995 row.request_only
996 .clone()
997 .unwrap_or_else(|| NO_PEER_HANDLE_REASON.to_string()),
998 )
999 });
1000 rows.push(send);
1001 }
1002 }
1003 }
1004 for closed in &row.recent {
1005 any = true;
1006 let node = NodeId::Peer(key, Some(closed.id));
1007 let mine: Vec<&AccessLogEntry> = row
1008 .requests
1009 .iter()
1010 .filter(|r| r.connection_id == Some(closed.id))
1011 .collect();
1012 let peer_open = state.is_open(&node);
1013 let generating: Vec<_> = activity
1014 .iter()
1015 .copied()
1016 .filter(|work| llm_activity::connection(work) == Some(closed.id))
1017 .collect();
1018 let mut peer = Row::new(Some(key), 2);
1019 peer.busy = !peer_open && !generating.is_empty();
1020 peer.spans = vec![
1021 ("○ ".to_string(), Tone::Dim),
1022 (closed.remote_addr.clone(), Tone::Dim),
1023 (
1024 format!(
1025 " ↓{} ↑{} · {} (closed)",
1026 human_bytes(closed.bytes_received),
1027 human_bytes(closed.bytes_sent),
1028 mine.len()
1029 ),
1030 Tone::Dim,
1031 ),
1032 ];
1033 peer.on_enter = Activate::Toggle(node.clone());
1034 peer.expanded = Some(peer_open);
1035 rows.push(peer);
1036 if peer_open {
1037 conversation(&mut rows, state, &node, key, 3, &mine);
1038 generating_rows(&mut rows, key, 3, &generating);
1039 }
1040 }
1041 let loose: Vec<&AccessLogEntry> = row
1042 .requests
1043 .iter()
1044 .filter(|r| r.connection_id.is_none())
1045 .collect();
1046 let generating: Vec<_> = activity
1047 .iter()
1048 .copied()
1049 .filter(|work| llm_activity::connection(work).is_none())
1050 .collect();
1051 if !loose.is_empty() || !generating.is_empty() {
1052 any = true;
1053 let node = NodeId::Peer(key, None);
1054 let bucket_open = state.is_open(&node);
1055 let mut bucket = Row::new(Some(key), 2);
1056 bucket.busy = !bucket_open && !generating.is_empty();
1057 bucket.spans = vec![
1058 ("· (connectionless)".to_string(), Tone::Dim),
1059 (format!(" · {}", loose.len()), Tone::Dim),
1060 ];
1061 bucket.on_enter = Activate::Toggle(node.clone());
1062 bucket.expanded = Some(bucket_open);
1063 rows.push(bucket);
1064 if bucket_open {
1065 conversation(&mut rows, state, &node, key, 3, &loose);
1066 generating_rows(&mut rows, key, 3, &generating);
1067 }
1068 }
1069 let orphaned: Vec<_> = activity
1071 .iter()
1072 .copied()
1073 .filter(|work| {
1074 llm_activity::connection(work).is_some_and(|id| {
1075 !row.conns.iter().any(|c| c.id == id) && !row.recent.iter().any(|c| c.id == id)
1076 })
1077 })
1078 .collect();
1079 generating_rows(&mut rows, key, 2, &orphaned);
1080 any |= !orphaned.is_empty();
1081 if !any {
1082 rows.push(Row::note(
1083 key,
1084 2,
1085 format!(
1086 "(no connections yet — listening on {})",
1087 row.local_addr
1088 .clone()
1089 .unwrap_or_else(|| format!(":{}", row.port))
1090 ),
1091 Tone::Dim,
1092 ));
1093 }
1094 let mut add = Row::new(Some(key), 2);
1096 add.buttons.push(match &row.client_counterpart {
1097 Some(p) => Button::on(
1098 InstanceAction::ConnectClient,
1099 format!("+ {} client", p.to_lowercase()),
1100 ),
1101 None => Button::off(
1102 InstanceAction::ConnectClient,
1103 "+ client",
1104 "no client implementation for this protocol is compiled in",
1105 ),
1106 });
1107 rows.push(add);
1108 }
1109
1110 rule_rows(instance, state, &mut rows);
1111 config_rows(instance, state, width, &mut rows);
1112 rows
1113}
1114
1115fn client_rows(
1116 row: &ClientRow,
1117 state: &CardState,
1118 metrics: Option<&Throughput>,
1119 width: usize,
1120 activity: &[&LlmActivity],
1121) -> Vec<Row> {
1122 let key = UiKey::Client(row.id);
1123 let instance = InstanceRef::Client(row);
1124 let card = NodeId::Card(key);
1125 let open = state.is_open(&card);
1126 let mut rows = Vec::new();
1127
1128 let mut header = Row::new(Some(key), 0);
1129 header.header = Some(client_line(row));
1130 header.on_enter = Activate::Toggle(card.clone());
1131 header.expanded = Some(open);
1132 rows.push(header);
1133 if !open {
1134 return rows;
1135 }
1136
1137 waiting_rows(instance, &mut rows);
1138
1139 use crate::state::client::ClientStatus;
1140 let (status, tone) = match &row.status {
1141 ClientStatus::Connected => ("Connected", Tone::Good),
1142 ClientStatus::Connecting => ("Connecting", Tone::Warn),
1143 ClientStatus::Disconnected => ("Disconnected", Tone::Dim),
1144 ClientStatus::Error(_) => ("Error", Tone::Bad),
1145 };
1146 let (rx, tx) = row
1147 .connection
1148 .as_ref()
1149 .map(|c| (c.bytes_received, c.bytes_sent))
1150 .unwrap_or((0, 0));
1151 let mut facts = Row::new(Some(key), 1);
1152 facts.spans = vec![
1153 (status.to_string(), tone),
1154 (
1155 format!(
1156 " · up {} · {} · {} req",
1157 human_duration(row.uptime_secs),
1158 traffic_text(rx, tx, metrics),
1159 row.requests.len()
1160 ),
1161 Tone::Dim,
1162 ),
1163 ];
1164 rows.push(facts);
1165 if let ClientStatus::Error(e) = &row.status {
1166 rows.push(Row::note(
1167 key,
1168 1,
1169 fit(e, width.saturating_sub(4)),
1170 Tone::Bad,
1171 ));
1172 }
1173 rows.push(driver_row(instance));
1174
1175 rows.extend(button_rows(key, 1, instance_buttons(instance), width));
1176
1177 let (group, open) = group_row(
1179 key,
1180 Group::Send,
1181 state,
1182 match row.send_state {
1183 SendState::Ready => format!("{} verb(s)", row.send_actions.len()),
1184 SendState::NotConnected => "not connected".to_string(),
1185 SendState::ProtocolUnsupported => "no command channel yet".to_string(),
1186 },
1187 );
1188 rows.push(group);
1189 if open {
1190 match row.send_state {
1191 SendState::Ready => {}
1192 SendState::NotConnected => rows.push(Row::note(
1193 key,
1194 2,
1195 "(cannot send — not connected; connect redials)",
1196 Tone::Dim,
1197 )),
1198 SendState::ProtocolUnsupported => rows.push(Row::note(
1199 key,
1200 2,
1201 "(cannot send — this client's loop has no command channel yet)",
1202 Tone::Dim,
1203 )),
1204 }
1205 if row.send_actions.is_empty() {
1206 rows.push(Row::note(
1207 key,
1208 2,
1209 "(this protocol declares no client verbs)",
1210 Tone::Dim,
1211 ));
1212 }
1213 for (index, verb) in row.send_actions.iter().enumerate() {
1214 let mut r = Row::new(Some(key), 2);
1215 r.spans = vec![
1216 (format!("{:<18} ", fit(&verb.name, 18)), Tone::Accent),
1217 (verb.description.clone(), Tone::Dim),
1218 ];
1219 r.on_enter = if row.send_state == SendState::Ready {
1220 Activate::Action(InstanceAction::SendAction(index))
1221 } else {
1222 Activate::None
1223 };
1224 rows.push(r);
1225 }
1226 }
1227
1228 let connection_count = row.history.len().max(usize::from(row.connection.is_some()));
1231 let (mut group, open) = group_row(
1232 key,
1233 Group::Connections,
1234 state,
1235 format!("{connection_count} · {} messages", row.requests.len()),
1236 );
1237 group.busy = !open && !activity.is_empty();
1238 rows.push(group);
1239 if open {
1240 let send_button = || match row.send_state {
1241 SendState::Ready if !row.send_actions.is_empty() => {
1242 Button::on(InstanceAction::Send, "send message")
1243 }
1244 SendState::Ready => Button::off(
1245 InstanceAction::Send,
1246 "send message",
1247 "this protocol declares no client verbs",
1248 ),
1249 SendState::NotConnected => {
1250 Button::off(InstanceAction::Send, "send message", "not connected")
1251 }
1252 SendState::ProtocolUnsupported => Button::off(
1253 InstanceAction::Send,
1254 "send message",
1255 "this client's loop has no command channel yet",
1256 ),
1257 };
1258 if row.history.is_empty() {
1259 match &row.connection {
1260 Some(c) => {
1261 let node = NodeId::Attempt(key, 0);
1262 let conn_open = state.is_open(&node);
1263 let mut r = Row::new(Some(key), 2);
1264 r.busy = !conn_open && !activity.is_empty();
1265 r.spans = vec![
1266 ("● ".to_string(), Tone::Good),
1267 (c.remote_addr.clone(), Tone::Normal),
1268 (
1269 format!(
1270 " ↓{} ↑{} · {}",
1271 human_bytes(c.bytes_received),
1272 human_bytes(c.bytes_sent),
1273 row.requests.len()
1274 ),
1275 Tone::Dim,
1276 ),
1277 ];
1278 r.on_enter = Activate::Toggle(node.clone());
1279 r.expanded = Some(conn_open);
1280 rows.push(r);
1281 if conn_open {
1282 let all: Vec<&AccessLogEntry> = row.requests.iter().collect();
1283 conversation(&mut rows, state, &node, key, 3, &all);
1284 generating_rows(&mut rows, key, 3, activity);
1285 let mut send = Row::new(Some(key), 3);
1286 send.buttons.push(send_button());
1287 rows.push(send);
1288 }
1289 }
1290 None => {
1291 generating_rows(&mut rows, key, 2, activity);
1292 if activity.is_empty() {
1293 rows.push(Row::note(key, 2, "(no connections yet)", Tone::Dim));
1294 }
1295 }
1296 }
1297 } else {
1298 let starts: Vec<u64> = row.history.iter().map(|a| a.started_unix_ms).collect();
1299 let last = row.history.len() - 1;
1300 for (index, attempt) in row.history.iter().enumerate().rev() {
1301 let from = if index == 0 { 0 } else { starts[index] };
1302 let to = starts.get(index + 1).copied().unwrap_or(u64::MAX);
1303 let mine: Vec<&AccessLogEntry> = row
1304 .requests
1305 .iter()
1306 .filter(|r| r.unix_ms >= from && r.unix_ms < to)
1307 .collect();
1308 let live =
1309 index == last && attempt.ended_unix_ms.is_none() && row.connection.is_some();
1310 let node = NodeId::Attempt(key, attempt.started_unix_ms);
1311 let conn_open = state.is_open(&node);
1312 let mut r = Row::new(Some(key), 2);
1313 r.busy = index == last && !conn_open && !activity.is_empty();
1314 r.spans = vec![
1315 (
1316 if live { "● " } else { "○ " }.to_string(),
1317 if live { Tone::Good } else { Tone::Dim },
1318 ),
1319 (
1320 format!("{} {}", clock(attempt.started_unix_ms), attempt.remote_addr),
1321 if live { Tone::Normal } else { Tone::Dim },
1322 ),
1323 (
1324 if live {
1325 let c = row.connection.as_ref().expect("checked above");
1326 format!(
1327 " ↓{} ↑{} · {}",
1328 human_bytes(c.bytes_received),
1329 human_bytes(c.bytes_sent),
1330 mine.len()
1331 )
1332 } else {
1333 format!(" {} · {}", attempt.outcome, mine.len())
1334 },
1335 Tone::Dim,
1336 ),
1337 ];
1338 r.on_enter = Activate::Toggle(node.clone());
1339 r.expanded = Some(conn_open);
1340 rows.push(r);
1341 if conn_open {
1342 conversation(&mut rows, state, &node, key, 3, &mine);
1343 if index == last {
1344 generating_rows(&mut rows, key, 3, activity);
1345 }
1346 if live {
1347 let mut send = Row::new(Some(key), 3);
1348 send.buttons.push(send_button());
1349 rows.push(send);
1350 }
1351 }
1352 }
1353 }
1354 }
1355
1356 rule_rows(instance, state, &mut rows);
1357 config_rows(instance, state, width, &mut rows);
1358 rows
1359}
1360
1361pub fn rows(
1363 snapshot: &RailSnapshot,
1364 state: &CardState,
1365 metrics: &HashMap<UiKey, Throughput>,
1366 width: usize,
1367) -> Vec<Row> {
1368 let mut rows = Vec::new();
1369 for server in &snapshot.servers {
1370 let activity: Vec<_> = snapshot
1371 .llm_activity
1372 .iter()
1373 .filter(|work| llm_activity::owner(work) == Some(UiKey::Server(server.id)))
1374 .collect();
1375 rows.extend(server_rows(
1376 server,
1377 state,
1378 metrics.get(&UiKey::Server(server.id)),
1379 width,
1380 &activity,
1381 ));
1382 }
1383 for client in &snapshot.clients {
1384 let activity: Vec<_> = snapshot
1385 .llm_activity
1386 .iter()
1387 .filter(|work| llm_activity::owner(work) == Some(UiKey::Client(client.id)))
1388 .collect();
1389 rows.extend(client_rows(
1390 client,
1391 state,
1392 metrics.get(&UiKey::Client(client.id)),
1393 width,
1394 &activity,
1395 ));
1396 }
1397 let mut new = Row::new(None, 0);
1398 new.spans = vec![("+ new server or client".to_string(), Tone::Accent)];
1399 new.on_enter = Activate::NewInstance;
1400 rows.push(new);
1401 if snapshot.servers.is_empty() && snapshot.clients.is_empty() {
1402 for text in [
1403 "",
1404 "Nothing is running yet.",
1405 "",
1406 "That row, or a, picks a protocol",
1407 "and starts it. The instance shows",
1408 "up here with its buttons, peers,",
1409 "rules and config, and stays.",
1410 "↑↓ walk the rows, ←→ the buttons.",
1411 "",
1412 "Or ask the model in the chat:",
1413 "“start an http server on 8080”.",
1414 ] {
1415 let mut note = Row::new(None, 0);
1416 note.spans = vec![(format!(" {text}"), Tone::Dim)];
1417 rows.push(note);
1418 }
1419 }
1420 rows
1421}
1422
1423pub fn header_index(rows: &[Row], key: UiKey) -> Option<usize> {
1425 rows.iter()
1426 .position(|r| r.key == Some(key) && r.header.is_some())
1427}