Skip to main content

netget/tui/
cards.rs

1//! The instance canvas: every server and client, always visible, each as a
2//! card with its buttons and its sub-sections.
3//!
4//! There is no selection step. The whole column is one list of rows: a
5//! card's header, the requests parked for the human, its facts, its buttons
6//! (an aligned grid, never a ragged wrap), then collapsible sections —
7//! `peers` with the conversation on each peer beneath it and a send button
8//! under that, `rules`, `config`, and for a client `send` and `connections`. ↑/↓ walk the rows, ←/→ walk the
9//! buttons on a row, Enter acts on whatever the cursor is on. Everything
10//! that *does* something is an [`InstanceAction`] run by `actions::run`, so a
11//! letter, a button and a click cannot diverge.
12
13use 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/// Everything the dashboard can do to an instance.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum InstanceAction {
27    /// Stop a server / remove a client.
28    Stop,
29    /// Open the config form.
30    Edit,
31    /// Open the routing editor on the table.
32    Rules,
33    /// Open the routing editor on a fresh rule.
34    AddRule,
35    /// Open the routing editor on one rule.
36    EditRule(usize),
37    /// Delete one rule and apply.
38    DeleteRule(usize),
39    /// Move one rule earlier (-1) or later (+1) and apply.
40    MoveRule(usize, i8),
41    /// Cycle MANUAL → LLM → SILENT and apply.
42    CycleDriver,
43    /// Connect a client of the counterpart protocol to this server.
44    ConnectClient,
45    /// Open the composer on the action list.
46    Send,
47    /// Open the composer on one action, by index into `send_actions`.
48    SendAction(usize),
49    /// Compose for one live server connection.
50    MessagePeer(u32),
51    /// Close one live server connection from our side.
52    DisconnectPeer(u32),
53    /// Hang up a client but keep it.
54    Disconnect,
55    /// Redial a disconnected client.
56    Connect,
57    Wireshark,
58    /// Protocol description and maturity, into the stream.
59    Docs,
60    /// Open the answer modal for a parked request.
61    Answer(u64),
62    /// Open the request/response detail for one access-log entry.
63    OpenRequest(u64),
64}
65
66/// The letter that runs an action without a button.
67pub 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/// One button on a row.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Button {
84    pub action: InstanceAction,
85    pub label: String,
86    pub key: Option<char>,
87    /// A disabled button stays visible (so the capability is discoverable)
88    /// and says why when pressed.
89    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/// A card's sub-sections.
116#[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/// Identity of a collapsible node, stable across re-polls.
138#[derive(Debug, Clone, PartialEq, Eq, Hash)]
139pub enum NodeId {
140    Card(UiKey),
141    Group(UiKey, Group),
142    /// A server connection; `None` is the connectionless bucket.
143    Peer(UiKey, Option<u32>),
144    /// A client connection attempt, by start time.
145    Attempt(UiKey, u64),
146}
147
148/// What is folded and what is unfolded, across every card.
149#[derive(Debug, Default, Clone)]
150pub struct CardState {
151    /// Open-by-default nodes the user closed.
152    collapsed: HashSet<NodeId>,
153    /// Closed-by-default nodes the user opened.
154    opened: HashSet<NodeId>,
155    /// Nodes whose child cap has been lifted.
156    show_all: HashSet<NodeId>,
157}
158
159impl CardState {
160    /// Settings are consulted sometimes; traffic is what the dashboard is
161    /// for. Rules and config start folded, everything else open.
162    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    /// Forget folds for removed instances, evicted peers and old attempts.
209    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
273/// How many children a list shows before "… N more".
274pub const CHILD_LIMIT: usize = 5;
275
276/// What a live peer's disabled `[ send message ]` says when its protocol registered no peer
277/// handle and does not declare [`crate::protocol::metadata::ProtocolMetadataV2::request_only`]:
278/// the protocol could message a peer, NetGet just has no path for it yet. A request-only
279/// protocol says its own reason instead, because for it there is no "yet".
280pub 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/// What Enter does on a row's label.
284#[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    /// The card this row belongs to; `None` for the canvas's own rows.
296    pub key: Option<UiKey>,
297    pub depth: u16,
298    /// A card header, rendered as the one-line instance summary.
299    pub header: Option<InstanceLine>,
300    pub spans: Vec<(String, Tone)>,
301    pub buttons: Vec<Button>,
302    /// Buttons on this row are padded to this many columns, so the rows of
303    /// a card's button grid line up. Zero means natural width.
304    pub button_width: usize,
305    pub on_enter: Activate,
306    /// Present on collapsible rows.
307    pub expanded: Option<bool>,
308    /// Animate before the label without changing the row's identity or actions.
309    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    /// Whether the label itself is a cursor stop.
334    pub fn has_label_stop(&self) -> bool {
335        self.on_enter != Activate::None
336    }
337
338    /// Cursor positions on this row: the label (when it acts) then each
339    /// button. Zero means the cursor skips the row.
340    pub fn positions(&self) -> usize {
341        usize::from(self.has_label_stop()) + self.buttons.len()
342    }
343
344    /// The button at cursor column `col`, if `col` names one.
345    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
355/// `:53121` for `127.0.0.1:53121` — the port is what tells peers apart.
356fn port_of(addr: &str) -> String {
357    addr.rsplit_once(':')
358        .map(|(_, p)| format!(":{p}"))
359        .unwrap_or_else(|| addr.to_string())
360}
361
362/// The card's own buttons, whichever section is open.
363pub 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
418/// Lay a card's buttons out as an aligned grid: every cell as wide as the
419/// widest label, as many per row as fit, so ←/→ walks a row and ↑/↓ the
420/// grid — nothing wraps mid-list.
421fn 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
455/// The keys a protocol's payload is most likely to carry its text under,
456/// in the order they are tried.
457const PAYLOAD_KEYS: [&str; 14] = [
458    "data", "text", "command", "line", "message", "body", "payload", "query", "path", "url",
459    "domain", "name", "key", "value",
460];
461
462/// One line for a request or an action: the payload's text if there is an
463/// obvious one, else the object compacted. What crossed the wire, not the
464/// JSON around it.
465pub 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            // A request line reads as one: `GET /index.html`.
470            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    // A peer's bytes, so ESC/C1/BEL go too (a space each, as `line_field` does for a value
503    // that must stay one row): ratatui's `Paragraph` would otherwise hand them to the
504    // operator's terminal as the escape sequences they are.
505    crate::utils::sanitize::line_field(&text.replace('\r', "").replace('\n', "⏎"))
506}
507
508/// An access-log entry as the messages that crossed the wire: what came in
509/// (the event), then what went out (each action). An injected send is
510/// recorded with the action as its request, so it is one outgoing row.
511fn 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                // Failure envelopes and outcomes: their single key names them.
548                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
566/// The conversation on one connection, oldest first, capped to the latest
567/// entries with an "… N earlier" row that lifts the cap.
568fn 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    // Facts.
879    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    // Peers, each with the conversation on it beneath.
916    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        // A connection can disappear before its last model call completes.
1070        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        // Connecting a client belongs with the peers it will join.
1095        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    // Send: the client's own verbs.
1178    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    // Connections: every attempt, the conversation on it beneath (attributed
1229    // by time), and the way to add to it.
1230    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
1361/// Every card's rows, then the row that starts a new instance.
1362pub 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
1423/// The index of `key`'s header row.
1424pub fn header_index(rows: &[Row], key: UiKey) -> Option<usize> {
1425    rows.iter()
1426        .position(|r| r.key == Some(key) && r.header.is_some())
1427}