Skip to main content

khora_editor/panels/
control_plane.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Control Plane workspace v3 — live DCC view.
16//!
17//! Reads the engine's [`AgentRegistry`] each frame, snapshots
18//! [`AgentStatus`] and [`ExecutionTiming`] for every agent, and renders:
19//! - a DCC summary bar (live FPS / frame budget / heap)
20//! - an Agents list (real agents, grouped into the Critical / Important /
21//!   Optional buckets reported by their `execution_timing()`)
22//! - a Schedule view that groups agents by their `default_phase`
23//!   (`INIT / OBSERVE / TRANSFORM / MUTATE / OUTPUT / FINALIZE`) — per-phase
24//!   timing isn't exposed by `ExecutionScheduler` yet, so this is a
25//!   schedule, not a timeline.
26//! - an Inspector for the selected agent, showing real status fields.
27//!
28//! When the registry isn't available (engine not booted, headless test) the
29//! workspace falls back to a clear "(no agents registered)" state instead
30//! of mock rows.
31
32use std::sync::{Arc, Mutex};
33
34use khora_sdk::editor_ui::*;
35use khora_sdk::{
36    AgentId, AgentImportance, AgentRegistry, AgentStatus, DccContext, ExecutionPhase, StrategyId,
37};
38
39use crate::widgets::brand::paint_diamond_filled;
40use crate::widgets::chrome::{paint_panel_header, paint_status_dot, panel_tab};
41use crate::widgets::controls::paint_meter_bar;
42use crate::widgets::paint::{paint_hairline_h, paint_icon, paint_text_size, with_alpha};
43use khora_tool_ui::widgets::Health;
44
45const SUMMARY_BAR_HEIGHT: f32 = 88.0;
46const AGENTS_PANEL_WIDTH: f32 = 280.0;
47const INSPECTOR_PANEL_WIDTH: f32 = 360.0;
48const AGENT_ROW_HEIGHT: f32 = 60.0;
49const FRAME_TARGET_MS: f32 = 16.67;
50
51/// Snapshot of one agent for the duration of a single frame's UI.
52#[derive(Debug, Clone)]
53struct AgentSnapshot {
54    id: AgentId,
55    crate_name: &'static str,
56    importance: AgentImportance,
57    default_phase: ExecutionPhase,
58    priority: f32,
59    /// `report_status()` data — health / current strategy / message.
60    status: AgentStatus,
61}
62
63impl AgentSnapshot {
64    fn name(&self) -> String {
65        format!("{}", self.id)
66    }
67
68    fn importance_letter(&self) -> &'static str {
69        match self.importance {
70            AgentImportance::Critical => "C",
71            AgentImportance::Important => "I",
72            AgentImportance::Optional => "O",
73        }
74    }
75
76    fn importance_color(&self, theme: &UiTheme) -> [f32; 4] {
77        match self.importance {
78            AgentImportance::Critical => theme.error,
79            AgentImportance::Important => theme.warning,
80            AgentImportance::Optional => theme.text_muted,
81        }
82    }
83
84    fn strategy_label(&self) -> &'static str {
85        match self.status.current_strategy {
86            StrategyId::LowPower => "LowPower",
87            StrategyId::Balanced => "Balanced",
88            StrategyId::HighPerformance => "HighPerformance",
89            StrategyId::Custom(_) => "Custom",
90        }
91    }
92}
93
94/// Crate-of-origin convention: built-in `khora-agents` agents have known
95/// `AgentId` values; everything else is conventionally classified as
96/// `user-plugin` (extension). This mapping is data-only — it doesn't try to
97/// inspect Cargo metadata at runtime.
98fn crate_for_id(id: AgentId) -> &'static str {
99    match id {
100        AgentId::Renderer
101        | AgentId::ShadowRenderer
102        | AgentId::Overlay
103        | AgentId::Skybox
104        | AgentId::Physics
105        | AgentId::Ecs
106        | AgentId::Ui
107        | AgentId::Audio
108        | AgentId::Asset => "khora-agents",
109    }
110}
111
112/// How many recent frame-time samples the summary sparkline keeps.
113const FRAME_HISTORY: usize = 48;
114
115pub struct ControlPlanePanel {
116    state: Arc<Mutex<EditorState>>,
117    theme: UiTheme,
118    registry: Option<Arc<Mutex<AgentRegistry>>>,
119    dcc_context: Option<Arc<std::sync::RwLock<DccContext>>>,
120    selected_idx: usize,
121    /// Recent frame-time samples (ms), oldest first. The DCC's aggregate cost
122    /// is the frame budget itself, and it *is* recorded every frame — unlike
123    /// per-agent cost, which the engine doesn't yet expose — so this is the
124    /// one sparkline the Control Plane can draw truthfully today.
125    frame_history: std::collections::VecDeque<f32>,
126    /// How far the agents column is scrolled.
127    agents_scroll: khora_tool_ui::widgets::ScrollState,
128}
129
130impl ControlPlanePanel {
131    pub fn new(
132        state: Arc<Mutex<EditorState>>,
133        theme: UiTheme,
134        registry: Option<Arc<Mutex<AgentRegistry>>>,
135        dcc_context: Option<Arc<std::sync::RwLock<DccContext>>>,
136    ) -> Self {
137        Self {
138            state,
139            theme,
140            registry,
141            dcc_context,
142            selected_idx: 0,
143            frame_history: std::collections::VecDeque::with_capacity(FRAME_HISTORY),
144            agents_scroll: khora_tool_ui::widgets::ScrollState::default(),
145        }
146    }
147
148    /// Records one frame-time sample, keeping the buffer bounded.
149    fn push_frame_sample(&mut self, ms: f32) {
150        if self.frame_history.len() == FRAME_HISTORY {
151            self.frame_history.pop_front();
152        }
153        self.frame_history.push_back(ms);
154    }
155
156    /// Snapshots all agents for this frame. Returns an empty Vec if the
157    /// registry isn't available (e.g. headless boot).
158    fn snapshot_agents(&self) -> Vec<AgentSnapshot> {
159        let Some(ref reg_arc) = self.registry else {
160            return Vec::new();
161        };
162        let Ok(reg) = reg_arc.lock() else {
163            return Vec::new();
164        };
165        let mut out = Vec::new();
166        for agent_arc in reg.iter() {
167            let Ok(agent) = agent_arc.lock() else {
168                continue;
169            };
170            let timing = agent.execution_timing();
171            let status = agent.report_status();
172            let id = agent.id();
173            out.push(AgentSnapshot {
174                id,
175                crate_name: crate_for_id(id),
176                importance: timing.importance,
177                default_phase: timing.default_phase,
178                priority: timing.priority,
179                status,
180            });
181        }
182        out
183    }
184}
185
186impl EditorPanel for ControlPlanePanel {
187    fn id(&self) -> &str {
188        "khora.editor.control_plane"
189    }
190
191    fn title(&self) -> &str {
192        "Control Plane"
193    }
194
195    fn ui(&mut self, ui: &mut dyn UiBuilder) {
196        // No mode check: the workbench only lays this panel out in the
197        // Control Plane workspace, which is the only tree that names it.
198        let theme = self.theme.clone();
199        let panel_rect = ui.panel_rect();
200        let [px, py, pw, ph] = panel_rect;
201
202        ui.paint_rect_filled([px, py], [pw, ph], theme.background, 0.0);
203
204        let agents = self.snapshot_agents();
205        if self.selected_idx >= agents.len() && !agents.is_empty() {
206            self.selected_idx = 0;
207        }
208
209        // ── 1. DCC summary bar ───────────────────────
210        // Prefer DCC context (live hardware/budget) when available, fall
211        // back to telemetry snapshot from EditorState otherwise.
212        let dcc_snap = self
213            .dcc_context
214            .as_ref()
215            .and_then(|h| h.read().ok().map(|c| c.clone()));
216        let mut snap = self
217            .state
218            .lock()
219            .ok()
220            .map(|s| {
221                (
222                    s.status.fps,
223                    s.status.frame_time_ms,
224                    s.status.memory_used_mb,
225                    s.status.cpu_load,
226                    s.status.gpu_load,
227                    s.status.vram_mb,
228                )
229            })
230            .unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0, 0.0));
231        if let Some(ctx) = dcc_snap.as_ref() {
232            // Override with DCC numbers when present (more authoritative for
233            // CPU/GPU load + VRAM since they come from the same hardware
234            // probe the engine uses for budgeting).
235            snap.3 = ctx.hardware.cpu_load;
236            snap.4 = ctx.hardware.gpu_load;
237            if let Some(vram_used) = ctx.hardware.available_vram.and_then(|avail| {
238                ctx.hardware
239                    .total_vram
240                    .map(|total| (total.saturating_sub(avail)) as f32 / (1024.0 * 1024.0))
241            }) {
242                snap.5 = vram_used;
243            }
244        }
245        // Record this frame's time before drawing, so the budget sparkline
246        // includes the current sample.
247        self.push_frame_sample(snap.1);
248        let frame_samples: Vec<f32> = self.frame_history.iter().copied().collect();
249        self.paint_summary_bar(
250            ui,
251            [px + 8.0, py + 8.0, pw - 16.0, SUMMARY_BAR_HEIGHT],
252            snap,
253            dcc_snap.as_ref(),
254            agents.len(),
255            &theme,
256            &frame_samples,
257        );
258
259        // ── 2. Body grid: agents | schedule | inspector
260        let body_y = py + 8.0 + SUMMARY_BAR_HEIGHT + 8.0;
261        let body_h = (ph - SUMMARY_BAR_HEIGHT - 24.0).max(0.0);
262
263        let agents_x = px + 8.0;
264        let timeline_x = agents_x + AGENTS_PANEL_WIDTH + 8.0;
265        let timeline_w = pw - 16.0 - AGENTS_PANEL_WIDTH - INSPECTOR_PANEL_WIDTH - 16.0;
266        let inspector_x = timeline_x + timeline_w + 8.0;
267
268        self.paint_agents_panel(
269            ui,
270            [agents_x, body_y, AGENTS_PANEL_WIDTH, body_h],
271            &agents,
272            &theme,
273        );
274        self.paint_schedule_panel(
275            ui,
276            [timeline_x, body_y, timeline_w, body_h],
277            &agents,
278            &theme,
279        );
280        self.paint_inspector_panel(
281            ui,
282            [inspector_x, body_y, INSPECTOR_PANEL_WIDTH, body_h],
283            agents.get(self.selected_idx),
284            &theme,
285        );
286    }
287}
288
289impl ControlPlanePanel {
290    #[allow(clippy::too_many_arguments)] // A paint helper; the args are all data it draws.
291    fn paint_summary_bar(
292        &self,
293        ui: &mut dyn UiBuilder,
294        rect: [f32; 4],
295        snap: (f32, f32, f32, f32, f32, f32),
296        dcc: Option<&DccContext>,
297        agent_count: usize,
298        theme: &UiTheme,
299        frame_samples: &[f32],
300    ) {
301        let [x, y, w, h] = rect;
302        ui.paint_rect_filled([x, y], [w, h], theme.surface, theme.radius_lg);
303        ui.paint_rect_stroke(
304            [x, y],
305            [w, h],
306            with_alpha(theme.separator, 0.55),
307            theme.radius_lg,
308            1.0,
309        );
310
311        // Brand block (left). Width is computed from the actual rendered
312        // sub-text so the stats cells start *after* it instead of at a
313        // hard-coded 320px (which used to overlap on common screen widths).
314        paint_diamond_filled(ui, x + 24.0, y + h * 0.5, 8.0, theme.primary);
315        paint_text_size(
316            ui,
317            [x + 40.0, y + 14.0],
318            "Dynamic Context Core",
319            14.0,
320            theme.text,
321        );
322        let mode_str: String = dcc
323            .map(|c| match &c.mode {
324                khora_sdk::EngineMode::Playing => "Playing".to_owned(),
325                khora_sdk::EngineMode::Custom(name) => name.clone(),
326            })
327            .unwrap_or_else(|| "—".to_owned());
328        let mult = dcc.map(|c| c.global_budget_multiplier).unwrap_or(1.0);
329        // Sub-text on two compact lines so it doesn't run into the stats grid.
330        let sub_line1 = format!("khora-control · {} · budget×{:.2}", mode_str, mult,);
331        let sub_line2 = format!(
332            "{} agent{} · {:.0} fps · {:.2}/{:.2}ms",
333            agent_count,
334            if agent_count == 1 { "" } else { "s" },
335            snap.0,
336            snap.1,
337            FRAME_TARGET_MS,
338        );
339        ui.paint_text_styled(
340            [x + 40.0, y + 32.0],
341            &sub_line1,
342            10.5,
343            theme.text_muted,
344            FontFamilyHint::Monospace,
345            TextAlign::Left,
346        );
347        ui.paint_text_styled(
348            [x + 40.0, y + 46.0],
349            &sub_line2,
350            10.5,
351            theme.text_muted,
352            FontFamilyHint::Monospace,
353            TextAlign::Left,
354        );
355
356        // Compute brand block width (longest of the two lines + diamond gutter).
357        let brand_title_w =
358            ui.measure_text("Dynamic Context Core", 14.0, FontFamilyHint::Proportional)[0];
359        let sub1_w = ui.measure_text(&sub_line1, 10.5, FontFamilyHint::Monospace)[0];
360        let sub2_w = ui.measure_text(&sub_line2, 10.5, FontFamilyHint::Monospace)[0];
361        let brand_w = 40.0 + brand_title_w.max(sub1_w).max(sub2_w) + 24.0; // diamond + text + breathing
362
363        // 5 stats cells (right) — all real values now.
364        let stats_x = x + brand_w.max(220.0);
365        let stats_w = (w - brand_w.max(220.0) - 16.0).max(120.0);
366        let cell_w = stats_w / 5.0;
367        let frame_frac = (snap.1 / FRAME_TARGET_MS).clamp(0.0, 1.0);
368        let frame_color = if frame_frac > 0.85 {
369            theme.error
370        } else if frame_frac > 0.6 {
371            theme.warning
372        } else {
373            theme.success
374        };
375        let cpu_pct = (snap.3 * 100.0).clamp(0.0, 100.0);
376        let gpu_pct = (snap.4 * 100.0).clamp(0.0, 100.0);
377        let stats: [(&str, String, f32, [f32; 4]); 5] = [
378            (
379                "FRAME BUDGET",
380                format!("{:.2} / {:.2}ms", snap.1, FRAME_TARGET_MS),
381                frame_frac,
382                frame_color,
383            ),
384            (
385                "CPU",
386                format!("{:.0}%", cpu_pct),
387                snap.3.clamp(0.0, 1.0),
388                if cpu_pct > 70.0 {
389                    theme.warning
390                } else {
391                    theme.accent_b
392                },
393            ),
394            (
395                "GPU",
396                format!("{:.0}%", gpu_pct),
397                snap.4.clamp(0.0, 1.0),
398                if gpu_pct > 70.0 {
399                    theme.warning
400                } else {
401                    theme.success
402                },
403            ),
404            (
405                "VRAM",
406                if snap.5 > 0.0 {
407                    format!("{:.1} GB", snap.5 / 1024.0)
408                } else {
409                    "—".to_owned()
410                },
411                if snap.5 > 0.0 {
412                    (snap.5 / 12_288.0).clamp(0.0, 1.0)
413                } else {
414                    0.0
415                },
416                theme.primary,
417            ),
418            (
419                "HEAP",
420                format!("{:.0} MB", snap.2),
421                (snap.2 / 2048.0).clamp(0.0, 1.0),
422                theme.primary,
423            ),
424        ];
425        for (i, (label, value, frac, color)) in stats.iter().enumerate() {
426            let cx = stats_x + i as f32 * cell_w;
427            ui.paint_text_styled(
428                [cx, y + 18.0],
429                label,
430                9.5,
431                theme.text_muted,
432                FontFamilyHint::Proportional,
433                TextAlign::Left,
434            );
435            ui.paint_text_styled(
436                [cx, y + 32.0],
437                value,
438                12.5,
439                theme.text,
440                FontFamilyHint::Monospace,
441                TextAlign::Left,
442            );
443            // The frame budget shows a trend (it's the DCC's real cost signal);
444            // the other cells are instantaneous, so a single bar fits them.
445            if i == 0 && frame_samples.len() >= 4 {
446                khora_tool_ui::widgets::sparkline(
447                    ui,
448                    theme,
449                    [cx, y + 44.0, cell_w - 16.0, 22.0],
450                    frame_samples,
451                );
452            } else {
453                paint_meter_bar(ui, [cx, y + 56.0], cell_w - 16.0, *frac, *color, theme);
454            }
455        }
456    }
457
458    fn paint_agents_panel(
459        &mut self,
460        ui: &mut dyn UiBuilder,
461        rect: [f32; 4],
462        agents: &[AgentSnapshot],
463        theme: &UiTheme,
464    ) {
465        let [x, y, w, h] = rect;
466        ui.paint_rect_filled([x, y], [w, h], theme.surface, theme.radius_lg);
467        ui.paint_rect_stroke(
468            [x, y],
469            [w, h],
470            with_alpha(theme.separator, 0.55),
471            theme.radius_lg,
472            1.0,
473        );
474
475        paint_panel_header(ui, [x, y, w, 34.0], 34.0, theme);
476        let _ = panel_tab(
477            ui,
478            "cp-tab-agents",
479            [x + 6.0, y + 6.0],
480            "Agents",
481            Some(&format!("{}", agents.len())),
482            true,
483            theme,
484        );
485
486        if agents.is_empty() {
487            ui.paint_text_styled(
488                [x + 16.0, y + 50.0],
489                "(no agents registered yet)",
490                11.5,
491                theme.text_muted,
492                FontFamilyHint::Proportional,
493                TextAlign::Left,
494            );
495            return;
496        }
497
498        // Group by crate (built-in vs user-plugin) for the section headers.
499        //
500        // The list scrolls: it used to run straight off the bottom of the panel
501        // with no bound at all, so past roughly eight agents the rows painted
502        // outside their pane and became unclickable.
503        let rows_top = y + 40.0;
504        let view = [x, rows_top, w, (y + h - rows_top).max(0.0)];
505        let sections = agents
506            .iter()
507            .map(|a| a.crate_name)
508            .collect::<std::collections::BTreeSet<_>>()
509            .len() as f32;
510        let content_h = agents.len() as f32 * (AGENT_ROW_HEIGHT + 2.0) + sections * 14.0;
511        self.agents_scroll.update(ui, view, content_h);
512        ui.push_clip_rect(view);
513
514        let mut row_y = rows_top - self.agents_scroll.offset();
515        let mut current_section: Option<&str> = None;
516        for (i, agent) in agents.iter().enumerate() {
517            if Some(agent.crate_name) != current_section {
518                ui.paint_text_styled(
519                    [x + 12.0, row_y],
520                    agent.crate_name,
521                    10.0,
522                    theme.text_muted,
523                    FontFamilyHint::Monospace,
524                    TextAlign::Left,
525                );
526                row_y += 14.0;
527                current_section = Some(agent.crate_name);
528            }
529            if row_y + AGENT_ROW_HEIGHT >= view[1] && row_y <= view[1] + view[3] {
530                self.paint_agent_row(ui, x + 6.0, row_y, w - 12.0, agent, i, theme);
531            }
532            row_y += AGENT_ROW_HEIGHT + 2.0;
533        }
534
535        ui.pop_clip_rect();
536        khora_tool_ui::widgets::scrollbar(
537            ui,
538            theme,
539            view,
540            content_h,
541            &mut self.agents_scroll,
542            "cp-agents-scroll",
543        );
544    }
545
546    #[allow(clippy::too_many_arguments)]
547    fn paint_agent_row(
548        &mut self,
549        ui: &mut dyn UiBuilder,
550        x: f32,
551        y: f32,
552        w: f32,
553        agent: &AgentSnapshot,
554        idx: usize,
555        theme: &UiTheme,
556    ) {
557        let active = self.selected_idx == idx;
558        let interaction =
559            ui.interact_rect(&format!("cp-agent-{}", idx), [x, y, w, AGENT_ROW_HEIGHT]);
560        if active {
561            ui.paint_rect_filled(
562                [x, y],
563                [w, AGENT_ROW_HEIGHT],
564                with_alpha(theme.primary, 0.12),
565                theme.radius_md,
566            );
567            ui.paint_rect_stroke(
568                [x, y],
569                [w, AGENT_ROW_HEIGHT],
570                with_alpha(theme.primary, 0.30),
571                theme.radius_md,
572                1.0,
573            );
574        } else if interaction.hovered {
575            ui.paint_rect_filled(
576                [x, y],
577                [w, AGENT_ROW_HEIGHT],
578                with_alpha(theme.surface_elevated, 0.6),
579                theme.radius_md,
580            );
581        }
582        if interaction.clicked {
583            self.selected_idx = idx;
584        }
585
586        // Icon box
587        ui.paint_rect_filled([x + 8.0, y + 8.0], [22.0, 22.0], theme.surface_active, 4.0);
588        paint_icon(ui, [x + 12.0, y + 12.0], Icon::Cpu, 14.0, theme.primary);
589
590        // Top row: name + importance badge + status dot
591        let name = agent.name();
592        paint_text_size(ui, [x + 38.0, y + 7.0], &name, 12.5, theme.text);
593        // Stalled indicator
594        if agent.status.is_stalled {
595            paint_status_dot(ui, [x + w - 50.0, y + 14.0], theme.error);
596        }
597        // Importance badge
598        let badge_x = x + w - 26.0;
599        ui.paint_rect_filled(
600            [badge_x, y + 8.0],
601            [16.0, 14.0],
602            with_alpha(agent.importance_color(theme), 0.18),
603            3.0,
604        );
605        ui.paint_text_styled(
606            [badge_x + 8.0, y + 9.5],
607            agent.importance_letter(),
608            9.0,
609            agent.importance_color(theme),
610            FontFamilyHint::Monospace,
611            TextAlign::Center,
612        );
613
614        // Strategy
615        ui.paint_text_styled(
616            [x + 38.0, y + 24.0],
617            agent.strategy_label(),
618            10.5,
619            theme.text_dim,
620            FontFamilyHint::Monospace,
621            TextAlign::Left,
622        );
623
624        // Health meter (real value: 0..1 from report_status). The thresholds
625        // live in the shared `Health` type so the bar, the dot and the
626        // "healthy / degraded" label can never disagree about the same agent.
627        let health = agent.status.health_score.clamp(0.0, 1.0);
628        let bar_color = Health::from_ratio(health).color(theme);
629        paint_meter_bar(ui, [x + 38.0, y + 40.0], w - 56.0, health, bar_color, theme);
630
631        // Foot: phase + priority
632        ui.paint_text_styled(
633            [x + 38.0, y + 47.0],
634            &format!("p={:.2}", agent.priority),
635            10.0,
636            theme.text_dim,
637            FontFamilyHint::Monospace,
638            TextAlign::Left,
639        );
640        ui.paint_text_styled(
641            [x + w - 14.0, y + 47.0],
642            &format!("{}", agent.default_phase),
643            10.0,
644            theme.text_muted,
645            FontFamilyHint::Monospace,
646            TextAlign::Right,
647        );
648    }
649
650    fn paint_schedule_panel(
651        &self,
652        ui: &mut dyn UiBuilder,
653        rect: [f32; 4],
654        agents: &[AgentSnapshot],
655        theme: &UiTheme,
656    ) {
657        let [x, y, w, h] = rect;
658        ui.paint_rect_filled([x, y], [w, h], theme.surface, theme.radius_lg);
659        ui.paint_rect_stroke(
660            [x, y],
661            [w, h],
662            with_alpha(theme.separator, 0.55),
663            theme.radius_lg,
664            1.0,
665        );
666
667        paint_panel_header(ui, [x, y, w, 34.0], 34.0, theme);
668        let _ = panel_tab(
669            ui,
670            "cp-tab-schedule",
671            [x + 6.0, y + 6.0],
672            "Schedule",
673            None,
674            true,
675            theme,
676        );
677        ui.paint_text_styled(
678            [x + w - 14.0, y + 13.0],
679            "16.67ms target · per-phase timing WIP",
680            10.5,
681            theme.text_muted,
682            FontFamilyHint::Monospace,
683            TextAlign::Right,
684        );
685
686        // List the real built-in phases. Custom phases are listed too if any
687        // agent declares one outside the built-in set.
688        let mut row_y = y + 46.0;
689        for phase in ExecutionPhase::DEFAULT_ORDER {
690            let phase_color = phase_color_for(*phase, theme);
691            let in_phase: Vec<&AgentSnapshot> = agents
692                .iter()
693                .filter(|a| a.default_phase == *phase)
694                .collect();
695
696            // Phase header
697            ui.paint_circle_filled([x + 16.0, row_y + 8.0], 3.5, phase_color);
698            ui.paint_text_styled(
699                [x + 26.0, row_y + 4.0],
700                &format!("{}", phase),
701                11.0,
702                theme.text,
703                FontFamilyHint::Monospace,
704                TextAlign::Left,
705            );
706            ui.paint_text_styled(
707                [x + w - 14.0, row_y + 4.0],
708                &format!(
709                    "{} agent{}",
710                    in_phase.len(),
711                    if in_phase.len() == 1 { "" } else { "s" }
712                ),
713                10.0,
714                theme.text_muted,
715                FontFamilyHint::Monospace,
716                TextAlign::Right,
717            );
718            row_y += 22.0;
719
720            // Listed agents
721            if in_phase.is_empty() {
722                ui.paint_text_styled(
723                    [x + 32.0, row_y],
724                    "(none)",
725                    10.5,
726                    theme.text_muted,
727                    FontFamilyHint::Proportional,
728                    TextAlign::Left,
729                );
730                row_y += 18.0;
731            } else {
732                for agent in in_phase {
733                    ui.paint_text_styled(
734                        [x + 32.0, row_y],
735                        &agent.name(),
736                        11.0,
737                        theme.primary,
738                        FontFamilyHint::Monospace,
739                        TextAlign::Left,
740                    );
741                    ui.paint_text_styled(
742                        [x + 160.0, row_y],
743                        agent.strategy_label(),
744                        10.5,
745                        theme.text_dim,
746                        FontFamilyHint::Monospace,
747                        TextAlign::Left,
748                    );
749                    ui.paint_text_styled(
750                        [x + w - 14.0, row_y],
751                        &format!("p={:.2}", agent.priority),
752                        10.0,
753                        theme.text_muted,
754                        FontFamilyHint::Monospace,
755                        TextAlign::Right,
756                    );
757                    row_y += 18.0;
758                }
759            }
760
761            paint_hairline_h(
762                ui,
763                x + 14.0,
764                row_y + 2.0,
765                w - 28.0,
766                with_alpha(theme.separator, 0.30),
767            );
768            row_y += 6.0;
769            if row_y > y + h - 24.0 {
770                break;
771            }
772        }
773    }
774
775    fn paint_inspector_panel(
776        &self,
777        ui: &mut dyn UiBuilder,
778        rect: [f32; 4],
779        agent: Option<&AgentSnapshot>,
780        theme: &UiTheme,
781    ) {
782        let [x, y, w, h] = rect;
783        ui.paint_rect_filled([x, y], [w, h], theme.surface, theme.radius_lg);
784        ui.paint_rect_stroke(
785            [x, y],
786            [w, h],
787            with_alpha(theme.separator, 0.55),
788            theme.radius_lg,
789            1.0,
790        );
791
792        paint_panel_header(ui, [x, y, w, 34.0], 34.0, theme);
793        let _ = panel_tab(
794            ui,
795            "cp-tab-inspector",
796            [x + 6.0, y + 6.0],
797            "Inspector",
798            None,
799            true,
800            theme,
801        );
802
803        let Some(agent) = agent else {
804            ui.paint_text_styled(
805                [x + 16.0, y + 50.0],
806                "(no agent selected)",
807                11.5,
808                theme.text_muted,
809                FontFamilyHint::Proportional,
810                TextAlign::Left,
811            );
812            return;
813        };
814
815        // Header (icon tile + name + crate tag + status pill)
816        ui.paint_rect_filled(
817            [x + 12.0, y + 46.0],
818            [36.0, 36.0],
819            theme.surface_active,
820            8.0,
821        );
822        paint_icon(ui, [x + 22.0, y + 56.0], Icon::Cpu, 16.0, theme.primary);
823
824        let name = agent.name();
825        paint_text_size(ui, [x + 58.0, y + 46.0], &name, 14.5, theme.text);
826
827        let tag = agent.crate_name;
828        let tag_w = ui.measure_text(tag, 10.0, FontFamilyHint::Monospace)[0] + 14.0;
829        ui.paint_rect_filled(
830            [x + 58.0, y + 68.0],
831            [tag_w, 16.0],
832            theme.surface_active,
833            3.0,
834        );
835        ui.paint_text_styled(
836            [x + 58.0 + tag_w * 0.5, y + 70.0],
837            tag,
838            10.0,
839            theme.text_dim,
840            FontFamilyHint::Monospace,
841            TextAlign::Center,
842        );
843
844        // Status pill — same thresholds as the health bar (see `Health`), so
845        // the word and the bar always agree.
846        let (status_label, status_color) = if agent.status.is_stalled {
847            ("stalled", theme.error)
848        } else {
849            match Health::from_ratio(agent.status.health_score) {
850                Health::Good => ("healthy", theme.success),
851                Health::Degraded => ("degraded", theme.warning),
852                Health::Bad => ("failing", theme.error),
853            }
854        };
855        let pill_x = x + 58.0 + tag_w + 8.0;
856        let pill_label_w =
857            ui.measure_text(status_label, 10.5, FontFamilyHint::Proportional)[0] + 22.0;
858        ui.paint_rect_filled(
859            [pill_x, y + 68.0],
860            [pill_label_w, 16.0],
861            with_alpha(status_color, 0.18),
862            999.0,
863        );
864        paint_status_dot(ui, [pill_x + 8.0, y + 76.0], status_color);
865        paint_text_size(
866            ui,
867            [pill_x + 14.0, y + 70.0],
868            status_label,
869            10.5,
870            status_color,
871        );
872
873        // Section divider
874        paint_hairline_h(
875            ui,
876            x + 8.0,
877            y + 100.0,
878            w - 16.0,
879            with_alpha(theme.separator, 0.55),
880        );
881
882        // Cards — each shows real fields from AgentStatus + ExecutionTiming.
883        let mut cy = y + 108.0;
884        cy = paint_card_box(
885            ui,
886            x + 8.0,
887            cy,
888            w - 16.0,
889            "Execution Timing",
890            Icon::Settings,
891            theme,
892        );
893        kv(
894            ui,
895            x + 18.0,
896            cy + 6.0,
897            w - 36.0,
898            "Default phase",
899            &format!("{}", agent.default_phase),
900            theme,
901        );
902        cy += 22.0;
903        kv(
904            ui,
905            x + 18.0,
906            cy + 6.0,
907            w - 36.0,
908            "Importance",
909            agent.importance_letter_label(),
910            theme,
911        );
912        cy += 22.0;
913        kv(
914            ui,
915            x + 18.0,
916            cy + 6.0,
917            w - 36.0,
918            "Priority",
919            &format!("{:.2}", agent.priority),
920            theme,
921        );
922        cy += 28.0;
923
924        cy = paint_card_box(ui, x + 8.0, cy, w - 16.0, "Health", Icon::Zap, theme);
925        kv(
926            ui,
927            x + 18.0,
928            cy + 6.0,
929            w - 36.0,
930            "Score",
931            &format!("{:.2}", agent.status.health_score),
932            theme,
933        );
934        cy += 22.0;
935        kv(
936            ui,
937            x + 18.0,
938            cy + 6.0,
939            w - 36.0,
940            "Stalled",
941            if agent.status.is_stalled { "yes" } else { "no" },
942            theme,
943        );
944        cy += 22.0;
945        let bar_color = Health::from_ratio(agent.status.health_score).color(theme);
946        paint_meter_bar(
947            ui,
948            [x + 18.0, cy + 4.0],
949            w - 36.0,
950            agent.status.health_score.clamp(0.0, 1.0),
951            bar_color,
952            theme,
953        );
954        cy += 18.0;
955
956        cy = paint_card_box(
957            ui,
958            x + 8.0,
959            cy,
960            w - 16.0,
961            "Active Strategy",
962            Icon::Branch,
963            theme,
964        );
965        ui.paint_rect_filled(
966            [x + 18.0, cy],
967            [w - 36.0, 22.0],
968            with_alpha(theme.success, 0.10),
969            theme.radius_sm,
970        );
971        ui.paint_rect_stroke(
972            [x + 18.0, cy],
973            [w - 36.0, 22.0],
974            with_alpha(theme.success, 0.25),
975            theme.radius_sm,
976            1.0,
977        );
978        paint_status_dot(ui, [x + 24.0, cy + 11.0], theme.success);
979        paint_text_size(
980            ui,
981            [x + 36.0, cy + 5.0],
982            agent.strategy_label(),
983            11.5,
984            theme.text,
985        );
986        cy += 30.0;
987
988        if !agent.status.message.is_empty() {
989            cy = paint_card_box(ui, x + 8.0, cy, w - 16.0, "Message", Icon::Info, theme);
990            ui.paint_text_styled(
991                [x + 18.0, cy + 4.0],
992                &agent.status.message,
993                11.0,
994                theme.text_dim,
995                FontFamilyHint::Proportional,
996                TextAlign::Left,
997            );
998        }
999    }
1000}
1001
1002impl AgentSnapshot {
1003    fn importance_letter_label(&self) -> &'static str {
1004        match self.importance {
1005            AgentImportance::Critical => "Critical",
1006            AgentImportance::Important => "Important",
1007            AgentImportance::Optional => "Optional",
1008        }
1009    }
1010}
1011
1012fn phase_color_for(phase: ExecutionPhase, theme: &UiTheme) -> [f32; 4] {
1013    if phase == ExecutionPhase::INIT {
1014        theme.text_muted
1015    } else if phase == ExecutionPhase::OBSERVE {
1016        theme.accent_b
1017    } else if phase == ExecutionPhase::TRANSFORM {
1018        theme.accent_a
1019    } else if phase == ExecutionPhase::MUTATE {
1020        theme.warning
1021    } else if phase == ExecutionPhase::OUTPUT {
1022        theme.primary
1023    } else if phase == ExecutionPhase::FINALIZE {
1024        theme.success
1025    } else {
1026        theme.text
1027    }
1028}
1029
1030fn paint_card_box(
1031    ui: &mut dyn UiBuilder,
1032    x: f32,
1033    y: f32,
1034    w: f32,
1035    title: &str,
1036    icon: Icon,
1037    theme: &UiTheme,
1038) -> f32 {
1039    let header_h = 26.0;
1040    ui.paint_rect_filled(
1041        [x, y],
1042        [w, header_h],
1043        theme.surface_elevated,
1044        theme.radius_md,
1045    );
1046    paint_icon(ui, [x + 8.0, y + 7.0], icon, 12.0, theme.primary_dim);
1047    paint_text_size(ui, [x + 26.0, y + 7.0], title, 12.0, theme.text);
1048    y + header_h + 4.0
1049}
1050
1051/// Render a key/value row with the key left-aligned and the value
1052/// right-aligned within `[x, x+w]`.
1053fn kv(ui: &mut dyn UiBuilder, x: f32, y: f32, w: f32, key: &str, value: &str, theme: &UiTheme) {
1054    paint_text_size(ui, [x, y], key, 11.0, theme.text_dim);
1055    ui.paint_text_styled(
1056        [x + w - 4.0, y],
1057        value,
1058        11.0,
1059        theme.text,
1060        FontFamilyHint::Monospace,
1061        TextAlign::Right,
1062    );
1063}