1use crate::error::{Autom8Error, Result};
7use crate::state::{IterationStatus, MachineState, SessionStatus, StateManager};
8use crate::ui::gui::components::{
9 badge_background_color, format_relative_time, format_run_duration, format_state,
10 is_terminal_state, state_to_color, strip_worktree_prefix, truncate_with_ellipsis,
11 CollapsibleSection, MAX_BRANCH_LENGTH,
12};
13use crate::ui::gui::config::{
14 BoolFieldChanges, ConfigBoolField, ConfigEditorActions, ConfigScope, ConfigTabState,
15 ConfigTextField, TextFieldChanges, CONFIG_SCOPE_ROW_HEIGHT, CONFIG_SCOPE_ROW_PADDING_H,
16 CONFIG_SCOPE_ROW_PADDING_V,
17};
18use crate::ui::gui::modal::{Modal, ModalAction, ModalButton};
19use crate::ui::gui::theme::{self, colors, rounding, spacing};
20use crate::ui::gui::typography::{self, FontSize, FontWeight};
21use crate::ui::shared::{
22 load_project_run_history, load_session_by_id, load_ui_data, ProjectData, RunHistoryEntry,
23 SessionData,
24};
25use eframe::egui::{self, Color32, Key, Order, Pos2, Rect, Rounding, Sense, Stroke, Vec2};
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28
29const DEFAULT_WIDTH: f32 = 1200.0;
31
32const DEFAULT_HEIGHT: f32 = 800.0;
34
35const MIN_WIDTH: f32 = 400.0;
37
38const MIN_HEIGHT: f32 = 300.0;
40
41#[allow(dead_code)]
44const HEADER_HEIGHT: f32 = 48.0;
45
46const TITLE_BAR_HEIGHT: f32 = 48.0;
52
53const TITLE_BAR_LEFT_OFFSET: f32 = 72.0;
55
56const TAB_UNDERLINE_HEIGHT: f32 = 2.0;
58
59const TAB_PADDING_H: f32 = 16.0; pub const DEFAULT_REFRESH_INTERVAL_MS: u64 = 500;
64
65const OUTPUT_LINES_TO_SHOW: usize = 50;
72
73const LIVE_OUTPUT_FRESHNESS_SECS: i64 = 5;
77
78const PROJECT_ROW_HEIGHT: f32 = 56.0;
86
87const PROJECT_ROW_PADDING_H: f32 = 12.0; const PROJECT_ROW_PADDING_V: f32 = 12.0; const PROJECT_STATUS_DOT_RADIUS: f32 = 5.0;
95
96const SPLIT_DIVIDER_WIDTH: f32 = 1.0;
102
103const SPLIT_DIVIDER_MARGIN: f32 = 12.0; const SPLIT_PANEL_MIN_WIDTH: f32 = 200.0;
108
109const SIDEBAR_WIDTH: f32 = 220.0;
116
117const SIDEBAR_COLLAPSED_WIDTH: f32 = 0.0;
120
121const SIDEBAR_TOGGLE_SIZE: f32 = 34.0;
127
128const SIDEBAR_TOGGLE_PADDING: f32 = 8.0;
130
131const SIDEBAR_ITEM_HEIGHT: f32 = 40.0;
133
134const SIDEBAR_ITEM_PADDING_H: f32 = 16.0; #[allow(dead_code)]
140const SIDEBAR_ITEM_PADDING_V: f32 = 8.0; const SIDEBAR_ACTIVE_INDICATOR_WIDTH: f32 = 3.0;
144
145const SIDEBAR_ITEM_ROUNDING: f32 = 6.0;
147
148const SIDEBAR_ICON_SIZE: f32 = 120.0;
151
152const CONTEXT_MENU_MIN_WIDTH: f32 = 100.0;
158
159const CONTEXT_MENU_MAX_WIDTH: f32 = 300.0;
161
162const CONTEXT_MENU_ITEM_HEIGHT: f32 = 32.0;
164
165const CONTEXT_MENU_PADDING_H: f32 = 12.0; const CONTEXT_MENU_PADDING_V: f32 = 6.0;
170
171const CONTEXT_MENU_ARROW_SIZE: f32 = 8.0;
173
174const CONTEXT_MENU_CURSOR_OFFSET: f32 = 2.0;
176
177const CONTEXT_MENU_SUBMENU_GAP: f32 = 2.0;
179
180struct ContextMenuItemResponse {
184 clicked: bool,
186 hovered: bool,
188 hovered_raw: bool,
190 rect: Rect,
192}
193
194fn calculate_menu_width_from_text_width(max_text_width: f32) -> f32 {
207 let padding = CONTEXT_MENU_PADDING_H * 2.0 + CONTEXT_MENU_PADDING_H * 2.0;
210 let calculated_width = max_text_width + padding;
211
212 calculated_width.clamp(CONTEXT_MENU_MIN_WIDTH, CONTEXT_MENU_MAX_WIDTH)
214}
215
216fn calculate_context_menu_width(ctx: &egui::Context, items: &[ContextMenuItem]) -> f32 {
224 let font_id = typography::font(FontSize::Body, FontWeight::Regular);
225
226 let max_text_width = items
227 .iter()
228 .filter_map(|item| {
229 match item {
230 ContextMenuItem::Action { label, .. } => {
231 let galley = ctx.fonts(|fonts| {
233 fonts.layout_no_wrap(label.clone(), font_id.clone(), Color32::WHITE)
234 });
235 Some(galley.rect.width())
236 }
237 ContextMenuItem::Submenu { label, .. } => {
238 let galley = ctx.fonts(|fonts| {
240 fonts.layout_no_wrap(label.clone(), font_id.clone(), Color32::WHITE)
241 });
242 Some(galley.rect.width() + CONTEXT_MENU_ARROW_SIZE + CONTEXT_MENU_PADDING_H)
244 }
245 ContextMenuItem::Separator => None, }
247 })
248 .fold(0.0_f32, |max, width| max.max(width));
249
250 calculate_menu_width_from_text_width(max_text_width)
251}
252
253#[derive(Debug, Clone, PartialEq)]
262enum OutputSource {
263 Live(Vec<String>),
266 Iteration(Vec<String>),
269 StatusMessage(String),
272 NoData,
274}
275
276fn get_output_for_session(session: &SessionData) -> OutputSource {
298 let machine_state = session
300 .run
301 .as_ref()
302 .map(|r| r.machine_state)
303 .unwrap_or(MachineState::Idle);
304
305 if machine_state == MachineState::RunningClaude {
307 if let Some(ref live) = session.live_output {
308 let age = chrono::Utc::now().signed_duration_since(live.updated_at);
310 if age.num_seconds() < LIVE_OUTPUT_FRESHNESS_SECS && !live.output_lines.is_empty() {
311 let take_count = OUTPUT_LINES_TO_SHOW.min(live.output_lines.len());
313 let start = live.output_lines.len().saturating_sub(take_count);
314 let lines: Vec<String> = live.output_lines[start..].to_vec();
315 return OutputSource::Live(lines);
316 }
317 }
318 }
319
320 if let Some(ref run) = session.run {
324 for iter in run.iterations.iter().rev() {
326 if !iter.output_snippet.is_empty() {
327 let lines: Vec<String> = iter
329 .output_snippet
330 .lines()
331 .collect::<Vec<_>>()
332 .into_iter()
333 .rev()
334 .take(OUTPUT_LINES_TO_SHOW)
335 .collect::<Vec<_>>()
336 .into_iter()
337 .rev()
338 .map(|s| s.to_string())
339 .collect();
340 return OutputSource::Iteration(lines);
341 }
342 }
343 }
344
345 if session.live_output.is_none() {
348 return OutputSource::NoData;
349 }
350
351 let message = match machine_state {
353 MachineState::Idle => "Waiting to start...",
354 MachineState::LoadingSpec => "Loading spec file...",
355 MachineState::GeneratingSpec => "Generating spec from markdown...",
356 MachineState::Initializing => "Initializing run...",
357 MachineState::PickingStory => "Selecting next story...",
358 MachineState::RunningClaude => "Waiting for output...",
362 MachineState::Reviewing => "Reviewing changes...",
363 MachineState::Correcting => "Applying corrections...",
364 MachineState::Committing => "Committing changes...",
365 MachineState::CreatingPR => "Creating pull request...",
366 MachineState::Completed => "Run completed successfully!",
367 MachineState::Failed => "Run failed.",
368 };
369 OutputSource::StatusMessage(message.to_string())
370}
371
372fn is_resumable_session(session: &SessionStatus) -> bool {
379 if session.is_stale {
381 return false;
382 }
383
384 if session.metadata.is_running {
386 return true;
387 }
388
389 if let Some(state) = &session.machine_state {
391 match state {
392 MachineState::Completed | MachineState::Idle => false,
393 _ => true, }
395 } else {
396 false
397 }
398}
399
400fn format_sessions_as_text(sessions: &[SessionStatus]) -> Vec<String> {
405 let mut lines = Vec::new();
406
407 if sessions.is_empty() {
408 lines.push("No sessions found for this project.".to_string());
409 return lines;
410 }
411
412 lines.push("Sessions for this project:".to_string());
413 lines.push(String::new());
414
415 for session in sessions {
416 let metadata = &session.metadata;
417
418 let indicator = if session.is_stale {
420 "✗"
421 } else if session.is_current {
422 "→"
423 } else if metadata.is_running {
424 "●"
425 } else {
426 "○"
427 };
428
429 let mut header = format!("{} {}", indicator, metadata.session_id);
431 if session.is_current {
432 header.push_str(" (current)");
433 }
434 if session.is_stale {
435 header.push_str(" [stale]");
436 }
437 lines.push(header);
438
439 lines.push(format!(" Branch: {}", metadata.branch_name));
441
442 if let Some(state) = &session.machine_state {
444 let state_str = format_machine_state_text(state);
445 lines.push(format!(" State: {}", state_str));
446 }
447
448 if let Some(story) = &session.current_story {
450 lines.push(format!(" Story: {}", story));
451 }
452
453 lines.push(format!(
455 " Started: {}",
456 metadata.created_at.format("%Y-%m-%d %H:%M")
457 ));
458
459 lines.push(String::new());
460 }
461
462 let running_count = sessions
464 .iter()
465 .filter(|s| s.metadata.is_running && !s.is_stale)
466 .count();
467 let stale_count = sessions.iter().filter(|s| s.is_stale).count();
468
469 let mut summary = format!(
470 "({} session{}",
471 sessions.len(),
472 if sessions.len() == 1 { "" } else { "s" }
473 );
474 if running_count > 0 {
475 summary.push_str(&format!(", {} running", running_count));
476 }
477 if stale_count > 0 {
478 summary.push_str(&format!(", {} stale", stale_count));
479 }
480 summary.push(')');
481 lines.push(summary);
482
483 lines
484}
485
486fn format_machine_state_text(state: &MachineState) -> &'static str {
488 match state {
489 MachineState::Idle => "Idle",
490 MachineState::LoadingSpec => "Loading Spec",
491 MachineState::GeneratingSpec => "Generating Spec",
492 MachineState::Initializing => "Initializing",
493 MachineState::PickingStory => "Picking Story",
494 MachineState::RunningClaude => "Running Claude",
495 MachineState::Reviewing => "Reviewing",
496 MachineState::Correcting => "Correcting",
497 MachineState::Committing => "Committing",
498 MachineState::CreatingPR => "Creating PR",
499 MachineState::Completed => "Completed",
500 MachineState::Failed => "Failed",
501 }
502}
503
504fn format_resume_info_as_text(session: &ResumableSessionInfo) -> Vec<String> {
510 let mut lines = Vec::new();
511
512 lines.push("Resume Session Information".to_string());
513 lines.push(String::new());
514 lines.push(format!("Session ID: {}", session.session_id));
515 lines.push(format!("Branch: {}", session.branch_name));
516 lines.push(format!(
517 "Worktree Path: {}",
518 session.worktree_path.display()
519 ));
520 lines.push(format!(
521 "Current State: {}",
522 format_machine_state_text(&session.machine_state)
523 ));
524 lines.push(String::new());
525 lines.push(format!(
526 "To resume, run `autom8 resume --session {}` in terminal",
527 session.session_id
528 ));
529
530 lines
531}
532
533fn format_project_description_as_text(desc: &crate::config::ProjectDescription) -> Vec<String> {
538 use crate::state::RunStatus;
539
540 let mut lines = Vec::new();
541
542 lines.push(format!("Project: {}", desc.name));
544 lines.push(format!("Path: {}", desc.path.display()));
545 lines.push(String::new());
546
547 let status_text = match desc.run_status {
549 Some(RunStatus::Running) => "[running]",
550 Some(RunStatus::Failed) => "[failed]",
551 Some(RunStatus::Interrupted) => "[interrupted]",
552 Some(RunStatus::Completed) => "[completed]",
553 None => "[idle]",
554 };
555 lines.push(format!("Status: {}", status_text));
556
557 if let Some(branch) = &desc.current_branch {
559 lines.push(format!("Branch: {}", branch));
560 }
561
562 if let Some(story) = &desc.current_story {
564 lines.push(format!("Current Story: {}", story));
565 }
566 lines.push(String::new());
567
568 if desc.specs.is_empty() {
570 lines.push("No specs found.".to_string());
571 } else {
572 lines.push(format!("Specs: ({} total)", desc.specs.len()));
573 lines.push(String::new());
574
575 for spec in &desc.specs {
576 lines.extend(format_spec_summary_as_text(spec));
577 }
578 }
579
580 lines.push("─────────────────────────────────────────────────────────".to_string());
582 lines.push(format!(
583 "Files: {} spec md, {} spec json, {} archived runs",
584 desc.spec_md_count,
585 desc.specs.len(),
586 desc.runs_count
587 ));
588
589 lines
590}
591
592fn format_spec_summary_as_text(spec: &crate::config::SpecSummary) -> Vec<String> {
597 let mut lines = Vec::new();
598
599 let active_label = if spec.is_active { " (Active)" } else { "" };
601 lines.push(format!("━━━ {}{}", spec.filename, active_label));
602
603 if !spec.is_active {
606 let desc_preview = if spec.description.len() > 80 {
607 format!("{}...", &spec.description[..80])
608 } else {
609 spec.description.clone()
610 };
611 let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
612 lines.push(first_line.to_string());
613 lines.push(format!(
614 "({}/{} stories complete)",
615 spec.completed_count, spec.total_count
616 ));
617 lines.push(String::new());
618 return lines;
619 }
620
621 lines.push(format!("Project: {}", spec.project_name));
623 lines.push(format!("Branch: {}", spec.branch_name));
624
625 let desc_preview = if spec.description.len() > 100 {
627 format!("{}...", &spec.description[..100])
628 } else {
629 spec.description.clone()
630 };
631 let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
632 lines.push(format!("Description: {}", first_line));
633 lines.push(String::new());
634
635 let progress_bar = make_progress_bar_text(spec.completed_count, spec.total_count, 12);
637 lines.push(format!(
638 "Progress: [{}] {}/{} stories complete",
639 progress_bar, spec.completed_count, spec.total_count
640 ));
641 lines.push(String::new());
642
643 lines.push("User Stories:".to_string());
645 for story in &spec.stories {
646 let status_icon = if story.passes { "✓" } else { "○" };
647 lines.push(format!(" {} {}: {}", status_icon, story.id, story.title));
648 }
649 lines.push(String::new());
650
651 lines
652}
653
654fn make_progress_bar_text(completed: usize, total: usize, width: usize) -> String {
656 if total == 0 {
657 return " ".repeat(width);
658 }
659 let filled = (completed * width) / total;
660 let empty = width - filled;
661 format!("{}{}", "█".repeat(filled), "░".repeat(empty))
662}
663
664fn format_cleanup_summary_as_text(
669 summary: &crate::commands::CleanupSummary,
670 operation: &str,
671) -> Vec<String> {
672 use crate::commands::format_bytes_display;
673
674 let mut lines = Vec::new();
675
676 lines.push(format!("Cleanup Operation: {}", operation));
677 lines.push(String::new());
678
679 if summary.sessions_removed == 0 && summary.worktrees_removed == 0 {
681 lines.push("No sessions or worktrees were removed.".to_string());
682 } else {
683 let freed_str = format_bytes_display(summary.bytes_freed);
684 lines.push(format!(
685 "Removed {} session{}, {} worktree{}, freed {}",
686 summary.sessions_removed,
687 if summary.sessions_removed == 1 {
688 ""
689 } else {
690 "s"
691 },
692 summary.worktrees_removed,
693 if summary.worktrees_removed == 1 {
694 ""
695 } else {
696 "s"
697 },
698 freed_str
699 ));
700 }
701
702 if !summary.sessions_skipped.is_empty() {
704 lines.push(String::new());
705 lines.push(format!(
706 "Skipped {} session{}:",
707 summary.sessions_skipped.len(),
708 if summary.sessions_skipped.len() == 1 {
709 ""
710 } else {
711 "s"
712 }
713 ));
714 for skipped in &summary.sessions_skipped {
715 lines.push(format!(" - {}: {}", skipped.session_id, skipped.reason));
716 }
717 }
718
719 if !summary.errors.is_empty() {
721 lines.push(String::new());
722 lines.push("Errors during cleanup:".to_string());
723 for error in &summary.errors {
724 lines.push(format!(" - {}", error));
725 }
726 }
727
728 lines
729}
730
731fn format_data_cleanup_summary_as_text(
736 summary: &crate::commands::DataCleanupSummary,
737) -> Vec<String> {
738 use crate::commands::format_bytes_display;
739
740 let mut lines = Vec::new();
741
742 lines.push("Cleanup Operation: Clean Data".to_string());
743 lines.push(String::new());
744
745 if summary.specs_removed == 0 && summary.runs_removed == 0 {
747 lines.push("No specs or runs were removed.".to_string());
748 } else {
749 let freed_str = format_bytes_display(summary.bytes_freed);
750 lines.push(format!(
751 "Removed {} spec{}, {} run{}, freed {}",
752 summary.specs_removed,
753 if summary.specs_removed == 1 { "" } else { "s" },
754 summary.runs_removed,
755 if summary.runs_removed == 1 { "" } else { "s" },
756 freed_str
757 ));
758 }
759
760 if !summary.errors.is_empty() {
762 lines.push(String::new());
763 lines.push("Errors during cleanup:".to_string());
764 for error in &summary.errors {
765 lines.push(format!(" - {}", error));
766 }
767 }
768
769 lines
770}
771
772fn format_removal_summary_as_text(
777 summary: &crate::commands::RemovalSummary,
778 project_name: &str,
779) -> Vec<String> {
780 use crate::commands::format_bytes_display;
781
782 let mut lines = Vec::new();
783
784 lines.push(format!("Remove Project: {}", project_name));
785 lines.push(String::new());
786
787 if summary.worktrees_removed == 0 && !summary.config_deleted {
789 if summary.errors.is_empty() {
790 lines.push("Nothing was removed.".to_string());
791 } else {
792 lines.push("Failed to remove project.".to_string());
793 }
794 } else {
795 let freed_str = format_bytes_display(summary.bytes_freed);
796 let mut results = Vec::new();
797
798 if summary.worktrees_removed > 0 {
799 results.push(format!(
800 "{} worktree{}",
801 summary.worktrees_removed,
802 if summary.worktrees_removed == 1 {
803 ""
804 } else {
805 "s"
806 }
807 ));
808 }
809
810 if summary.config_deleted {
811 results.push("config directory".to_string());
812 }
813
814 lines.push(format!("Removed: {}", results.join(", ")));
815 lines.push(format!("Freed: {}", freed_str));
816 }
817
818 if !summary.worktrees_skipped.is_empty() {
820 lines.push(String::new());
821 lines.push(format!(
822 "Skipped {} worktree{} (active runs):",
823 summary.worktrees_skipped.len(),
824 if summary.worktrees_skipped.len() == 1 {
825 ""
826 } else {
827 "s"
828 }
829 ));
830 for skipped in &summary.worktrees_skipped {
831 lines.push(format!(
832 " - {}: {}",
833 skipped.path.display(),
834 skipped.reason
835 ));
836 }
837 }
838
839 if !summary.errors.is_empty() {
841 lines.push(String::new());
842 lines.push("Errors during removal:".to_string());
843 for error in &summary.errors {
844 lines.push(format!(" - {}", error));
845 }
846 }
847
848 if summary.errors.is_empty() && (summary.worktrees_removed > 0 || summary.config_deleted) {
850 lines.push(String::new());
851 lines.push(format!(
852 "Project '{}' has been removed from autom8.",
853 project_name
854 ));
855 }
856
857 lines
858}
859
860#[derive(Debug, Clone, PartialEq, Eq)]
866pub enum ContextMenuItem {
867 Action {
869 label: String,
871 action: ContextMenuAction,
873 enabled: bool,
875 },
876 Separator,
878 Submenu {
880 label: String,
882 id: String,
884 enabled: bool,
886 items: Vec<ContextMenuItem>,
888 hint: Option<String>,
890 },
891}
892
893impl ContextMenuItem {
894 pub fn action(label: impl Into<String>, action: ContextMenuAction) -> Self {
896 Self::Action {
897 label: label.into(),
898 action,
899 enabled: true,
900 }
901 }
902
903 pub fn action_disabled(label: impl Into<String>, action: ContextMenuAction) -> Self {
905 Self::Action {
906 label: label.into(),
907 action,
908 enabled: false,
909 }
910 }
911
912 pub fn separator() -> Self {
914 Self::Separator
915 }
916
917 pub fn submenu(
919 label: impl Into<String>,
920 id: impl Into<String>,
921 items: Vec<ContextMenuItem>,
922 ) -> Self {
923 let items_vec = items;
924 Self::Submenu {
925 label: label.into(),
926 id: id.into(),
927 enabled: !items_vec.is_empty(),
928 items: items_vec,
929 hint: None,
930 }
931 }
932
933 pub fn submenu_disabled(
935 label: impl Into<String>,
936 id: impl Into<String>,
937 hint: impl Into<String>,
938 ) -> Self {
939 Self::Submenu {
940 label: label.into(),
941 id: id.into(),
942 enabled: false,
943 items: Vec::new(),
944 hint: Some(hint.into()),
945 }
946 }
947}
948
949#[derive(Debug, Clone, PartialEq, Eq)]
951pub enum ContextMenuAction {
952 Status,
954 Describe,
956 Resume(Option<String>),
958 CleanWorktrees,
960 CleanOrphaned,
962 CleanData,
964 RemoveProject,
966}
967
968#[derive(Debug, Clone)]
971pub struct ResumableSessionInfo {
972 pub session_id: String,
974 pub branch_name: String,
976 pub worktree_path: std::path::PathBuf,
978 pub machine_state: MachineState,
980}
981
982impl ResumableSessionInfo {
983 pub fn new(
985 session_id: impl Into<String>,
986 branch_name: impl Into<String>,
987 worktree_path: std::path::PathBuf,
988 machine_state: MachineState,
989 ) -> Self {
990 Self {
991 session_id: session_id.into(),
992 branch_name: branch_name.into(),
993 worktree_path,
994 machine_state,
995 }
996 }
997
998 pub fn truncated_id(&self) -> &str {
1000 if self.session_id.len() > 8 {
1001 &self.session_id[..8]
1002 } else {
1003 &self.session_id
1004 }
1005 }
1006
1007 pub fn menu_label(&self) -> String {
1010 format!("{} ({})", self.branch_name, self.truncated_id())
1011 }
1012}
1013
1014#[derive(Debug, Clone, Default)]
1017pub struct CleanableInfo {
1018 pub cleanable_worktrees: usize,
1021 pub orphaned_sessions: usize,
1023 pub cleanable_specs: usize,
1026 pub cleanable_runs: usize,
1029}
1030
1031impl CleanableInfo {
1032 pub fn has_cleanable(&self) -> bool {
1035 self.cleanable_worktrees > 0
1036 || self.orphaned_sessions > 0
1037 || self.cleanable_specs > 0
1038 || self.cleanable_runs > 0
1039 }
1040}
1041
1042#[allow(dead_code)] fn is_cleanable_session(session: &SessionStatus) -> bool {
1049 !session.metadata.is_running
1052}
1053
1054fn count_cleanable_specs(
1060 spec_dir: &std::path::Path,
1061 active_spec_paths: &std::collections::HashSet<std::path::PathBuf>,
1062) -> usize {
1063 if !spec_dir.exists() {
1064 return 0;
1065 }
1066
1067 let mut cleanable_count = 0;
1069
1070 if let Ok(entries) = std::fs::read_dir(spec_dir) {
1071 for entry in entries.flatten() {
1072 let path = entry.path();
1073 if path.extension().map(|e| e == "json").unwrap_or(false) {
1074 if !active_spec_paths.contains(&path) {
1076 cleanable_count += 1;
1077 }
1078 }
1079 }
1080 }
1081
1082 cleanable_count
1083}
1084
1085fn count_cleanable_runs(runs_dir: &std::path::Path) -> usize {
1089 if !runs_dir.exists() {
1090 return 0;
1091 }
1092
1093 std::fs::read_dir(runs_dir)
1094 .map(|entries| entries.filter_map(|e| e.ok()).count())
1095 .unwrap_or(0)
1096}
1097
1098#[derive(Debug, Clone)]
1100pub struct ContextMenuState {
1101 pub position: Pos2,
1103 pub project_name: String,
1105 pub items: Vec<ContextMenuItem>,
1107 pub open_submenu: Option<String>,
1109 pub submenu_position: Option<Pos2>,
1111}
1112
1113impl ContextMenuState {
1114 pub fn new(position: Pos2, project_name: String, items: Vec<ContextMenuItem>) -> Self {
1116 Self {
1117 position,
1118 project_name,
1119 items,
1120 open_submenu: None,
1121 submenu_position: None,
1122 }
1123 }
1124
1125 pub fn open_submenu(&mut self, id: String, position: Pos2) {
1127 self.open_submenu = Some(id);
1128 self.submenu_position = Some(position);
1129 }
1130
1131 pub fn close_submenu(&mut self) {
1133 self.open_submenu = None;
1134 self.submenu_position = None;
1135 }
1136}
1137
1138#[derive(Debug, Clone, Default)]
1141pub struct ProjectRowInteraction {
1142 pub clicked: bool,
1144 pub right_click_pos: Option<Pos2>,
1146}
1147
1148impl ProjectRowInteraction {
1149 pub fn none() -> Self {
1151 Self::default()
1152 }
1153
1154 pub fn click() -> Self {
1156 Self {
1157 clicked: true,
1158 right_click_pos: None,
1159 }
1160 }
1161
1162 pub fn right_click(pos: Pos2) -> Self {
1164 Self {
1165 clicked: false,
1166 right_click_pos: Some(pos),
1167 }
1168 }
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Eq)]
1177pub enum CommandStatus {
1178 Running,
1180 Completed,
1182 Failed,
1184}
1185
1186#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1188pub struct CommandOutputId {
1189 pub project: String,
1191 pub command: String,
1193 pub id: String,
1195}
1196
1197impl CommandOutputId {
1198 pub fn new(project: impl Into<String>, command: impl Into<String>) -> Self {
1200 Self {
1201 project: project.into(),
1202 command: command.into(),
1203 id: uuid::Uuid::new_v4().to_string(),
1204 }
1205 }
1206
1207 #[cfg(test)]
1209 pub fn with_id(
1210 project: impl Into<String>,
1211 command: impl Into<String>,
1212 id: impl Into<String>,
1213 ) -> Self {
1214 Self {
1215 project: project.into(),
1216 command: command.into(),
1217 id: id.into(),
1218 }
1219 }
1220
1221 pub fn cache_key(&self) -> String {
1223 format!("{}:{}:{}", self.project, self.command, self.id)
1224 }
1225
1226 pub fn tab_label(&self) -> String {
1228 let command_display = if self.command.is_empty() {
1230 "Command".to_string()
1231 } else {
1232 let mut chars = self.command.chars();
1233 match chars.next() {
1234 None => "Command".to_string(),
1235 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1236 }
1237 };
1238 format!("{}: {}", command_display, self.project)
1239 }
1240}
1241
1242#[derive(Debug, Clone)]
1244pub struct CommandExecution {
1245 pub id: CommandOutputId,
1247 pub status: CommandStatus,
1249 pub stdout: Vec<String>,
1251 pub stderr: Vec<String>,
1253 pub exit_code: Option<i32>,
1255 pub auto_scroll: bool,
1257}
1258
1259impl CommandExecution {
1260 pub fn new(id: CommandOutputId) -> Self {
1262 Self {
1263 id,
1264 status: CommandStatus::Running,
1265 stdout: Vec::new(),
1266 stderr: Vec::new(),
1267 exit_code: None,
1268 auto_scroll: true,
1269 }
1270 }
1271
1272 pub fn add_stdout(&mut self, line: String) {
1274 self.stdout.push(line);
1275 }
1276
1277 pub fn add_stderr(&mut self, line: String) {
1279 self.stderr.push(line);
1280 }
1281
1282 pub fn complete(&mut self, exit_code: i32) {
1284 self.exit_code = Some(exit_code);
1285 self.status = if exit_code == 0 {
1286 CommandStatus::Completed
1287 } else {
1288 CommandStatus::Failed
1289 };
1290 }
1291
1292 pub fn fail(&mut self, error_message: String) {
1294 self.stderr.push(error_message);
1295 self.status = CommandStatus::Failed;
1296 }
1297
1298 pub fn is_running(&self) -> bool {
1300 self.status == CommandStatus::Running
1301 }
1302
1303 pub fn is_finished(&self) -> bool {
1305 self.status != CommandStatus::Running
1306 }
1307
1308 pub fn combined_output(&self) -> Vec<&str> {
1311 let mut output: Vec<&str> = self.stdout.iter().map(|s| s.as_str()).collect();
1312 if !self.stderr.is_empty() {
1313 output.extend(self.stderr.iter().map(|s| s.as_str()));
1314 }
1315 output
1316 }
1317}
1318
1319#[derive(Debug, Clone)]
1325pub enum CommandMessage {
1326 Stdout { cache_key: String, line: String },
1328 Stderr { cache_key: String, line: String },
1330 Completed { cache_key: String, exit_code: i32 },
1332 Failed { cache_key: String, error: String },
1334 ProjectRemoved { project_name: String },
1336 CleanupCompleted { result: CleanupResult },
1338}
1339
1340#[derive(Debug, Clone, PartialEq)]
1346pub enum PendingCleanOperation {
1347 Worktrees { project_name: String },
1349 Orphaned { project_name: String },
1351 Data {
1353 project_name: String,
1354 specs_count: usize,
1355 runs_count: usize,
1356 },
1357 RemoveProject { project_name: String },
1359}
1360
1361impl PendingCleanOperation {
1362 fn title(&self) -> &'static str {
1364 match self {
1365 Self::Worktrees { .. } => "Clean Worktrees",
1366 Self::Orphaned { .. } => "Clean Orphaned Sessions",
1367 Self::Data { .. } => "Clean Project Data",
1369 Self::RemoveProject { .. } => "Remove Project",
1370 }
1371 }
1372
1373 fn confirm_button_label(&self) -> &'static str {
1376 match self {
1377 Self::Data { .. } => "Delete",
1378 _ => "Confirm",
1379 }
1380 }
1381
1382 fn message(&self) -> String {
1384 match self {
1385 Self::Worktrees { project_name } => {
1386 format!(
1387 "This will remove completed worktrees and their session state for '{}'.\n\n\
1388 Are you sure you want to continue?",
1389 project_name
1390 )
1391 }
1392 Self::Orphaned { project_name } => {
1393 format!(
1394 "This will remove session state for orphaned sessions (where the worktree \
1395 has been deleted) for '{}'.\n\n\
1396 Are you sure you want to continue?",
1397 project_name
1398 )
1399 }
1400 Self::Data {
1401 project_name,
1402 specs_count,
1403 runs_count,
1404 } => {
1405 let mut items = Vec::new();
1407 if *runs_count > 0 {
1408 items.push(format!(
1409 "{} archived run{}",
1410 runs_count,
1411 if *runs_count == 1 { "" } else { "s" }
1412 ));
1413 }
1414 if *specs_count > 0 {
1415 items.push(format!(
1416 "{} spec{}",
1417 specs_count,
1418 if *specs_count == 1 { "" } else { "s" }
1419 ));
1420 }
1421 let items_str = items.join(", ");
1422 format!(
1423 "This will delete {} for '{}'.\n\n\
1424 Are you sure you want to continue?",
1425 items_str, project_name
1426 )
1427 }
1428 Self::RemoveProject { project_name } => {
1429 format!(
1430 "This will remove all worktrees (except those with active runs) and delete \
1431 the autom8 configuration for '{}'.\n\n\
1432 This cannot be undone.",
1433 project_name
1434 )
1435 }
1436 }
1437 }
1438
1439 fn project_name(&self) -> &str {
1441 match self {
1442 Self::Worktrees { project_name }
1443 | Self::Orphaned { project_name }
1444 | Self::Data { project_name, .. }
1445 | Self::RemoveProject { project_name } => project_name,
1446 }
1447 }
1448}
1449
1450#[derive(Debug, Clone)]
1460pub enum CleanupResult {
1461 Worktrees {
1463 project_name: String,
1464 worktrees_removed: usize,
1465 sessions_removed: usize,
1466 bytes_freed: u64,
1467 skipped_count: usize,
1468 error_count: usize,
1469 },
1470 Orphaned {
1472 project_name: String,
1473 sessions_removed: usize,
1474 bytes_freed: u64,
1475 error_count: usize,
1476 },
1477 RemoveProject {
1479 project_name: String,
1480 worktrees_removed: usize,
1481 config_deleted: bool,
1482 bytes_freed: u64,
1483 skipped_count: usize,
1484 error_count: usize,
1485 },
1486 Data {
1488 project_name: String,
1489 specs_removed: usize,
1490 runs_removed: usize,
1491 bytes_freed: u64,
1492 error_count: usize,
1493 },
1494}
1495
1496impl CleanupResult {
1497 pub fn title(&self) -> &'static str {
1499 match self {
1500 Self::Worktrees { .. } => "Cleanup Complete",
1501 Self::Orphaned { .. } => "Cleanup Complete",
1502 Self::Data { .. } => "Cleanup Complete",
1503 Self::RemoveProject { .. } => "Project Removed",
1504 }
1505 }
1506
1507 pub fn message(&self) -> String {
1509 use crate::commands::format_bytes_display;
1510
1511 match self {
1512 Self::Worktrees {
1513 worktrees_removed,
1514 sessions_removed,
1515 bytes_freed,
1516 skipped_count,
1517 error_count,
1518 ..
1519 } => {
1520 let mut parts = Vec::new();
1521
1522 if *worktrees_removed > 0 || *sessions_removed > 0 {
1523 let freed = format_bytes_display(*bytes_freed);
1524 parts.push(format!(
1525 "Removed {} worktree{} and {} session{}, freed {}.",
1526 worktrees_removed,
1527 if *worktrees_removed == 1 { "" } else { "s" },
1528 sessions_removed,
1529 if *sessions_removed == 1 { "" } else { "s" },
1530 freed
1531 ));
1532 } else {
1533 parts.push("No worktrees or sessions were removed.".to_string());
1534 }
1535
1536 if *skipped_count > 0 {
1537 parts.push(format!(
1538 "{} session{} skipped (active runs or uncommitted changes).",
1539 skipped_count,
1540 if *skipped_count == 1 {
1541 " was"
1542 } else {
1543 "s were"
1544 }
1545 ));
1546 }
1547
1548 if *error_count > 0 {
1549 parts.push(format!(
1550 "{} error{} occurred. Check the command output tab for details.",
1551 error_count,
1552 if *error_count == 1 { "" } else { "s" }
1553 ));
1554 }
1555
1556 parts.join("\n\n")
1557 }
1558 Self::Orphaned {
1559 sessions_removed,
1560 bytes_freed,
1561 error_count,
1562 ..
1563 } => {
1564 let mut parts = Vec::new();
1565
1566 if *sessions_removed > 0 {
1567 let freed = format_bytes_display(*bytes_freed);
1568 parts.push(format!(
1569 "Removed {} orphaned session{}, freed {}.",
1570 sessions_removed,
1571 if *sessions_removed == 1 { "" } else { "s" },
1572 freed
1573 ));
1574 } else {
1575 parts.push("No orphaned sessions were found.".to_string());
1576 }
1577
1578 if *error_count > 0 {
1579 parts.push(format!(
1580 "{} error{} occurred. Check the command output tab for details.",
1581 error_count,
1582 if *error_count == 1 { "" } else { "s" }
1583 ));
1584 }
1585
1586 parts.join("\n\n")
1587 }
1588 Self::RemoveProject {
1589 project_name,
1590 worktrees_removed,
1591 config_deleted,
1592 bytes_freed,
1593 skipped_count,
1594 error_count,
1595 } => {
1596 let mut parts = Vec::new();
1597
1598 if *config_deleted {
1599 let freed = format_bytes_display(*bytes_freed);
1600 let mut summary = format!("Project '{}' has been removed.", project_name);
1601 if *worktrees_removed > 0 {
1602 summary.push_str(&format!(
1603 "\n\nRemoved {} worktree{}, freed {}.",
1604 worktrees_removed,
1605 if *worktrees_removed == 1 { "" } else { "s" },
1606 freed
1607 ));
1608 }
1609 parts.push(summary);
1610 } else {
1611 parts.push(format!(
1612 "Failed to fully remove project '{}'.",
1613 project_name
1614 ));
1615 }
1616
1617 if *skipped_count > 0 {
1618 parts.push(format!(
1619 "{} worktree{} skipped (active runs).",
1620 skipped_count,
1621 if *skipped_count == 1 {
1622 " was"
1623 } else {
1624 "s were"
1625 }
1626 ));
1627 }
1628
1629 if *error_count > 0 {
1630 parts.push(format!(
1631 "{} error{} occurred. Check the command output tab for details.",
1632 error_count,
1633 if *error_count == 1 { "" } else { "s" }
1634 ));
1635 }
1636
1637 parts.join("\n\n")
1638 }
1639 Self::Data {
1640 specs_removed,
1641 runs_removed,
1642 bytes_freed,
1643 error_count,
1644 ..
1645 } => {
1646 let mut parts = Vec::new();
1647
1648 if *specs_removed > 0 || *runs_removed > 0 {
1649 let freed = format_bytes_display(*bytes_freed);
1650 let mut items = Vec::new();
1651 if *specs_removed > 0 {
1652 items.push(format!(
1653 "{} spec{}",
1654 specs_removed,
1655 if *specs_removed == 1 { "" } else { "s" }
1656 ));
1657 }
1658 if *runs_removed > 0 {
1659 items.push(format!(
1660 "{} archived run{}",
1661 runs_removed,
1662 if *runs_removed == 1 { "" } else { "s" }
1663 ));
1664 }
1665 parts.push(format!("Removed {}, freed {}.", items.join(" and "), freed));
1666 } else {
1667 parts.push("No data was removed.".to_string());
1668 }
1669
1670 if *error_count > 0 {
1671 parts.push(format!(
1672 "{} error{} occurred. Check the command output tab for details.",
1673 error_count,
1674 if *error_count == 1 { "" } else { "s" }
1675 ));
1676 }
1677
1678 parts.join("\n\n")
1679 }
1680 }
1681 }
1682
1683 pub fn has_errors(&self) -> bool {
1685 match self {
1686 Self::Worktrees { error_count, .. }
1687 | Self::Orphaned { error_count, .. }
1688 | Self::RemoveProject { error_count, .. }
1689 | Self::Data { error_count, .. } => *error_count > 0,
1690 }
1691 }
1692}
1693
1694pub trait RunHistoryEntryExt {
1700 fn status_color(&self) -> Color32;
1702}
1703
1704impl RunHistoryEntryExt for RunHistoryEntry {
1705 fn status_color(&self) -> Color32 {
1706 match self.status {
1707 crate::state::RunStatus::Completed => colors::STATUS_SUCCESS,
1708 crate::state::RunStatus::Failed => colors::STATUS_ERROR,
1709 crate::state::RunStatus::Running => colors::STATUS_RUNNING,
1710 crate::state::RunStatus::Interrupted => colors::STATUS_WARNING,
1711 }
1712 }
1713}
1714
1715#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
1726pub enum TabId {
1727 #[default]
1729 ActiveRuns,
1730 Projects,
1732 Config,
1734 CreateSpec,
1736 RunDetail(String),
1739 CommandOutput(String),
1742}
1743
1744#[derive(Debug, Clone)]
1746pub struct TabInfo {
1747 pub id: TabId,
1749 pub label: String,
1751 pub closable: bool,
1753}
1754
1755impl TabInfo {
1756 pub fn permanent(id: TabId, label: impl Into<String>) -> Self {
1758 Self {
1759 id,
1760 label: label.into(),
1761 closable: false,
1762 }
1763 }
1764
1765 pub fn closable(id: TabId, label: impl Into<String>) -> Self {
1767 Self {
1768 id,
1769 label: label.into(),
1770 closable: true,
1771 }
1772 }
1773}
1774
1775#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1778pub enum Tab {
1779 #[default]
1781 ActiveRuns,
1782 Projects,
1784 Config,
1786 CreateSpec,
1788}
1789
1790impl Tab {
1791 pub fn label(self) -> &'static str {
1793 match self {
1794 Tab::ActiveRuns => "Active Runs",
1795 Tab::Projects => "Projects",
1796 Tab::Config => "Config",
1797 Tab::CreateSpec => "Create Spec",
1798 }
1799 }
1800
1801 pub fn all() -> &'static [Tab] {
1803 &[Tab::ActiveRuns, Tab::Projects, Tab::Config, Tab::CreateSpec]
1804 }
1805
1806 pub fn to_tab_id(self) -> TabId {
1808 match self {
1809 Tab::ActiveRuns => TabId::ActiveRuns,
1810 Tab::Projects => TabId::Projects,
1811 Tab::Config => TabId::Config,
1812 Tab::CreateSpec => TabId::CreateSpec,
1813 }
1814 }
1815}
1816
1817const TAB_BAR_MAX_SCROLL_WIDTH: f32 = 800.0;
1819
1820const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
1822
1823const TAB_CLOSE_PADDING: f32 = 4.0;
1825
1826const TAB_LABEL_CLOSE_GAP: f32 = 8.0;
1829
1830const CONTENT_TAB_BAR_HEIGHT: f32 = 32.0;
1833
1834const CHAT_BUBBLE_MAX_WIDTH_RATIO: f32 = 0.75;
1840
1841const CHAT_BUBBLE_PADDING: f32 = 12.0;
1843
1844const CHAT_BUBBLE_ROUNDING: f32 = 16.0;
1846
1847const CHAT_MESSAGE_SPACING: f32 = 12.0;
1849
1850const USER_BUBBLE_COLOR: Color32 = Color32::from_rgb(238, 235, 229);
1852
1853const CLAUDE_BUBBLE_COLOR: Color32 = Color32::from_rgb(255, 255, 255);
1855
1856const INPUT_BAR_HEIGHT: f32 = 56.0;
1862
1863const INPUT_FIELD_ROUNDING: f32 = 12.0;
1865
1866const SEND_BUTTON_COLOR: Color32 = Color32::from_rgb(0, 122, 255);
1868
1869const SEND_BUTTON_HOVER_COLOR: Color32 = Color32::from_rgb(0, 100, 210);
1871
1872const SEND_BUTTON_DISABLED_COLOR: Color32 = Color32::from_rgb(200, 200, 200);
1874
1875const SEND_BUTTON_SIZE: f32 = 36.0;
1877
1878#[derive(Debug, Clone)]
1884pub enum ClaudeMessage {
1885 Spawning,
1888 Started,
1890 Output(String),
1892 ResponsePaused,
1895 Finished {
1898 success: bool,
1900 error: Option<String>,
1902 },
1903 SpawnError(String),
1905}
1906
1907pub struct ClaudeStdinHandle {
1911 writer: std::sync::Mutex<Option<std::process::ChildStdin>>,
1913}
1914
1915impl ClaudeStdinHandle {
1916 pub fn new(stdin: std::process::ChildStdin) -> Self {
1918 Self {
1919 writer: std::sync::Mutex::new(Some(stdin)),
1920 }
1921 }
1922
1923 pub fn send(&self, message: &str) -> bool {
1927 use std::io::Write;
1928 if let Ok(mut guard) = self.writer.lock() {
1929 if let Some(ref mut stdin) = *guard {
1930 if let Err(e) = writeln!(stdin, "{}", message) {
1932 eprintln!("Failed to write to Claude stdin: {}", e);
1933 return false;
1934 }
1935 if let Err(e) = stdin.flush() {
1936 eprintln!("Failed to flush Claude stdin: {}", e);
1937 return false;
1938 }
1939 return true;
1940 }
1941 }
1942 false
1943 }
1944
1945 pub fn close(&self) {
1947 if let Ok(mut guard) = self.writer.lock() {
1948 *guard = None;
1950 }
1951 }
1952}
1953
1954#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1960pub enum ChatMessageSender {
1961 User,
1963 Claude,
1965}
1966
1967#[derive(Debug, Clone)]
1969pub struct ChatMessage {
1970 pub sender: ChatMessageSender,
1972 pub content: String,
1974 pub timestamp: Instant,
1976}
1977
1978impl ChatMessage {
1979 pub fn new(sender: ChatMessageSender, content: impl Into<String>) -> Self {
1981 Self {
1982 sender,
1983 content: content.into(),
1984 timestamp: Instant::now(),
1985 }
1986 }
1987
1988 pub fn user(content: impl Into<String>) -> Self {
1990 Self::new(ChatMessageSender::User, content)
1991 }
1992
1993 pub fn claude(content: impl Into<String>) -> Self {
1995 Self::new(ChatMessageSender::Claude, content)
1996 }
1997}
1998
1999#[derive(Debug, Clone, Copy, PartialEq)]
2005enum StoryStatus {
2006 Completed,
2008 Active,
2010 Pending,
2012 Failed,
2014}
2015
2016impl StoryStatus {
2017 fn color(self) -> Color32 {
2019 match self {
2020 StoryStatus::Completed => colors::STATUS_SUCCESS,
2021 StoryStatus::Active => colors::STATUS_RUNNING,
2022 StoryStatus::Pending => colors::TEXT_MUTED,
2023 StoryStatus::Failed => colors::STATUS_ERROR,
2024 }
2025 }
2026
2027 fn background(self) -> Color32 {
2029 match self {
2030 StoryStatus::Completed => colors::STATUS_SUCCESS_BG,
2031 StoryStatus::Active => colors::STATUS_RUNNING_BG,
2032 StoryStatus::Pending => colors::SURFACE_HOVER,
2033 StoryStatus::Failed => colors::STATUS_ERROR_BG,
2034 }
2035 }
2036
2037 fn indicator(self) -> &'static str {
2039 match self {
2040 StoryStatus::Completed => "[done]",
2041 StoryStatus::Active => "[...]",
2042 StoryStatus::Pending => "[ ]",
2043 StoryStatus::Failed => "[x]",
2044 }
2045 }
2046}
2047
2048#[derive(Debug, Clone)]
2050struct StoryItem {
2051 id: String,
2053 title: String,
2055 status: StoryStatus,
2057 work_summary: Option<String>,
2059}
2060
2061fn load_story_items(session: &SessionData) -> Vec<StoryItem> {
2072 let Some(ref run) = session.run else {
2073 return Vec::new();
2074 };
2075
2076 let Some(ref user_stories) = session.cached_user_stories else {
2078 return Vec::new();
2079 };
2080
2081 let current_story_id = run.current_story.as_deref();
2082
2083 let failed_stories: std::collections::HashSet<&str> = run
2085 .iterations
2086 .iter()
2087 .filter(|iter| iter.status == IterationStatus::Failed)
2088 .map(|iter| iter.story_id.as_str())
2089 .collect();
2090
2091 let mut work_summaries: std::collections::HashMap<&str, &str> =
2094 std::collections::HashMap::new();
2095 for iter in &run.iterations {
2096 if iter.status == IterationStatus::Success {
2097 if let Some(ref summary) = iter.work_summary {
2098 work_summaries.insert(&iter.story_id, summary);
2099 }
2100 }
2101 }
2102
2103 let mut items: Vec<StoryItem> = user_stories
2105 .iter()
2106 .map(|story| {
2107 let status = if Some(story.id.as_str()) == current_story_id {
2108 StoryStatus::Active
2109 } else if story.passes {
2110 StoryStatus::Completed
2111 } else if failed_stories.contains(story.id.as_str()) {
2112 StoryStatus::Failed
2113 } else {
2114 StoryStatus::Pending
2115 };
2116
2117 let work_summary = if status == StoryStatus::Completed {
2119 work_summaries.get(story.id.as_str()).map(|s| s.to_string())
2120 } else {
2121 None
2122 };
2123
2124 StoryItem {
2125 id: story.id.clone(),
2126 title: story.title.clone(),
2127 status,
2128 work_summary,
2129 }
2130 })
2131 .collect();
2132
2133 items.sort_by(|a, b| {
2137 let order = |s: &StoryItem| match s.status {
2138 StoryStatus::Active => 0,
2139 StoryStatus::Completed => 1,
2140 StoryStatus::Failed => 2,
2141 StoryStatus::Pending => 3,
2142 };
2143 order(a).cmp(&order(b))
2144 });
2145
2146 items
2147}
2148
2149pub struct Autom8App {
2154 current_tab: Tab,
2156
2157 tabs: Vec<TabInfo>,
2162 active_tab_id: TabId,
2164 previous_tab_id: Option<TabId>,
2167
2168 projects: Vec<ProjectData>,
2173 sessions: Vec<SessionData>,
2176 has_active_runs: bool,
2178
2179 selected_project: Option<String>,
2185
2186 run_history: Vec<RunHistoryEntry>,
2192
2193 run_detail_cache: std::collections::HashMap<String, crate::state::RunState>,
2196
2197 run_history_loading: bool,
2200
2201 run_history_error: Option<String>,
2203
2204 initial_load_complete: bool,
2210
2211 last_refresh: Instant,
2216 refresh_interval: Duration,
2218
2219 sidebar_collapsed: bool,
2226
2227 selected_session_id: Option<String>,
2233
2234 closed_session_tabs: std::collections::HashSet<String>,
2237
2238 seen_sessions: std::collections::HashMap<String, SessionData>,
2242
2243 pub config_state: ConfigTabState,
2249
2250 context_menu: Option<ContextMenuState>,
2257
2258 command_executions: std::collections::HashMap<String, CommandExecution>,
2264
2265 command_rx: std::sync::mpsc::Receiver<CommandMessage>,
2271 command_tx: std::sync::mpsc::Sender<CommandMessage>,
2274
2275 pending_clean_confirmation: Option<PendingCleanOperation>,
2281
2282 pending_result_modal: Option<CleanupResult>,
2288
2289 section_collapsed_state: std::collections::HashMap<String, bool>,
2296
2297 create_spec_selected_project: Option<String>,
2303 chat_messages: Vec<ChatMessage>,
2306 chat_scroll_to_bottom: bool,
2309 chat_input_text: String,
2312 is_waiting_for_claude: bool,
2315
2316 claude_rx: std::sync::mpsc::Receiver<ClaudeMessage>,
2321 claude_tx: std::sync::mpsc::Sender<ClaudeMessage>,
2324 claude_stdin: Option<Arc<ClaudeStdinHandle>>,
2327 claude_child: Arc<std::sync::Mutex<Option<std::process::Child>>>,
2331 claude_response_buffer: String,
2334 claude_error: Option<String>,
2337 claude_starting: bool,
2341 last_claude_output_time: Option<Instant>,
2344 claude_response_in_progress: bool,
2347
2348 generated_spec_path: Option<std::path::PathBuf>,
2354 spec_confirmed: bool,
2357 claude_finished: bool,
2360
2361 pending_project_change: Option<String>,
2368 pending_start_new_spec: bool,
2371}
2372
2373impl Default for Autom8App {
2374 fn default() -> Self {
2375 Self::new()
2376 }
2377}
2378
2379impl Autom8App {
2380 pub fn new() -> Self {
2382 Self::with_refresh_interval(Duration::from_millis(DEFAULT_REFRESH_INTERVAL_MS))
2383 }
2384
2385 pub fn with_refresh_interval(refresh_interval: Duration) -> Self {
2391 let tabs = vec![
2393 TabInfo::permanent(TabId::ActiveRuns, "Active Runs"),
2394 TabInfo::permanent(TabId::Projects, "Projects"),
2395 TabInfo::permanent(TabId::Config, "Config"),
2396 ];
2397
2398 let (command_tx, command_rx) = std::sync::mpsc::channel();
2400
2401 let (claude_tx, claude_rx) = std::sync::mpsc::channel();
2403
2404 let mut app = Self {
2405 current_tab: Tab::default(),
2406 tabs,
2407 active_tab_id: TabId::default(),
2408 previous_tab_id: None,
2409 projects: Vec::new(),
2410 sessions: Vec::new(),
2411 has_active_runs: false,
2412 selected_project: None,
2413 run_history: Vec::new(),
2414 run_detail_cache: std::collections::HashMap::new(),
2415 run_history_loading: false,
2416 run_history_error: None,
2417 initial_load_complete: false,
2418 last_refresh: Instant::now(),
2419 refresh_interval,
2420 sidebar_collapsed: false,
2421 selected_session_id: None,
2422 closed_session_tabs: std::collections::HashSet::new(),
2423 seen_sessions: std::collections::HashMap::new(),
2424 config_state: ConfigTabState::new(),
2425 context_menu: None,
2426 command_executions: std::collections::HashMap::new(),
2427 command_rx,
2428 command_tx,
2429 pending_clean_confirmation: None,
2430 pending_result_modal: None,
2431 section_collapsed_state: std::collections::HashMap::new(),
2432 create_spec_selected_project: None,
2433 chat_messages: Vec::new(),
2434 chat_scroll_to_bottom: false,
2435 chat_input_text: String::new(),
2436 is_waiting_for_claude: false,
2437 claude_rx,
2438 claude_tx,
2439 claude_stdin: None,
2440 claude_child: Arc::new(std::sync::Mutex::new(None)),
2441 claude_response_buffer: String::new(),
2442 claude_error: None,
2443 claude_starting: false,
2444 last_claude_output_time: None,
2445 claude_response_in_progress: false,
2446 generated_spec_path: None,
2447 spec_confirmed: false,
2448 claude_finished: false,
2449 pending_project_change: None,
2450 pending_start_new_spec: false,
2451 };
2452 app.refresh_data();
2454 app.initial_load_complete = true;
2455 app
2456 }
2457
2458 pub fn is_initial_load_complete(&self) -> bool {
2460 self.initial_load_complete
2461 }
2462
2463 pub fn current_tab(&self) -> Tab {
2465 self.current_tab
2466 }
2467
2468 pub fn projects(&self) -> &[ProjectData] {
2470 &self.projects
2471 }
2472
2473 pub fn sessions(&self) -> &[SessionData] {
2475 &self.sessions
2476 }
2477
2478 pub fn has_active_runs(&self) -> bool {
2480 self.has_active_runs
2481 }
2482
2483 pub fn refresh_interval(&self) -> Duration {
2485 self.refresh_interval
2486 }
2487
2488 pub fn set_refresh_interval(&mut self, interval: Duration) {
2490 self.refresh_interval = interval;
2491 }
2492
2493 pub fn is_sidebar_collapsed(&self) -> bool {
2499 self.sidebar_collapsed
2500 }
2501
2502 pub fn set_sidebar_collapsed(&mut self, collapsed: bool) {
2504 self.sidebar_collapsed = collapsed;
2505 }
2506
2507 pub fn toggle_sidebar(&mut self) {
2509 self.sidebar_collapsed = !self.sidebar_collapsed;
2510 }
2511
2512 pub fn selected_config_scope(&self) -> &ConfigScope {
2518 self.config_state.selected_scope()
2519 }
2520
2521 pub fn set_selected_config_scope(&mut self, scope: ConfigScope) {
2523 self.config_state.set_selected_scope(scope);
2524 }
2525
2526 pub fn config_scope_projects(&self) -> &[String] {
2528 self.config_state.scope_projects()
2529 }
2530
2531 pub fn project_has_config(&self, project_name: &str) -> bool {
2533 self.config_state.project_has_config(project_name)
2534 }
2535
2536 fn refresh_config_scope_data(&mut self) {
2538 self.config_state.refresh_scope_data();
2539 }
2540
2541 pub fn cached_global_config(&self) -> Option<&crate::config::Config> {
2543 self.config_state.cached_global_config()
2544 }
2545
2546 pub fn global_config_error(&self) -> Option<&str> {
2548 self.config_state.global_config_error()
2549 }
2550
2551 pub fn cached_project_config(&self, project_name: &str) -> Option<&crate::config::Config> {
2553 self.config_state.cached_project_config(project_name)
2554 }
2555
2556 pub fn project_config_error(&self) -> Option<&str> {
2558 self.config_state.project_config_error()
2559 }
2560
2561 fn create_project_config_from_global(
2563 &mut self,
2564 project_name: &str,
2565 ) -> std::result::Result<(), String> {
2566 self.config_state
2567 .create_project_config_from_global(project_name)
2568 }
2569
2570 fn apply_config_bool_changes(
2572 &mut self,
2573 is_global: bool,
2574 project_name: Option<&str>,
2575 changes: &[(ConfigBoolField, bool)],
2576 ) {
2577 self.config_state
2578 .apply_bool_changes(is_global, project_name, changes);
2579 }
2580
2581 fn apply_config_text_changes(
2583 &mut self,
2584 is_global: bool,
2585 project_name: Option<&str>,
2586 changes: &[(ConfigTextField, String)],
2587 ) {
2588 self.config_state
2589 .apply_text_changes(is_global, project_name, changes);
2590 }
2591
2592 fn reset_config_to_defaults(&mut self, is_global: bool, project_name: Option<&str>) {
2594 self.config_state.reset_to_defaults(is_global, project_name);
2595 }
2596
2597 pub fn is_context_menu_open(&self) -> bool {
2603 self.context_menu.is_some()
2604 }
2605
2606 pub fn context_menu(&self) -> Option<&ContextMenuState> {
2608 self.context_menu.as_ref()
2609 }
2610
2611 pub fn open_context_menu(&mut self, position: Pos2, project_name: String) {
2613 let items = self.build_context_menu_items(&project_name);
2615
2616 self.context_menu = Some(ContextMenuState::new(position, project_name, items));
2617 }
2618
2619 pub fn close_context_menu(&mut self) {
2621 self.context_menu = None;
2622 }
2623
2624 fn get_resumable_sessions(&self, project_name: &str) -> Vec<ResumableSessionInfo> {
2633 let sm = match StateManager::for_project(project_name) {
2635 Ok(sm) => sm,
2636 Err(_) => return Vec::new(),
2637 };
2638
2639 let sessions = match sm.list_sessions_with_status() {
2641 Ok(sessions) => sessions,
2642 Err(_) => return Vec::new(),
2643 };
2644
2645 sessions
2647 .into_iter()
2648 .filter(is_resumable_session)
2649 .filter_map(|s| {
2650 let machine_state = s.machine_state?;
2653 Some(ResumableSessionInfo::new(
2654 s.metadata.session_id,
2655 s.metadata.branch_name,
2656 s.metadata.worktree_path,
2657 machine_state,
2658 ))
2659 })
2660 .collect()
2661 }
2662
2663 fn get_resumable_session_by_id(
2667 &self,
2668 project_name: &str,
2669 session_id: &str,
2670 ) -> Option<ResumableSessionInfo> {
2671 self.get_resumable_sessions(project_name)
2672 .into_iter()
2673 .find(|s| s.session_id == session_id)
2674 }
2675
2676 fn get_cleanable_info(&self, project_name: &str) -> CleanableInfo {
2690 let sm = match StateManager::for_project(project_name) {
2692 Ok(sm) => sm,
2693 Err(_) => return CleanableInfo::default(),
2694 };
2695
2696 let sessions = match sm.list_sessions_with_status() {
2698 Ok(sessions) => sessions,
2699 Err(_) => return CleanableInfo::default(),
2700 };
2701
2702 let mut info = CleanableInfo::default();
2703
2704 let mut active_spec_paths: std::collections::HashSet<std::path::PathBuf> =
2706 std::collections::HashSet::new();
2707
2708 for session in &sessions {
2709 if session.metadata.session_id == "main" {
2711 if session.metadata.is_running {
2713 if let Some(session_sm) = sm.get_session(&session.metadata.session_id) {
2714 if let Ok(Some(state)) = session_sm.load_current() {
2715 active_spec_paths.insert(state.spec_json_path.clone());
2716 if let Some(md_path) = &state.spec_md_path {
2717 active_spec_paths.insert(md_path.clone());
2718 }
2719 }
2720 }
2721 }
2722 continue;
2723 }
2724
2725 if session.is_stale {
2726 info.orphaned_sessions += 1;
2728 } else if !session.metadata.is_running {
2729 info.cleanable_worktrees += 1;
2732 } else {
2733 if let Some(session_sm) = sm.get_session(&session.metadata.session_id) {
2735 if let Ok(Some(state)) = session_sm.load_current() {
2736 active_spec_paths.insert(state.spec_json_path.clone());
2737 if let Some(md_path) = &state.spec_md_path {
2738 active_spec_paths.insert(md_path.clone());
2739 }
2740 }
2741 }
2742 }
2743 }
2744
2745 info.cleanable_specs = count_cleanable_specs(&sm.spec_dir(), &active_spec_paths);
2747
2748 info.cleanable_runs = count_cleanable_runs(&sm.runs_dir());
2750
2751 info
2752 }
2753
2754 fn build_context_menu_items(&self, project_name: &str) -> Vec<ContextMenuItem> {
2757 let resumable_sessions = self.get_resumable_sessions(project_name);
2759
2760 let resume_item = match resumable_sessions.len() {
2762 0 => {
2763 ContextMenuItem::action_disabled("Resume", ContextMenuAction::Resume(None))
2765 }
2766 1 => {
2767 let session = &resumable_sessions[0];
2769 let label = format!("Resume ({})", session.branch_name);
2770 ContextMenuItem::action(
2771 label,
2772 ContextMenuAction::Resume(Some(session.session_id.clone())),
2773 )
2774 }
2775 _ => {
2776 let submenu_items: Vec<ContextMenuItem> = resumable_sessions
2778 .iter()
2779 .map(|session| {
2780 ContextMenuItem::action(
2781 session.menu_label(),
2782 ContextMenuAction::Resume(Some(session.session_id.clone())),
2783 )
2784 })
2785 .collect();
2786 ContextMenuItem::submenu("Resume", "resume", submenu_items)
2787 }
2788 };
2789
2790 let cleanable_info = self.get_cleanable_info(project_name);
2792
2793 let clean_item = if !cleanable_info.has_cleanable() {
2795 ContextMenuItem::submenu_disabled("Clean", "clean", "Nothing to clean")
2797 } else {
2798 let mut submenu_items = Vec::new();
2800
2801 if cleanable_info.cleanable_worktrees > 0 {
2802 let label = format!("Worktrees ({})", cleanable_info.cleanable_worktrees);
2803 submenu_items.push(ContextMenuItem::action(
2804 label,
2805 ContextMenuAction::CleanWorktrees,
2806 ));
2807 }
2808
2809 if cleanable_info.orphaned_sessions > 0 {
2810 let label = format!("Orphaned ({})", cleanable_info.orphaned_sessions);
2811 submenu_items.push(ContextMenuItem::action(
2812 label,
2813 ContextMenuAction::CleanOrphaned,
2814 ));
2815 }
2816
2817 let data_count = cleanable_info.cleanable_specs + cleanable_info.cleanable_runs;
2819 if data_count > 0 {
2820 let label = format!("Data ({})", data_count);
2821 submenu_items.push(ContextMenuItem::action(label, ContextMenuAction::CleanData));
2822 }
2823
2824 ContextMenuItem::submenu("Clean", "clean", submenu_items)
2825 };
2826
2827 vec![
2828 ContextMenuItem::action("Status", ContextMenuAction::Status),
2829 ContextMenuItem::action("Describe", ContextMenuAction::Describe),
2830 ContextMenuItem::Separator,
2831 resume_item,
2832 ContextMenuItem::Separator,
2833 clean_item,
2834 ContextMenuItem::Separator,
2835 ContextMenuItem::action("Remove Project", ContextMenuAction::RemoveProject),
2836 ]
2837 }
2838
2839 pub fn selected_project(&self) -> Option<&str> {
2841 self.selected_project.as_deref()
2842 }
2843
2844 pub fn toggle_project_selection(&mut self, project_name: &str) {
2849 if self.selected_project.as_deref() == Some(project_name) {
2850 self.selected_project = None;
2852 self.run_history.clear();
2853 self.run_history_loading = false;
2854 self.run_history_error = None;
2855 } else {
2856 self.selected_project = Some(project_name.to_string());
2858 self.load_run_history(project_name);
2859 }
2860 }
2861
2862 fn load_run_history(&mut self, project_name: &str) {
2866 self.run_history.clear();
2867 self.run_history_error = None;
2868 self.run_history_loading = true;
2869
2870 match load_project_run_history(project_name) {
2872 Ok(history) => {
2873 self.run_history = history;
2874 }
2875 Err(e) => {
2876 self.run_history_error = Some(format!("Failed to load run history: {}", e));
2877 }
2878 }
2879
2880 self.run_history_loading = false;
2881 }
2882
2883 pub fn run_history(&self) -> &[RunHistoryEntry] {
2885 &self.run_history
2886 }
2887
2888 pub fn is_run_history_loading(&self) -> bool {
2890 self.run_history_loading
2891 }
2892
2893 pub fn run_history_error(&self) -> Option<&str> {
2895 self.run_history_error.as_deref()
2896 }
2897
2898 pub fn is_project_selected(&self, project_name: &str) -> bool {
2900 self.selected_project.as_deref() == Some(project_name)
2901 }
2902
2903 pub fn tabs(&self) -> &[TabInfo] {
2909 &self.tabs
2910 }
2911
2912 pub fn active_tab_id(&self) -> &TabId {
2914 &self.active_tab_id
2915 }
2916
2917 pub fn tab_count(&self) -> usize {
2919 self.tabs.len()
2920 }
2921
2922 pub fn closable_tab_count(&self) -> usize {
2924 self.tabs.iter().filter(|t| t.closable).count()
2925 }
2926
2927 pub fn set_active_tab(&mut self, tab_id: TabId) {
2931 if self.active_tab_id != tab_id {
2933 self.previous_tab_id = Some(self.active_tab_id.clone());
2934 }
2935
2936 match &tab_id {
2938 TabId::ActiveRuns => self.current_tab = Tab::ActiveRuns,
2939 TabId::Projects => self.current_tab = Tab::Projects,
2940 TabId::Config => self.current_tab = Tab::Config,
2941 TabId::CreateSpec => self.current_tab = Tab::CreateSpec,
2942 TabId::RunDetail(_) | TabId::CommandOutput(_) => {
2943 }
2946 }
2947 self.active_tab_id = tab_id;
2948 }
2949
2950 pub fn has_tab(&self, tab_id: &TabId) -> bool {
2952 self.tabs.iter().any(|t| t.id == *tab_id)
2953 }
2954
2955 pub fn open_run_detail_tab(&mut self, run_id: &str, run_label: &str) -> bool {
2959 let tab_id = TabId::RunDetail(run_id.to_string());
2960
2961 if self.has_tab(&tab_id) {
2963 self.set_active_tab(tab_id);
2964 return false;
2965 }
2966
2967 let tab = TabInfo::closable(tab_id.clone(), run_label);
2969 self.tabs.push(tab);
2970 self.set_active_tab(tab_id);
2971 true
2972 }
2973
2974 pub fn open_run_detail_from_entry(
2977 &mut self,
2978 entry: &RunHistoryEntry,
2979 run_state: Option<crate::state::RunState>,
2980 ) {
2981 let label = format!(
2982 "Run - {}",
2983 entry
2984 .started_at
2985 .with_timezone(&chrono::Local)
2986 .format("%Y-%m-%d %I:%M %p")
2987 );
2988
2989 if let Some(state) = run_state {
2991 self.run_detail_cache.insert(entry.run_id.clone(), state);
2992 }
2993
2994 self.open_run_detail_tab(&entry.run_id, &label);
2995 }
2996
2997 pub fn open_command_output_tab(&mut self, project: &str, command: &str) -> CommandOutputId {
3001 let id = CommandOutputId::new(project, command);
3002 let cache_key = id.cache_key();
3003 let tab_id = TabId::CommandOutput(cache_key.clone());
3004 let label = id.tab_label();
3005
3006 let execution = CommandExecution::new(id.clone());
3008 self.command_executions.insert(cache_key, execution);
3009
3010 let tab = TabInfo::closable(tab_id.clone(), label);
3012 self.tabs.push(tab);
3013 self.set_active_tab(tab_id);
3014
3015 id
3016 }
3017
3018 pub fn get_command_execution(&self, cache_key: &str) -> Option<&CommandExecution> {
3020 self.command_executions.get(cache_key)
3021 }
3022
3023 pub fn get_command_execution_mut(&mut self, cache_key: &str) -> Option<&mut CommandExecution> {
3025 self.command_executions.get_mut(cache_key)
3026 }
3027
3028 pub fn add_command_stdout(&mut self, cache_key: &str, line: String) {
3030 if let Some(exec) = self.command_executions.get_mut(cache_key) {
3031 exec.add_stdout(line);
3032 }
3033 }
3034
3035 pub fn add_command_stderr(&mut self, cache_key: &str, line: String) {
3037 if let Some(exec) = self.command_executions.get_mut(cache_key) {
3038 exec.add_stderr(line);
3039 }
3040 }
3041
3042 pub fn complete_command(&mut self, cache_key: &str, exit_code: i32) {
3044 if let Some(exec) = self.command_executions.get_mut(cache_key) {
3045 exec.complete(exit_code);
3046 }
3047 }
3048
3049 pub fn fail_command(&mut self, cache_key: &str, error_message: String) {
3051 if let Some(exec) = self.command_executions.get_mut(cache_key) {
3052 exec.fail(error_message);
3053 }
3054 }
3055
3056 pub fn spawn_status_command(&mut self, project_name: &str) {
3061 let id = self.open_command_output_tab(project_name, "status");
3063 let cache_key = id.cache_key();
3064 let tx = self.command_tx.clone();
3065 let project = project_name.to_string();
3066
3067 std::thread::spawn(move || {
3068 match StateManager::for_project(&project) {
3070 Ok(state_manager) => {
3071 match state_manager.list_sessions_with_status() {
3072 Ok(sessions) => {
3073 let lines = format_sessions_as_text(&sessions);
3075 for line in lines {
3076 let _ = tx.send(CommandMessage::Stdout {
3077 cache_key: cache_key.clone(),
3078 line,
3079 });
3080 }
3081 let _ = tx.send(CommandMessage::Completed {
3082 cache_key,
3083 exit_code: 0,
3084 });
3085 }
3086 Err(e) => {
3087 let _ = tx.send(CommandMessage::Failed {
3088 cache_key,
3089 error: format!("Failed to list sessions: {}", e),
3090 });
3091 }
3092 }
3093 }
3094 Err(e) => {
3095 let _ = tx.send(CommandMessage::Failed {
3096 cache_key,
3097 error: format!("Failed to load project: {}", e),
3098 });
3099 }
3100 }
3101 });
3102 }
3103
3104 pub fn spawn_describe_command(&mut self, project_name: &str) {
3109 let id = self.open_command_output_tab(project_name, "describe");
3111 let cache_key = id.cache_key();
3112 let tx = self.command_tx.clone();
3113 let project = project_name.to_string();
3114
3115 std::thread::spawn(move || {
3116 match crate::config::get_project_description(&project) {
3118 Ok(Some(desc)) => {
3119 let lines = format_project_description_as_text(&desc);
3121 for line in lines {
3122 let _ = tx.send(CommandMessage::Stdout {
3123 cache_key: cache_key.clone(),
3124 line,
3125 });
3126 }
3127 let _ = tx.send(CommandMessage::Completed {
3128 cache_key,
3129 exit_code: 0,
3130 });
3131 }
3132 Ok(None) => {
3133 let _ = tx.send(CommandMessage::Stdout {
3134 cache_key: cache_key.clone(),
3135 line: format!("Project '{}' not found.", project),
3136 });
3137 let _ = tx.send(CommandMessage::Completed {
3138 cache_key,
3139 exit_code: 1,
3140 });
3141 }
3142 Err(e) => {
3143 let _ = tx.send(CommandMessage::Failed {
3144 cache_key,
3145 error: format!("Failed to get project description: {}", e),
3146 });
3147 }
3148 }
3149 });
3150 }
3151
3152 pub fn show_resume_info(&mut self, project_name: &str, session_id: &str) {
3158 let id = self.open_command_output_tab(project_name, "resume");
3160 let cache_key = id.cache_key();
3161 let tx = self.command_tx.clone();
3162
3163 match self.get_resumable_session_by_id(project_name, session_id) {
3165 Some(session) => {
3166 let lines = format_resume_info_as_text(&session);
3168 for line in lines {
3169 let _ = tx.send(CommandMessage::Stdout {
3170 cache_key: cache_key.clone(),
3171 line,
3172 });
3173 }
3174 let _ = tx.send(CommandMessage::Completed {
3175 cache_key,
3176 exit_code: 0,
3177 });
3178 }
3179 None => {
3180 let _ = tx.send(CommandMessage::Stdout {
3181 cache_key: cache_key.clone(),
3182 line: format!("Session '{}' not found or no longer resumable.", session_id),
3183 });
3184 let _ = tx.send(CommandMessage::Completed {
3185 cache_key,
3186 exit_code: 1,
3187 });
3188 }
3189 }
3190 }
3191
3192 pub fn spawn_clean_worktrees_command(&mut self, project_name: &str) {
3199 let id = self.open_command_output_tab(project_name, "clean-worktrees");
3201 let cache_key = id.cache_key();
3202 let tx = self.command_tx.clone();
3203 let project = project_name.to_string();
3204
3205 std::thread::spawn(move || {
3206 use crate::commands::{clean_worktrees_direct, DirectCleanOptions};
3207
3208 let options = DirectCleanOptions {
3210 worktrees: true,
3211 force: false,
3212 };
3213
3214 match clean_worktrees_direct(&project, options) {
3215 Ok(summary) => {
3216 let lines = format_cleanup_summary_as_text(&summary, "Clean Worktrees");
3218 for line in lines {
3219 let _ = tx.send(CommandMessage::Stdout {
3220 cache_key: cache_key.clone(),
3221 line,
3222 });
3223 }
3224 let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3225 let _ = tx.send(CommandMessage::Completed {
3226 cache_key,
3227 exit_code,
3228 });
3229
3230 let _ = tx.send(CommandMessage::CleanupCompleted {
3232 result: CleanupResult::Worktrees {
3233 project_name: project,
3234 worktrees_removed: summary.worktrees_removed,
3235 sessions_removed: summary.sessions_removed,
3236 bytes_freed: summary.bytes_freed,
3237 skipped_count: summary.sessions_skipped.len(),
3238 error_count: summary.errors.len(),
3239 },
3240 });
3241 }
3242 Err(e) => {
3243 let _ = tx.send(CommandMessage::Failed {
3244 cache_key,
3245 error: format!("Failed to clean sessions: {}", e),
3246 });
3247 }
3248 }
3249 });
3250 }
3251
3252 pub fn spawn_clean_orphaned_command(&mut self, project_name: &str) {
3259 let id = self.open_command_output_tab(project_name, "clean-orphaned");
3261 let cache_key = id.cache_key();
3262 let tx = self.command_tx.clone();
3263 let project = project_name.to_string();
3264
3265 std::thread::spawn(move || {
3266 use crate::commands::clean_orphaned_direct;
3267
3268 match clean_orphaned_direct(&project) {
3270 Ok(summary) => {
3271 let lines = format_cleanup_summary_as_text(&summary, "Clean Orphaned");
3273 for line in lines {
3274 let _ = tx.send(CommandMessage::Stdout {
3275 cache_key: cache_key.clone(),
3276 line,
3277 });
3278 }
3279 let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3280 let _ = tx.send(CommandMessage::Completed {
3281 cache_key,
3282 exit_code,
3283 });
3284
3285 let _ = tx.send(CommandMessage::CleanupCompleted {
3287 result: CleanupResult::Orphaned {
3288 project_name: project,
3289 sessions_removed: summary.sessions_removed,
3290 bytes_freed: summary.bytes_freed,
3291 error_count: summary.errors.len(),
3292 },
3293 });
3294 }
3295 Err(e) => {
3296 let _ = tx.send(CommandMessage::Failed {
3297 cache_key,
3298 error: format!("Failed to clean orphaned sessions: {}", e),
3299 });
3300 }
3301 }
3302 });
3303 }
3304
3305 pub fn spawn_clean_data_command(&mut self, project_name: &str) {
3310 let id = self.open_command_output_tab(project_name, "clean-data");
3312 let cache_key = id.cache_key();
3313 let tx = self.command_tx.clone();
3314 let project = project_name.to_string();
3315
3316 std::thread::spawn(move || {
3317 use crate::commands::clean_data_direct;
3318
3319 match clean_data_direct(&project) {
3321 Ok(summary) => {
3322 let lines = format_data_cleanup_summary_as_text(&summary);
3324 for line in lines {
3325 let _ = tx.send(CommandMessage::Stdout {
3326 cache_key: cache_key.clone(),
3327 line,
3328 });
3329 }
3330 let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3331 let _ = tx.send(CommandMessage::Completed {
3332 cache_key,
3333 exit_code,
3334 });
3335
3336 let _ = tx.send(CommandMessage::CleanupCompleted {
3338 result: CleanupResult::Data {
3339 project_name: project,
3340 specs_removed: summary.specs_removed,
3341 runs_removed: summary.runs_removed,
3342 bytes_freed: summary.bytes_freed,
3343 error_count: summary.errors.len(),
3344 },
3345 });
3346 }
3347 Err(e) => {
3348 let _ = tx.send(CommandMessage::Failed {
3349 cache_key,
3350 error: format!("Failed to clean data: {}", e),
3351 });
3352 }
3353 }
3354 });
3355 }
3356
3357 pub fn spawn_remove_project_command(&mut self, project_name: &str) {
3363 let id = self.open_command_output_tab(project_name, "remove-project");
3365 let cache_key = id.cache_key();
3366 let tx = self.command_tx.clone();
3367 let project = project_name.to_string();
3368
3369 std::thread::spawn(move || {
3370 use crate::commands::remove_project_direct;
3371
3372 match remove_project_direct(&project) {
3373 Ok(summary) => {
3374 let lines = format_removal_summary_as_text(&summary, &project);
3376 for line in lines {
3377 let _ = tx.send(CommandMessage::Stdout {
3378 cache_key: cache_key.clone(),
3379 line,
3380 });
3381 }
3382 let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
3383 let _ = tx.send(CommandMessage::Completed {
3384 cache_key: cache_key.clone(),
3385 exit_code,
3386 });
3387
3388 if summary.config_deleted {
3392 let _ = tx.send(CommandMessage::ProjectRemoved {
3393 project_name: project.clone(),
3394 });
3395 }
3396
3397 let _ = tx.send(CommandMessage::CleanupCompleted {
3399 result: CleanupResult::RemoveProject {
3400 project_name: project,
3401 worktrees_removed: summary.worktrees_removed,
3402 config_deleted: summary.config_deleted,
3403 bytes_freed: summary.bytes_freed,
3404 skipped_count: summary.worktrees_skipped.len(),
3405 error_count: summary.errors.len(),
3406 },
3407 });
3408 }
3409 Err(e) => {
3410 let _ = tx.send(CommandMessage::Failed {
3411 cache_key,
3412 error: format!("Failed to remove project: {}", e),
3413 });
3414 }
3415 }
3416 });
3417 }
3418
3419 fn poll_command_messages(&mut self) {
3422 while let Ok(msg) = self.command_rx.try_recv() {
3424 match msg {
3425 CommandMessage::Stdout { cache_key, line } => {
3426 self.add_command_stdout(&cache_key, line);
3427 }
3428 CommandMessage::Stderr { cache_key, line } => {
3429 self.add_command_stderr(&cache_key, line);
3430 }
3431 CommandMessage::Completed {
3432 cache_key,
3433 exit_code,
3434 } => {
3435 self.complete_command(&cache_key, exit_code);
3436 }
3437 CommandMessage::Failed { cache_key, error } => {
3438 self.fail_command(&cache_key, error);
3439 }
3440 CommandMessage::ProjectRemoved { project_name } => {
3441 self.remove_project_from_sidebar(&project_name);
3443 }
3444 CommandMessage::CleanupCompleted { result } => {
3445 self.pending_result_modal = Some(result);
3447 self.refresh_data();
3450 }
3451 }
3452 }
3453 }
3454
3455 fn remove_project_from_sidebar(&mut self, project_name: &str) {
3460 self.projects.retain(|p| p.info.name != project_name);
3461 }
3462
3463 pub fn close_tab(&mut self, tab_id: &TabId) -> bool {
3468 let tab_index = match self.tabs.iter().position(|t| t.id == *tab_id) {
3470 Some(idx) => idx,
3471 None => return false,
3472 };
3473
3474 if !self.tabs[tab_index].closable {
3476 return false;
3477 }
3478
3479 let was_active = self.active_tab_id == *tab_id;
3481
3482 if self.previous_tab_id.as_ref() == Some(tab_id) {
3484 self.previous_tab_id = None;
3485 }
3486
3487 self.tabs.remove(tab_index);
3489
3490 if let TabId::RunDetail(run_id) = tab_id {
3492 self.run_detail_cache.remove(run_id);
3493 }
3494
3495 if let TabId::CommandOutput(cache_key) = tab_id {
3497 self.command_executions.remove(cache_key);
3498 }
3499
3500 if was_active {
3502 if let Some(prev_id) = self.previous_tab_id.take() {
3504 if self.has_tab(&prev_id) {
3505 self.set_active_tab(prev_id);
3506 return true;
3507 }
3508 }
3509
3510 if tab_index > 0 && tab_index <= self.tabs.len() {
3512 self.set_active_tab(self.tabs[tab_index - 1].id.clone());
3513 } else if !self.tabs.is_empty() {
3514 self.set_active_tab(TabId::Projects);
3516 }
3517 }
3518
3519 true
3520 }
3521
3522 pub fn close_all_dynamic_tabs(&mut self) -> usize {
3525 let to_close: Vec<TabId> = self
3526 .tabs
3527 .iter()
3528 .filter(|t| t.closable)
3529 .map(|t| t.id.clone())
3530 .collect();
3531
3532 let count = to_close.len();
3533 for tab_id in to_close {
3534 self.close_tab(&tab_id);
3535 }
3536 count
3537 }
3538
3539 pub fn get_cached_run_state(&self, run_id: &str) -> Option<&crate::state::RunState> {
3541 self.run_detail_cache.get(run_id)
3542 }
3543
3544 pub fn maybe_refresh(&mut self) {
3553 if self.last_refresh.elapsed() >= self.refresh_interval {
3554 self.refresh_data();
3555 }
3556 }
3557
3558 pub fn refresh_data(&mut self) {
3564 self.last_refresh = Instant::now();
3565
3566 let ui_data = load_ui_data(None).unwrap_or_default();
3569
3570 self.projects = ui_data.projects;
3571 self.sessions = ui_data.sessions;
3572 self.has_active_runs = ui_data.has_active_runs;
3573
3574 let current_ids: std::collections::HashSet<&str> = self
3576 .sessions
3577 .iter()
3578 .map(|s| s.metadata.session_id.as_str())
3579 .collect();
3580
3581 for session in &self.sessions {
3583 let session_id = &session.metadata.session_id;
3584 if !self.closed_session_tabs.contains(session_id) {
3585 self.seen_sessions
3586 .insert(session_id.clone(), session.clone());
3587 }
3588 }
3589
3590 let to_reload: Vec<(String, String)> = self
3593 .seen_sessions
3594 .iter()
3595 .filter(|(id, _)| !current_ids.contains(id.as_str()))
3596 .filter(|(id, _)| !self.closed_session_tabs.contains(*id))
3597 .map(|(id, s)| (s.project_name.clone(), id.clone()))
3598 .collect();
3599
3600 for (project_name, session_id) in to_reload {
3601 if let Some(updated) = load_session_by_id(&project_name, &session_id) {
3602 self.seen_sessions.insert(session_id, updated);
3604 } else {
3605 if let Some(existing) = self.seen_sessions.get(&session_id).cloned() {
3607 if let Some(ref run) = existing.run {
3608 if let Some(archived_run) =
3609 crate::ui::shared::load_archived_run(&project_name, &run.run_id)
3610 {
3611 let mut updated = existing;
3613 updated.run = Some(archived_run);
3614 updated.metadata.is_running = false;
3615 self.seen_sessions.insert(session_id, updated);
3616 }
3617 }
3618 }
3619 }
3620 }
3621
3622 if let Some(ref project) = self.selected_project {
3624 let project_name = project.clone();
3625 self.load_run_history(&project_name);
3626 }
3627 }
3628
3629 fn get_visible_sessions(&self) -> Vec<SessionData> {
3631 self.seen_sessions
3632 .values()
3633 .filter(|s| !self.closed_session_tabs.contains(&s.metadata.session_id))
3634 .cloned()
3635 .collect()
3636 }
3637
3638 fn find_session_by_id(&self, session_id: &str) -> Option<SessionData> {
3640 self.seen_sessions.get(session_id).cloned()
3641 }
3642}
3643
3644impl eframe::App for Autom8App {
3645 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
3646 self.maybe_refresh();
3648
3649 self.poll_command_messages();
3651
3652 self.poll_claude_messages();
3654
3655 ctx.request_repaint_after(self.refresh_interval);
3657
3658 self.render_title_bar(ctx);
3660
3661 let sidebar_width = if self.sidebar_collapsed {
3664 SIDEBAR_COLLAPSED_WIDTH
3665 } else {
3666 SIDEBAR_WIDTH
3667 };
3668
3669 if !self.sidebar_collapsed {
3672 egui::SidePanel::left("sidebar")
3673 .exact_width(sidebar_width)
3674 .resizable(false)
3675 .frame(
3676 egui::Frame::none()
3677 .fill(colors::BACKGROUND)
3678 .inner_margin(egui::Margin {
3679 left: spacing::MD,
3680 right: spacing::MD,
3681 top: spacing::LG,
3682 bottom: spacing::LG,
3683 })
3684 .stroke(Stroke::new(1.0, colors::SEPARATOR)),
3685 )
3686 .show(ctx, |ui| {
3687 self.render_sidebar(ui);
3688 });
3689 }
3690
3691 egui::CentralPanel::default()
3693 .frame(
3694 egui::Frame::none()
3695 .fill(colors::BACKGROUND)
3696 .inner_margin(egui::Margin::same(spacing::LG)),
3697 )
3698 .show(ctx, |ui| {
3699 self.render_content(ui);
3700 });
3701
3702 if self.context_menu.is_some() {
3704 if ctx.input(|i| i.key_pressed(Key::Escape)) {
3706 self.close_context_menu();
3707 }
3708 }
3709
3710 self.render_context_menu(ctx);
3712
3713 self.render_confirmation_dialog(ctx);
3715
3716 self.render_result_modal(ctx);
3718
3719 self.render_project_change_confirmation(ctx);
3721
3722 self.render_start_new_spec_confirmation(ctx);
3724 }
3725}
3726
3727impl Drop for Autom8App {
3736 fn drop(&mut self) {
3737 if let Ok(mut guard) = self.claude_child.lock() {
3739 if let Some(mut child) = guard.take() {
3740 let _ = child.kill();
3741 let _ = child.wait();
3742 }
3743 }
3744 if let Some(ref stdin_handle) = self.claude_stdin {
3746 stdin_handle.close();
3747 }
3748 }
3749}
3750
3751impl Autom8App {
3752 fn render_title_bar(&mut self, ctx: &egui::Context) {
3763 egui::TopBottomPanel::top("title_bar")
3764 .exact_height(TITLE_BAR_HEIGHT)
3765 .frame(
3766 egui::Frame::none()
3767 .fill(colors::SURFACE)
3768 .inner_margin(egui::Margin::ZERO),
3769 )
3770 .show(ctx, |ui| {
3771 let title_bar_rect = ui.max_rect();
3773 let response = ui.interact(
3774 title_bar_rect,
3775 ui.id().with("title_bar_drag"),
3776 Sense::click_and_drag(),
3777 );
3778
3779 if response.drag_started() {
3781 ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
3782 }
3783
3784 if response.double_clicked() {
3786 ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(
3787 !ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)),
3788 ));
3789 }
3790
3791 ui.add_space(5.0);
3793 ui.horizontal(|ui| {
3794 ui.add_space(TITLE_BAR_LEFT_OFFSET);
3796
3797 let separator_height = SIDEBAR_TOGGLE_SIZE;
3799 let (separator_rect, _) =
3800 ui.allocate_exact_size(egui::vec2(1.0, separator_height), Sense::hover());
3801 ui.painter().vline(
3802 separator_rect.center().x,
3803 separator_rect.y_range(),
3804 Stroke::new(1.0, colors::SEPARATOR),
3805 );
3806
3807 ui.add_space(SIDEBAR_TOGGLE_PADDING);
3809
3810 let toggle_response =
3812 self.render_sidebar_toggle_button(ui, self.sidebar_collapsed);
3813 if toggle_response.clicked() {
3814 self.sidebar_collapsed = !self.sidebar_collapsed;
3815 }
3816 });
3817 });
3818 }
3819
3820 fn render_context_menu(&mut self, ctx: &egui::Context) {
3831 let menu_state = match &self.context_menu {
3833 Some(state) => state.clone(),
3834 None => return,
3835 };
3836
3837 let screen_rect = ctx.screen_rect();
3839
3840 let menu_width = calculate_context_menu_width(ctx, &menu_state.items);
3842 let item_count = menu_state
3843 .items
3844 .iter()
3845 .filter(|item| !matches!(item, ContextMenuItem::Separator))
3846 .count();
3847 let separator_count = menu_state
3848 .items
3849 .iter()
3850 .filter(|item| matches!(item, ContextMenuItem::Separator))
3851 .count();
3852 let menu_height = (item_count as f32 * CONTEXT_MENU_ITEM_HEIGHT)
3853 + (separator_count as f32 * (spacing::SM + 1.0))
3854 + (CONTEXT_MENU_PADDING_V * 2.0);
3855
3856 let mut menu_pos = menu_state.position;
3858 menu_pos.x += CONTEXT_MENU_CURSOR_OFFSET;
3859 menu_pos.y += CONTEXT_MENU_CURSOR_OFFSET;
3860
3861 if menu_pos.x + menu_width > screen_rect.max.x - spacing::SM {
3863 menu_pos.x = screen_rect.max.x - menu_width - spacing::SM;
3864 }
3865
3866 if menu_pos.y + menu_height > screen_rect.max.y - spacing::SM {
3868 menu_pos.y = screen_rect.max.y - menu_height - spacing::SM;
3869 }
3870
3871 menu_pos.x = menu_pos.x.max(spacing::SM);
3873 menu_pos.y = menu_pos.y.max(spacing::SM);
3874
3875 let mut should_close = false;
3877 let mut selected_action: Option<ContextMenuAction> = None;
3878
3879 let mut hovered_submenu: Option<(String, Vec<ContextMenuItem>, Rect)> = None;
3881
3882 let pointer_pos = ctx.input(|i| i.pointer.hover_pos());
3884 let primary_clicked = ctx.input(|i| i.pointer.primary_clicked());
3885
3886 egui::Area::new(egui::Id::new("context_menu"))
3888 .order(Order::Foreground)
3889 .fixed_pos(menu_pos)
3890 .show(ctx, |ui| {
3891 egui::Frame::none()
3892 .fill(colors::SURFACE)
3893 .rounding(Rounding::same(rounding::CARD))
3894 .shadow(crate::ui::gui::theme::shadow::elevated())
3895 .stroke(Stroke::new(1.0, colors::BORDER))
3896 .inner_margin(egui::Margin::symmetric(0.0, CONTEXT_MENU_PADDING_V))
3897 .show(ui, |ui| {
3898 ui.set_min_width(menu_width);
3899 ui.set_max_width(menu_width);
3900
3901 for item in &menu_state.items {
3902 match item {
3903 ContextMenuItem::Action {
3904 label,
3905 action,
3906 enabled,
3907 } => {
3908 let response =
3909 self.render_context_menu_item(ui, label, *enabled, false);
3910 if response.clicked {
3911 selected_action = Some(action.clone());
3912 should_close = true;
3913 }
3914 }
3915 ContextMenuItem::Separator => {
3916 ui.add_space(spacing::XS);
3917 let rect = ui.available_rect_before_wrap();
3918 let separator_rect =
3919 Rect::from_min_size(rect.min, Vec2::new(menu_width, 1.0));
3920 ui.painter().rect_filled(
3921 separator_rect,
3922 Rounding::ZERO,
3923 colors::SEPARATOR,
3924 );
3925 ui.allocate_space(Vec2::new(menu_width, 1.0));
3926 ui.add_space(spacing::XS);
3927 }
3928 ContextMenuItem::Submenu {
3929 label,
3930 id,
3931 enabled,
3932 items,
3933 hint,
3934 } => {
3935 let response =
3937 self.render_context_menu_item(ui, label, *enabled, true);
3938 if response.hovered && *enabled && !items.is_empty() {
3939 hovered_submenu =
3941 Some((id.clone(), items.clone(), response.rect));
3942 }
3943 if response.hovered_raw && !*enabled {
3945 if let Some(hint_text) = hint {
3946 egui::show_tooltip_at_pointer(
3947 ui.ctx(),
3948 ui.layer_id(),
3949 egui::Id::new("submenu_hint").with(id),
3950 |ui| {
3951 ui.label(hint_text);
3952 },
3953 );
3954 }
3955 }
3956 }
3957 }
3958 }
3959 });
3960 });
3961
3962 let menu_rect = Rect::from_min_size(menu_pos, Vec2::new(menu_width, menu_height));
3964
3965 let mut submenu_rect: Option<Rect> = None;
3967
3968 let submenu_to_render = if let Some((id, items, trigger_rect)) = hovered_submenu {
3971 if let Some(menu) = &mut self.context_menu {
3973 let submenu_pos = Pos2::new(
3974 menu_pos.x + menu_width + CONTEXT_MENU_SUBMENU_GAP,
3975 trigger_rect.min.y,
3976 );
3977 menu.open_submenu(id.clone(), submenu_pos);
3978 }
3979 Some((items, trigger_rect))
3980 } else if let (Some(open_id), Some(open_pos)) =
3981 (&menu_state.open_submenu, menu_state.submenu_position)
3982 {
3983 let items = menu_state.items.iter().find_map(|item| {
3985 if let ContextMenuItem::Submenu { id, items, .. } = item {
3986 if id == open_id {
3987 return Some(items.clone());
3988 }
3989 }
3990 None
3991 });
3992 let trigger_rect = Rect::from_min_size(
3994 Pos2::new(menu_pos.x, open_pos.y),
3995 Vec2::new(menu_width, CONTEXT_MENU_ITEM_HEIGHT),
3996 );
3997 items.map(|i| (i, trigger_rect))
3998 } else {
3999 if let Some(menu) = &mut self.context_menu {
4001 menu.close_submenu();
4002 }
4003 None
4004 };
4005
4006 if let Some((submenu_items, trigger_rect)) = submenu_to_render {
4008 if !submenu_items.is_empty() {
4009 let submenu_width = calculate_context_menu_width(ctx, &submenu_items);
4011 let submenu_item_count = submenu_items
4012 .iter()
4013 .filter(|item| !matches!(item, ContextMenuItem::Separator))
4014 .count();
4015 let submenu_separator_count = submenu_items
4016 .iter()
4017 .filter(|item| matches!(item, ContextMenuItem::Separator))
4018 .count();
4019 let submenu_height = (submenu_item_count as f32 * CONTEXT_MENU_ITEM_HEIGHT)
4020 + (submenu_separator_count as f32 * (spacing::SM + 1.0))
4021 + (CONTEXT_MENU_PADDING_V * 2.0);
4022
4023 let mut submenu_pos = Pos2::new(
4025 menu_pos.x + menu_width + CONTEXT_MENU_SUBMENU_GAP,
4026 trigger_rect.min.y - CONTEXT_MENU_PADDING_V,
4027 );
4028
4029 if submenu_pos.x + submenu_width > screen_rect.max.x - spacing::SM {
4031 submenu_pos.x = menu_pos.x - submenu_width - CONTEXT_MENU_SUBMENU_GAP;
4033 }
4034
4035 if submenu_pos.y + submenu_height > screen_rect.max.y - spacing::SM {
4037 submenu_pos.y = screen_rect.max.y - submenu_height - spacing::SM;
4038 }
4039
4040 submenu_pos.y = submenu_pos.y.max(spacing::SM);
4042
4043 submenu_rect = Some(Rect::from_min_size(
4045 submenu_pos,
4046 Vec2::new(submenu_width, submenu_height),
4047 ));
4048
4049 egui::Area::new(egui::Id::new("context_submenu"))
4051 .order(Order::Foreground)
4052 .fixed_pos(submenu_pos)
4053 .show(ctx, |ui| {
4054 egui::Frame::none()
4055 .fill(colors::SURFACE)
4056 .rounding(Rounding::same(rounding::CARD))
4057 .shadow(crate::ui::gui::theme::shadow::elevated())
4058 .stroke(Stroke::new(1.0, colors::BORDER))
4059 .inner_margin(egui::Margin::symmetric(0.0, CONTEXT_MENU_PADDING_V))
4060 .show(ui, |ui| {
4061 ui.set_min_width(submenu_width);
4062 ui.set_max_width(submenu_width);
4063
4064 for item in &submenu_items {
4065 match item {
4066 ContextMenuItem::Action {
4067 label,
4068 action,
4069 enabled,
4070 } => {
4071 let response = self.render_context_menu_item(
4072 ui, label, *enabled, false,
4073 );
4074 if response.clicked {
4075 selected_action = Some(action.clone());
4076 should_close = true;
4077 }
4078 }
4079 ContextMenuItem::Separator => {
4080 ui.add_space(spacing::XS);
4081 let rect = ui.available_rect_before_wrap();
4082 let separator_rect = Rect::from_min_size(
4083 rect.min,
4084 Vec2::new(submenu_width, 1.0),
4085 );
4086 ui.painter().rect_filled(
4087 separator_rect,
4088 Rounding::ZERO,
4089 colors::SEPARATOR,
4090 );
4091 ui.allocate_space(Vec2::new(submenu_width, 1.0));
4092 ui.add_space(spacing::XS);
4093 }
4094 ContextMenuItem::Submenu { .. } => {
4095 }
4097 }
4098 }
4099 });
4100 });
4101 }
4102 }
4103
4104 if primary_clicked {
4106 if let Some(pos) = pointer_pos {
4107 let in_menu = menu_rect.contains(pos);
4108 let in_submenu = submenu_rect.map(|r| r.contains(pos)).unwrap_or(false);
4109 if !in_menu && !in_submenu {
4110 should_close = true;
4111 }
4112 }
4113 }
4114
4115 if let Some(action) = selected_action {
4117 let project_name = menu_state.project_name.clone();
4118 match action {
4119 ContextMenuAction::Status => {
4120 self.spawn_status_command(&project_name);
4122 }
4123 ContextMenuAction::Describe => {
4124 self.spawn_describe_command(&project_name);
4126 }
4127 ContextMenuAction::Resume(session_id) => {
4128 if let Some(id) = session_id {
4130 self.show_resume_info(&project_name, &id);
4131 }
4132 }
4135 ContextMenuAction::CleanWorktrees => {
4136 self.pending_clean_confirmation = Some(PendingCleanOperation::Worktrees {
4138 project_name: project_name.clone(),
4139 });
4140 }
4141 ContextMenuAction::CleanOrphaned => {
4142 self.pending_clean_confirmation = Some(PendingCleanOperation::Orphaned {
4144 project_name: project_name.clone(),
4145 });
4146 }
4147 ContextMenuAction::CleanData => {
4148 let cleanable_info = self.get_cleanable_info(&project_name);
4150 self.pending_clean_confirmation = Some(PendingCleanOperation::Data {
4151 project_name: project_name.clone(),
4152 specs_count: cleanable_info.cleanable_specs,
4153 runs_count: cleanable_info.cleanable_runs,
4154 });
4155 }
4156 ContextMenuAction::RemoveProject => {
4157 self.pending_clean_confirmation = Some(PendingCleanOperation::RemoveProject {
4159 project_name: project_name.clone(),
4160 });
4161 }
4162 }
4163 }
4164
4165 if should_close {
4167 self.close_context_menu();
4168 }
4169 }
4170
4171 fn render_context_menu_item(
4175 &self,
4176 ui: &mut egui::Ui,
4177 label: &str,
4178 enabled: bool,
4179 has_submenu: bool,
4180 ) -> ContextMenuItemResponse {
4181 let item_size = Vec2::new(ui.available_width(), CONTEXT_MENU_ITEM_HEIGHT);
4182 let (rect, response) = ui.allocate_exact_size(item_size, Sense::click());
4183
4184 let is_hovered = response.hovered() && enabled;
4185 let painter = ui.painter();
4186
4187 if is_hovered {
4189 painter.rect_filled(rect, Rounding::ZERO, colors::SURFACE_HOVER);
4190 }
4191
4192 let text_x = rect.min.x + CONTEXT_MENU_PADDING_H;
4194 let text_color = if enabled {
4195 colors::TEXT_PRIMARY
4196 } else {
4197 colors::TEXT_DISABLED
4198 };
4199
4200 let galley = painter.layout_no_wrap(
4202 label.to_string(),
4203 typography::font(FontSize::Body, FontWeight::Regular),
4204 text_color,
4205 );
4206 let text_y = rect.center().y - galley.rect.height() / 2.0;
4207 painter.galley(Pos2::new(text_x, text_y), galley, Color32::TRANSPARENT);
4208
4209 if has_submenu {
4211 let arrow_x = rect.max.x - CONTEXT_MENU_PADDING_H - CONTEXT_MENU_ARROW_SIZE;
4212 let arrow_y = rect.center().y;
4213 let arrow_color = if enabled {
4214 colors::TEXT_SECONDARY
4215 } else {
4216 colors::TEXT_DISABLED
4217 };
4218
4219 let arrow_points = [
4221 Pos2::new(arrow_x, arrow_y - CONTEXT_MENU_ARROW_SIZE / 2.0),
4222 Pos2::new(arrow_x + CONTEXT_MENU_ARROW_SIZE / 2.0, arrow_y),
4223 Pos2::new(arrow_x, arrow_y + CONTEXT_MENU_ARROW_SIZE / 2.0),
4224 ];
4225 painter.line_segment(
4226 [arrow_points[0], arrow_points[1]],
4227 Stroke::new(1.5, arrow_color),
4228 );
4229 painter.line_segment(
4230 [arrow_points[1], arrow_points[2]],
4231 Stroke::new(1.5, arrow_color),
4232 );
4233 }
4234
4235 let screen_rect = ui.clip_rect();
4237 let screen_item_rect = Rect::from_min_max(
4238 Pos2::new(screen_rect.min.x, rect.min.y),
4239 Pos2::new(screen_rect.min.x + ui.available_width(), rect.max.y),
4240 );
4241
4242 if enabled && response.hovered() {
4244 ui.ctx()
4245 .output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
4246 }
4247
4248 ContextMenuItemResponse {
4249 clicked: response.clicked() && enabled,
4250 hovered: is_hovered,
4251 hovered_raw: response.hovered(),
4252 rect: screen_item_rect,
4253 }
4254 }
4255
4256 fn render_confirmation_dialog(&mut self, ctx: &egui::Context) {
4266 let pending = match &self.pending_clean_confirmation {
4268 Some(op) => op.clone(),
4269 None => return,
4270 };
4271
4272 let modal = Modal::new(pending.title())
4275 .id("clean_confirmation")
4276 .message(pending.message())
4277 .cancel_button(ModalButton::secondary("Cancel"))
4278 .confirm_button(ModalButton::destructive(pending.confirm_button_label()));
4279
4280 match modal.show(ctx) {
4282 ModalAction::Confirmed => {
4283 let project_name = pending.project_name().to_string();
4285 match pending {
4286 PendingCleanOperation::Worktrees { .. } => {
4287 self.spawn_clean_worktrees_command(&project_name);
4288 }
4289 PendingCleanOperation::Orphaned { .. } => {
4290 self.spawn_clean_orphaned_command(&project_name);
4291 }
4292 PendingCleanOperation::Data { .. } => {
4293 self.spawn_clean_data_command(&project_name);
4295 }
4296 PendingCleanOperation::RemoveProject { .. } => {
4297 self.spawn_remove_project_command(&project_name);
4299 }
4300 }
4301 self.pending_clean_confirmation = None;
4302 }
4303 ModalAction::Cancelled => {
4304 self.pending_clean_confirmation = None;
4305 }
4306 ModalAction::None => {
4307 }
4309 }
4310 }
4311
4312 fn render_result_modal(&mut self, ctx: &egui::Context) {
4322 let result = match &self.pending_result_modal {
4324 Some(r) => r.clone(),
4325 None => return,
4326 };
4327
4328 let modal = Modal::new(result.title())
4331 .id("cleanup_result")
4332 .message(result.message())
4333 .no_cancel_button()
4334 .confirm_button(ModalButton::new("OK"));
4335
4336 match modal.show(ctx) {
4339 ModalAction::Confirmed | ModalAction::Cancelled => {
4340 self.pending_result_modal = None;
4341 }
4342 ModalAction::None => {
4343 }
4345 }
4346 }
4347
4348 fn render_project_change_confirmation(&mut self, ctx: &egui::Context) {
4357 let pending_project = match &self.pending_project_change {
4359 Some(name) => name.clone(),
4360 None => return,
4361 };
4362
4363 let modal = Modal::new("Switch Project?")
4365 .id("project_change_confirmation")
4366 .message(
4367 "You have an active spec creation session. \
4368 Switching projects will discard your current conversation and any unsaved work.\n\n\
4369 Do you want to continue?",
4370 )
4371 .cancel_button(ModalButton::secondary("Cancel"))
4372 .confirm_button(ModalButton::destructive("Switch Project"));
4373
4374 match modal.show(ctx) {
4376 ModalAction::Confirmed => {
4377 self.reset_create_spec_session();
4379 self.create_spec_selected_project = Some(pending_project);
4380 self.pending_project_change = None;
4381 }
4382 ModalAction::Cancelled => {
4383 self.pending_project_change = None;
4385 }
4386 ModalAction::None => {
4387 }
4389 }
4390 }
4391
4392 fn render_start_new_spec_confirmation(&mut self, ctx: &egui::Context) {
4397 if !self.pending_start_new_spec {
4399 return;
4400 }
4401
4402 let message = if let Some(ref spec_path) = self.generated_spec_path {
4404 format!(
4405 "Your spec has been saved to:\n\n{}\n\n\
4406 Make sure you've copied the run command or noted the file location before starting a new spec.\n\n\
4407 Do you want to start a new spec?",
4408 spec_path.display()
4409 )
4410 } else {
4411 "Make sure you've saved any important information from this session.\n\n\
4412 Do you want to start a new spec?"
4413 .to_string()
4414 };
4415
4416 let modal = Modal::new("Close?")
4418 .id("start_new_spec_confirmation")
4419 .message(&message)
4420 .cancel_button(ModalButton::secondary("Cancel"))
4421 .confirm_button(ModalButton::new("Close"));
4422
4423 match modal.show(ctx) {
4425 ModalAction::Confirmed => {
4426 self.reset_create_spec_session();
4428 self.pending_start_new_spec = false;
4429 }
4430 ModalAction::Cancelled => {
4431 self.pending_start_new_spec = false;
4433 }
4434 ModalAction::None => {
4435 }
4437 }
4438 }
4439
4440 fn render_sidebar_toggle_button(
4453 &self,
4454 ui: &mut egui::Ui,
4455 is_collapsed: bool,
4456 ) -> egui::Response {
4457 let button_size = egui::vec2(SIDEBAR_TOGGLE_SIZE, SIDEBAR_TOGGLE_SIZE);
4458 let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
4459 let is_hovered = response.hovered();
4460
4461 if is_hovered {
4463 ui.painter().rect_filled(
4464 rect,
4465 Rounding::same(rounding::BUTTON),
4466 colors::SURFACE_HOVER,
4467 );
4468 }
4469
4470 let icon_color = if is_hovered {
4474 colors::TEXT_PRIMARY
4475 } else {
4476 colors::TEXT_SECONDARY
4477 };
4478
4479 let painter = ui.painter();
4480 let center = rect.center();
4481
4482 if is_collapsed {
4483 let line_width = 12.0;
4485 let line_spacing = 4.0;
4486 let half_width = line_width / 2.0;
4487
4488 for i in -1..=1 {
4489 let y = center.y + (i as f32) * line_spacing;
4490 painter.line_segment(
4491 [
4492 egui::pos2(center.x - half_width, y),
4493 egui::pos2(center.x + half_width, y),
4494 ],
4495 Stroke::new(1.5, icon_color),
4496 );
4497 }
4498 } else {
4499 let icon_rect = Rect::from_center_size(center, egui::vec2(14.0, 12.0));
4502
4503 painter.rect_stroke(icon_rect, Rounding::same(1.0), Stroke::new(1.5, icon_color));
4505
4506 let divider_x = icon_rect.left() + 5.0;
4508 painter.line_segment(
4509 [
4510 egui::pos2(divider_x, icon_rect.top() + 1.0),
4511 egui::pos2(divider_x, icon_rect.bottom() - 1.0),
4512 ],
4513 Stroke::new(1.0, icon_color),
4514 );
4515
4516 let line_start_x = divider_x + 2.0;
4518 let line_end_x = icon_rect.right() - 2.0;
4519 for i in 0..2 {
4520 let y = icon_rect.top() + 4.0 + (i as f32) * 4.0;
4521 painter.line_segment(
4522 [egui::pos2(line_start_x, y), egui::pos2(line_end_x, y)],
4523 Stroke::new(1.0, icon_color),
4524 );
4525 }
4526 }
4527
4528 let tooltip_text = if is_collapsed {
4530 "Show sidebar"
4531 } else {
4532 "Hide sidebar"
4533 };
4534 response
4535 .on_hover_text(tooltip_text)
4536 .on_hover_cursor(egui::CursorIcon::PointingHand)
4537 }
4538
4539 fn render_sidebar(&mut self, ui: &mut egui::Ui) {
4549 ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
4551 ui.add_space(spacing::SM);
4553
4554 let mut tab_to_activate: Option<TabId> = None;
4556
4557 let permanent_tabs: Vec<(TabId, &'static str)> = vec![
4559 (TabId::ActiveRuns, "Active Runs"),
4560 (TabId::Projects, "Projects"),
4561 (TabId::Config, "Config"),
4562 (TabId::CreateSpec, "Create Spec"),
4563 ];
4564
4565 for (tab_id, label) in permanent_tabs {
4566 let is_active = self.active_tab_id == tab_id;
4567 if self.render_sidebar_item(ui, label, is_active) {
4568 tab_to_activate = Some(tab_id);
4569 }
4570 ui.add_space(spacing::XS);
4571 }
4572
4573 if let Some(tab_id) = tab_to_activate {
4575 self.set_active_tab(tab_id);
4576 }
4577
4578 let animation_height = 150.0;
4581 let icon_section_height = SIDEBAR_ICON_SIZE + spacing::LG * 2.0; ui.add_space(ui.available_height() - animation_height - icon_section_height);
4583
4584 ui.add_space(spacing::LG);
4587 ui.horizontal(|ui| {
4588 let sidebar_width = ui.available_width();
4589 let icon_offset = (sidebar_width - SIDEBAR_ICON_SIZE) / 2.0;
4590 ui.add_space(icon_offset);
4591 ui.add(
4592 egui::Image::new(egui::include_image!("../../../assets/icon.png"))
4593 .fit_to_exact_size(egui::vec2(SIDEBAR_ICON_SIZE, SIDEBAR_ICON_SIZE)),
4594 );
4595 });
4596 ui.add_space(spacing::LG);
4597
4598 let sidebar_width = ui.available_width();
4601 super::animation::render_rising_particles(ui, sidebar_width, animation_height);
4602
4603 super::animation::schedule_frame(ui.ctx());
4605 });
4606 }
4607
4608 fn render_sidebar_item(&self, ui: &mut egui::Ui, label: &str, is_active: bool) -> bool {
4612 let available_width = ui.available_width();
4614 let item_size = egui::vec2(available_width, SIDEBAR_ITEM_HEIGHT);
4615
4616 let (rect, response) = ui.allocate_exact_size(item_size, Sense::click());
4618 let is_hovered = response.hovered();
4619
4620 let bg_color = if is_active {
4622 colors::SURFACE_SELECTED
4623 } else if is_hovered {
4624 colors::SURFACE_HOVER
4625 } else {
4626 Color32::TRANSPARENT
4627 };
4628
4629 if bg_color != Color32::TRANSPARENT {
4631 ui.painter()
4632 .rect_filled(rect, Rounding::same(SIDEBAR_ITEM_ROUNDING), bg_color);
4633 }
4634
4635 if is_active {
4637 let indicator_rect = Rect::from_min_size(
4638 rect.min,
4639 egui::vec2(SIDEBAR_ACTIVE_INDICATOR_WIDTH, rect.height()),
4640 );
4641 ui.painter().rect_filled(
4642 indicator_rect,
4643 Rounding {
4644 nw: SIDEBAR_ITEM_ROUNDING,
4645 sw: SIDEBAR_ITEM_ROUNDING,
4646 ne: 0.0,
4647 se: 0.0,
4648 },
4649 colors::ACCENT,
4650 );
4651 }
4652
4653 let text_color = if is_active {
4655 colors::TEXT_PRIMARY
4656 } else {
4657 colors::TEXT_SECONDARY
4658 };
4659
4660 let text_pos = egui::pos2(rect.left() + SIDEBAR_ITEM_PADDING_H, rect.center().y);
4662
4663 ui.painter().text(
4664 text_pos,
4665 egui::Align2::LEFT_CENTER,
4666 label,
4667 typography::font(
4668 FontSize::Body,
4669 if is_active {
4670 FontWeight::SemiBold
4671 } else {
4672 FontWeight::Medium
4673 },
4674 ),
4675 text_color,
4676 );
4677
4678 response
4679 .on_hover_cursor(egui::CursorIcon::PointingHand)
4680 .clicked()
4681 }
4682
4683 #[allow(dead_code)]
4690 fn render_header(&mut self, ui: &mut egui::Ui) {
4691 let scroll_width = ui.available_width().min(TAB_BAR_MAX_SCROLL_WIDTH);
4693
4694 ui.horizontal_centered(|ui| {
4695 ui.add_space(spacing::XS);
4696
4697 egui::ScrollArea::horizontal()
4698 .max_width(scroll_width)
4699 .auto_shrink([false, false])
4700 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
4701 .show(ui, |ui| {
4702 ui.horizontal(|ui| {
4703 let mut tab_to_activate: Option<TabId> = None;
4705 let mut tab_to_close: Option<TabId> = None;
4706
4707 let tabs_snapshot: Vec<(TabId, String, bool)> = self
4709 .tabs
4710 .iter()
4711 .map(|t| (t.id.clone(), t.label.clone(), t.closable))
4712 .collect();
4713
4714 for (tab_id, label, closable) in &tabs_snapshot {
4715 let is_active = self.active_tab_id == *tab_id;
4716 let (clicked, close_clicked) =
4717 self.render_dynamic_tab(ui, label, *closable, is_active);
4718
4719 if clicked {
4720 tab_to_activate = Some(tab_id.clone());
4721 }
4722 if close_clicked {
4723 tab_to_close = Some(tab_id.clone());
4724 }
4725 ui.add_space(spacing::XS);
4726 }
4727
4728 if let Some(tab_id) = tab_to_close {
4730 self.close_tab(&tab_id);
4731 } else if let Some(tab_id) = tab_to_activate {
4732 self.set_active_tab(tab_id);
4733 }
4734 });
4735 });
4736 });
4737
4738 let rect = ui.max_rect();
4740 ui.painter().hline(
4741 rect.x_range(),
4742 rect.bottom(),
4743 Stroke::new(1.0, colors::BORDER),
4744 );
4745 }
4746
4747 #[allow(dead_code)]
4751 fn render_dynamic_tab(
4752 &self,
4753 ui: &mut egui::Ui,
4754 label: &str,
4755 closable: bool,
4756 is_active: bool,
4757 ) -> (bool, bool) {
4758 let text_galley = ui.fonts(|f| {
4760 f.layout_no_wrap(
4761 label.to_string(),
4762 typography::font(FontSize::Body, FontWeight::Medium),
4763 colors::TEXT_PRIMARY,
4764 )
4765 });
4766 let text_size = text_galley.size();
4767
4768 let close_button_space = if closable {
4770 TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING
4771 } else {
4772 0.0
4773 };
4774 let tab_width = text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
4775 let tab_size = egui::vec2(tab_width, HEADER_HEIGHT - TAB_UNDERLINE_HEIGHT);
4776
4777 let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
4779
4780 let is_hovered = response.hovered();
4781
4782 if is_hovered && !is_active {
4784 ui.painter().rect_filled(
4785 rect,
4786 Rounding::same(rounding::BUTTON),
4787 colors::SURFACE_HOVER,
4788 );
4789 }
4790
4791 let text_color = if is_active {
4793 colors::TEXT_PRIMARY
4794 } else if is_hovered {
4795 colors::TEXT_SECONDARY
4796 } else {
4797 colors::TEXT_MUTED
4798 };
4799
4800 let text_x = if closable {
4801 rect.left() + TAB_PADDING_H
4802 } else {
4803 rect.center().x - text_size.x / 2.0
4804 };
4805 let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
4806
4807 ui.painter().galley(
4808 text_pos,
4809 ui.fonts(|f| {
4810 f.layout_no_wrap(
4811 label.to_string(),
4812 typography::font(
4813 FontSize::Body,
4814 if is_active {
4815 FontWeight::SemiBold
4816 } else {
4817 FontWeight::Medium
4818 },
4819 ),
4820 text_color,
4821 )
4822 }),
4823 Color32::TRANSPARENT,
4824 );
4825
4826 let mut close_clicked = false;
4828 if closable {
4829 let close_rect = Rect::from_min_size(
4830 egui::pos2(
4831 rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
4832 rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
4833 ),
4834 egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
4835 );
4836
4837 let close_hovered = ui
4839 .ctx()
4840 .input(|i| i.pointer.hover_pos())
4841 .is_some_and(|pos| close_rect.contains(pos));
4842
4843 if close_hovered {
4845 ui.painter().rect_filled(
4846 close_rect,
4847 Rounding::same(rounding::SMALL),
4848 colors::SURFACE_HOVER,
4849 );
4850 ui.ctx()
4852 .output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
4853 }
4854
4855 let x_color = if close_hovered {
4857 colors::TEXT_PRIMARY
4858 } else {
4859 colors::TEXT_MUTED
4860 };
4861 let x_center = close_rect.center();
4862 let x_size = TAB_CLOSE_BUTTON_SIZE * 0.35 * if close_hovered { 1.15 } else { 1.0 };
4863 ui.painter().line_segment(
4864 [
4865 egui::pos2(x_center.x - x_size, x_center.y - x_size),
4866 egui::pos2(x_center.x + x_size, x_center.y + x_size),
4867 ],
4868 Stroke::new(1.5, x_color),
4869 );
4870 ui.painter().line_segment(
4871 [
4872 egui::pos2(x_center.x + x_size, x_center.y - x_size),
4873 egui::pos2(x_center.x - x_size, x_center.y + x_size),
4874 ],
4875 Stroke::new(1.5, x_color),
4876 );
4877
4878 if response.clicked() && close_hovered {
4880 close_clicked = true;
4881 }
4882 }
4883
4884 if is_active {
4886 let underline_rect = egui::Rect::from_min_size(
4887 egui::pos2(rect.left(), rect.bottom() - TAB_UNDERLINE_HEIGHT),
4888 egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
4889 );
4890 ui.painter()
4891 .rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
4892 }
4893
4894 let tab_clicked = response.clicked() && !close_clicked;
4896
4897 (tab_clicked, close_clicked)
4898 }
4899
4900 fn render_content(&mut self, ui: &mut egui::Ui) {
4906 let has_dynamic_tabs = self.closable_tab_count() > 0;
4908
4909 if has_dynamic_tabs {
4910 self.render_content_tab_bar(ui);
4912
4913 let separator_rect = ui.available_rect_before_wrap();
4915 ui.painter().hline(
4916 separator_rect.x_range(),
4917 separator_rect.top(),
4918 Stroke::new(1.0, colors::SEPARATOR),
4919 );
4920
4921 ui.add_space(spacing::SM);
4922 }
4923
4924 match &self.active_tab_id {
4926 TabId::ActiveRuns => self.render_active_runs(ui),
4927 TabId::Projects => self.render_projects(ui),
4928 TabId::Config => self.render_config(ui),
4929 TabId::CreateSpec => self.render_create_spec(ui),
4930 TabId::RunDetail(run_id) => {
4931 let run_id = run_id.clone();
4932 self.render_run_detail(ui, &run_id);
4933 }
4934 TabId::CommandOutput(cache_key) => {
4935 let cache_key = cache_key.clone();
4936 self.render_command_output(ui, &cache_key);
4937 }
4938 }
4939 }
4940
4941 fn render_content_tab_bar(&mut self, ui: &mut egui::Ui) {
4953 let available_width = ui.available_width();
4955 let scroll_width = available_width.min(TAB_BAR_MAX_SCROLL_WIDTH);
4956
4957 ui.allocate_ui_with_layout(
4958 egui::vec2(available_width, CONTENT_TAB_BAR_HEIGHT),
4959 egui::Layout::left_to_right(egui::Align::Center),
4960 |ui| {
4961 let mut tab_to_activate: Option<TabId> = None;
4963 let mut tab_to_close: Option<TabId> = None;
4964
4965 egui::ScrollArea::horizontal()
4966 .max_width(scroll_width)
4967 .auto_shrink([false, false])
4968 .scroll_bar_visibility(
4969 egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
4970 )
4971 .show(ui, |ui| {
4972 ui.horizontal_centered(|ui| {
4973 ui.add_space(spacing::XS);
4974
4975 let dynamic_tabs: Vec<(TabId, String)> = self
4977 .tabs
4978 .iter()
4979 .filter(|t| t.closable)
4980 .map(|t| (t.id.clone(), t.label.clone()))
4981 .collect();
4982
4983 for (tab_id, label) in &dynamic_tabs {
4984 let is_active = self.active_tab_id == *tab_id;
4985 let (clicked, close_clicked) =
4986 self.render_content_tab(ui, label, is_active);
4987
4988 if clicked {
4989 tab_to_activate = Some(tab_id.clone());
4990 }
4991 if close_clicked {
4992 tab_to_close = Some(tab_id.clone());
4993 }
4994 ui.add_space(spacing::XS);
4995 }
4996 });
4997 });
4998
4999 if let Some(tab_id) = tab_to_close {
5001 self.close_tab(&tab_id);
5002 } else if let Some(tab_id) = tab_to_activate {
5003 self.set_active_tab(tab_id);
5004 }
5005 },
5006 );
5007 }
5008
5009 fn render_content_tab(&self, ui: &mut egui::Ui, label: &str, is_active: bool) -> (bool, bool) {
5014 let text_galley = ui.fonts(|f| {
5016 f.layout_no_wrap(
5017 label.to_string(),
5018 typography::font(FontSize::Body, FontWeight::Medium),
5019 colors::TEXT_PRIMARY,
5020 )
5021 });
5022 let text_size = text_galley.size();
5023
5024 let close_button_space = TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING;
5026 let tab_width = text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
5027 let tab_height = CONTENT_TAB_BAR_HEIGHT - TAB_UNDERLINE_HEIGHT - spacing::XS;
5028 let tab_size = egui::vec2(tab_width, tab_height);
5029
5030 let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
5032 let is_hovered = response.hovered();
5033
5034 let bg_color = if is_active {
5036 colors::SURFACE_SELECTED
5037 } else if is_hovered {
5038 colors::SURFACE_HOVER
5039 } else {
5040 Color32::TRANSPARENT
5041 };
5042
5043 if bg_color != Color32::TRANSPARENT {
5044 ui.painter()
5045 .rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
5046 }
5047
5048 let text_color = if is_active {
5050 colors::TEXT_PRIMARY
5051 } else if is_hovered {
5052 colors::TEXT_SECONDARY
5053 } else {
5054 colors::TEXT_MUTED
5055 };
5056
5057 let text_x = rect.left() + TAB_PADDING_H;
5058 let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
5059
5060 ui.painter().galley(
5061 text_pos,
5062 ui.fonts(|f| {
5063 f.layout_no_wrap(
5064 label.to_string(),
5065 typography::font(
5066 FontSize::Body,
5067 if is_active {
5068 FontWeight::SemiBold
5069 } else {
5070 FontWeight::Medium
5071 },
5072 ),
5073 text_color,
5074 )
5075 }),
5076 Color32::TRANSPARENT,
5077 );
5078
5079 let close_rect = Rect::from_min_size(
5081 egui::pos2(
5082 rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
5083 rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
5084 ),
5085 egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
5086 );
5087
5088 let close_hovered = ui
5090 .ctx()
5091 .input(|i| i.pointer.hover_pos())
5092 .is_some_and(|pos| close_rect.contains(pos));
5093
5094 if close_hovered {
5096 ui.painter().rect_filled(
5097 close_rect,
5098 Rounding::same(rounding::SMALL),
5099 colors::SURFACE_HOVER,
5100 );
5101 ui.ctx()
5103 .output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
5104 }
5105
5106 let x_color = if close_hovered {
5108 colors::TEXT_PRIMARY
5109 } else {
5110 colors::TEXT_MUTED
5111 };
5112 let x_center = close_rect.center();
5113 let x_size = TAB_CLOSE_BUTTON_SIZE * 0.3 * if close_hovered { 1.15 } else { 1.0 };
5114
5115 ui.painter().line_segment(
5116 [
5117 egui::pos2(x_center.x - x_size, x_center.y - x_size),
5118 egui::pos2(x_center.x + x_size, x_center.y + x_size),
5119 ],
5120 Stroke::new(1.5, x_color),
5121 );
5122 ui.painter().line_segment(
5123 [
5124 egui::pos2(x_center.x + x_size, x_center.y - x_size),
5125 egui::pos2(x_center.x - x_size, x_center.y + x_size),
5126 ],
5127 Stroke::new(1.5, x_color),
5128 );
5129
5130 if is_active {
5132 let underline_rect = egui::Rect::from_min_size(
5133 egui::pos2(rect.left(), rect.bottom()),
5134 egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
5135 );
5136 ui.painter()
5137 .rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
5138 }
5139
5140 let close_clicked = response.clicked() && close_hovered;
5142 let tab_clicked = response.clicked() && !close_hovered;
5143
5144 (tab_clicked, close_clicked)
5145 }
5146
5147 fn render_config(&mut self, ui: &mut egui::Ui) {
5153 self.refresh_config_scope_data();
5155
5156 let mut editor_actions = ConfigEditorActions::default();
5158
5159 let available_width = ui.available_width();
5161 let available_height = ui.available_height();
5162
5163 let divider_total_width = SPLIT_DIVIDER_WIDTH + SPLIT_DIVIDER_MARGIN * 2.0;
5166 let panel_width =
5167 ((available_width - divider_total_width) / 2.0).max(SPLIT_PANEL_MIN_WIDTH);
5168
5169 ui.horizontal(|ui| {
5170 ui.allocate_ui_with_layout(
5172 Vec2::new(panel_width, available_height),
5173 egui::Layout::top_down(egui::Align::LEFT),
5174 |ui| {
5175 self.render_config_left_panel(ui);
5176 },
5177 );
5178
5179 ui.add_space(SPLIT_DIVIDER_MARGIN);
5181
5182 let divider_rect = ui.available_rect_before_wrap();
5184 let divider_line_rect = Rect::from_min_size(
5185 divider_rect.min,
5186 Vec2::new(SPLIT_DIVIDER_WIDTH, available_height),
5187 );
5188 ui.painter()
5189 .rect_filled(divider_line_rect, Rounding::ZERO, colors::SEPARATOR);
5190 ui.add_space(SPLIT_DIVIDER_WIDTH);
5191
5192 ui.add_space(SPLIT_DIVIDER_MARGIN);
5193
5194 let actions_response = ui.allocate_ui_with_layout(
5197 Vec2::new(ui.available_width(), available_height),
5198 egui::Layout::top_down(egui::Align::LEFT),
5199 |ui| self.render_config_right_panel(ui),
5200 );
5201
5202 editor_actions = actions_response.inner;
5203 });
5204
5205 if let Some(project_name) = editor_actions.create_project_config {
5207 if let Err(e) = self.create_project_config_from_global(&project_name) {
5208 self.config_state.project_config_error = Some(e);
5209 }
5210 }
5211
5212 if !editor_actions.bool_changes.is_empty() {
5214 self.apply_config_bool_changes(
5215 editor_actions.is_global,
5216 editor_actions.project_name.as_deref(),
5217 &editor_actions.bool_changes,
5218 );
5219 }
5220
5221 if !editor_actions.text_changes.is_empty() {
5223 self.apply_config_text_changes(
5224 editor_actions.is_global,
5225 editor_actions.project_name.as_deref(),
5226 &editor_actions.text_changes,
5227 );
5228 }
5229
5230 if editor_actions.reset_to_defaults {
5232 self.reset_config_to_defaults(
5233 editor_actions.is_global,
5234 editor_actions.project_name.as_deref(),
5235 );
5236 }
5237 }
5238
5239 fn render_config_left_panel(&mut self, ui: &mut egui::Ui) {
5244 ui.label(
5246 egui::RichText::new("Scope")
5247 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
5248 .color(colors::TEXT_PRIMARY),
5249 );
5250
5251 ui.add_space(spacing::SM);
5252
5253 egui::ScrollArea::vertical()
5255 .id_salt("config_scope_list")
5256 .auto_shrink([false, false])
5257 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
5258 .show(ui, |ui| {
5259 if self.render_config_scope_item(ui, ConfigScope::Global, true) {
5261 self.config_state.selected_scope = ConfigScope::Global;
5262 }
5263
5264 ui.add_space(spacing::SM);
5265
5266 let projects: Vec<String> = self.config_state.scope_projects.clone();
5268 for project in projects {
5269 let has_config = self.project_has_config(&project);
5270 let scope = ConfigScope::Project(project.clone());
5271 if self.render_config_scope_item(ui, scope.clone(), has_config) {
5272 self.config_state.selected_scope = scope;
5273 }
5274 ui.add_space(spacing::XS);
5275 }
5276 });
5277 }
5278
5279 fn render_config_scope_item(
5283 &self,
5284 ui: &mut egui::Ui,
5285 scope: ConfigScope,
5286 has_config: bool,
5287 ) -> bool {
5288 let is_selected = self.config_state.selected_scope == scope;
5289
5290 let (display_text, text_color) = match &scope {
5292 ConfigScope::Global => ("Global".to_string(), colors::TEXT_PRIMARY),
5293 ConfigScope::Project(name) => {
5294 if has_config {
5295 (name.clone(), colors::TEXT_PRIMARY)
5296 } else {
5297 (format!("{} (global)", name), colors::TEXT_MUTED)
5299 }
5300 }
5301 };
5302
5303 let (rect, response) = ui.allocate_exact_size(
5305 Vec2::new(ui.available_width(), CONFIG_SCOPE_ROW_HEIGHT),
5306 Sense::click(),
5307 );
5308
5309 if ui.is_rect_visible(rect) {
5311 let bg_color = if is_selected {
5312 colors::SURFACE_SELECTED
5313 } else if response.hovered() {
5314 colors::SURFACE_HOVER
5315 } else {
5316 Color32::TRANSPARENT
5317 };
5318
5319 ui.painter()
5320 .rect_filled(rect, Rounding::same(SIDEBAR_ITEM_ROUNDING), bg_color);
5321
5322 if is_selected {
5324 let indicator_rect = Rect::from_min_size(
5325 rect.min,
5326 Vec2::new(SIDEBAR_ACTIVE_INDICATOR_WIDTH, rect.height()),
5327 );
5328 ui.painter().rect_filled(
5329 indicator_rect,
5330 Rounding::same(SIDEBAR_ACTIVE_INDICATOR_WIDTH / 2.0),
5331 colors::ACCENT,
5332 );
5333 }
5334
5335 let text_rect = rect.shrink2(Vec2::new(
5337 CONFIG_SCOPE_ROW_PADDING_H
5338 + (if is_selected {
5339 SIDEBAR_ACTIVE_INDICATOR_WIDTH + 4.0
5340 } else {
5341 0.0
5342 }),
5343 CONFIG_SCOPE_ROW_PADDING_V,
5344 ));
5345
5346 let font_weight = if is_selected {
5347 FontWeight::SemiBold
5348 } else {
5349 FontWeight::Regular
5350 };
5351
5352 ui.painter().text(
5353 text_rect.left_center(),
5354 egui::Align2::LEFT_CENTER,
5355 &display_text,
5356 typography::font(FontSize::Body, font_weight),
5357 text_color,
5358 );
5359 }
5360
5361 response.clicked()
5362 }
5363
5364 fn render_config_right_panel(&self, ui: &mut egui::Ui) -> ConfigEditorActions {
5377 let mut actions = ConfigEditorActions::default();
5378
5379 let (header_text, tooltip_text) = match &self.config_state.selected_scope {
5381 ConfigScope::Global => {
5382 actions.is_global = true;
5383 let path = crate::config::global_config_path()
5384 .map(|p| p.display().to_string())
5385 .unwrap_or_else(|_| "~/.config/autom8/config.toml".to_string());
5386 ("Global Config".to_string(), path)
5387 }
5388 ConfigScope::Project(name) => {
5389 actions.is_global = false;
5390 actions.project_name = Some(name.clone());
5391 let path = crate::config::project_config_path_for(name)
5392 .map(|p| p.display().to_string())
5393 .unwrap_or_else(|_| format!("~/.config/autom8/{}/config.toml", name));
5394 if self.project_has_config(name) {
5395 (format!("Project Config: {}", name), path)
5396 } else {
5397 (format!("Project Config: {} (using global)", name), path)
5398 }
5399 }
5400 };
5401
5402 let header_response = ui.label(
5404 egui::RichText::new(&header_text)
5405 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
5406 .color(colors::TEXT_PRIMARY),
5407 );
5408 header_response.on_hover_text(&tooltip_text);
5409
5410 ui.add_space(spacing::MD);
5411
5412 match &self.config_state.selected_scope {
5414 ConfigScope::Global => {
5415 let (bool_changes, text_changes, reset_clicked) =
5416 self.render_global_config_editor(ui);
5417 actions.bool_changes = bool_changes;
5418 actions.text_changes = text_changes;
5419 actions.reset_to_defaults = reset_clicked;
5420 }
5421 ConfigScope::Project(name) => {
5422 if self.project_has_config(name) {
5425 let (bool_changes, text_changes, reset_clicked) =
5426 self.render_project_config_editor(ui, name);
5427 actions.bool_changes = bool_changes;
5428 actions.text_changes = text_changes;
5429 actions.reset_to_defaults = reset_clicked;
5430 } else {
5431 let project_name = name.clone();
5433 egui::ScrollArea::vertical()
5434 .id_salt("config_editor")
5435 .auto_shrink([false, false])
5436 .show(ui, |ui| {
5437 ui.add_space(spacing::XXL);
5438 ui.vertical_centered(|ui| {
5439 ui.label(
5441 egui::RichText::new(
5442 "This project does not have a config file.\nIt uses the global configuration.",
5443 )
5444 .font(typography::font(FontSize::Body, FontWeight::Regular))
5445 .color(colors::TEXT_MUTED),
5446 );
5447
5448 ui.add_space(spacing::LG);
5449
5450 if self.render_create_config_button(ui) {
5452 actions.create_project_config = Some(project_name.clone());
5453 }
5454 });
5455 });
5456 }
5457 }
5458 }
5459
5460 if self.config_state.last_modified.is_some() {
5462 ui.add_space(spacing::MD);
5463 ui.label(
5464 egui::RichText::new("Changes take effect on next run")
5465 .font(typography::font(FontSize::Small, FontWeight::Regular))
5466 .color(colors::TEXT_MUTED),
5467 );
5468 }
5469
5470 actions
5471 }
5472
5473 fn render_create_config_button(&self, ui: &mut egui::Ui) -> bool {
5477 let button_text = "Create Project Config";
5478 let text_galley = ui.fonts(|f| {
5479 f.layout_no_wrap(
5480 button_text.to_string(),
5481 typography::font(FontSize::Body, FontWeight::Medium),
5482 colors::TEXT_PRIMARY,
5483 )
5484 });
5485 let text_size = text_galley.size();
5486
5487 let button_padding_h = spacing::LG;
5489 let button_padding_v = spacing::SM;
5490 let button_size = Vec2::new(
5491 text_size.x + button_padding_h * 2.0,
5492 text_size.y + button_padding_v * 2.0,
5493 );
5494
5495 let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
5497 let is_hovered = response.hovered();
5498
5499 let bg_color = if is_hovered {
5501 colors::ACCENT
5502 } else {
5503 colors::ACCENT_SUBTLE
5504 };
5505 ui.painter()
5506 .rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
5507
5508 let text_color = if is_hovered {
5510 colors::TEXT_PRIMARY
5511 } else {
5512 colors::ACCENT
5513 };
5514 let text_pos = rect.center() - text_size / 2.0;
5515 ui.painter().galley(
5516 text_pos,
5517 ui.fonts(|f| {
5518 f.layout_no_wrap(
5519 button_text.to_string(),
5520 typography::font(FontSize::Body, FontWeight::Medium),
5521 text_color,
5522 )
5523 }),
5524 text_color,
5525 );
5526
5527 response.clicked()
5528 }
5529
5530 fn render_reset_to_defaults_button(&self, ui: &mut egui::Ui) -> bool {
5535 let button_text = "Reset to Defaults";
5536 let text_galley = ui.fonts(|f| {
5537 f.layout_no_wrap(
5538 button_text.to_string(),
5539 typography::font(FontSize::Small, FontWeight::Regular),
5540 colors::TEXT_MUTED,
5541 )
5542 });
5543 let text_size = text_galley.size();
5544
5545 let button_padding_h = spacing::MD;
5547 let button_padding_v = spacing::XS;
5548 let button_size = Vec2::new(
5549 text_size.x + button_padding_h * 2.0,
5550 text_size.y + button_padding_v * 2.0,
5551 );
5552
5553 let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
5555 let is_hovered = response.hovered();
5556
5557 if is_hovered {
5559 ui.painter()
5560 .rect_filled(rect, Rounding::same(rounding::BUTTON), colors::SURFACE);
5561 }
5562
5563 let text_color = if is_hovered {
5565 colors::TEXT_SECONDARY
5566 } else {
5567 colors::TEXT_MUTED
5568 };
5569 let text_pos = rect.center() - text_size / 2.0;
5570 ui.painter().galley(
5571 text_pos,
5572 ui.fonts(|f| {
5573 f.layout_no_wrap(
5574 button_text.to_string(),
5575 typography::font(FontSize::Small, FontWeight::Regular),
5576 text_color,
5577 )
5578 }),
5579 text_color,
5580 );
5581
5582 response.clicked()
5583 }
5584
5585 fn render_global_config_editor(
5596 &self,
5597 ui: &mut egui::Ui,
5598 ) -> (BoolFieldChanges, TextFieldChanges, bool) {
5599 let mut bool_changes: Vec<(ConfigBoolField, bool)> = Vec::new();
5600 let mut text_changes: Vec<(ConfigTextField, String)> = Vec::new();
5601 let mut reset_clicked = false;
5602
5603 if let Some(error) = &self.config_state.global_config_error {
5605 ui.add_space(spacing::MD);
5606 ui.label(
5607 egui::RichText::new(error)
5608 .font(typography::font(FontSize::Body, FontWeight::Regular))
5609 .color(colors::STATUS_ERROR),
5610 );
5611 return (bool_changes, text_changes, reset_clicked);
5612 }
5613
5614 let Some(config) = &self.config_state.cached_global_config else {
5616 ui.add_space(spacing::MD);
5617 ui.label(
5618 egui::RichText::new("Loading configuration...")
5619 .font(typography::font(FontSize::Body, FontWeight::Regular))
5620 .color(colors::TEXT_MUTED),
5621 );
5622 return (bool_changes, text_changes, reset_clicked);
5623 };
5624
5625 let mut review = config.review;
5627 let mut commit = config.commit;
5628 let mut pull_request = config.pull_request;
5629 let mut pull_request_draft = config.pull_request_draft;
5630 let mut worktree = config.worktree;
5631 let mut worktree_cleanup = config.worktree_cleanup;
5632
5633 let mut worktree_path_pattern = config.worktree_path_pattern.clone();
5635
5636 egui::ScrollArea::vertical()
5638 .id_salt("config_editor")
5639 .auto_shrink([false, false])
5640 .show(ui, |ui| {
5641 self.render_config_group_header(ui, "Pipeline");
5643 ui.add_space(spacing::SM);
5644
5645 if self.render_config_bool_field(
5646 ui,
5647 "review",
5648 &mut review,
5649 "Code review before committing. When enabled, changes are reviewed for quality before being committed.",
5650 ) {
5651 bool_changes.push((ConfigBoolField::Review, review));
5652 }
5653
5654 ui.add_space(spacing::SM);
5655
5656 if self.render_config_bool_field(
5659 ui,
5660 "commit",
5661 &mut commit,
5662 "Automatic git commits. When enabled, changes are automatically committed after implementation.",
5663 ) {
5664 bool_changes.push((ConfigBoolField::Commit, commit));
5665 if !commit && pull_request {
5667 pull_request = false;
5668 bool_changes.push((ConfigBoolField::PullRequest, false));
5669 if pull_request_draft {
5671 pull_request_draft = false;
5672 bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5673 }
5674 }
5675 }
5676
5677 ui.add_space(spacing::SM);
5678
5679 if self.render_config_bool_field_with_disabled(
5682 ui,
5683 "pull_request",
5684 &mut pull_request,
5685 "Automatic PR creation. When enabled, a pull request is created after committing. Requires commit to be enabled.",
5686 !commit, Some("Pull requests require commits to be enabled"),
5688 ) {
5689 bool_changes.push((ConfigBoolField::PullRequest, pull_request));
5690 if !pull_request && pull_request_draft {
5692 pull_request_draft = false;
5693 bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5694 }
5695 }
5696
5697 ui.add_space(spacing::SM);
5698
5699 if self.render_config_bool_field_with_disabled(
5702 ui,
5703 "pull_request_draft",
5704 &mut pull_request_draft,
5705 "Create PRs as drafts. When enabled, PRs are created in draft mode (not ready for review). Requires pull_request to be enabled.",
5706 !pull_request, Some("Draft PRs require pull requests to be enabled"),
5708 ) {
5709 bool_changes.push((ConfigBoolField::PullRequestDraft, pull_request_draft));
5710 }
5711
5712 ui.add_space(spacing::XL);
5713
5714 self.render_config_group_header(ui, "Worktree");
5716 ui.add_space(spacing::SM);
5717
5718 if self.render_config_bool_field(
5719 ui,
5720 "worktree",
5721 &mut worktree,
5722 "Automatic worktree creation. When enabled, creates a dedicated worktree for each run, enabling parallel sessions.",
5723 ) {
5724 bool_changes.push((ConfigBoolField::Worktree, worktree));
5725 }
5726
5727 ui.add_space(spacing::SM);
5728
5729 if let Some(new_value) = self.render_config_text_field(
5731 ui,
5732 "worktree_path_pattern",
5733 &mut worktree_path_pattern,
5734 "Pattern for worktree directory names. Placeholders: {repo} = repository name, {branch} = branch name.",
5735 ) {
5736 text_changes.push((ConfigTextField::WorktreePathPattern, new_value));
5737 }
5738
5739 ui.add_space(spacing::SM);
5740
5741 if self.render_config_bool_field(
5742 ui,
5743 "worktree_cleanup",
5744 &mut worktree_cleanup,
5745 "Automatic worktree cleanup. When enabled, removes worktrees after successful completion. Failed runs keep their worktrees.",
5746 ) {
5747 bool_changes.push((ConfigBoolField::WorktreeCleanup, worktree_cleanup));
5748 }
5749
5750 ui.add_space(spacing::XXL);
5752
5753 if self.render_reset_to_defaults_button(ui) {
5756 reset_clicked = true;
5757 }
5758
5759 ui.add_space(spacing::XL);
5761 });
5762
5763 (bool_changes, text_changes, reset_clicked)
5764 }
5765
5766 fn render_project_config_editor(
5774 &self,
5775 ui: &mut egui::Ui,
5776 project_name: &str,
5777 ) -> (BoolFieldChanges, TextFieldChanges, bool) {
5778 let mut bool_changes: Vec<(ConfigBoolField, bool)> = Vec::new();
5779 let mut text_changes: Vec<(ConfigTextField, String)> = Vec::new();
5780 let mut reset_clicked = false;
5781
5782 if let Some(error) = &self.config_state.project_config_error {
5784 ui.add_space(spacing::MD);
5785 ui.label(
5786 egui::RichText::new(error)
5787 .font(typography::font(FontSize::Body, FontWeight::Regular))
5788 .color(colors::STATUS_ERROR),
5789 );
5790 return (bool_changes, text_changes, reset_clicked);
5791 }
5792
5793 let Some(config) = self.cached_project_config(project_name) else {
5795 ui.add_space(spacing::MD);
5796 ui.label(
5797 egui::RichText::new("Loading configuration...")
5798 .font(typography::font(FontSize::Body, FontWeight::Regular))
5799 .color(colors::TEXT_MUTED),
5800 );
5801 return (bool_changes, text_changes, reset_clicked);
5802 };
5803
5804 let mut review = config.review;
5806 let mut commit = config.commit;
5807 let mut pull_request = config.pull_request;
5808 let mut pull_request_draft = config.pull_request_draft;
5809 let mut worktree = config.worktree;
5810 let mut worktree_cleanup = config.worktree_cleanup;
5811
5812 let mut worktree_path_pattern = config.worktree_path_pattern.clone();
5814
5815 egui::ScrollArea::vertical()
5817 .id_salt("project_config_editor")
5818 .auto_shrink([false, false])
5819 .show(ui, |ui| {
5820 self.render_config_group_header(ui, "Pipeline");
5822 ui.add_space(spacing::SM);
5823
5824 if self.render_config_bool_field(
5825 ui,
5826 "review",
5827 &mut review,
5828 "Code review before committing. When enabled, changes are reviewed for quality before being committed.",
5829 ) {
5830 bool_changes.push((ConfigBoolField::Review, review));
5831 }
5832
5833 ui.add_space(spacing::SM);
5834
5835 if self.render_config_bool_field(
5838 ui,
5839 "commit",
5840 &mut commit,
5841 "Automatic git commits. When enabled, changes are automatically committed after implementation.",
5842 ) {
5843 bool_changes.push((ConfigBoolField::Commit, commit));
5844 if !commit && pull_request {
5846 pull_request = false;
5847 bool_changes.push((ConfigBoolField::PullRequest, false));
5848 if pull_request_draft {
5850 pull_request_draft = false;
5851 bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5852 }
5853 }
5854 }
5855
5856 ui.add_space(spacing::SM);
5857
5858 if self.render_config_bool_field_with_disabled(
5861 ui,
5862 "pull_request",
5863 &mut pull_request,
5864 "Automatic PR creation. When enabled, a pull request is created after committing. Requires commit to be enabled.",
5865 !commit, Some("Pull requests require commits to be enabled"),
5867 ) {
5868 bool_changes.push((ConfigBoolField::PullRequest, pull_request));
5869 if !pull_request && pull_request_draft {
5871 pull_request_draft = false;
5872 bool_changes.push((ConfigBoolField::PullRequestDraft, false));
5873 }
5874 }
5875
5876 ui.add_space(spacing::SM);
5877
5878 if self.render_config_bool_field_with_disabled(
5881 ui,
5882 "pull_request_draft",
5883 &mut pull_request_draft,
5884 "Create PRs as drafts. When enabled, PRs are created in draft mode (not ready for review). Requires pull_request to be enabled.",
5885 !pull_request, Some("Draft PRs require pull requests to be enabled"),
5887 ) {
5888 bool_changes.push((ConfigBoolField::PullRequestDraft, pull_request_draft));
5889 }
5890
5891 ui.add_space(spacing::XL);
5892
5893 self.render_config_group_header(ui, "Worktree");
5895 ui.add_space(spacing::SM);
5896
5897 if self.render_config_bool_field(
5898 ui,
5899 "worktree",
5900 &mut worktree,
5901 "Automatic worktree creation. When enabled, creates a dedicated worktree for each run, enabling parallel sessions.",
5902 ) {
5903 bool_changes.push((ConfigBoolField::Worktree, worktree));
5904 }
5905
5906 ui.add_space(spacing::SM);
5907
5908 if let Some(new_value) = self.render_config_text_field(
5910 ui,
5911 "worktree_path_pattern",
5912 &mut worktree_path_pattern,
5913 "Pattern for worktree directory names. Placeholders: {repo} = repository name, {branch} = branch name.",
5914 ) {
5915 text_changes.push((ConfigTextField::WorktreePathPattern, new_value));
5916 }
5917
5918 ui.add_space(spacing::SM);
5919
5920 if self.render_config_bool_field(
5921 ui,
5922 "worktree_cleanup",
5923 &mut worktree_cleanup,
5924 "Automatic worktree cleanup. When enabled, removes worktrees after successful completion. Failed runs keep their worktrees.",
5925 ) {
5926 bool_changes.push((ConfigBoolField::WorktreeCleanup, worktree_cleanup));
5927 }
5928
5929 ui.add_space(spacing::XXL);
5931
5932 if self.render_reset_to_defaults_button(ui) {
5935 reset_clicked = true;
5936 }
5937
5938 ui.add_space(spacing::XL);
5940 });
5941
5942 (bool_changes, text_changes, reset_clicked)
5943 }
5944
5945 fn render_config_group_header(&self, ui: &mut egui::Ui, title: &str) {
5947 ui.label(
5948 egui::RichText::new(title)
5949 .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
5950 .color(colors::TEXT_PRIMARY),
5951 );
5952 }
5953
5954 fn render_config_bool_field(
5971 &self,
5972 ui: &mut egui::Ui,
5973 name: &str,
5974 value: &mut bool,
5975 help_text: &str,
5976 ) -> bool {
5977 self.render_config_bool_field_with_disabled(ui, name, value, help_text, false, None)
5978 }
5979
5980 fn render_config_bool_field_with_disabled(
5999 &self,
6000 ui: &mut egui::Ui,
6001 name: &str,
6002 value: &mut bool,
6003 help_text: &str,
6004 disabled: bool,
6005 disabled_tooltip: Option<&str>,
6006 ) -> bool {
6007 let original_value = *value;
6008
6009 ui.horizontal(|ui| {
6010 let text_color = if disabled {
6012 colors::TEXT_DISABLED
6013 } else {
6014 colors::TEXT_PRIMARY
6015 };
6016 ui.label(
6017 egui::RichText::new(name)
6018 .font(typography::font(FontSize::Body, FontWeight::Medium))
6019 .color(text_color),
6020 );
6021
6022 ui.add_space(spacing::SM);
6023
6024 if disabled {
6026 let response = ui.add(Self::toggle_switch_disabled(*value));
6027 if let Some(tooltip) = disabled_tooltip {
6029 response.on_hover_text(tooltip);
6030 }
6031 } else {
6032 ui.add(Self::toggle_switch(value));
6033 }
6034 });
6035
6036 let help_color = if disabled {
6038 colors::TEXT_DISABLED
6039 } else {
6040 colors::TEXT_MUTED
6041 };
6042 ui.label(
6043 egui::RichText::new(help_text)
6044 .font(typography::font(FontSize::Small, FontWeight::Regular))
6045 .color(help_color),
6046 );
6047
6048 *value != original_value
6050 }
6051
6052 fn toggle_switch(on: &mut bool) -> impl egui::Widget + '_ {
6057 move |ui: &mut egui::Ui| -> egui::Response {
6058 let desired_size = Vec2::new(36.0, 20.0);
6060
6061 let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
6063
6064 if response.clicked() {
6066 *on = !*on;
6067 response.mark_changed();
6068 }
6069
6070 if ui.is_rect_visible(rect) {
6072 let how_on = ui.ctx().animate_bool_responsive(response.id, *on);
6073 let visuals = ui.style().interact_selectable(&response, *on);
6074
6075 let rect = rect.expand(visuals.expansion);
6077 let radius = 0.5 * rect.height();
6078
6079 let bg_color = if *on {
6081 colors::ACCENT_SUBTLE
6082 } else {
6083 colors::SURFACE_HOVER
6084 };
6085 ui.painter()
6086 .rect_filled(rect, Rounding::same(radius), bg_color);
6087
6088 let border_color = if *on { colors::ACCENT } else { colors::BORDER };
6090 ui.painter().rect_stroke(
6091 rect,
6092 Rounding::same(radius),
6093 Stroke::new(1.0, border_color),
6094 );
6095
6096 let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
6098 let center = egui::pos2(circle_x, rect.center().y);
6099 let knob_radius = radius * 0.75;
6100
6101 ui.painter().circle_filled(
6103 center + egui::vec2(0.5, 0.5),
6104 knob_radius,
6105 Color32::from_black_alpha(30),
6106 );
6107
6108 ui.painter()
6110 .circle_filled(center, knob_radius, colors::TEXT_PRIMARY);
6111 }
6112
6113 response
6114 }
6115 }
6116
6117 fn toggle_switch_disabled(on: bool) -> impl egui::Widget {
6123 move |ui: &mut egui::Ui| -> egui::Response {
6124 let desired_size = Vec2::new(36.0, 20.0);
6126
6127 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
6130
6131 if ui.is_rect_visible(rect) {
6133 let how_on = ui.ctx().animate_bool_responsive(response.id, on);
6135
6136 let radius = 0.5 * rect.height();
6138
6139 let bg_color = colors::SURFACE_HOVER;
6141 ui.painter()
6142 .rect_filled(rect, Rounding::same(radius), bg_color);
6143
6144 ui.painter().rect_stroke(
6146 rect,
6147 Rounding::same(radius),
6148 Stroke::new(1.0, colors::BORDER),
6149 );
6150
6151 let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
6153 let center = egui::pos2(circle_x, rect.center().y);
6154 let knob_radius = radius * 0.75;
6155
6156 ui.painter()
6160 .circle_filled(center, knob_radius, colors::TEXT_DISABLED);
6161 }
6162
6163 response
6164 }
6165 }
6166
6167 fn render_config_text_field(
6175 &self,
6176 ui: &mut egui::Ui,
6177 name: &str,
6178 value: &mut String,
6179 help_text: &str,
6180 ) -> Option<String> {
6181 let mut changed_value: Option<String> = None;
6182
6183 ui.horizontal(|ui| {
6184 ui.label(
6186 egui::RichText::new(name)
6187 .font(typography::font(FontSize::Body, FontWeight::Medium))
6188 .color(colors::TEXT_PRIMARY),
6189 );
6190
6191 ui.add_space(spacing::SM);
6192
6193 let text_edit = egui::TextEdit::singleline(value)
6195 .font(typography::mono(FontSize::Body))
6196 .text_color(colors::TEXT_SECONDARY)
6197 .desired_width(250.0);
6198
6199 let response = ui.add(text_edit);
6200 if response.changed() {
6201 changed_value = Some(value.clone());
6202 }
6203 });
6204
6205 ui.label(
6207 egui::RichText::new(help_text)
6208 .font(typography::font(FontSize::Small, FontWeight::Regular))
6209 .color(colors::TEXT_MUTED),
6210 );
6211
6212 if name == "worktree_path_pattern" {
6214 let mut warnings: Vec<&str> = Vec::new();
6215
6216 if !value.contains("{repo}") {
6217 warnings.push("Missing {repo} placeholder");
6218 }
6219 if !value.contains("{branch}") {
6220 warnings.push("Missing {branch} placeholder");
6221 }
6222
6223 if !warnings.is_empty() {
6225 ui.add_space(spacing::XS);
6226 for warning in warnings {
6227 ui.horizontal(|ui| {
6228 ui.label(
6229 egui::RichText::new("⚠")
6230 .font(typography::font(FontSize::Small, FontWeight::Regular))
6231 .color(colors::STATUS_WARNING),
6232 );
6233 ui.add_space(spacing::XS);
6234 ui.label(
6235 egui::RichText::new(warning)
6236 .font(typography::font(FontSize::Small, FontWeight::Regular))
6237 .color(colors::STATUS_WARNING),
6238 );
6239 });
6240 }
6241 }
6242 }
6243
6244 changed_value
6245 }
6246
6247 fn render_create_spec(&mut self, ui: &mut egui::Ui) {
6262 ui.label(
6264 egui::RichText::new("Create Spec")
6265 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
6266 .color(colors::TEXT_PRIMARY),
6267 );
6268
6269 ui.add_space(spacing::MD);
6270
6271 self.render_create_spec_project_dropdown(ui);
6273
6274 ui.add_space(spacing::LG);
6275
6276 if self.projects.is_empty() {
6278 egui::ScrollArea::vertical()
6280 .auto_shrink([false, false])
6281 .show(ui, |ui| {
6282 ui.add_space(spacing::LG);
6283 self.render_create_spec_no_projects(ui);
6284 });
6285 } else if self.create_spec_selected_project.is_none() {
6286 egui::ScrollArea::vertical()
6288 .auto_shrink([false, false])
6289 .show(ui, |ui| {
6290 ui.add_space(spacing::LG);
6291 self.render_create_spec_select_prompt(ui);
6292 });
6293 } else {
6294 let available_height = ui.available_height();
6298
6299 let bottom_padding = spacing::XXL + spacing::XL; let separator_height = spacing::SM * 2.0 + 1.0; let input_bar_height = INPUT_BAR_HEIGHT + spacing::MD;
6303 let reserved_bottom = input_bar_height + separator_height + bottom_padding;
6304
6305 ui.allocate_ui(
6307 egui::vec2(ui.available_width(), available_height - reserved_bottom),
6308 |ui| {
6309 self.render_create_spec_chat_area(ui);
6310 },
6311 );
6312
6313 ui.add_space(spacing::SM);
6315 ui.separator();
6316 ui.add_space(spacing::SM);
6317
6318 self.render_create_spec_input_bar(ui);
6320
6321 ui.add_space(bottom_padding);
6323 }
6324 }
6325
6326 fn render_create_spec_project_dropdown(&mut self, ui: &mut egui::Ui) {
6331 ui.horizontal(|ui| {
6332 ui.label(
6333 egui::RichText::new("Project:")
6334 .font(typography::font(FontSize::Body, FontWeight::Medium))
6335 .color(colors::TEXT_PRIMARY),
6336 );
6337
6338 ui.add_space(spacing::SM);
6339
6340 let selected_text = self
6342 .create_spec_selected_project
6343 .as_deref()
6344 .unwrap_or("Select a project...");
6345
6346 let combo_id = ui.make_persistent_id("create_spec_project_dropdown");
6348 egui::ComboBox::from_id_salt(combo_id)
6349 .selected_text(selected_text)
6350 .width(250.0)
6351 .show_ui(ui, |ui| {
6352 for project in &self.projects {
6354 let project_name = &project.info.name;
6355 let is_selected =
6356 self.create_spec_selected_project.as_ref() == Some(project_name);
6357
6358 if ui.selectable_label(is_selected, project_name).clicked() {
6359 let is_different_project =
6361 self.create_spec_selected_project.as_ref() != Some(project_name);
6362
6363 if is_different_project && self.has_active_spec_session() {
6364 self.pending_project_change = Some(project_name.clone());
6366 } else {
6367 self.create_spec_selected_project = Some(project_name.clone());
6369 }
6370 }
6371 }
6372 });
6373
6374 if self.has_active_spec_session() {
6376 ui.add_space(spacing::MD);
6377 let start_over_btn = egui::Button::new(
6378 egui::RichText::new("Start Over")
6379 .font(typography::font(FontSize::Small, FontWeight::Medium))
6380 .color(colors::TEXT_SECONDARY),
6381 )
6382 .fill(colors::SURFACE_ELEVATED)
6383 .stroke(Stroke::new(1.0, colors::BORDER))
6384 .rounding(Rounding::same(rounding::BUTTON));
6385
6386 if ui.add(start_over_btn).clicked() {
6387 self.reset_create_spec_session();
6388 }
6389 }
6390 });
6391
6392 if let Some(ref project_name) = self.create_spec_selected_project {
6394 ui.add_space(spacing::XS);
6395 ui.label(
6396 egui::RichText::new(format!("Selected: {}", project_name))
6397 .font(typography::font(FontSize::Small, FontWeight::Regular))
6398 .color(colors::TEXT_SECONDARY),
6399 );
6400 }
6401 }
6402
6403 fn render_create_spec_no_projects(&self, ui: &mut egui::Ui) {
6405 ui.vertical_centered(|ui| {
6406 ui.add_space(spacing::XL);
6407
6408ui.label(
6409 egui::RichText::new("No Projects Registered")
6410 .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
6411 .color(colors::TEXT_PRIMARY),
6412 );
6413
6414 ui.add_space(spacing::SM);
6415
6416 let message = "No projects registered. Run `autom8` at least once in any repository to register it.";
6417 ui.label(
6418 egui::RichText::new(message)
6419 .font(typography::font(FontSize::Body, FontWeight::Regular))
6420 .color(colors::TEXT_SECONDARY),
6421 );
6422 });
6423 }
6424
6425 fn render_create_spec_select_prompt(&self, ui: &mut egui::Ui) {
6427 ui.vertical_centered(|ui| {
6428 ui.add_space(spacing::XXL);
6429
6430 ui.label(
6431 egui::RichText::new("Create a New Specification")
6432 .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
6433 .color(colors::TEXT_PRIMARY),
6434 );
6435
6436 ui.add_space(spacing::SM);
6437
6438 ui.label(
6439 egui::RichText::new("Select a project to begin creating a spec")
6440 .font(typography::font(FontSize::Body, FontWeight::Regular))
6441 .color(colors::TEXT_SECONDARY),
6442 );
6443
6444 ui.add_space(spacing::SM);
6445
6446 ui.label(
6447 egui::RichText::new("Note that this is in beta, the more reliable way is to use the CLI by simply running autom8 in your project directory.")
6448 .font(typography::font(FontSize::Body, FontWeight::Regular))
6449 .color(colors::TEXT_SECONDARY),
6450 );
6451
6452 ui.add_space(spacing::LG);
6453
6454 ui.label(
6456 egui::RichText::new(
6457 "To register a new project, run `autom8` from the project directory",
6458 )
6459 .font(typography::font(FontSize::Caption, FontWeight::Regular))
6460 .color(colors::TEXT_MUTED),
6461 );
6462 });
6463 }
6464
6465 fn render_create_spec_chat_area(&mut self, ui: &mut egui::Ui) {
6475 let available_width = ui.available_width();
6477 let max_bubble_width = available_width * CHAT_BUBBLE_MAX_WIDTH_RATIO;
6478
6479 let scroll_id = ui.make_persistent_id("create_spec_chat_scroll");
6481 let mut scroll_area = egui::ScrollArea::vertical()
6482 .id_salt(scroll_id)
6483 .auto_shrink([false, false])
6484 .stick_to_bottom(true);
6485
6486 if self.chat_scroll_to_bottom {
6488 scroll_area = scroll_area
6489 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
6490 }
6491
6492 let mut should_retry = false;
6494 let mut should_confirm_spec = false;
6496 let mut should_start_new = false;
6497
6498 scroll_area.show(ui, |ui| {
6499 ui.add_space(spacing::LG);
6500
6501 if self.chat_messages.is_empty() && self.claude_error.is_none() {
6502 self.render_chat_empty_state(ui);
6504 } else {
6505 for (index, message) in self.chat_messages.iter().enumerate() {
6507 self.render_chat_message(ui, message, max_bubble_width, index);
6508 ui.add_space(CHAT_MESSAGE_SPACING);
6509 }
6510
6511 if self.claude_starting {
6513 ui.add_space(CHAT_MESSAGE_SPACING);
6514 self.render_starting_indicator(ui);
6515 } else if self.is_waiting_for_claude {
6516 ui.add_space(CHAT_MESSAGE_SPACING);
6518 self.render_typing_indicator(ui);
6519 }
6520
6521 if let Some(ref error) = self.claude_error {
6523 ui.add_space(CHAT_MESSAGE_SPACING);
6524 should_retry = self.render_claude_error(ui, error, max_bubble_width);
6525 }
6526
6527 if self.claude_finished && self.generated_spec_path.is_some() {
6529 ui.add_space(CHAT_MESSAGE_SPACING);
6530 let (confirm, start_new) = self.render_spec_completion_ui(ui, max_bubble_width);
6531 should_confirm_spec = confirm;
6532 should_start_new = start_new;
6533 }
6534 }
6535
6536 ui.add_space(spacing::XL);
6538 });
6539
6540 if should_retry {
6542 self.retry_claude();
6543 }
6544
6545 if should_confirm_spec {
6547 self.confirm_spec();
6548 }
6549 if should_start_new {
6550 self.pending_start_new_spec = true;
6552 }
6553
6554 if self.chat_scroll_to_bottom {
6556 self.chat_scroll_to_bottom = false;
6557 }
6558 }
6559
6560 fn render_typing_indicator(&self, ui: &mut egui::Ui) {
6565 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6566 let frame = egui::Frame::none()
6568 .fill(CLAUDE_BUBBLE_COLOR)
6569 .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
6570 .inner_margin(egui::Margin::symmetric(CHAT_BUBBLE_PADDING, spacing::SM))
6571 .stroke(Stroke::new(1.0, colors::BORDER));
6572
6573 frame.show(ui, |ui| {
6574 ui.horizontal(|ui| {
6575 ui.spinner();
6577 ui.add_space(spacing::SM);
6578 ui.label(
6579 egui::RichText::new("Claude is thinking...")
6580 .font(typography::font(FontSize::Body, FontWeight::Regular))
6581 .color(colors::TEXT_MUTED),
6582 );
6583 });
6584 });
6585 });
6586 }
6587
6588 fn render_starting_indicator(&self, ui: &mut egui::Ui) {
6593 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6594 let frame = egui::Frame::none()
6596 .fill(CLAUDE_BUBBLE_COLOR)
6597 .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
6598 .inner_margin(egui::Margin::symmetric(CHAT_BUBBLE_PADDING, spacing::SM))
6599 .stroke(Stroke::new(1.0, colors::BORDER));
6600
6601 frame.show(ui, |ui| {
6602 ui.horizontal(|ui| {
6603 ui.spinner();
6605 ui.add_space(spacing::SM);
6606 ui.label(
6607 egui::RichText::new("Starting Claude...")
6608 .font(typography::font(FontSize::Body, FontWeight::Regular))
6609 .color(colors::TEXT_MUTED),
6610 );
6611 });
6612 });
6613 });
6614 }
6615
6616 fn render_claude_error(&self, ui: &mut egui::Ui, error: &str, _max_bubble_width: f32) -> bool {
6623 let mut should_retry = false;
6624
6625 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6626 let frame = egui::Frame::none()
6628 .fill(colors::STATUS_ERROR_BG)
6629 .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
6630 .inner_margin(egui::Margin::same(CHAT_BUBBLE_PADDING))
6631 .stroke(Stroke::new(1.0, colors::STATUS_ERROR));
6632
6633 frame.show(ui, |ui| {
6634 ui.vertical(|ui| {
6635 ui.label(
6637 egui::RichText::new("Error")
6638 .font(typography::font(FontSize::Body, FontWeight::SemiBold))
6639 .color(colors::STATUS_ERROR),
6640 );
6641
6642 ui.add_space(spacing::XS);
6643
6644 ui.label(
6646 egui::RichText::new(error)
6647 .font(typography::font(FontSize::Body, FontWeight::Regular))
6648 .color(colors::TEXT_PRIMARY),
6649 );
6650
6651 ui.add_space(spacing::SM);
6652
6653 let retry_button = egui::Button::new(
6655 egui::RichText::new("Retry")
6656 .font(typography::font(FontSize::Body, FontWeight::Medium))
6657 .color(colors::SURFACE),
6658 )
6659 .fill(colors::STATUS_ERROR)
6660 .rounding(Rounding::same(spacing::SM));
6661
6662 if ui.add(retry_button).clicked() {
6663 should_retry = true;
6664 }
6665 });
6666 });
6667 });
6668
6669 should_retry
6670 }
6671
6672 fn render_create_spec_input_bar(&mut self, ui: &mut egui::Ui) {
6682 let placeholder = if self.chat_messages.is_empty() {
6684 "Describe the feature you want to build..."
6685 } else {
6686 "Reply..."
6687 };
6688
6689 let input_not_empty = !self.chat_input_text.trim().is_empty();
6691 let can_send = input_not_empty && !self.is_waiting_for_claude;
6692
6693 let mut should_send = false;
6695
6696 let total_width = ui.available_width();
6699 let button_area_width = spacing::SM + SEND_BUTTON_SIZE;
6700 let input_frame_width = total_width - button_area_width;
6701
6702 ui.horizontal(|ui| {
6703 let input_frame = egui::Frame::none()
6705 .fill(colors::SURFACE)
6706 .rounding(Rounding::same(INPUT_FIELD_ROUNDING))
6707 .stroke(Stroke::new(1.0, colors::BORDER))
6708 .inner_margin(egui::Margin::symmetric(spacing::MD, spacing::SM));
6709
6710 let frame_response = input_frame.show(ui, |ui| {
6711 ui.set_width(input_frame_width - spacing::MD * 2.0 - 2.0);
6713
6714 let max_input_height = 100.0;
6716
6717 egui::ScrollArea::vertical()
6718 .max_height(max_input_height)
6719 .show(ui, |ui| {
6720 let text_edit = egui::TextEdit::multiline(&mut self.chat_input_text)
6722 .hint_text(
6723 egui::RichText::new(placeholder)
6724 .color(colors::TEXT_MUTED)
6725 .font(typography::font(FontSize::Body, FontWeight::Regular)),
6726 )
6727 .font(typography::font(FontSize::Body, FontWeight::Regular))
6728 .text_color(colors::TEXT_PRIMARY)
6729 .frame(false)
6730 .desired_width(f32::INFINITY)
6731 .desired_rows(1)
6732 .lock_focus(true)
6733 .interactive(!self.is_waiting_for_claude);
6734
6735 let response = ui.add(text_edit);
6736
6737 if response.has_focus() && !self.is_waiting_for_claude {
6739 let modifiers = ui.input(|i| i.modifiers);
6740 let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter));
6741
6742 if enter_pressed && !modifiers.shift && can_send {
6743 should_send = true;
6744 }
6745 }
6746 });
6747 });
6748
6749 let frame_height = frame_response.response.rect.height();
6751
6752 ui.add_space(spacing::SM);
6753
6754 ui.vertical(|ui| {
6756 let button_vertical_offset = (frame_height - SEND_BUTTON_SIZE) / 2.0;
6758 if button_vertical_offset > 0.0 {
6759 ui.add_space(button_vertical_offset);
6760 }
6761
6762 let (rect, response) = ui.allocate_exact_size(
6763 egui::vec2(SEND_BUTTON_SIZE, SEND_BUTTON_SIZE),
6764 egui::Sense::click(),
6765 );
6766
6767 if ui.is_rect_visible(rect) {
6768 let actual_color = if !can_send {
6770 SEND_BUTTON_DISABLED_COLOR
6771 } else if response.hovered() {
6772 SEND_BUTTON_HOVER_COLOR
6773 } else {
6774 SEND_BUTTON_COLOR
6775 };
6776
6777 ui.painter().rect_filled(
6779 rect,
6780 Rounding::same(SEND_BUTTON_SIZE / 2.0),
6781 actual_color,
6782 );
6783
6784 let icon_color = Color32::WHITE;
6786 let center = rect.center();
6787
6788 let arrow_points = vec![
6789 egui::pos2(center.x - 6.0, center.y - 5.0),
6790 egui::pos2(center.x + 6.0, center.y),
6791 egui::pos2(center.x - 6.0, center.y + 5.0),
6792 egui::pos2(center.x - 3.0, center.y),
6793 ];
6794 ui.painter().add(egui::Shape::convex_polygon(
6795 arrow_points,
6796 icon_color,
6797 Stroke::NONE,
6798 ));
6799 }
6800
6801 if response.clicked() && can_send {
6802 should_send = true;
6803 }
6804 });
6805
6806 if self.is_waiting_for_claude {
6808 ui.add_space(spacing::SM);
6809 ui.spinner();
6810 }
6811 });
6812
6813 if should_send {
6815 self.send_chat_message();
6816 }
6817 }
6818
6819 fn send_chat_message(&mut self) {
6829 let message = self.chat_input_text.trim().to_string();
6830 if message.is_empty() {
6831 return;
6832 }
6833
6834 if self.is_waiting_for_claude {
6836 return;
6837 }
6838
6839 self.add_user_message(&message);
6841
6842 self.chat_input_text.clear();
6844
6845 let has_claude_response = self
6847 .chat_messages
6848 .iter()
6849 .any(|m| m.sender == ChatMessageSender::Claude);
6850
6851 self.spawn_claude_for_message(&message, !has_claude_response);
6853 }
6854
6855 fn render_chat_empty_state(&self, ui: &mut egui::Ui) {
6859 ui.vertical_centered(|ui| {
6860 ui.add_space(spacing::XXL);
6861 ui.add_space(spacing::XXL);
6862
6863 ui.label(
6864 egui::RichText::new("Describe the feature you want to build...")
6865 .font(typography::font(FontSize::Large, FontWeight::Regular))
6866 .color(colors::TEXT_MUTED),
6867 );
6868
6869 ui.add_space(spacing::MD);
6870
6871 ui.label(
6872 egui::RichText::new("Claude will help you create a detailed specification")
6873 .font(typography::font(FontSize::Body, FontWeight::Regular))
6874 .color(colors::TEXT_DISABLED),
6875 );
6876 });
6877 }
6878
6879 fn render_chat_message(
6884 &self,
6885 ui: &mut egui::Ui,
6886 message: &ChatMessage,
6887 max_bubble_width: f32,
6888 message_index: usize,
6889 ) {
6890 let is_user = message.sender == ChatMessageSender::User;
6891
6892 if is_user {
6894 ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
6896 self.render_message_bubble(ui, message, max_bubble_width, message_index, true);
6897 });
6898 } else {
6899 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
6901 self.render_message_bubble(ui, message, max_bubble_width, message_index, false);
6902 });
6903 }
6904 }
6905
6906 fn render_message_bubble(
6908 &self,
6909 ui: &mut egui::Ui,
6910 message: &ChatMessage,
6911 max_bubble_width: f32,
6912 message_index: usize,
6913 is_user: bool,
6914 ) {
6915 let bubble_color = if is_user {
6916 USER_BUBBLE_COLOR
6917 } else {
6918 CLAUDE_BUBBLE_COLOR
6919 };
6920
6921 let text_color = colors::TEXT_PRIMARY;
6922
6923 let font_id = typography::font(FontSize::Body, FontWeight::Regular);
6925 let content_max_width = max_bubble_width - CHAT_BUBBLE_PADDING * 2.0;
6926
6927 let mut job = egui::text::LayoutJob::single_section(
6929 message.content.clone(),
6930 egui::TextFormat {
6931 font_id: font_id.clone(),
6932 color: text_color,
6933 ..Default::default()
6934 },
6935 );
6936 job.wrap = egui::text::TextWrapping {
6937 max_width: content_max_width,
6938 ..Default::default()
6939 };
6940
6941 let galley = ui.fonts(|f| f.layout_job(job.clone()));
6943 let text_size = galley.rect.size();
6944
6945 let min_bubble_width = 50.0;
6947 let bubble_content_width = text_size.x.max(min_bubble_width).min(content_max_width);
6948
6949 let order_text = format!("#{}", message_index + 1);
6951 let order_galley = ui.fonts(|f| {
6952 f.layout_no_wrap(
6953 order_text.clone(),
6954 typography::font(FontSize::Caption, FontWeight::Regular),
6955 colors::TEXT_DISABLED,
6956 )
6957 });
6958 let order_height = order_galley.rect.height();
6959
6960 let total_content_height = text_size.y + spacing::XS + order_height;
6962
6963 let bubble_width = bubble_content_width + CHAT_BUBBLE_PADDING * 2.0;
6965 let bubble_height = total_content_height + CHAT_BUBBLE_PADDING * 2.0;
6966
6967 let (rect, _response) = ui.allocate_exact_size(
6969 egui::vec2(bubble_width, bubble_height),
6970 egui::Sense::hover(),
6971 );
6972
6973 if ui.is_rect_visible(rect) {
6974 let painter = ui.painter();
6975
6976 if !is_user {
6978 let shadow = theme::shadow::subtle();
6979 let shadow_rect = rect.translate(shadow.offset);
6980 painter.rect_filled(
6981 shadow_rect.expand(shadow.spread),
6982 Rounding::same(CHAT_BUBBLE_ROUNDING),
6983 shadow.color,
6984 );
6985 }
6986
6987 painter.rect_filled(rect, Rounding::same(CHAT_BUBBLE_ROUNDING), bubble_color);
6989
6990 if !is_user {
6992 painter.rect_stroke(
6993 rect,
6994 Rounding::same(CHAT_BUBBLE_ROUNDING),
6995 Stroke::new(1.0, colors::BORDER),
6996 );
6997 }
6998
6999 let text_pos = rect.min + egui::vec2(CHAT_BUBBLE_PADDING, CHAT_BUBBLE_PADDING);
7001 painter.galley(text_pos, galley, text_color);
7002
7003 let order_y = text_pos.y + text_size.y + spacing::XS;
7005 let order_x = if is_user {
7006 rect.max.x - CHAT_BUBBLE_PADDING - order_galley.rect.width()
7008 } else {
7009 text_pos.x
7011 };
7012 painter.galley(
7013 egui::pos2(order_x, order_y),
7014 order_galley,
7015 colors::TEXT_DISABLED,
7016 );
7017 }
7018 }
7019
7020 #[allow(dead_code)]
7025 pub fn add_chat_message(&mut self, message: ChatMessage) {
7026 self.chat_messages.push(message);
7027 self.chat_scroll_to_bottom = true;
7028 }
7029
7030 #[allow(dead_code)]
7032 pub fn add_user_message(&mut self, content: impl Into<String>) {
7033 self.add_chat_message(ChatMessage::user(content));
7034 }
7035
7036 #[allow(dead_code)]
7038 pub fn add_claude_message(&mut self, content: impl Into<String>) {
7039 self.add_chat_message(ChatMessage::claude(content));
7040 }
7041
7042 #[allow(dead_code)]
7044 pub fn clear_chat_messages(&mut self) {
7045 self.chat_messages.clear();
7046 }
7047
7048 fn spawn_claude_for_message(&mut self, user_message: &str, is_first_message: bool) {
7061 use crate::claude::extract_text_from_stream_line;
7062 use std::io::{BufRead, BufReader, Write};
7063 use std::process::{Command, Stdio};
7064
7065 self.claude_error = None;
7067 self.claude_response_in_progress = false;
7068 self.last_claude_output_time = None;
7069 self.claude_finished = false;
7070
7071 self.claude_starting = true;
7073 self.is_waiting_for_claude = true;
7074
7075 let tx = self.claude_tx.clone();
7076
7077 let prompt = if is_first_message {
7079 format!(
7081 "{}\n\n---\n\nUser's request:\n\n{}\n",
7082 crate::prompts::SPEC_SKILL_PROMPT,
7083 user_message
7084 )
7085 } else {
7086 let mut context = format!(
7088 "{}\n\n---\n\nConversation so far:\n\n",
7089 crate::prompts::SPEC_SKILL_PROMPT
7090 );
7091 for msg in &self.chat_messages {
7092 match msg.sender {
7093 ChatMessageSender::User => {
7094 context.push_str(&format!("User: {}\n\n", msg.content));
7095 }
7096 ChatMessageSender::Claude => {
7097 context.push_str(&format!("Assistant: {}\n\n", msg.content));
7098 }
7099 }
7100 }
7101 context.push_str(&format!(
7102 "User: {}\n\nPlease continue the conversation and help refine the specification.",
7103 user_message
7104 ));
7105 context
7106 };
7107
7108 let child_result = Command::new("claude")
7110 .args(["--print", "--output-format", "stream-json", "--verbose"])
7111 .stdin(Stdio::piped())
7112 .stdout(Stdio::piped())
7113 .stderr(Stdio::piped())
7114 .spawn();
7115
7116 let mut child = match child_result {
7117 Ok(child) => child,
7118 Err(e) => {
7119 let error_msg = if e.kind() == std::io::ErrorKind::NotFound {
7120 "Claude CLI not found. Please install it from https://github.com/anthropics/claude-code".to_string()
7121 } else {
7122 format!("Failed to spawn Claude: {}", e)
7123 };
7124 self.claude_error = Some(error_msg);
7125 self.is_waiting_for_claude = false;
7126 self.claude_starting = false;
7127 return;
7128 }
7129 };
7130
7131 if let Some(mut stdin) = child.stdin.take() {
7133 if let Err(e) = stdin.write_all(prompt.as_bytes()) {
7134 self.claude_error = Some(format!("Failed to write prompt to Claude: {}", e));
7135 self.is_waiting_for_claude = false;
7136 self.claude_starting = false;
7137 return;
7138 }
7139 }
7141
7142 self.claude_stdin = None;
7144
7145 let stdout = child.stdout.take();
7147 let stderr = child.stderr.take();
7148
7149 let child_handle = self.claude_child.clone();
7151 {
7152 let mut guard = child_handle.lock().unwrap();
7153 *guard = Some(child);
7154 }
7155
7156 std::thread::spawn(move || {
7158 let _ = tx.send(ClaudeMessage::Started);
7160
7161 if let Some(stdout) = stdout {
7163 let reader = BufReader::new(stdout);
7164 for line in reader.lines() {
7165 match line {
7166 Ok(json_line) => {
7167 if let Some(text) = extract_text_from_stream_line(&json_line) {
7169 let _ = tx.send(ClaudeMessage::Output(text));
7170 }
7171 }
7172 Err(_) => {
7173 break;
7175 }
7176 }
7177 }
7178 }
7179
7180 let mut stderr_content = String::new();
7182 if let Some(stderr) = stderr {
7183 let reader = BufReader::new(stderr);
7184 for text in reader.lines().take(10).flatten() {
7185 if !text.is_empty() {
7186 stderr_content.push_str(&text);
7187 stderr_content.push('\n');
7188 }
7189 }
7190 }
7191
7192 let mut guard = child_handle.lock().unwrap();
7194 if let Some(mut child) = guard.take() {
7195 match child.wait() {
7196 Ok(status) => {
7197 let success = status.success();
7198 let error = if !success {
7199 if stderr_content.is_empty() {
7200 Some(format!("Claude exited with status: {}", status))
7201 } else {
7202 Some(format!("Claude error: {}", stderr_content.trim()))
7203 }
7204 } else {
7205 None
7206 };
7207 let _ = tx.send(ClaudeMessage::Finished { success, error });
7208 }
7209 Err(e) => {
7210 let _ = tx.send(ClaudeMessage::Finished {
7211 success: false,
7212 error: Some(format!("Failed to wait for Claude: {}", e)),
7213 });
7214 }
7215 }
7216 }
7217 });
7219 }
7220
7221 fn spawn_claude_interactive(&mut self, initial_message: &str) {
7223 self.spawn_claude_for_message(initial_message, true);
7224 }
7225 fn poll_claude_messages(&mut self) {
7230 const RESPONSE_PAUSE_TIMEOUT: Duration = Duration::from_millis(1500);
7232
7233 while let Ok(msg) = self.claude_rx.try_recv() {
7235 match msg {
7236 ClaudeMessage::Spawning => {
7237 }
7240 ClaudeMessage::Started => {
7241 self.claude_starting = false;
7243 self.claude_response_in_progress = true;
7244 }
7245 ClaudeMessage::Output(text) => {
7246 if !self.claude_response_buffer.is_empty() {
7248 self.claude_response_buffer.push('\n');
7249 }
7250 self.claude_response_buffer.push_str(&text);
7251
7252 self.detect_spec_path_in_output(&text);
7254
7255 self.last_claude_output_time = Some(Instant::now());
7257 self.claude_response_in_progress = true;
7258 }
7259 ClaudeMessage::ResponsePaused => {
7260 self.flush_claude_response_buffer();
7262 self.is_waiting_for_claude = false;
7263 self.claude_response_in_progress = false;
7264 }
7265 ClaudeMessage::Finished { success, error } => {
7266 self.flush_claude_response_buffer();
7268
7269 self.is_waiting_for_claude = false;
7271 self.claude_starting = false;
7272 self.claude_response_in_progress = false;
7273 self.last_claude_output_time = None;
7274 self.claude_stdin = None;
7276
7277 if success {
7278 self.claude_finished = true;
7280 } else if let Some(err) = error {
7281 self.claude_error = Some(err);
7282 }
7283 }
7284 ClaudeMessage::SpawnError(error) => {
7285 self.claude_error = Some(error);
7287 self.is_waiting_for_claude = false;
7288 self.claude_starting = false;
7289 self.claude_response_in_progress = false;
7290 self.claude_stdin = None;
7291 }
7292 }
7293 }
7294
7295 if self.claude_stdin.is_some() && self.claude_response_in_progress {
7298 if let Some(last_output) = self.last_claude_output_time {
7299 if last_output.elapsed() >= RESPONSE_PAUSE_TIMEOUT {
7300 self.flush_claude_response_buffer();
7302 self.is_waiting_for_claude = false;
7303 self.claude_response_in_progress = false;
7304 }
7305 }
7306 }
7307 }
7308
7309 fn flush_claude_response_buffer(&mut self) {
7313 if !self.claude_response_buffer.is_empty() {
7314 let response = std::mem::take(&mut self.claude_response_buffer);
7315 self.add_claude_message(response);
7316 }
7317 }
7318
7319 fn detect_spec_path_in_output(&mut self, text: &str) {
7327 if self.generated_spec_path.is_some() {
7329 return;
7330 }
7331
7332 let is_valid_spec_path = |path_str: &str| -> bool {
7334 if !path_str.contains("/spec/spec-") || !path_str.ends_with(".md") {
7336 return false;
7337 }
7338 if path_str.chars().any(|c| c.is_control()) || path_str.len() > 500 {
7340 return false;
7341 }
7342 let filename = path_str.rsplit('/').next().unwrap_or("");
7344 if filename.contains(' ') || filename.is_empty() {
7345 return false;
7346 }
7347 true
7348 };
7349
7350 if let Some(start) = text.find("~/.config/autom8/") {
7352 if let Some(rel_end) = text[start..].find(".md") {
7353 let path_str = &text[start..start + rel_end + 3];
7354 if is_valid_spec_path(path_str) {
7355 if let Some(home) = dirs::home_dir() {
7356 let expanded = path_str.replacen("~", &home.to_string_lossy(), 1);
7357 self.generated_spec_path = Some(std::path::PathBuf::from(expanded));
7358 return;
7359 }
7360 }
7361 }
7362 }
7363
7364 for word in text.split_whitespace() {
7366 let cleaned = word.trim_matches(|c: char| {
7368 c == '"' || c == '\'' || c == '`' || c == '(' || c == ')' || c == ',' || c == ':'
7369 });
7370
7371 if cleaned.contains(".config/autom8/") && is_valid_spec_path(cleaned) {
7372 let path = std::path::PathBuf::from(cleaned);
7373 if path.is_absolute() {
7374 self.generated_spec_path = Some(path);
7375 return;
7376 } else if cleaned.starts_with('~') {
7377 if let Some(home) = dirs::home_dir() {
7378 let expanded = cleaned.replacen("~", &home.to_string_lossy(), 1);
7379 self.generated_spec_path = Some(std::path::PathBuf::from(expanded));
7380 return;
7381 }
7382 }
7383 }
7384 }
7385 }
7386
7387 #[allow(dead_code)]
7391 fn is_claude_running(&self) -> bool {
7392 self.is_waiting_for_claude
7393 }
7394
7395 fn retry_claude(&mut self) {
7401 self.claude_error = None;
7402
7403 let first_user_message = self
7405 .chat_messages
7406 .iter()
7407 .find(|m| m.sender == ChatMessageSender::User)
7408 .map(|m| m.content.clone());
7409
7410 if let Some(message) = first_user_message {
7411 self.spawn_claude_interactive(&message);
7413 }
7414 }
7415
7416 fn render_spec_completion_ui(&self, ui: &mut egui::Ui, _max_bubble_width: f32) -> (bool, bool) {
7430 let mut should_confirm = false;
7431 let mut should_start_new = false;
7432
7433 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
7434 let frame = egui::Frame::none()
7436 .fill(colors::STATUS_SUCCESS_BG)
7437 .rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
7438 .inner_margin(egui::Margin::same(CHAT_BUBBLE_PADDING))
7439 .stroke(Stroke::new(1.0, colors::STATUS_SUCCESS));
7440
7441 frame.show(ui, |ui| {
7442 ui.vertical(|ui| {
7443 if self.spec_confirmed {
7444 self.render_spec_run_command(ui);
7446
7447 ui.add_space(spacing::MD);
7448
7449 let start_new_button = egui::Button::new(
7451 egui::RichText::new("Close")
7452 .font(typography::font(FontSize::Body, FontWeight::Medium))
7453 .color(colors::SURFACE),
7454 )
7455 .fill(colors::ACCENT)
7456 .rounding(Rounding::same(spacing::SM));
7457
7458 if ui.add(start_new_button).clicked() {
7459 should_start_new = true;
7460 }
7461 } else {
7462 ui.label(
7464 egui::RichText::new("Spec Generated!")
7465 .font(typography::font(FontSize::Body, FontWeight::SemiBold))
7466 .color(colors::STATUS_SUCCESS),
7467 );
7468
7469 ui.add_space(spacing::SM);
7470
7471 if let Some(ref spec_path) = self.generated_spec_path {
7473 let path_display = spec_path.display().to_string();
7474 ui.horizontal(|ui| {
7475 ui.label(
7476 egui::RichText::new("File:")
7477 .font(typography::font(FontSize::Body, FontWeight::Medium))
7478 .color(colors::TEXT_PRIMARY),
7479 );
7480 ui.add_space(spacing::XS);
7481 let mut path_text = path_display.clone();
7483 ui.add(
7484 egui::TextEdit::singleline(&mut path_text)
7485 .font(typography::font(
7486 FontSize::Small,
7487 FontWeight::Regular,
7488 ))
7489 .text_color(colors::TEXT_SECONDARY)
7490 .frame(false)
7491 .interactive(true)
7492 .desired_width(f32::INFINITY),
7493 );
7494 });
7495 }
7496
7497 ui.add_space(spacing::MD);
7498
7499 let confirm_button = egui::Button::new(
7501 egui::RichText::new("Confirm & Get Run Command")
7502 .font(typography::font(FontSize::Body, FontWeight::Medium))
7503 .color(colors::SURFACE),
7504 )
7505 .fill(colors::STATUS_SUCCESS)
7506 .rounding(Rounding::same(spacing::SM));
7507
7508 if ui.add(confirm_button).clicked() {
7509 should_confirm = true;
7510 }
7511
7512 ui.add_space(spacing::MD);
7514 ui.label(
7515 egui::RichText::new(
7516 "Want changes? Keep chatting below to refine the spec.",
7517 )
7518 .font(typography::font(FontSize::Small, FontWeight::Regular))
7519 .color(colors::TEXT_MUTED),
7520 );
7521 }
7522 });
7523 });
7524 });
7525
7526 (should_confirm, should_start_new)
7527 }
7528
7529 fn render_spec_run_command(&self, ui: &mut egui::Ui) {
7531 ui.label(
7533 egui::RichText::new("Ready to Run!")
7534 .font(typography::font(FontSize::Body, FontWeight::SemiBold))
7535 .color(colors::STATUS_SUCCESS),
7536 );
7537
7538 ui.add_space(spacing::SM);
7539
7540 ui.label(
7542 egui::RichText::new("Open your terminal and run:")
7543 .font(typography::font(FontSize::Body, FontWeight::Regular))
7544 .color(colors::TEXT_PRIMARY),
7545 );
7546
7547 ui.add_space(spacing::SM);
7548
7549 let command = self.build_spec_run_command();
7551
7552 ui.horizontal(|ui| {
7554 let cmd_frame = egui::Frame::none()
7556 .fill(colors::SURFACE)
7557 .rounding(Rounding::same(spacing::XS))
7558 .inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
7559 .stroke(Stroke::new(1.0, colors::BORDER));
7560
7561 cmd_frame.show(ui, |ui| {
7562 let mut cmd_text = command.clone();
7564 ui.add(
7565 egui::TextEdit::singleline(&mut cmd_text)
7566 .font(egui::FontId::monospace(12.0))
7567 .text_color(colors::TEXT_PRIMARY)
7568 .frame(false)
7569 .interactive(true)
7570 .desired_width(400.0),
7571 );
7572 });
7573
7574 ui.add_space(spacing::SM);
7575
7576 let copy_button = egui::Button::new(
7578 egui::RichText::new("Copy")
7579 .font(typography::font(FontSize::Small, FontWeight::Medium)),
7580 )
7581 .fill(colors::SURFACE)
7582 .stroke(Stroke::new(1.0, colors::BORDER))
7583 .rounding(Rounding::same(spacing::XS));
7584
7585 if ui
7586 .add(copy_button)
7587 .on_hover_text("Copy to clipboard")
7588 .clicked()
7589 {
7590 ui.output_mut(|o| o.copied_text = command);
7591 }
7592 });
7593 }
7594
7595 fn build_spec_run_command(&self) -> String {
7600 let spec_path = self
7601 .generated_spec_path
7602 .as_ref()
7603 .map(|p| p.display().to_string())
7604 .unwrap_or_else(|| "<spec-path>".to_string());
7605
7606 let project_root = self.find_project_root_for_selected_project();
7608
7609 match project_root {
7610 Some(root) => format!("cd \"{}\" && autom8 \"{}\"", root.display(), spec_path),
7611 None => format!("autom8 \"{}\"", spec_path),
7612 }
7613 }
7614
7615 fn find_project_root_for_selected_project(&self) -> Option<std::path::PathBuf> {
7619 let selected_project = self.create_spec_selected_project.as_ref()?;
7620 crate::config::get_project_repo_path(selected_project)
7621 }
7622
7623 fn confirm_spec(&mut self) {
7625 self.spec_confirmed = true;
7626 self.chat_scroll_to_bottom = true;
7627 }
7628
7629 fn has_active_spec_session(&self) -> bool {
7637 !self.chat_messages.is_empty()
7638 || self.claude_stdin.is_some()
7639 || self.is_waiting_for_claude
7640 || self.generated_spec_path.is_some()
7641 }
7642
7643 fn reset_create_spec_session(&mut self) {
7653 if let Ok(mut guard) = self.claude_child.lock() {
7655 if let Some(mut child) = guard.take() {
7656 let _ = child.kill();
7658 let _ = child.wait();
7660 }
7661 }
7662
7663 if let Some(ref stdin_handle) = self.claude_stdin {
7665 stdin_handle.close();
7666 }
7667
7668 self.chat_messages.clear();
7670 self.chat_input_text.clear();
7671 self.chat_scroll_to_bottom = false;
7672
7673 self.claude_stdin = None;
7675 self.claude_response_buffer.clear();
7676 self.claude_error = None;
7677 self.is_waiting_for_claude = false;
7678 self.claude_starting = false;
7679 self.last_claude_output_time = None;
7680 self.claude_response_in_progress = false;
7681
7682 self.generated_spec_path = None;
7684 self.spec_confirmed = false;
7685 self.claude_finished = false;
7686 }
7687
7688 fn render_run_detail(&self, ui: &mut egui::Ui, run_id: &str) {
7690 ui.label(
7692 egui::RichText::new(format!("Run Details: {}", run_id))
7693 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
7694 .color(colors::TEXT_PRIMARY),
7695 );
7696
7697 ui.add_space(spacing::MD);
7698
7699 if let Some(run_state) = self.run_detail_cache.get(run_id) {
7701 self.render_run_state_details(ui, run_state);
7703 } else {
7704 egui::ScrollArea::vertical()
7706 .auto_shrink([false, false])
7707 .show(ui, |ui| {
7708 ui.add_space(spacing::XXL);
7709 ui.vertical_centered(|ui| {
7710 ui.label(
7711 egui::RichText::new("Run details not available")
7712 .font(typography::font(FontSize::Heading, FontWeight::Medium))
7713 .color(colors::TEXT_MUTED),
7714 );
7715
7716 ui.add_space(spacing::SM);
7717
7718 ui.label(
7719 egui::RichText::new(
7720 "This run may have been archived or the data is unavailable.",
7721 )
7722 .font(typography::font(FontSize::Body, FontWeight::Regular))
7723 .color(colors::TEXT_MUTED),
7724 );
7725 });
7726 });
7727 }
7728 }
7729
7730 fn render_command_output(&self, ui: &mut egui::Ui, cache_key: &str) {
7732 let execution = match self.command_executions.get(cache_key) {
7734 Some(exec) => exec,
7735 None => {
7736 egui::ScrollArea::vertical()
7738 .auto_shrink([false, false])
7739 .show(ui, |ui| {
7740 ui.add_space(spacing::XXL);
7741 ui.vertical_centered(|ui| {
7742 ui.label(
7743 egui::RichText::new("Command output not available")
7744 .font(typography::font(FontSize::Heading, FontWeight::Medium))
7745 .color(colors::TEXT_MUTED),
7746 );
7747 });
7748 });
7749 return;
7750 }
7751 };
7752
7753 self.render_command_output_header(ui, execution);
7755
7756 ui.add_space(spacing::MD);
7757
7758 self.render_command_output_content(ui, execution, cache_key);
7760 }
7761
7762 fn render_command_output_header(&self, ui: &mut egui::Ui, execution: &CommandExecution) {
7764 ui.horizontal(|ui| {
7765 let (status_text, status_color) = match execution.status {
7767 CommandStatus::Running => ("Running", colors::STATUS_RUNNING),
7768 CommandStatus::Completed => ("Completed", colors::STATUS_SUCCESS),
7769 CommandStatus::Failed => ("Failed", colors::STATUS_ERROR),
7770 };
7771
7772 let badge_galley = ui.fonts(|f| {
7773 f.layout_no_wrap(
7774 status_text.to_string(),
7775 typography::font(FontSize::Body, FontWeight::Medium),
7776 colors::TEXT_PRIMARY,
7777 )
7778 });
7779 let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
7780 let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
7781
7782 let (badge_rect, _) =
7783 ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
7784
7785 ui.painter().rect_filled(
7786 badge_rect,
7787 Rounding::same(rounding::SMALL),
7788 badge_background_color(status_color),
7789 );
7790
7791 let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
7792 ui.painter().galley(text_pos, badge_galley, status_color);
7793
7794 ui.add_space(spacing::MD);
7795
7796 if execution.status == CommandStatus::Running {
7798 self.render_inline_spinner(ui);
7799 ui.add_space(spacing::SM);
7800 }
7801
7802 ui.label(
7804 egui::RichText::new(execution.id.tab_label())
7805 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
7806 .color(colors::TEXT_PRIMARY),
7807 );
7808 });
7809
7810 if let Some(exit_code) = execution.exit_code {
7812 ui.add_space(spacing::SM);
7813 ui.horizontal(|ui| {
7814 ui.label(
7815 egui::RichText::new("Exit code:")
7816 .font(typography::font(FontSize::Body, FontWeight::Medium))
7817 .color(colors::TEXT_SECONDARY),
7818 );
7819 ui.add_space(spacing::XS);
7820
7821 let exit_color = if exit_code == 0 {
7822 colors::STATUS_SUCCESS
7823 } else {
7824 colors::STATUS_ERROR
7825 };
7826 ui.label(
7827 egui::RichText::new(exit_code.to_string())
7828 .font(typography::mono(FontSize::Body))
7829 .color(exit_color),
7830 );
7831 });
7832 }
7833 }
7834
7835 fn render_inline_spinner(&self, ui: &mut egui::Ui) {
7837 let spinner_size = 16.0;
7838 let (rect, _) = ui.allocate_exact_size(Vec2::splat(spinner_size), Sense::hover());
7839
7840 if ui.is_rect_visible(rect) {
7841 let center = rect.center();
7842 let radius = spinner_size / 2.0 - 2.0;
7843 let time = ui.input(|i| i.time);
7844 let start_angle = (time * 2.0) as f32 % std::f32::consts::TAU;
7845 let arc_length = std::f32::consts::PI * 1.5;
7846
7847 let n_points = 32;
7848 let points: Vec<_> = (0..=n_points)
7849 .map(|i| {
7850 let angle = start_angle + arc_length * (i as f32 / n_points as f32);
7851 egui::pos2(
7852 center.x + radius * angle.cos(),
7853 center.y + radius * angle.sin(),
7854 )
7855 })
7856 .collect();
7857
7858 ui.painter()
7859 .add(egui::Shape::line(points, Stroke::new(2.0, colors::ACCENT)));
7860
7861 ui.ctx().request_repaint();
7863 }
7864 }
7865
7866 fn render_command_output_content(
7868 &self,
7869 ui: &mut egui::Ui,
7870 execution: &CommandExecution,
7871 _cache_key: &str,
7872 ) {
7873 let scroll_id = egui::Id::new("command_output_scroll").with(execution.id.cache_key());
7875
7876 let scroll_area = egui::ScrollArea::vertical()
7878 .id_salt(scroll_id)
7879 .auto_shrink([false, false])
7880 .stick_to_bottom(execution.auto_scroll);
7881
7882 if execution.is_running() {
7884 ui.ctx().request_repaint();
7885 }
7886
7887 scroll_area.show(ui, |ui| {
7888 let available_rect = ui.available_rect_before_wrap();
7890 ui.painter().rect_filled(
7891 available_rect,
7892 Rounding::same(rounding::BUTTON),
7893 colors::SURFACE_HOVER,
7894 );
7895
7896 ui.add_space(spacing::SM);
7897
7898 egui::Frame::none()
7899 .inner_margin(spacing::MD)
7900 .show(ui, |ui| {
7901 if !execution.stdout.is_empty() {
7903 for line in &execution.stdout {
7904 ui.add(
7906 egui::Label::new(
7907 egui::RichText::new(line)
7908 .font(typography::mono(FontSize::Small))
7909 .color(colors::TEXT_PRIMARY),
7910 )
7911 .selectable(true)
7912 .wrap_mode(egui::TextWrapMode::Wrap),
7913 );
7914 }
7915 }
7916
7917 if !execution.stderr.is_empty() {
7919 if !execution.stdout.is_empty() {
7920 ui.add_space(spacing::SM);
7921 ui.separator();
7922 ui.add_space(spacing::SM);
7923 ui.label(
7924 egui::RichText::new("Errors:")
7925 .font(typography::font(FontSize::Small, FontWeight::Medium))
7926 .color(colors::STATUS_ERROR),
7927 );
7928 ui.add_space(spacing::XS);
7929 }
7930
7931 for line in &execution.stderr {
7932 ui.add(
7933 egui::Label::new(
7934 egui::RichText::new(line)
7935 .font(typography::mono(FontSize::Small))
7936 .color(colors::STATUS_ERROR),
7937 )
7938 .selectable(true)
7939 .wrap_mode(egui::TextWrapMode::Wrap),
7940 );
7941 }
7942 }
7943
7944 if execution.stdout.is_empty()
7946 && execution.stderr.is_empty()
7947 && execution.is_running()
7948 {
7949 ui.label(
7950 egui::RichText::new("Waiting for output...")
7951 .font(typography::font(FontSize::Body, FontWeight::Regular))
7952 .color(colors::TEXT_MUTED)
7953 .italics(),
7954 );
7955 }
7956
7957 if execution.stdout.is_empty()
7959 && execution.stderr.is_empty()
7960 && execution.is_finished()
7961 {
7962 ui.label(
7963 egui::RichText::new("Command completed with no output.")
7964 .font(typography::font(FontSize::Body, FontWeight::Regular))
7965 .color(colors::TEXT_MUTED)
7966 .italics(),
7967 );
7968 }
7969 });
7970 });
7971 }
7972
7973 fn render_run_state_details(&self, ui: &mut egui::Ui, run_state: &crate::state::RunState) {
7975 egui::ScrollArea::vertical()
7976 .auto_shrink([false, false])
7977 .show(ui, |ui| {
7978 self.render_run_summary_card(ui, run_state);
7982
7983 ui.add_space(spacing::LG);
7984 ui.separator();
7985 ui.add_space(spacing::MD);
7986
7987 ui.label(
7991 egui::RichText::new("Stories")
7992 .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
7993 .color(colors::TEXT_PRIMARY),
7994 );
7995
7996 ui.add_space(spacing::SM);
7997
7998 if run_state.iterations.is_empty() {
7999 ui.label(
8000 egui::RichText::new("No stories processed yet")
8001 .font(typography::font(FontSize::Body, FontWeight::Regular))
8002 .color(colors::TEXT_MUTED),
8003 );
8004 } else {
8005 let mut story_order: Vec<String> = Vec::new();
8007 let mut story_iterations: std::collections::HashMap<
8008 String,
8009 Vec<&crate::state::IterationRecord>,
8010 > = std::collections::HashMap::new();
8011
8012 for iter in &run_state.iterations {
8013 if !story_iterations.contains_key(&iter.story_id) {
8014 story_order.push(iter.story_id.clone());
8015 }
8016 story_iterations
8017 .entry(iter.story_id.clone())
8018 .or_default()
8019 .push(iter);
8020 }
8021
8022 for story_id in &story_order {
8024 let iterations = story_iterations.get(story_id).unwrap();
8025 self.render_story_detail_card(ui, story_id, iterations);
8026 ui.add_space(spacing::MD);
8027 }
8028 }
8029 });
8030 }
8031
8032 fn render_run_summary_card(&self, ui: &mut egui::Ui, run_state: &crate::state::RunState) {
8034 ui.horizontal(|ui| {
8036 let status_text = match run_state.status {
8038 crate::state::RunStatus::Completed => "Completed",
8039 crate::state::RunStatus::Failed => "Failed",
8040 crate::state::RunStatus::Running => "Running",
8041 crate::state::RunStatus::Interrupted => "Interrupted",
8042 };
8043 let status_color = match run_state.status {
8044 crate::state::RunStatus::Completed => colors::STATUS_SUCCESS,
8045 crate::state::RunStatus::Failed => colors::STATUS_ERROR,
8046 crate::state::RunStatus::Running => colors::STATUS_RUNNING,
8047 crate::state::RunStatus::Interrupted => colors::STATUS_WARNING,
8048 };
8049
8050 let badge_galley = ui.fonts(|f| {
8051 f.layout_no_wrap(
8052 status_text.to_string(),
8053 typography::font(FontSize::Body, FontWeight::Medium),
8054 colors::TEXT_PRIMARY,
8055 )
8056 });
8057 let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
8058 let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
8059
8060 let (badge_rect, _) =
8061 ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
8062
8063 ui.painter().rect_filled(
8064 badge_rect,
8065 Rounding::same(rounding::SMALL),
8066 badge_background_color(status_color),
8067 );
8068
8069 let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
8070 ui.painter().galley(text_pos, badge_galley, status_color);
8071
8072 ui.add_space(spacing::MD);
8073
8074 ui.label(
8076 egui::RichText::new(format!(
8077 "Run ID: {}",
8078 &run_state.run_id[..8.min(run_state.run_id.len())]
8079 ))
8080 .font(typography::font(FontSize::Small, FontWeight::Regular))
8081 .color(colors::TEXT_MUTED),
8082 );
8083 });
8084
8085 ui.add_space(spacing::MD);
8086
8087 egui::Grid::new("run_timing_grid")
8089 .num_columns(2)
8090 .spacing([spacing::LG, spacing::XS])
8091 .show(ui, |ui| {
8092 ui.label(
8094 egui::RichText::new("Start Time:")
8095 .font(typography::font(FontSize::Body, FontWeight::Medium))
8096 .color(colors::TEXT_SECONDARY),
8097 );
8098 ui.label(
8099 egui::RichText::new(
8100 run_state
8101 .started_at
8102 .with_timezone(&chrono::Local)
8103 .format("%Y-%m-%d %I:%M:%S %p")
8104 .to_string(),
8105 )
8106 .font(typography::font(FontSize::Body, FontWeight::Regular))
8107 .color(colors::TEXT_PRIMARY),
8108 );
8109 ui.end_row();
8110
8111 ui.label(
8113 egui::RichText::new("End Time:")
8114 .font(typography::font(FontSize::Body, FontWeight::Medium))
8115 .color(colors::TEXT_SECONDARY),
8116 );
8117 if let Some(finished) = run_state.finished_at {
8118 ui.label(
8119 egui::RichText::new(
8120 finished
8121 .with_timezone(&chrono::Local)
8122 .format("%Y-%m-%d %I:%M:%S %p")
8123 .to_string(),
8124 )
8125 .font(typography::font(FontSize::Body, FontWeight::Regular))
8126 .color(colors::TEXT_PRIMARY),
8127 );
8128 } else {
8129 ui.label(
8130 egui::RichText::new("In progress...")
8131 .font(typography::font(FontSize::Body, FontWeight::Regular))
8132 .color(colors::STATUS_RUNNING),
8133 );
8134 }
8135 ui.end_row();
8136
8137 ui.label(
8139 egui::RichText::new("Duration:")
8140 .font(typography::font(FontSize::Body, FontWeight::Medium))
8141 .color(colors::TEXT_SECONDARY),
8142 );
8143 let duration_str = if let Some(finished) = run_state.finished_at {
8144 let duration = finished - run_state.started_at;
8145 Self::format_duration_detailed(duration)
8146 } else {
8147 let duration = chrono::Utc::now() - run_state.started_at;
8148 format!("{} (ongoing)", Self::format_duration_detailed(duration))
8149 };
8150 ui.label(
8151 egui::RichText::new(duration_str)
8152 .font(typography::font(FontSize::Body, FontWeight::Regular))
8153 .color(colors::TEXT_PRIMARY),
8154 );
8155 ui.end_row();
8156
8157 ui.label(
8159 egui::RichText::new("Branch:")
8160 .font(typography::font(FontSize::Body, FontWeight::Medium))
8161 .color(colors::TEXT_SECONDARY),
8162 );
8163 ui.label(
8164 egui::RichText::new(&run_state.branch)
8165 .font(typography::font(FontSize::Body, FontWeight::Regular))
8166 .color(colors::ACCENT),
8167 );
8168 ui.end_row();
8169
8170 let completed_count = run_state
8172 .iterations
8173 .iter()
8174 .filter(|i| i.status == crate::state::IterationStatus::Success)
8175 .map(|i| &i.story_id)
8176 .collect::<std::collections::HashSet<_>>()
8177 .len();
8178 let total_stories = run_state
8179 .iterations
8180 .iter()
8181 .map(|i| &i.story_id)
8182 .collect::<std::collections::HashSet<_>>()
8183 .len();
8184
8185 if total_stories > 0 {
8186 ui.label(
8187 egui::RichText::new("Stories:")
8188 .font(typography::font(FontSize::Body, FontWeight::Medium))
8189 .color(colors::TEXT_SECONDARY),
8190 );
8191 ui.label(
8192 egui::RichText::new(format!(
8193 "{}/{} completed",
8194 completed_count, total_stories
8195 ))
8196 .font(typography::font(FontSize::Body, FontWeight::Regular))
8197 .color(colors::TEXT_PRIMARY),
8198 );
8199 ui.end_row();
8200 }
8201
8202 ui.label(
8204 egui::RichText::new("Total Tokens:")
8205 .font(typography::font(FontSize::Body, FontWeight::Medium))
8206 .color(colors::TEXT_SECONDARY),
8207 );
8208 if let Some(ref usage) = run_state.total_usage {
8209 let total = Self::format_tokens(usage.total_tokens());
8210 let input = Self::format_tokens(usage.input_tokens);
8211 let output = Self::format_tokens(usage.output_tokens);
8212 ui.label(
8213 egui::RichText::new(format!("{} ({} in / {} out)", total, input, output))
8214 .font(typography::font(FontSize::Body, FontWeight::Regular))
8215 .color(colors::TEXT_PRIMARY),
8216 );
8217 } else {
8218 ui.label(
8219 egui::RichText::new("N/A")
8220 .font(typography::font(FontSize::Body, FontWeight::Regular))
8221 .color(colors::TEXT_MUTED),
8222 );
8223 }
8224 ui.end_row();
8225
8226 if let Some(ref usage) = run_state.total_usage {
8228 if usage.cache_read_tokens > 0 || usage.cache_creation_tokens > 0 {
8229 ui.label(
8230 egui::RichText::new("Cache:")
8231 .font(typography::font(FontSize::Body, FontWeight::Medium))
8232 .color(colors::TEXT_SECONDARY),
8233 );
8234 let cache_read = Self::format_tokens(usage.cache_read_tokens);
8235 let cache_created = Self::format_tokens(usage.cache_creation_tokens);
8236 ui.label(
8237 egui::RichText::new(format!(
8238 "{} read / {} created",
8239 cache_read, cache_created
8240 ))
8241 .font(typography::font(FontSize::Body, FontWeight::Regular))
8242 .color(colors::TEXT_PRIMARY),
8243 );
8244 ui.end_row();
8245 }
8246
8247 if let Some(ref model) = usage.model {
8249 ui.label(
8250 egui::RichText::new("Model:")
8251 .font(typography::font(FontSize::Body, FontWeight::Medium))
8252 .color(colors::TEXT_SECONDARY),
8253 );
8254 ui.label(
8255 egui::RichText::new(model)
8256 .font(typography::font(FontSize::Body, FontWeight::Regular))
8257 .color(colors::TEXT_PRIMARY),
8258 );
8259 ui.end_row();
8260 }
8261 }
8262 });
8263
8264 let pseudo_phases = ["Planning", "Final Review", "PR & Commit"];
8266 let has_pseudo_phase_usage = pseudo_phases
8267 .iter()
8268 .any(|phase| run_state.phase_usage.contains_key(*phase));
8269
8270 if has_pseudo_phase_usage {
8271 ui.add_space(spacing::SM);
8272
8273 ui.label(
8274 egui::RichText::new("Phase Breakdown")
8275 .font(typography::font(FontSize::Small, FontWeight::SemiBold))
8276 .color(colors::TEXT_SECONDARY),
8277 );
8278
8279 ui.add_space(spacing::XS);
8280
8281 egui::Grid::new("phase_usage_grid")
8282 .num_columns(2)
8283 .spacing([spacing::LG, spacing::XS])
8284 .show(ui, |ui| {
8285 for phase in pseudo_phases {
8286 if let Some(usage) = run_state.phase_usage.get(phase) {
8287 ui.label(
8288 egui::RichText::new(format!("{}:", phase))
8289 .font(typography::font(FontSize::Small, FontWeight::Regular))
8290 .color(colors::TEXT_SECONDARY),
8291 );
8292 ui.label(
8293 egui::RichText::new(format!(
8294 "{} tokens",
8295 Self::format_tokens(usage.total_tokens())
8296 ))
8297 .font(typography::font(FontSize::Small, FontWeight::Regular))
8298 .color(colors::TEXT_PRIMARY),
8299 );
8300 ui.end_row();
8301 }
8302 }
8303 });
8304 }
8305 }
8306
8307 fn render_story_detail_card(
8309 &self,
8310 ui: &mut egui::Ui,
8311 story_id: &str,
8312 iterations: &[&crate::state::IterationRecord],
8313 ) {
8314 let last_iter = iterations.last().unwrap();
8315 let status_color = match last_iter.status {
8316 crate::state::IterationStatus::Success => colors::STATUS_SUCCESS,
8317 crate::state::IterationStatus::Failed => colors::STATUS_ERROR,
8318 crate::state::IterationStatus::Running => colors::STATUS_RUNNING,
8319 };
8320
8321 let available_width = ui.available_width();
8323 egui::Frame::none()
8324 .fill(colors::SURFACE_HOVER)
8325 .rounding(Rounding::same(rounding::CARD))
8326 .inner_margin(egui::Margin::same(spacing::MD))
8327 .show(ui, |ui| {
8328 ui.set_min_width(available_width - spacing::MD * 2.0);
8329
8330 ui.horizontal(|ui| {
8332 let (dot_rect, _) =
8334 ui.allocate_exact_size(Vec2::splat(spacing::MD), Sense::hover());
8335 ui.painter()
8336 .circle_filled(dot_rect.center(), 5.0, status_color);
8337
8338 ui.label(
8340 egui::RichText::new(story_id)
8341 .font(typography::font(FontSize::Body, FontWeight::SemiBold))
8342 .color(colors::TEXT_PRIMARY),
8343 );
8344
8345 let status_text = match last_iter.status {
8347 crate::state::IterationStatus::Success => "Success",
8348 crate::state::IterationStatus::Failed => "Failed",
8349 crate::state::IterationStatus::Running => "Running",
8350 };
8351
8352 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
8353 let badge_galley = ui.fonts(|f| {
8354 f.layout_no_wrap(
8355 status_text.to_string(),
8356 typography::font(FontSize::Small, FontWeight::Medium),
8357 status_color,
8358 )
8359 });
8360 let badge_width = badge_galley.rect.width() + spacing::SM * 2.0;
8361 let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
8362
8363 let (badge_rect, _) = ui.allocate_exact_size(
8364 Vec2::new(badge_width, badge_height),
8365 Sense::hover(),
8366 );
8367
8368 ui.painter().rect_filled(
8369 badge_rect,
8370 Rounding::same(rounding::SMALL),
8371 badge_background_color(status_color),
8372 );
8373
8374 let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
8375 ui.painter().galley(text_pos, badge_galley, status_color);
8376 });
8377 });
8378
8379 let work_summary = iterations
8381 .iter()
8382 .rev()
8383 .find_map(|iter| iter.work_summary.as_ref());
8384
8385 if let Some(summary) = work_summary {
8386 ui.add_space(spacing::SM);
8387 ui.label(
8388 egui::RichText::new(truncate_with_ellipsis(summary, 200))
8389 .font(typography::font(FontSize::Small, FontWeight::Regular))
8390 .color(colors::TEXT_SECONDARY),
8391 );
8392 }
8393
8394 if iterations.len() > 1 {
8396 ui.add_space(spacing::SM);
8397 ui.separator();
8398 ui.add_space(spacing::SM);
8399
8400 ui.label(
8401 egui::RichText::new(format!("Iterations ({} total)", iterations.len()))
8402 .font(typography::font(FontSize::Small, FontWeight::SemiBold))
8403 .color(colors::TEXT_SECONDARY),
8404 );
8405
8406 ui.add_space(spacing::XS);
8407
8408 for (idx, iter) in iterations.iter().enumerate() {
8410 let iter_status_color = match iter.status {
8411 crate::state::IterationStatus::Success => colors::STATUS_SUCCESS,
8412 crate::state::IterationStatus::Failed => colors::STATUS_ERROR,
8413 crate::state::IterationStatus::Running => colors::STATUS_RUNNING,
8414 };
8415
8416 ui.horizontal(|ui| {
8417 let (dot_rect, _) =
8419 ui.allocate_exact_size(Vec2::splat(spacing::SM), Sense::hover());
8420 ui.painter()
8421 .circle_filled(dot_rect.center(), 3.0, iter_status_color);
8422
8423 ui.label(
8425 egui::RichText::new(format!("#{}", idx + 1))
8426 .font(typography::font(FontSize::Caption, FontWeight::Medium))
8427 .color(colors::TEXT_PRIMARY),
8428 );
8429
8430 let status_str = match iter.status {
8432 crate::state::IterationStatus::Success => "Success",
8433 crate::state::IterationStatus::Failed => "Failed (review cycle)",
8434 crate::state::IterationStatus::Running => "Running",
8435 };
8436 ui.label(
8437 egui::RichText::new(status_str)
8438 .font(typography::font(FontSize::Caption, FontWeight::Regular))
8439 .color(iter_status_color),
8440 );
8441
8442 if let Some(finished) = iter.finished_at {
8444 let duration = finished - iter.started_at;
8445 let duration_str = Self::format_duration_short(duration);
8446 ui.label(
8447 egui::RichText::new(format!("({})", duration_str))
8448 .font(typography::font(
8449 FontSize::Caption,
8450 FontWeight::Regular,
8451 ))
8452 .color(colors::TEXT_MUTED),
8453 );
8454 }
8455
8456 if let Some(ref usage) = iter.usage {
8458 ui.label(
8459 egui::RichText::new(format!(
8460 "• {} tokens",
8461 Self::format_tokens(usage.total_tokens())
8462 ))
8463 .font(typography::font(FontSize::Caption, FontWeight::Regular))
8464 .color(colors::TEXT_MUTED),
8465 );
8466 }
8467 });
8468 }
8469 } else {
8470 let iter = iterations[0];
8472 ui.add_space(spacing::XS);
8473
8474 let mut info_parts = Vec::new();
8476
8477 if let Some(finished) = iter.finished_at {
8478 let duration = finished - iter.started_at;
8479 info_parts.push(format!(
8480 "Duration: {}",
8481 Self::format_duration_detailed(duration)
8482 ));
8483 }
8484
8485 if let Some(ref usage) = iter.usage {
8486 info_parts.push(format!(
8487 "Tokens: {}",
8488 Self::format_tokens(usage.total_tokens())
8489 ));
8490 }
8491
8492 if !info_parts.is_empty() {
8493 ui.label(
8494 egui::RichText::new(info_parts.join(" • "))
8495 .font(typography::font(FontSize::Small, FontWeight::Regular))
8496 .color(colors::TEXT_MUTED),
8497 );
8498 }
8499 }
8500 });
8501 }
8502
8503 fn format_duration_detailed(duration: chrono::Duration) -> String {
8505 let total_seconds = duration.num_seconds().max(0);
8506 let hours = total_seconds / 3600;
8507 let minutes = (total_seconds % 3600) / 60;
8508 let seconds = total_seconds % 60;
8509
8510 if hours > 0 {
8511 format!("{}h {}m {}s", hours, minutes, seconds)
8512 } else if minutes > 0 {
8513 format!("{}m {}s", minutes, seconds)
8514 } else {
8515 format!("{}s", seconds)
8516 }
8517 }
8518
8519 fn format_duration_short(duration: chrono::Duration) -> String {
8521 let total_seconds = duration.num_seconds().max(0);
8522 let hours = total_seconds / 3600;
8523 let minutes = (total_seconds % 3600) / 60;
8524 let seconds = total_seconds % 60;
8525
8526 if hours > 0 {
8527 format!("{}h{}m", hours, minutes)
8528 } else if minutes > 0 {
8529 format!("{}m{}s", minutes, seconds)
8530 } else {
8531 format!("{}s", seconds)
8532 }
8533 }
8534
8535 fn format_tokens(tokens: u64) -> String {
8537 let s = tokens.to_string();
8538 let mut result = String::new();
8539 for (i, c) in s.chars().rev().enumerate() {
8540 if i > 0 && i % 3 == 0 {
8541 result.push(',');
8542 }
8543 result.push(c);
8544 }
8545 result.chars().rev().collect()
8546 }
8547
8548 fn render_active_runs(&mut self, ui: &mut egui::Ui) {
8559 let available_width = ui.available_width();
8561 let available_height = ui.available_height();
8562
8563 ui.allocate_ui_with_layout(
8564 egui::vec2(available_width, available_height),
8565 egui::Layout::top_down(egui::Align::LEFT),
8566 |ui| {
8567 ui.label(
8569 egui::RichText::new("Active Runs")
8570 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
8571 .color(colors::TEXT_PRIMARY),
8572 );
8573
8574 ui.add_space(spacing::SM);
8575
8576 let visible_sessions = self.get_visible_sessions();
8578
8579 if visible_sessions.is_empty() {
8581 self.render_empty_active_runs(ui);
8582 } else {
8583 let current_selection_valid =
8586 self.selected_session_id.as_ref().is_some_and(|id| {
8587 visible_sessions
8588 .iter()
8589 .any(|s| s.metadata.session_id == *id)
8590 });
8591
8592 if !current_selection_valid {
8593 self.selected_session_id = visible_sessions
8595 .first()
8596 .map(|s| s.metadata.session_id.clone());
8597 }
8598
8599 self.render_active_session_tab_bar(ui);
8601
8602 ui.add_space(spacing::SM);
8603
8604 if let Some(selected_id) = self.selected_session_id.clone() {
8607 if let Some(session) = self.find_session_by_id(&selected_id) {
8608 self.render_expanded_session_view(ui, &session);
8609 }
8610 }
8611 }
8612 },
8613 );
8614 }
8615
8616 fn render_active_session_tab_bar(&mut self, ui: &mut egui::Ui) {
8624 let available_width = ui.available_width();
8625 let scroll_width = available_width.min(TAB_BAR_MAX_SCROLL_WIDTH);
8626
8627 let mut tab_to_select: Option<String> = None;
8629 let mut tab_to_close: Option<String> = None;
8630
8631 let visible_sessions: Vec<(String, String, Option<MachineState>)> = self
8634 .get_visible_sessions()
8635 .iter()
8636 .map(|s| {
8637 let branch_label = strip_worktree_prefix(&s.metadata.branch_name, &s.project_name);
8638 let state = s.run.as_ref().map(|r| r.machine_state);
8639 (s.metadata.session_id.clone(), branch_label, state)
8640 })
8641 .collect();
8642
8643 ui.allocate_ui_with_layout(
8644 egui::vec2(available_width, CONTENT_TAB_BAR_HEIGHT),
8645 egui::Layout::left_to_right(egui::Align::Center),
8646 |ui| {
8647 egui::ScrollArea::horizontal()
8648 .max_width(scroll_width)
8649 .auto_shrink([false, false])
8650 .scroll_bar_visibility(
8651 egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
8652 )
8653 .show(ui, |ui| {
8654 ui.horizontal_centered(|ui| {
8655 ui.add_space(spacing::XS);
8656
8657 for (session_id, branch_label, state) in &visible_sessions {
8658 let is_active = self
8659 .selected_session_id
8660 .as_ref()
8661 .is_some_and(|id| id == session_id);
8662
8663 let (tab_clicked, close_clicked) = self.render_active_session_tab(
8664 ui,
8665 branch_label,
8666 is_active,
8667 *state,
8668 );
8669
8670 if tab_clicked {
8671 tab_to_select = Some(session_id.clone());
8672 }
8673 if close_clicked {
8674 tab_to_close = Some(session_id.clone());
8675 }
8676 ui.add_space(spacing::XS);
8677 }
8678 });
8679 });
8680 },
8681 );
8682
8683 if let Some(session_id) = tab_to_select {
8685 self.selected_session_id = Some(session_id);
8686 }
8687
8688 if let Some(session_id) = tab_to_close {
8690 self.close_session_tab(&session_id);
8691 }
8692 }
8693
8694 fn close_session_tab(&mut self, session_id: &str) {
8696 self.closed_session_tabs.insert(session_id.to_string());
8698
8699 self.seen_sessions.remove(session_id);
8701
8702 if self
8704 .selected_session_id
8705 .as_ref()
8706 .is_some_and(|id| id == session_id)
8707 {
8708 self.selected_session_id = None;
8709 }
8710 }
8711
8712 fn render_active_session_tab(
8719 &self,
8720 ui: &mut egui::Ui,
8721 label: &str,
8722 is_active: bool,
8723 state: Option<MachineState>,
8724 ) -> (bool, bool) {
8725 let show_close_button = state.is_none_or(is_terminal_state);
8728
8729 let text_galley = ui.fonts(|f| {
8731 f.layout_no_wrap(
8732 label.to_string(),
8733 typography::font(FontSize::Body, FontWeight::Medium),
8734 colors::TEXT_PRIMARY,
8735 )
8736 });
8737 let text_size = text_galley.size();
8738
8739 let status_dot_radius = 4.0;
8741 let status_dot_spacing = spacing::SM;
8742 let status_dot_space = status_dot_radius * 2.0 + status_dot_spacing;
8743
8744 let close_button_space = if show_close_button {
8748 TAB_LABEL_CLOSE_GAP + TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING
8749 } else {
8750 0.0
8751 };
8752 let tab_width = status_dot_space + text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
8753 let tab_height = CONTENT_TAB_BAR_HEIGHT - TAB_UNDERLINE_HEIGHT - spacing::XS;
8754 let tab_size = egui::vec2(tab_width, tab_height);
8755
8756 let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
8758 let is_hovered = response.hovered();
8759
8760 let bg_color = if is_active {
8762 colors::SURFACE_SELECTED
8763 } else if is_hovered {
8764 colors::SURFACE_HOVER
8765 } else {
8766 Color32::TRANSPARENT
8767 };
8768
8769 if bg_color != Color32::TRANSPARENT {
8770 ui.painter()
8771 .rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
8772 }
8773
8774 let status_color = state.map(state_to_color).unwrap_or(colors::STATUS_IDLE);
8777 let indicator_center = egui::pos2(
8778 rect.left() + TAB_PADDING_H + status_dot_radius,
8779 rect.center().y,
8780 );
8781
8782 let is_terminal = state.is_none_or(is_terminal_state);
8784 if is_terminal {
8785 let check_size = status_dot_radius * 0.9; let stroke = Stroke::new(2.0, status_color);
8789
8790 let start = egui::pos2(indicator_center.x - check_size, indicator_center.y);
8793 let mid = egui::pos2(
8794 indicator_center.x - check_size * 0.3,
8795 indicator_center.y + check_size * 0.7,
8796 );
8797 let end = egui::pos2(
8798 indicator_center.x + check_size,
8799 indicator_center.y - check_size * 0.6,
8800 );
8801
8802 ui.painter().line_segment([start, mid], stroke);
8803 ui.painter().line_segment([mid, end], stroke);
8804 } else {
8805 ui.painter()
8807 .circle_filled(indicator_center, status_dot_radius, status_color);
8808 }
8809
8810 let text_color = if is_active {
8812 colors::TEXT_PRIMARY
8813 } else if is_hovered {
8814 colors::TEXT_SECONDARY
8815 } else {
8816 colors::TEXT_MUTED
8817 };
8818
8819 let text_x = rect.left() + TAB_PADDING_H + status_dot_space;
8820 let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
8821
8822 ui.painter().galley(
8823 text_pos,
8824 ui.fonts(|f| {
8825 f.layout_no_wrap(
8826 label.to_string(),
8827 typography::font(
8828 FontSize::Body,
8829 if is_active {
8830 FontWeight::SemiBold
8831 } else {
8832 FontWeight::Medium
8833 },
8834 ),
8835 text_color,
8836 )
8837 }),
8838 Color32::TRANSPARENT,
8839 );
8840
8841 let close_hovered = if show_close_button {
8843 let close_rect = Rect::from_min_size(
8844 egui::pos2(
8845 rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
8846 rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
8847 ),
8848 egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
8849 );
8850
8851 let hovered = ui
8853 .ctx()
8854 .input(|i| i.pointer.hover_pos())
8855 .is_some_and(|pos| close_rect.contains(pos));
8856
8857 if hovered {
8859 ui.painter().rect_filled(
8860 close_rect,
8861 Rounding::same(rounding::SMALL),
8862 colors::SURFACE_HOVER,
8863 );
8864 }
8865
8866 let x_color = if hovered {
8868 colors::TEXT_PRIMARY
8869 } else {
8870 colors::TEXT_MUTED
8871 };
8872 let x_center = close_rect.center();
8873 let x_size = TAB_CLOSE_BUTTON_SIZE * 0.3;
8874
8875 ui.painter().line_segment(
8876 [
8877 egui::pos2(x_center.x - x_size, x_center.y - x_size),
8878 egui::pos2(x_center.x + x_size, x_center.y + x_size),
8879 ],
8880 Stroke::new(1.5, x_color),
8881 );
8882 ui.painter().line_segment(
8883 [
8884 egui::pos2(x_center.x + x_size, x_center.y - x_size),
8885 egui::pos2(x_center.x - x_size, x_center.y + x_size),
8886 ],
8887 Stroke::new(1.5, x_color),
8888 );
8889
8890 hovered
8891 } else {
8892 false
8894 };
8895
8896 if is_active {
8898 let underline_rect = egui::Rect::from_min_size(
8899 egui::pos2(rect.left(), rect.bottom()),
8900 egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
8901 );
8902 ui.painter()
8903 .rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
8904 }
8905
8906 let close_clicked = response.clicked() && close_hovered;
8909 let tab_clicked = response.clicked() && !close_hovered;
8910
8911 (tab_clicked, close_clicked)
8912 }
8913
8914 fn render_expanded_session_view(&mut self, ui: &mut egui::Ui, session: &SessionData) {
8924 let available_width = ui.available_width();
8925 let available_height = ui.available_height();
8926
8927 let content_padding = spacing::LG;
8929 let content_width = available_width - content_padding * 2.0;
8930 let section_gap = spacing::LG;
8931
8932 egui::ScrollArea::vertical()
8934 .id_salt(format!("expanded_view_{}", session.metadata.session_id))
8935 .auto_shrink([false, false])
8936 .show(ui, |ui| {
8937 ui.add_space(content_padding);
8938
8939 ui.horizontal(|ui| {
8940 ui.add_space(content_padding);
8941
8942 ui.vertical(|ui| {
8943 ui.set_width(content_width);
8944
8945 let branch_display = strip_worktree_prefix(
8947 &session.metadata.branch_name,
8948 &session.project_name,
8949 );
8950
8951 ui.horizontal(|ui| {
8952 ui.label(
8954 egui::RichText::new(&branch_display)
8955 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
8956 .color(colors::TEXT_PRIMARY),
8957 );
8958
8959 ui.add_space(spacing::MD);
8960
8961 let badge_text = if session.is_main_session {
8963 "main"
8964 } else {
8965 &session.metadata.session_id
8966 };
8967 let badge_color = if session.is_main_session {
8968 colors::ACCENT
8969 } else {
8970 colors::TEXT_SECONDARY
8971 };
8972 let badge_bg = if session.is_main_session {
8973 colors::ACCENT_SUBTLE
8974 } else {
8975 colors::SURFACE_HOVER
8976 };
8977
8978 egui::Frame::none()
8979 .fill(badge_bg)
8980 .rounding(rounding::SMALL)
8981 .inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
8982 .show(ui, |ui| {
8983 ui.label(
8984 egui::RichText::new(badge_text)
8985 .font(typography::font(
8986 FontSize::Small,
8987 FontWeight::Medium,
8988 ))
8989 .color(badge_color),
8990 );
8991 });
8992 });
8993
8994 ui.add_space(spacing::XS);
8995
8996 ui.label(
8998 egui::RichText::new(&session.project_name)
8999 .font(typography::font(FontSize::Body, FontWeight::Regular))
9000 .color(colors::TEXT_MUTED),
9001 );
9002
9003 ui.add_space(spacing::MD);
9004
9005 let appears_stuck = session.appears_stuck();
9007 let (state, state_color) = if let Some(ref run) = session.run {
9008 let base_color = state_to_color(run.machine_state);
9009 let color = if appears_stuck {
9010 colors::STATUS_WARNING
9011 } else {
9012 base_color
9013 };
9014 (run.machine_state, color)
9015 } else {
9016 (MachineState::Idle, colors::STATUS_IDLE)
9017 };
9018
9019 ui.horizontal(|ui| {
9020 let dot_size = 8.0;
9022 let (rect, _) = ui.allocate_exact_size(
9023 egui::vec2(dot_size, dot_size),
9024 Sense::hover(),
9025 );
9026 ui.painter()
9027 .circle_filled(rect.center(), dot_size / 2.0, state_color);
9028
9029 ui.add_space(spacing::SM);
9030
9031 let state_text = if appears_stuck {
9033 format!("{} (Not responding)", format_state(state))
9034 } else {
9035 format_state(state).to_string()
9036 };
9037 ui.label(
9038 egui::RichText::new(state_text)
9039 .font(typography::font(FontSize::Body, FontWeight::Medium))
9040 .color(colors::TEXT_PRIMARY),
9041 );
9042
9043 if let Some(ref progress) = session.progress {
9045 ui.add_space(spacing::MD);
9046 ui.label(
9047 egui::RichText::new(progress.as_fraction())
9048 .font(typography::font(FontSize::Body, FontWeight::Regular))
9049 .color(colors::TEXT_SECONDARY),
9050 );
9051
9052 if let Some(ref run) = session.run {
9054 if let Some(ref story_id) = run.current_story {
9055 ui.add_space(spacing::SM);
9056 ui.label(
9057 egui::RichText::new(story_id)
9058 .font(typography::font(
9059 FontSize::Body,
9060 FontWeight::Regular,
9061 ))
9062 .color(colors::TEXT_MUTED),
9063 );
9064 }
9065 }
9066 }
9067
9068 if let Some(ref run) = session.run {
9070 ui.add_space(spacing::MD);
9071 ui.label(
9072 egui::RichText::new(format_run_duration(
9073 run.started_at,
9074 run.finished_at,
9075 ))
9076 .font(typography::font(FontSize::Body, FontWeight::Regular))
9077 .color(colors::TEXT_MUTED),
9078 );
9079 }
9080
9081 if state != MachineState::Idle
9083 && !is_terminal_state(state)
9084 && session.progress.is_some()
9085 {
9086 ui.add_space(spacing::MD);
9087
9088 let max_animation_width = (content_width / 3.0).min(150.0);
9090
9091 if max_animation_width > 30.0 {
9092 let animation_height = 12.0;
9093 let (rect, _) = ui.allocate_exact_size(
9094 egui::vec2(max_animation_width, animation_height),
9095 Sense::hover(),
9096 );
9097 let time = ui.ctx().input(|i| i.time) as f32;
9098 super::animation::render_infinity(
9099 ui.painter(),
9100 time,
9101 rect,
9102 state_color,
9103 1.0,
9104 );
9105 super::animation::schedule_frame(ui.ctx());
9106 }
9107 }
9108 });
9109
9110 ui.add_space(spacing::LG);
9111
9112 ui.label(
9115 egui::RichText::new("Output")
9116 .font(typography::font(FontSize::Body, FontWeight::Medium))
9117 .color(colors::TEXT_SECONDARY),
9118 );
9119
9120 ui.add_space(spacing::SM);
9121
9122 let output_height = (available_height * 0.4).max(200.0);
9124 egui::Frame::none()
9125 .fill(colors::SURFACE_HOVER)
9126 .rounding(rounding::CARD)
9127 .inner_margin(egui::Margin::same(spacing::MD))
9128 .show(ui, |ui| {
9129 ui.set_min_height(output_height);
9130 ui.set_max_height(output_height);
9131 ui.set_width(content_width - spacing::MD * 2.0);
9132
9133 egui::ScrollArea::vertical()
9134 .id_salt(format!("output_{}", session.metadata.session_id))
9135 .auto_shrink([false, false])
9136 .stick_to_bottom(true)
9137 .show(ui, |ui| {
9138 let output_source = get_output_for_session(session);
9139 Self::render_output_content(ui, &output_source);
9140 });
9141 });
9142
9143 ui.add_space(section_gap);
9145
9146 let story_items = load_story_items(session);
9148 Self::render_stories_section(
9149 ui,
9150 &session.metadata.session_id,
9151 &story_items,
9152 content_width,
9153 &mut self.section_collapsed_state,
9154 );
9155
9156 ui.add_space(content_padding);
9158 });
9159 });
9160 });
9161 }
9162
9163 fn render_story_items_content(ui: &mut egui::Ui, story_items: &[StoryItem]) {
9165 if story_items.is_empty() {
9166 ui.label(
9167 egui::RichText::new("No stories found")
9168 .font(typography::font(FontSize::Body, FontWeight::Regular))
9169 .color(colors::TEXT_DISABLED),
9170 );
9171 } else {
9172 for (index, story) in story_items.iter().enumerate() {
9173 if index > 0 {
9174 ui.add_space(spacing::SM);
9175 }
9176
9177 let is_active = story.status == StoryStatus::Active;
9178
9179 egui::Frame::none()
9180 .fill(story.status.background())
9181 .rounding(rounding::SMALL)
9182 .inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
9183 .show(ui, |ui| {
9184 ui.horizontal(|ui| {
9185 ui.label(
9186 egui::RichText::new(story.status.indicator())
9187 .font(typography::font(FontSize::Body, FontWeight::Medium))
9188 .color(story.status.color()),
9189 );
9190
9191 ui.add_space(spacing::SM);
9192
9193 let id_weight = if is_active {
9194 FontWeight::SemiBold
9195 } else {
9196 FontWeight::Medium
9197 };
9198 let id_color = if is_active {
9199 colors::ACCENT
9200 } else {
9201 colors::TEXT_PRIMARY
9202 };
9203 ui.label(
9204 egui::RichText::new(&story.id)
9205 .font(typography::font(FontSize::Small, id_weight))
9206 .color(id_color),
9207 );
9208 });
9209
9210 let title_weight = if is_active {
9211 FontWeight::Medium
9212 } else {
9213 FontWeight::Regular
9214 };
9215 let title_color = if is_active {
9216 colors::TEXT_PRIMARY
9217 } else {
9218 colors::TEXT_SECONDARY
9219 };
9220 ui.label(
9221 egui::RichText::new(&story.title)
9222 .font(typography::font(FontSize::Small, title_weight))
9223 .color(title_color),
9224 );
9225
9226 if let Some(ref summary) = story.work_summary {
9228 ui.add_space(spacing::XS);
9229 ui.label(
9230 egui::RichText::new(summary)
9231 .font(typography::font(FontSize::Small, FontWeight::Regular))
9232 .color(colors::TEXT_MUTED),
9233 );
9234 }
9235 });
9236 }
9237 }
9238 }
9239
9240 fn render_output_content(ui: &mut egui::Ui, output_source: &OutputSource) {
9242 match output_source {
9243 OutputSource::Live(lines) | OutputSource::Iteration(lines) => {
9244 for line in lines {
9245 ui.label(
9246 egui::RichText::new(line.trim())
9247 .font(typography::mono(FontSize::Small))
9248 .color(colors::TEXT_SECONDARY),
9249 );
9250 }
9251 }
9252 OutputSource::StatusMessage(message) => {
9253 ui.label(
9254 egui::RichText::new(message)
9255 .font(typography::mono(FontSize::Small))
9256 .color(colors::TEXT_DISABLED),
9257 );
9258 }
9259 OutputSource::NoData => {
9260 ui.label(
9261 egui::RichText::new("No live output")
9262 .font(typography::mono(FontSize::Small))
9263 .color(colors::TEXT_DISABLED),
9264 );
9265 }
9266 }
9267 }
9268
9269 fn render_stories_section(
9271 ui: &mut egui::Ui,
9272 session_id: &str,
9273 story_items: &[StoryItem],
9274 panel_width: f32,
9275 collapsed_state: &mut std::collections::HashMap<String, bool>,
9276 ) {
9277 let stories_id = format!("{}_stories", session_id);
9279
9280 CollapsibleSection::new(&stories_id, "Stories")
9282 .default_expanded(true)
9283 .show(ui, collapsed_state, |ui| {
9284 egui::Frame::none()
9285 .fill(colors::SURFACE_HOVER)
9286 .rounding(rounding::CARD)
9287 .inner_margin(egui::Margin::same(spacing::MD))
9288 .show(ui, |ui| {
9289 ui.set_width(panel_width - spacing::MD * 2.0);
9290 Self::render_story_items_content(ui, story_items);
9291 });
9292 });
9293 }
9294
9295 fn render_empty_active_runs(&self, ui: &mut egui::Ui) {
9297 ui.add_space(spacing::XXL);
9298
9299 ui.vertical_centered(|ui| {
9301 ui.add_space(spacing::XXL + spacing::LG);
9302
9303 ui.label(
9304 egui::RichText::new("No active runs")
9305 .font(typography::font(FontSize::Heading, FontWeight::Medium))
9306 .color(colors::TEXT_MUTED),
9307 );
9308
9309 ui.add_space(spacing::SM);
9310
9311 ui.label(
9312 egui::RichText::new("Run autom8 to start implementing a feature")
9313 .font(typography::font(FontSize::Body, FontWeight::Regular))
9314 .color(colors::TEXT_MUTED),
9315 );
9316 });
9317 }
9318
9319 fn render_projects(&mut self, ui: &mut egui::Ui) {
9324 let available_width = ui.available_width();
9326 let available_height = ui.available_height();
9327
9328 let divider_total_width = SPLIT_DIVIDER_WIDTH + SPLIT_DIVIDER_MARGIN * 2.0;
9331 let panel_width =
9332 ((available_width - divider_total_width) / 2.0).max(SPLIT_PANEL_MIN_WIDTH);
9333
9334 let mut clicked_run_id: Option<String> = None;
9336
9337 ui.horizontal(|ui| {
9338 ui.allocate_ui_with_layout(
9340 Vec2::new(panel_width, available_height),
9341 egui::Layout::top_down(egui::Align::LEFT),
9342 |ui| {
9343 self.render_projects_left_panel(ui);
9344 },
9345 );
9346
9347 ui.add_space(SPLIT_DIVIDER_MARGIN);
9349
9350 let divider_rect = ui.available_rect_before_wrap();
9352 let divider_line_rect = Rect::from_min_size(
9353 divider_rect.min,
9354 Vec2::new(SPLIT_DIVIDER_WIDTH, available_height),
9355 );
9356 ui.painter()
9357 .rect_filled(divider_line_rect, Rounding::ZERO, colors::SEPARATOR);
9358 ui.add_space(SPLIT_DIVIDER_WIDTH);
9359
9360 ui.add_space(SPLIT_DIVIDER_MARGIN);
9361
9362 ui.allocate_ui_with_layout(
9364 Vec2::new(ui.available_width(), available_height),
9365 egui::Layout::top_down(egui::Align::LEFT),
9366 |ui| {
9367 clicked_run_id = self.render_projects_right_panel(ui);
9368 },
9369 );
9370 });
9371
9372 if let Some(run_id) = clicked_run_id {
9374 if let Some(entry) = self.run_history.iter().find(|e| e.run_id == run_id) {
9376 let entry_clone = entry.clone();
9377
9378 if let Some(ref project_name) = self.selected_project {
9380 let run_state = StateManager::for_project(project_name).ok().and_then(|sm| {
9381 sm.list_archived()
9382 .ok()
9383 .and_then(|runs| runs.into_iter().find(|r| r.run_id == run_id))
9384 });
9385
9386 self.open_run_detail_from_entry(&entry_clone, run_state);
9387 }
9388 }
9389 }
9390 }
9391
9392 fn render_projects_left_panel(&mut self, ui: &mut egui::Ui) {
9394 ui.label(
9396 egui::RichText::new("Projects")
9397 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
9398 .color(colors::TEXT_PRIMARY),
9399 );
9400
9401 ui.add_space(spacing::SM);
9402
9403 if self.projects.is_empty() {
9405 self.render_empty_projects(ui);
9406 } else {
9407 self.render_projects_list(ui);
9408 }
9409 }
9410
9411 fn render_projects_right_panel(&self, ui: &mut egui::Ui) -> Option<String> {
9415 let mut clicked_run_id: Option<String> = None;
9416
9417 if let Some(ref selected_name) = self.selected_project {
9418 ui.label(
9420 egui::RichText::new(format!("Run History: {}", selected_name))
9421 .font(typography::font(FontSize::Title, FontWeight::SemiBold))
9422 .color(colors::TEXT_PRIMARY),
9423 );
9424
9425 ui.add_space(spacing::MD);
9426
9427 if let Some(ref error) = self.run_history_error {
9429 self.render_run_history_error(ui, error);
9430 } else if self.run_history_loading {
9431 self.render_run_history_loading(ui);
9433 } else if self.run_history.is_empty() {
9434 self.render_run_history_empty(ui);
9436 } else {
9437 egui::ScrollArea::vertical()
9439 .id_salt("projects_right_panel")
9440 .auto_shrink([false, false])
9441 .scroll_bar_visibility(
9442 egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
9443 )
9444 .show(ui, |ui| {
9445 for entry in &self.run_history {
9446 if self.render_run_history_entry(ui, entry) {
9447 clicked_run_id = Some(entry.run_id.clone());
9448 }
9449 ui.add_space(spacing::SM);
9450 }
9451 });
9452 }
9453 } else {
9454 self.render_no_project_selected(ui);
9456 }
9457
9458 clicked_run_id
9459 }
9460
9461 fn render_run_history_loading(&self, ui: &mut egui::Ui) {
9463 ui.add_space(spacing::LG);
9464 ui.vertical_centered(|ui| {
9465 let spinner_size = 24.0;
9467 let (rect, _) = ui.allocate_exact_size(Vec2::splat(spinner_size), egui::Sense::hover());
9468
9469 if ui.is_rect_visible(rect) {
9470 let center = rect.center();
9472 let radius = spinner_size / 2.0 - 2.0;
9473 let time = ui.input(|i| i.time);
9474 let start_angle = (time * 2.0) as f32 % std::f32::consts::TAU;
9475 let arc_length = std::f32::consts::PI * 1.5;
9476
9477 let n_points = 32;
9479 let points: Vec<_> = (0..=n_points)
9480 .map(|i| {
9481 let angle = start_angle + (i as f32 / n_points as f32) * arc_length;
9482 egui::pos2(
9483 center.x + radius * angle.cos(),
9484 center.y + radius * angle.sin(),
9485 )
9486 })
9487 .collect();
9488
9489 ui.painter()
9490 .add(egui::Shape::line(points, Stroke::new(2.5, colors::ACCENT)));
9491
9492 ui.ctx().request_repaint();
9494 }
9495
9496 ui.add_space(spacing::SM);
9497
9498 ui.label(
9499 egui::RichText::new("Loading run history...")
9500 .font(typography::font(FontSize::Body, FontWeight::Regular))
9501 .color(colors::TEXT_MUTED),
9502 );
9503 });
9504 }
9505
9506 fn render_run_history_error(&self, ui: &mut egui::Ui, error: &str) {
9508 ui.add_space(spacing::LG);
9509 ui.vertical_centered(|ui| {
9510 ui.label(
9511 egui::RichText::new("Failed to load run history")
9512 .font(typography::font(FontSize::Body, FontWeight::Medium))
9513 .color(colors::STATUS_ERROR),
9514 );
9515
9516 ui.add_space(spacing::XS);
9517
9518 ui.label(
9519 egui::RichText::new(truncate_with_ellipsis(error, 60))
9520 .font(typography::font(FontSize::Small, FontWeight::Regular))
9521 .color(colors::TEXT_MUTED),
9522 );
9523 });
9524 }
9525
9526 fn render_run_history_empty(&self, ui: &mut egui::Ui) {
9528 ui.add_space(spacing::XXL);
9529 ui.vertical_centered(|ui| {
9530 ui.add_space(spacing::LG);
9531
9532 ui.label(
9533 egui::RichText::new("No run history")
9534 .font(typography::font(FontSize::Heading, FontWeight::Medium))
9535 .color(colors::TEXT_MUTED),
9536 );
9537
9538 ui.add_space(spacing::SM);
9539
9540 ui.label(
9541 egui::RichText::new("Completed runs will appear here")
9542 .font(typography::font(FontSize::Body, FontWeight::Regular))
9543 .color(colors::TEXT_MUTED),
9544 );
9545 });
9546 }
9547
9548 fn render_no_project_selected(&self, ui: &mut egui::Ui) {
9550 ui.add_space(spacing::XXL);
9551 ui.vertical_centered(|ui| {
9552 ui.label(
9553 egui::RichText::new("Select a project")
9554 .font(typography::font(FontSize::Heading, FontWeight::Medium))
9555 .color(colors::TEXT_MUTED),
9556 );
9557
9558 ui.add_space(spacing::SM);
9559
9560 ui.label(
9561 egui::RichText::new("Click on a project to view its run history")
9562 .font(typography::font(FontSize::Body, FontWeight::Regular))
9563 .color(colors::TEXT_MUTED),
9564 );
9565 });
9566 }
9567
9568 fn render_run_history_entry(&self, ui: &mut egui::Ui, entry: &RunHistoryEntry) -> bool {
9571 let available_width = ui.available_width();
9573 let card_height = 72.0; let (rect, response) =
9576 ui.allocate_exact_size(Vec2::new(available_width, card_height), Sense::click());
9577
9578 let is_hovered = response.hovered();
9579
9580 let bg_color = if is_hovered {
9583 colors::SURFACE_HOVER
9584 } else {
9585 colors::SURFACE
9586 };
9587
9588 let border = if is_hovered {
9590 Stroke::new(1.0, colors::BORDER_FOCUSED)
9591 } else {
9592 Stroke::new(1.0, colors::BORDER)
9593 };
9594
9595 ui.painter()
9596 .rect(rect, Rounding::same(rounding::CARD), bg_color, border);
9597
9598 let inner_rect = rect.shrink(spacing::MD);
9600 let mut child_ui = ui.new_child(
9601 egui::UiBuilder::new()
9602 .max_rect(inner_rect)
9603 .layout(egui::Layout::top_down(egui::Align::LEFT)),
9604 );
9605
9606 child_ui.horizontal(|ui| {
9608 let datetime_text = entry
9610 .started_at
9611 .with_timezone(&chrono::Local)
9612 .format("%Y-%m-%d %I:%M %p")
9613 .to_string();
9614 ui.label(
9615 egui::RichText::new(datetime_text)
9616 .font(typography::font(FontSize::Body, FontWeight::Medium))
9617 .color(colors::TEXT_PRIMARY),
9618 );
9619
9620 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
9621 let status_color = entry.status_color();
9623 let status_text = entry.status_text();
9624
9625 let badge_galley = ui.fonts(|f| {
9627 f.layout_no_wrap(
9628 status_text.to_string(),
9629 typography::font(FontSize::Small, FontWeight::Medium),
9630 colors::TEXT_PRIMARY,
9631 )
9632 });
9633 let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
9634 let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
9635
9636 let (badge_rect, _) =
9637 ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
9638
9639 ui.painter().rect_filled(
9640 badge_rect,
9641 Rounding::same(rounding::SMALL),
9642 badge_background_color(status_color),
9643 );
9644
9645 let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
9647 ui.painter().galley(text_pos, badge_galley, status_color);
9648 });
9649 });
9650
9651 child_ui.add_space(spacing::XS);
9652
9653 child_ui.horizontal(|ui| {
9655 ui.label(
9657 egui::RichText::new(entry.story_count_text())
9658 .font(typography::font(FontSize::Small, FontWeight::Regular))
9659 .color(colors::TEXT_SECONDARY),
9660 );
9661
9662 ui.add_space(spacing::MD);
9663
9664 let branch_display = truncate_with_ellipsis(&entry.branch, MAX_BRANCH_LENGTH);
9666 ui.label(
9667 egui::RichText::new(format!("⎇ {}", branch_display))
9668 .font(typography::font(FontSize::Small, FontWeight::Regular))
9669 .color(colors::TEXT_MUTED),
9670 );
9671 });
9672
9673 response.clicked()
9674 }
9675
9676 fn render_empty_projects(&self, ui: &mut egui::Ui) {
9678 ui.add_space(spacing::XXL);
9679
9680 ui.vertical_centered(|ui| {
9682 ui.add_space(spacing::XXL + spacing::LG);
9683
9684 ui.label(
9685 egui::RichText::new("No projects found")
9686 .font(typography::font(FontSize::Heading, FontWeight::Medium))
9687 .color(colors::TEXT_MUTED),
9688 );
9689
9690 ui.add_space(spacing::SM);
9691
9692 ui.label(
9693 egui::RichText::new("Projects will appear here after running autom8")
9694 .font(typography::font(FontSize::Body, FontWeight::Regular))
9695 .color(colors::TEXT_MUTED),
9696 );
9697 });
9698 }
9699
9700 fn render_projects_list(&mut self, ui: &mut egui::Ui) {
9702 let project_names: Vec<String> =
9704 self.projects.iter().map(|p| p.info.name.clone()).collect();
9705 let selected = self.selected_project.clone();
9706
9707 let mut interactions: Vec<(String, ProjectRowInteraction)> = Vec::new();
9709
9710 egui::ScrollArea::vertical()
9711 .id_salt("projects_left_panel")
9712 .auto_shrink([false, false])
9713 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
9714 .show(ui, |ui| {
9715 for (idx, project_name) in project_names.iter().enumerate() {
9716 let project = &self.projects[idx];
9717 let is_selected = selected.as_deref() == Some(project_name.as_str());
9718 let interaction = self.render_project_row(ui, project, is_selected);
9719 if interaction.clicked || interaction.right_click_pos.is_some() {
9720 interactions.push((project_name.clone(), interaction));
9721 }
9722 ui.add_space(spacing::XS);
9723 }
9724 });
9725
9726 for (project_name, interaction) in interactions {
9728 if interaction.clicked {
9729 self.toggle_project_selection(&project_name);
9731 } else if let Some(pos) = interaction.right_click_pos {
9732 self.open_context_menu(pos, project_name);
9734 }
9735 }
9736 }
9737
9738 fn count_active_sessions_for_project(&self, project_name: &str) -> usize {
9740 self.sessions
9741 .iter()
9742 .filter(|s| s.project_name == project_name && !s.is_stale)
9743 .count()
9744 }
9745
9746 fn project_status_color(&self, project: &ProjectData) -> Color32 {
9749 if let Some(ref error) = project.load_error {
9750 if !error.is_empty() {
9752 return colors::STATUS_ERROR;
9753 }
9754 }
9755
9756 if project.info.has_active_run {
9757 colors::STATUS_RUNNING
9758 } else {
9759 colors::STATUS_IDLE
9760 }
9761 }
9762
9763 fn project_status_text(&self, project: &ProjectData) -> String {
9766 if let Some(ref error) = project.load_error {
9768 if !error.is_empty() {
9769 return truncate_with_ellipsis(error, 30);
9770 }
9771 }
9772
9773 let active_count = self.count_active_sessions_for_project(&project.info.name);
9775
9776 if active_count > 1 {
9777 format!("{} sessions active", active_count)
9778 } else if project.info.has_active_run || active_count == 1 {
9779 "Running".to_string()
9780 } else if let Some(last_run) = project.info.last_run_date {
9781 format!("Last run: {}", format_relative_time(last_run))
9782 } else {
9783 "Idle".to_string()
9784 }
9785 }
9786
9787 fn render_project_row(
9790 &self,
9791 ui: &mut egui::Ui,
9792 project: &ProjectData,
9793 is_selected: bool,
9794 ) -> ProjectRowInteraction {
9795 let row_size = Vec2::new(ui.available_width(), PROJECT_ROW_HEIGHT);
9796
9797 let (rect, response) = ui.allocate_exact_size(row_size, Sense::click());
9799
9800 if !ui.is_rect_visible(rect) {
9802 return ProjectRowInteraction::none();
9803 }
9804
9805 let painter = ui.painter();
9806 let is_hovered = response.hovered();
9807 let was_clicked = response.clicked();
9808 let was_secondary_clicked = response.secondary_clicked();
9809
9810 response.on_hover_cursor(egui::CursorIcon::PointingHand);
9812
9813 let bg_color = if is_selected {
9815 colors::SURFACE_SELECTED
9816 } else if is_hovered {
9817 colors::SURFACE_HOVER
9818 } else {
9819 colors::SURFACE
9820 };
9821
9822 let border_color = if is_selected {
9824 colors::ACCENT
9825 } else if is_hovered {
9826 colors::BORDER_FOCUSED
9827 } else {
9828 colors::BORDER
9829 };
9830
9831 let border_width = if is_selected { 2.0 } else { 1.0 };
9832
9833 painter.rect(
9834 rect,
9835 Rounding::same(rounding::BUTTON),
9836 bg_color,
9837 Stroke::new(border_width, border_color),
9838 );
9839
9840 let content_rect = rect.shrink2(Vec2::new(PROJECT_ROW_PADDING_H, PROJECT_ROW_PADDING_V));
9842 let mut cursor_x = content_rect.min.x;
9843 let center_y = content_rect.center().y;
9844
9845 let status_color = self.project_status_color(project);
9849 let dot_center = egui::pos2(cursor_x + PROJECT_STATUS_DOT_RADIUS, center_y);
9850 painter.circle_filled(dot_center, PROJECT_STATUS_DOT_RADIUS, status_color);
9851 cursor_x += PROJECT_STATUS_DOT_RADIUS * 2.0 + spacing::MD;
9852
9853 let name_text = truncate_with_ellipsis(&project.info.name, 30);
9857 let name_galley = painter.layout_no_wrap(
9858 name_text,
9859 typography::font(FontSize::Body, FontWeight::SemiBold),
9860 colors::TEXT_PRIMARY,
9861 );
9862 let name_y = center_y - name_galley.rect.height() / 2.0 - 6.0;
9863 painter.galley(
9864 egui::pos2(cursor_x, name_y),
9865 name_galley.clone(),
9866 Color32::TRANSPARENT,
9867 );
9868
9869 let status_text = self.project_status_text(project);
9873 let status_text_color = if project.load_error.is_some() {
9874 colors::STATUS_ERROR
9875 } else if project.info.has_active_run
9876 || self.count_active_sessions_for_project(&project.info.name) > 0
9877 {
9878 colors::STATUS_RUNNING
9879 } else {
9880 colors::TEXT_MUTED
9881 };
9882 let status_galley = painter.layout_no_wrap(
9883 status_text,
9884 typography::font(FontSize::Caption, FontWeight::Regular),
9885 status_text_color,
9886 );
9887 let status_y = name_y + name_galley.rect.height() + spacing::XS;
9888 painter.galley(
9889 egui::pos2(cursor_x, status_y),
9890 status_galley,
9891 Color32::TRANSPARENT,
9892 );
9893
9894 if let Some(last_run) = project.info.last_run_date {
9898 let activity_text = format_relative_time(last_run);
9899 let activity_galley = painter.layout_no_wrap(
9900 activity_text,
9901 typography::font(FontSize::Caption, FontWeight::Regular),
9902 colors::TEXT_MUTED,
9903 );
9904 let activity_x = content_rect.max.x - activity_galley.rect.width();
9905 let activity_y = center_y - activity_galley.rect.height() / 2.0;
9906 painter.galley(
9907 egui::pos2(activity_x, activity_y),
9908 activity_galley,
9909 Color32::TRANSPARENT,
9910 );
9911 }
9912
9913 if was_secondary_clicked {
9915 let menu_pos = ui
9918 .ctx()
9919 .input(|i| i.pointer.hover_pos())
9920 .unwrap_or(rect.center());
9921 ProjectRowInteraction::right_click(menu_pos)
9922 } else if was_clicked {
9923 ProjectRowInteraction::click()
9924 } else {
9925 ProjectRowInteraction::none()
9926 }
9927 }
9928}
9929
9930fn load_window_icon() -> Option<Arc<egui::IconData>> {
9944 let icon_bytes = include_bytes!("../../../assets/icon.png");
9946
9947 match eframe::icon_data::from_png_bytes(icon_bytes) {
9949 Ok(icon_data) => Some(Arc::new(icon_data)),
9950 Err(_) => {
9951 None
9953 }
9954 }
9955}
9956
9957fn build_viewport() -> egui::ViewportBuilder {
9962 let mut builder = egui::ViewportBuilder::default()
9963 .with_title("autom8")
9964 .with_inner_size([DEFAULT_WIDTH, DEFAULT_HEIGHT])
9965 .with_min_inner_size([MIN_WIDTH, MIN_HEIGHT])
9966 .with_fullsize_content_view(true)
9967 .with_titlebar_shown(false)
9968 .with_title_shown(false);
9969
9970 if let Some(icon) = load_window_icon() {
9972 builder = builder.with_icon(icon);
9973 }
9974
9975 builder
9976}
9977
9978pub fn run_gui() -> Result<()> {
9987 let options = eframe::NativeOptions {
9988 viewport: build_viewport(),
9989 ..Default::default()
9990 };
9991
9992 eframe::run_native(
9993 "autom8",
9994 options,
9995 Box::new(|cc| {
9996 egui_extras::install_image_loaders(&cc.egui_ctx);
9998 typography::init(&cc.egui_ctx);
10000 theme::init(&cc.egui_ctx);
10002 Ok(Box::new(Autom8App::new()))
10003 }),
10004 )
10005 .map_err(|e| Autom8Error::GuiError(e.to_string()))
10006}
10007
10008#[cfg(test)]
10009mod tests {
10010 use super::*;
10011 use chrono::Utc;
10012 use std::path::PathBuf;
10013
10014 fn make_test_session_data(
10019 run: Option<crate::state::RunState>,
10020 live_output: Option<crate::state::LiveState>,
10021 ) -> SessionData {
10022 use crate::state::SessionMetadata;
10023
10024 SessionData {
10025 project_name: "test-project".to_string(),
10026 metadata: SessionMetadata {
10027 session_id: "main".to_string(),
10028 worktree_path: PathBuf::from("/test/path"),
10029 branch_name: "test-branch".to_string(),
10030 created_at: Utc::now(),
10031 last_active_at: Utc::now(),
10032 is_running: true,
10033 spec_json_path: None,
10034 },
10035 run,
10036 progress: None,
10037 load_error: None,
10038 is_main_session: true,
10039 is_stale: false,
10040 live_output,
10041 cached_user_stories: None,
10042 }
10043 }
10044
10045 fn make_test_run_state(machine_state: MachineState) -> crate::state::RunState {
10046 crate::state::RunState {
10047 run_id: "test-run".to_string(),
10048 status: crate::state::RunStatus::Running,
10049 machine_state,
10050 spec_json_path: PathBuf::from("/test/spec.json"),
10051 spec_md_path: None,
10052 branch: "test-branch".to_string(),
10053 current_story: None,
10054 iteration: 1,
10055 review_iteration: 0,
10056 started_at: Utc::now(),
10057 finished_at: None,
10058 iterations: vec![],
10059 config: None,
10060 knowledge: Default::default(),
10061 pre_story_commit: None,
10062 session_id: Some("main".to_string()),
10063 total_usage: None,
10064 phase_usage: std::collections::HashMap::new(),
10065 }
10066 }
10067
10068 #[test]
10073 fn test_app_initialization() {
10074 let app = Autom8App::new();
10075 assert_eq!(app.current_tab(), Tab::ActiveRuns);
10076 assert_eq!(app.tab_count(), 3); let interval = Duration::from_millis(100);
10079 let app2 = Autom8App::with_refresh_interval(interval);
10080 assert_eq!(app2.refresh_interval(), interval);
10081 }
10082
10083 #[test]
10088 fn test_tab_open_close() {
10089 let mut app = Autom8App::new();
10090
10091 assert!(app.open_run_detail_tab("run-1", "Run 1"));
10093 assert!(!app.open_run_detail_tab("run-1", "Run 1")); app.open_run_detail_tab("run-2", "Run 2");
10095
10096 assert_eq!(app.tab_count(), 5); assert_eq!(app.closable_tab_count(), 2);
10098
10099 assert!(app.close_tab(&TabId::RunDetail("run-1".to_string())));
10101 assert_eq!(app.closable_tab_count(), 1);
10102
10103 assert!(!app.close_tab(&TabId::ActiveRuns));
10105 assert!(!app.close_tab(&TabId::Config));
10106 }
10107
10108 #[test]
10113 fn test_run_history_entry_creation() {
10114 use crate::state::{IterationRecord, IterationStatus, RunState, RunStatus};
10115
10116 let mut run = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
10117 run.status = RunStatus::Completed;
10118 run.iterations.push(IterationRecord {
10119 number: 1,
10120 story_id: "US-001".to_string(),
10121 started_at: Utc::now(),
10122 finished_at: Some(Utc::now()),
10123 status: IterationStatus::Success,
10124 output_snippet: String::new(),
10125 work_summary: None,
10126 usage: None,
10127 });
10128 run.iterations.push(IterationRecord {
10129 number: 2,
10130 story_id: "US-002".to_string(),
10131 started_at: Utc::now(),
10132 finished_at: None,
10133 status: IterationStatus::Failed,
10134 output_snippet: String::new(),
10135 work_summary: None,
10136 usage: None,
10137 });
10138
10139 let entry = RunHistoryEntry::from_run_state("test-project".to_string(), &run);
10140 assert_eq!(entry.branch, "feature/test");
10141 assert_eq!(entry.completed_stories, 1);
10142 assert_eq!(entry.total_stories, 2);
10143 assert_eq!(entry.story_count_text(), "1/2 stories");
10144 assert_eq!(entry.status_color(), colors::STATUS_SUCCESS);
10145 }
10146
10147 #[test]
10152 fn test_config_scope_display() {
10153 assert_eq!(ConfigScope::Global.display_name(), "Global");
10154 assert_eq!(
10155 ConfigScope::Project("my-project".to_string()).display_name(),
10156 "my-project"
10157 );
10158 assert!(ConfigScope::Global.is_global());
10159 assert!(!ConfigScope::Project("test".to_string()).is_global());
10160 }
10161
10162 #[test]
10167 fn test_output_source_fresh_live_preferred() {
10168 let mut live = crate::state::LiveState::new(MachineState::RunningClaude);
10169 live.output_lines = vec!["Line 1".to_string(), "Line 2".to_string()];
10170
10171 let run = make_test_run_state(MachineState::RunningClaude);
10172 let session = make_test_session_data(Some(run), Some(live));
10173 let output = get_output_for_session(&session);
10174
10175 assert!(matches!(output, OutputSource::Live(_)));
10176 if let OutputSource::Live(lines) = output {
10177 assert_eq!(lines.len(), 2);
10178 }
10179 }
10180
10181 #[test]
10182 fn test_output_source_no_live_returns_no_data() {
10183 let run = make_test_run_state(MachineState::RunningClaude);
10184 let session = make_test_session_data(Some(run), None);
10185 let output = get_output_for_session(&session);
10186
10187 assert!(matches!(output, OutputSource::NoData));
10188 }
10189
10190 #[test]
10191 fn test_output_source_enum_variants() {
10192 let live = OutputSource::Live(vec!["test".to_string()]);
10193 let iter = OutputSource::Iteration(vec!["test".to_string()]);
10194 let status = OutputSource::StatusMessage("test".to_string());
10195 let no_data = OutputSource::NoData;
10196
10197 assert_ne!(live, iter);
10198 assert_ne!(status.clone(), no_data.clone());
10199 assert_eq!(status, OutputSource::StatusMessage("test".to_string()));
10200 }
10201
10202 #[test]
10205 fn test_iteration_output_preserved_when_live_empty() {
10206 use crate::state::{IterationRecord, IterationStatus, LiveState};
10207
10208 let mut run = make_test_run_state(MachineState::RunningClaude);
10210 run.iterations.push(IterationRecord {
10211 number: 1,
10212 story_id: "US-001".to_string(),
10213 started_at: Utc::now(),
10214 finished_at: None,
10215 status: IterationStatus::Running,
10216 output_snippet: "Previous iteration output\nLine 2\nLine 3".to_string(),
10217 work_summary: None,
10218 usage: None,
10219 });
10220
10221 let live = LiveState {
10223 output_lines: vec![], updated_at: Utc::now(),
10225 machine_state: MachineState::RunningClaude,
10226 last_heartbeat: Utc::now(),
10227 };
10228
10229 let session = make_test_session_data(Some(run), Some(live));
10230
10231 let output = get_output_for_session(&session);
10232
10233 match output {
10235 OutputSource::Iteration(lines) => {
10236 assert!(!lines.is_empty());
10237 assert!(lines
10238 .iter()
10239 .any(|l| l.contains("Previous iteration output")));
10240 }
10241 OutputSource::StatusMessage(msg) => {
10242 panic!(
10244 "Bug: Should have shown iteration output, not status message: {}",
10245 msg
10246 );
10247 }
10248 other => panic!("Unexpected output source: {:?}", other),
10249 }
10250 }
10251
10252 #[test]
10254 fn test_waiting_shown_only_when_no_output() {
10255 use crate::state::LiveState;
10256
10257 let run = make_test_run_state(MachineState::RunningClaude);
10259
10260 let live = LiveState {
10262 output_lines: vec![],
10263 updated_at: Utc::now(),
10264 machine_state: MachineState::RunningClaude,
10265 last_heartbeat: Utc::now(),
10266 };
10267
10268 let session = make_test_session_data(Some(run), Some(live));
10269
10270 let output = get_output_for_session(&session);
10271
10272 match output {
10274 OutputSource::StatusMessage(msg) => {
10275 assert_eq!(msg, "Waiting for output...");
10276 }
10277 other => panic!("Expected StatusMessage, got {:?}", other),
10278 }
10279 }
10280
10281 #[test]
10285 fn test_output_persists_across_state_transitions() {
10286 use crate::state::{IterationRecord, IterationStatus, LiveState};
10287
10288 let mut run = make_test_run_state(MachineState::Reviewing);
10293
10294 run.iterations.push(IterationRecord {
10296 number: 1,
10297 story_id: "US-001".to_string(),
10298 started_at: Utc::now(),
10299 finished_at: Some(Utc::now()),
10300 status: IterationStatus::Success,
10301 output_snippet: "Previous iteration completed\nImplemented feature X".to_string(),
10302 work_summary: Some("Implemented feature X".to_string()),
10303 usage: None,
10304 });
10305
10306 let mut live = LiveState::new(MachineState::RunningClaude);
10308 live.updated_at = Utc::now() - chrono::Duration::seconds(10); let session = make_test_session_data(Some(run), Some(live));
10311
10312 let output = get_output_for_session(&session);
10313
10314 match output {
10316 OutputSource::Iteration(lines) => {
10317 assert!(!lines.is_empty());
10318 assert!(lines
10319 .iter()
10320 .any(|l| l.contains("Previous iteration completed")));
10321 }
10322 OutputSource::StatusMessage(msg) => {
10323 panic!(
10324 "Bug: Should have shown iteration output during state transition, not: {}",
10325 msg
10326 );
10327 }
10328 other => panic!("Unexpected output source: {:?}", other),
10329 }
10330 }
10331
10332 #[test]
10335 fn test_previous_iteration_shown_when_current_has_no_output() {
10336 use crate::state::{IterationRecord, IterationStatus, LiveState};
10337
10338 let mut run = make_test_run_state(MachineState::RunningClaude);
10342
10343 run.iterations.push(IterationRecord {
10345 number: 1,
10346 story_id: "US-001".to_string(),
10347 started_at: Utc::now(),
10348 finished_at: Some(Utc::now()),
10349 status: IterationStatus::Success,
10350 output_snippet: "First iteration output\nDid something useful".to_string(),
10351 work_summary: Some("Did something useful".to_string()),
10352 usage: None,
10353 });
10354
10355 run.iterations.push(IterationRecord {
10357 number: 2,
10358 story_id: "US-002".to_string(),
10359 started_at: Utc::now(),
10360 finished_at: None,
10361 status: IterationStatus::Running,
10362 output_snippet: String::new(), work_summary: None,
10364 usage: None,
10365 });
10366
10367 let live = LiveState {
10369 output_lines: vec![], updated_at: Utc::now(),
10371 machine_state: MachineState::RunningClaude,
10372 last_heartbeat: Utc::now(),
10373 };
10374
10375 let session = make_test_session_data(Some(run), Some(live));
10376
10377 let output = get_output_for_session(&session);
10378
10379 match output {
10381 OutputSource::Iteration(lines) => {
10382 assert!(!lines.is_empty());
10383 assert!(lines.iter().any(|l| l.contains("First iteration output")));
10384 }
10385 OutputSource::StatusMessage(msg) => {
10386 panic!(
10387 "Bug: Should have shown previous iteration output, not: {}",
10388 msg
10389 );
10390 }
10391 other => panic!("Unexpected output source: {:?}", other),
10392 }
10393 }
10394}