1use 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#[derive(Debug, Clone)]
53struct AgentSnapshot {
54 id: AgentId,
55 crate_name: &'static str,
56 importance: AgentImportance,
57 default_phase: ExecutionPhase,
58 priority: f32,
59 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
94fn 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
112const 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 frame_history: std::collections::VecDeque<f32>,
126 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 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 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 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 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 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 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 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)] 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 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 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 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; 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 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 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 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 let name = agent.name();
592 paint_text_size(ui, [x + 38.0, y + 7.0], &name, 12.5, theme.text);
593 if agent.status.is_stalled {
595 paint_status_dot(ui, [x + w - 50.0, y + 14.0], theme.error);
596 }
597 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 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 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 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 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 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 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 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 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 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 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
1051fn 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}