use crate::error::{Autom8Error, Result};
use crate::state::{IterationStatus, MachineState, SessionStatus, StateManager};
use crate::ui::gui::components::{
badge_background_color, format_relative_time, format_run_duration, format_state,
is_terminal_state, state_to_color, strip_worktree_prefix, truncate_with_ellipsis,
CollapsibleSection, MAX_BRANCH_LENGTH,
};
use crate::ui::gui::config::{
BoolFieldChanges, ConfigBoolField, ConfigEditorActions, ConfigScope, ConfigTabState,
ConfigTextField, TextFieldChanges, CONFIG_SCOPE_ROW_HEIGHT, CONFIG_SCOPE_ROW_PADDING_H,
CONFIG_SCOPE_ROW_PADDING_V,
};
use crate::ui::gui::modal::{Modal, ModalAction, ModalButton};
use crate::ui::gui::theme::{self, colors, rounding, spacing};
use crate::ui::gui::typography::{self, FontSize, FontWeight};
use crate::ui::shared::{
load_project_run_history, load_session_by_id, load_ui_data, ProjectData, RunHistoryEntry,
SessionData,
};
use eframe::egui::{self, Color32, Key, Order, Pos2, Rect, Rounding, Sense, Stroke, Vec2};
use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_WIDTH: f32 = 1200.0;
const DEFAULT_HEIGHT: f32 = 800.0;
const MIN_WIDTH: f32 = 400.0;
const MIN_HEIGHT: f32 = 300.0;
#[allow(dead_code)]
const HEADER_HEIGHT: f32 = 48.0;
const TITLE_BAR_HEIGHT: f32 = 48.0;
const TITLE_BAR_LEFT_OFFSET: f32 = 72.0;
const TAB_UNDERLINE_HEIGHT: f32 = 2.0;
const TAB_PADDING_H: f32 = 16.0;
pub const DEFAULT_REFRESH_INTERVAL_MS: u64 = 500;
const OUTPUT_LINES_TO_SHOW: usize = 50;
const LIVE_OUTPUT_FRESHNESS_SECS: i64 = 5;
const PROJECT_ROW_HEIGHT: f32 = 56.0;
const PROJECT_ROW_PADDING_H: f32 = 12.0;
const PROJECT_ROW_PADDING_V: f32 = 12.0;
const PROJECT_STATUS_DOT_RADIUS: f32 = 5.0;
const SPLIT_DIVIDER_WIDTH: f32 = 1.0;
const SPLIT_DIVIDER_MARGIN: f32 = 12.0;
const SPLIT_PANEL_MIN_WIDTH: f32 = 200.0;
const SIDEBAR_WIDTH: f32 = 220.0;
const SIDEBAR_COLLAPSED_WIDTH: f32 = 0.0;
const SIDEBAR_TOGGLE_SIZE: f32 = 34.0;
const SIDEBAR_TOGGLE_PADDING: f32 = 8.0;
const SIDEBAR_ITEM_HEIGHT: f32 = 40.0;
const SIDEBAR_ITEM_PADDING_H: f32 = 16.0;
#[allow(dead_code)]
const SIDEBAR_ITEM_PADDING_V: f32 = 8.0;
const SIDEBAR_ACTIVE_INDICATOR_WIDTH: f32 = 3.0;
const SIDEBAR_ITEM_ROUNDING: f32 = 6.0;
const SIDEBAR_ICON_SIZE: f32 = 120.0;
const CONTEXT_MENU_MIN_WIDTH: f32 = 100.0;
const CONTEXT_MENU_MAX_WIDTH: f32 = 300.0;
const CONTEXT_MENU_ITEM_HEIGHT: f32 = 32.0;
const CONTEXT_MENU_PADDING_H: f32 = 12.0;
const CONTEXT_MENU_PADDING_V: f32 = 6.0;
const CONTEXT_MENU_ARROW_SIZE: f32 = 8.0;
const CONTEXT_MENU_CURSOR_OFFSET: f32 = 2.0;
const CONTEXT_MENU_SUBMENU_GAP: f32 = 2.0;
struct ContextMenuItemResponse {
clicked: bool,
hovered: bool,
hovered_raw: bool,
rect: Rect,
}
fn calculate_menu_width_from_text_width(max_text_width: f32) -> f32 {
let padding = CONTEXT_MENU_PADDING_H * 2.0 + CONTEXT_MENU_PADDING_H * 2.0;
let calculated_width = max_text_width + padding;
calculated_width.clamp(CONTEXT_MENU_MIN_WIDTH, CONTEXT_MENU_MAX_WIDTH)
}
fn calculate_context_menu_width(ctx: &egui::Context, items: &[ContextMenuItem]) -> f32 {
let font_id = typography::font(FontSize::Body, FontWeight::Regular);
let max_text_width = items
.iter()
.filter_map(|item| {
match item {
ContextMenuItem::Action { label, .. } => {
let galley = ctx.fonts(|fonts| {
fonts.layout_no_wrap(label.clone(), font_id.clone(), Color32::WHITE)
});
Some(galley.rect.width())
}
ContextMenuItem::Submenu { label, .. } => {
let galley = ctx.fonts(|fonts| {
fonts.layout_no_wrap(label.clone(), font_id.clone(), Color32::WHITE)
});
Some(galley.rect.width() + CONTEXT_MENU_ARROW_SIZE + CONTEXT_MENU_PADDING_H)
}
ContextMenuItem::Separator => None, }
})
.fold(0.0_f32, |max, width| max.max(width));
calculate_menu_width_from_text_width(max_text_width)
}
#[derive(Debug, Clone, PartialEq)]
enum OutputSource {
Live(Vec<String>),
Iteration(Vec<String>),
StatusMessage(String),
NoData,
}
fn get_output_for_session(session: &SessionData) -> OutputSource {
let machine_state = session
.run
.as_ref()
.map(|r| r.machine_state)
.unwrap_or(MachineState::Idle);
if machine_state == MachineState::RunningClaude {
if let Some(ref live) = session.live_output {
let age = chrono::Utc::now().signed_duration_since(live.updated_at);
if age.num_seconds() < LIVE_OUTPUT_FRESHNESS_SECS && !live.output_lines.is_empty() {
let take_count = OUTPUT_LINES_TO_SHOW.min(live.output_lines.len());
let start = live.output_lines.len().saturating_sub(take_count);
let lines: Vec<String> = live.output_lines[start..].to_vec();
return OutputSource::Live(lines);
}
}
}
if let Some(ref run) = session.run {
for iter in run.iterations.iter().rev() {
if !iter.output_snippet.is_empty() {
let lines: Vec<String> = iter
.output_snippet
.lines()
.collect::<Vec<_>>()
.into_iter()
.rev()
.take(OUTPUT_LINES_TO_SHOW)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|s| s.to_string())
.collect();
return OutputSource::Iteration(lines);
}
}
}
if session.live_output.is_none() {
return OutputSource::NoData;
}
let message = match machine_state {
MachineState::Idle => "Waiting to start...",
MachineState::LoadingSpec => "Loading spec file...",
MachineState::GeneratingSpec => "Generating spec from markdown...",
MachineState::Initializing => "Initializing run...",
MachineState::PickingStory => "Selecting next story...",
MachineState::RunningClaude => "Waiting for output...",
MachineState::Reviewing => "Reviewing changes...",
MachineState::Correcting => "Applying corrections...",
MachineState::Committing => "Committing changes...",
MachineState::CreatingPR => "Creating pull request...",
MachineState::Completed => "Run completed successfully!",
MachineState::Failed => "Run failed.",
};
OutputSource::StatusMessage(message.to_string())
}
fn is_resumable_session(session: &SessionStatus) -> bool {
if session.is_stale {
return false;
}
if session.metadata.is_running {
return true;
}
if let Some(state) = &session.machine_state {
match state {
MachineState::Completed | MachineState::Idle => false,
_ => true, }
} else {
false
}
}
fn format_sessions_as_text(sessions: &[SessionStatus]) -> Vec<String> {
let mut lines = Vec::new();
if sessions.is_empty() {
lines.push("No sessions found for this project.".to_string());
return lines;
}
lines.push("Sessions for this project:".to_string());
lines.push(String::new());
for session in sessions {
let metadata = &session.metadata;
let indicator = if session.is_stale {
"✗"
} else if session.is_current {
"→"
} else if metadata.is_running {
"●"
} else {
"○"
};
let mut header = format!("{} {}", indicator, metadata.session_id);
if session.is_current {
header.push_str(" (current)");
}
if session.is_stale {
header.push_str(" [stale]");
}
lines.push(header);
lines.push(format!(" Branch: {}", metadata.branch_name));
if let Some(state) = &session.machine_state {
let state_str = format_machine_state_text(state);
lines.push(format!(" State: {}", state_str));
}
if let Some(story) = &session.current_story {
lines.push(format!(" Story: {}", story));
}
lines.push(format!(
" Started: {}",
metadata.created_at.format("%Y-%m-%d %H:%M")
));
lines.push(String::new());
}
let running_count = sessions
.iter()
.filter(|s| s.metadata.is_running && !s.is_stale)
.count();
let stale_count = sessions.iter().filter(|s| s.is_stale).count();
let mut summary = format!(
"({} session{}",
sessions.len(),
if sessions.len() == 1 { "" } else { "s" }
);
if running_count > 0 {
summary.push_str(&format!(", {} running", running_count));
}
if stale_count > 0 {
summary.push_str(&format!(", {} stale", stale_count));
}
summary.push(')');
lines.push(summary);
lines
}
fn format_machine_state_text(state: &MachineState) -> &'static str {
match state {
MachineState::Idle => "Idle",
MachineState::LoadingSpec => "Loading Spec",
MachineState::GeneratingSpec => "Generating Spec",
MachineState::Initializing => "Initializing",
MachineState::PickingStory => "Picking Story",
MachineState::RunningClaude => "Running Claude",
MachineState::Reviewing => "Reviewing",
MachineState::Correcting => "Correcting",
MachineState::Committing => "Committing",
MachineState::CreatingPR => "Creating PR",
MachineState::Completed => "Completed",
MachineState::Failed => "Failed",
}
}
fn format_resume_info_as_text(session: &ResumableSessionInfo) -> Vec<String> {
let mut lines = Vec::new();
lines.push("Resume Session Information".to_string());
lines.push(String::new());
lines.push(format!("Session ID: {}", session.session_id));
lines.push(format!("Branch: {}", session.branch_name));
lines.push(format!(
"Worktree Path: {}",
session.worktree_path.display()
));
lines.push(format!(
"Current State: {}",
format_machine_state_text(&session.machine_state)
));
lines.push(String::new());
lines.push(format!(
"To resume, run `autom8 resume --session {}` in terminal",
session.session_id
));
lines
}
fn format_project_description_as_text(desc: &crate::config::ProjectDescription) -> Vec<String> {
use crate::state::RunStatus;
let mut lines = Vec::new();
lines.push(format!("Project: {}", desc.name));
lines.push(format!("Path: {}", desc.path.display()));
lines.push(String::new());
let status_text = match desc.run_status {
Some(RunStatus::Running) => "[running]",
Some(RunStatus::Failed) => "[failed]",
Some(RunStatus::Interrupted) => "[interrupted]",
Some(RunStatus::Completed) => "[completed]",
None => "[idle]",
};
lines.push(format!("Status: {}", status_text));
if let Some(branch) = &desc.current_branch {
lines.push(format!("Branch: {}", branch));
}
if let Some(story) = &desc.current_story {
lines.push(format!("Current Story: {}", story));
}
lines.push(String::new());
if desc.specs.is_empty() {
lines.push("No specs found.".to_string());
} else {
lines.push(format!("Specs: ({} total)", desc.specs.len()));
lines.push(String::new());
for spec in &desc.specs {
lines.extend(format_spec_summary_as_text(spec));
}
}
lines.push("─────────────────────────────────────────────────────────".to_string());
lines.push(format!(
"Files: {} spec md, {} spec json, {} archived runs",
desc.spec_md_count,
desc.specs.len(),
desc.runs_count
));
lines
}
fn format_spec_summary_as_text(spec: &crate::config::SpecSummary) -> Vec<String> {
let mut lines = Vec::new();
let active_label = if spec.is_active { " (Active)" } else { "" };
lines.push(format!("━━━ {}{}", spec.filename, active_label));
if !spec.is_active {
let desc_preview = if spec.description.len() > 80 {
format!("{}...", &spec.description[..80])
} else {
spec.description.clone()
};
let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
lines.push(first_line.to_string());
lines.push(format!(
"({}/{} stories complete)",
spec.completed_count, spec.total_count
));
lines.push(String::new());
return lines;
}
lines.push(format!("Project: {}", spec.project_name));
lines.push(format!("Branch: {}", spec.branch_name));
let desc_preview = if spec.description.len() > 100 {
format!("{}...", &spec.description[..100])
} else {
spec.description.clone()
};
let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
lines.push(format!("Description: {}", first_line));
lines.push(String::new());
let progress_bar = make_progress_bar_text(spec.completed_count, spec.total_count, 12);
lines.push(format!(
"Progress: [{}] {}/{} stories complete",
progress_bar, spec.completed_count, spec.total_count
));
lines.push(String::new());
lines.push("User Stories:".to_string());
for story in &spec.stories {
let status_icon = if story.passes { "✓" } else { "○" };
lines.push(format!(" {} {}: {}", status_icon, story.id, story.title));
}
lines.push(String::new());
lines
}
fn make_progress_bar_text(completed: usize, total: usize, width: usize) -> String {
if total == 0 {
return " ".repeat(width);
}
let filled = (completed * width) / total;
let empty = width - filled;
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}
fn format_cleanup_summary_as_text(
summary: &crate::commands::CleanupSummary,
operation: &str,
) -> Vec<String> {
use crate::commands::format_bytes_display;
let mut lines = Vec::new();
lines.push(format!("Cleanup Operation: {}", operation));
lines.push(String::new());
if summary.sessions_removed == 0 && summary.worktrees_removed == 0 {
lines.push("No sessions or worktrees were removed.".to_string());
} else {
let freed_str = format_bytes_display(summary.bytes_freed);
lines.push(format!(
"Removed {} session{}, {} worktree{}, freed {}",
summary.sessions_removed,
if summary.sessions_removed == 1 {
""
} else {
"s"
},
summary.worktrees_removed,
if summary.worktrees_removed == 1 {
""
} else {
"s"
},
freed_str
));
}
if !summary.sessions_skipped.is_empty() {
lines.push(String::new());
lines.push(format!(
"Skipped {} session{}:",
summary.sessions_skipped.len(),
if summary.sessions_skipped.len() == 1 {
""
} else {
"s"
}
));
for skipped in &summary.sessions_skipped {
lines.push(format!(" - {}: {}", skipped.session_id, skipped.reason));
}
}
if !summary.errors.is_empty() {
lines.push(String::new());
lines.push("Errors during cleanup:".to_string());
for error in &summary.errors {
lines.push(format!(" - {}", error));
}
}
lines
}
fn format_data_cleanup_summary_as_text(
summary: &crate::commands::DataCleanupSummary,
) -> Vec<String> {
use crate::commands::format_bytes_display;
let mut lines = Vec::new();
lines.push("Cleanup Operation: Clean Data".to_string());
lines.push(String::new());
if summary.specs_removed == 0 && summary.runs_removed == 0 {
lines.push("No specs or runs were removed.".to_string());
} else {
let freed_str = format_bytes_display(summary.bytes_freed);
lines.push(format!(
"Removed {} spec{}, {} run{}, freed {}",
summary.specs_removed,
if summary.specs_removed == 1 { "" } else { "s" },
summary.runs_removed,
if summary.runs_removed == 1 { "" } else { "s" },
freed_str
));
}
if !summary.errors.is_empty() {
lines.push(String::new());
lines.push("Errors during cleanup:".to_string());
for error in &summary.errors {
lines.push(format!(" - {}", error));
}
}
lines
}
fn format_removal_summary_as_text(
summary: &crate::commands::RemovalSummary,
project_name: &str,
) -> Vec<String> {
use crate::commands::format_bytes_display;
let mut lines = Vec::new();
lines.push(format!("Remove Project: {}", project_name));
lines.push(String::new());
if summary.worktrees_removed == 0 && !summary.config_deleted {
if summary.errors.is_empty() {
lines.push("Nothing was removed.".to_string());
} else {
lines.push("Failed to remove project.".to_string());
}
} else {
let freed_str = format_bytes_display(summary.bytes_freed);
let mut results = Vec::new();
if summary.worktrees_removed > 0 {
results.push(format!(
"{} worktree{}",
summary.worktrees_removed,
if summary.worktrees_removed == 1 {
""
} else {
"s"
}
));
}
if summary.config_deleted {
results.push("config directory".to_string());
}
lines.push(format!("Removed: {}", results.join(", ")));
lines.push(format!("Freed: {}", freed_str));
}
if !summary.worktrees_skipped.is_empty() {
lines.push(String::new());
lines.push(format!(
"Skipped {} worktree{} (active runs):",
summary.worktrees_skipped.len(),
if summary.worktrees_skipped.len() == 1 {
""
} else {
"s"
}
));
for skipped in &summary.worktrees_skipped {
lines.push(format!(
" - {}: {}",
skipped.path.display(),
skipped.reason
));
}
}
if !summary.errors.is_empty() {
lines.push(String::new());
lines.push("Errors during removal:".to_string());
for error in &summary.errors {
lines.push(format!(" - {}", error));
}
}
if summary.errors.is_empty() && (summary.worktrees_removed > 0 || summary.config_deleted) {
lines.push(String::new());
lines.push(format!(
"Project '{}' has been removed from autom8.",
project_name
));
}
lines
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuItem {
Action {
label: String,
action: ContextMenuAction,
enabled: bool,
},
Separator,
Submenu {
label: String,
id: String,
enabled: bool,
items: Vec<ContextMenuItem>,
hint: Option<String>,
},
}
impl ContextMenuItem {
pub fn action(label: impl Into<String>, action: ContextMenuAction) -> Self {
Self::Action {
label: label.into(),
action,
enabled: true,
}
}
pub fn action_disabled(label: impl Into<String>, action: ContextMenuAction) -> Self {
Self::Action {
label: label.into(),
action,
enabled: false,
}
}
pub fn separator() -> Self {
Self::Separator
}
pub fn submenu(
label: impl Into<String>,
id: impl Into<String>,
items: Vec<ContextMenuItem>,
) -> Self {
let items_vec = items;
Self::Submenu {
label: label.into(),
id: id.into(),
enabled: !items_vec.is_empty(),
items: items_vec,
hint: None,
}
}
pub fn submenu_disabled(
label: impl Into<String>,
id: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self::Submenu {
label: label.into(),
id: id.into(),
enabled: false,
items: Vec::new(),
hint: Some(hint.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuAction {
Status,
Describe,
Resume(Option<String>),
CleanWorktrees,
CleanOrphaned,
CleanData,
RemoveProject,
}
#[derive(Debug, Clone)]
pub struct ResumableSessionInfo {
pub session_id: String,
pub branch_name: String,
pub worktree_path: std::path::PathBuf,
pub machine_state: MachineState,
}
impl ResumableSessionInfo {
pub fn new(
session_id: impl Into<String>,
branch_name: impl Into<String>,
worktree_path: std::path::PathBuf,
machine_state: MachineState,
) -> Self {
Self {
session_id: session_id.into(),
branch_name: branch_name.into(),
worktree_path,
machine_state,
}
}
pub fn truncated_id(&self) -> &str {
if self.session_id.len() > 8 {
&self.session_id[..8]
} else {
&self.session_id
}
}
pub fn menu_label(&self) -> String {
format!("{} ({})", self.branch_name, self.truncated_id())
}
}
#[derive(Debug, Clone, Default)]
pub struct CleanableInfo {
pub cleanable_worktrees: usize,
pub orphaned_sessions: usize,
pub cleanable_specs: usize,
pub cleanable_runs: usize,
}
impl CleanableInfo {
pub fn has_cleanable(&self) -> bool {
self.cleanable_worktrees > 0
|| self.orphaned_sessions > 0
|| self.cleanable_specs > 0
|| self.cleanable_runs > 0
}
}
#[allow(dead_code)] fn is_cleanable_session(session: &SessionStatus) -> bool {
!session.metadata.is_running
}
fn count_cleanable_specs(
spec_dir: &std::path::Path,
active_spec_paths: &std::collections::HashSet<std::path::PathBuf>,
) -> usize {
if !spec_dir.exists() {
return 0;
}
let mut cleanable_count = 0;
if let Ok(entries) = std::fs::read_dir(spec_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if !active_spec_paths.contains(&path) {
cleanable_count += 1;
}
}
}
}
cleanable_count
}
fn count_cleanable_runs(runs_dir: &std::path::Path) -> usize {
if !runs_dir.exists() {
return 0;
}
std::fs::read_dir(runs_dir)
.map(|entries| entries.filter_map(|e| e.ok()).count())
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct ContextMenuState {
pub position: Pos2,
pub project_name: String,
pub items: Vec<ContextMenuItem>,
pub open_submenu: Option<String>,
pub submenu_position: Option<Pos2>,
}
impl ContextMenuState {
pub fn new(position: Pos2, project_name: String, items: Vec<ContextMenuItem>) -> Self {
Self {
position,
project_name,
items,
open_submenu: None,
submenu_position: None,
}
}
pub fn open_submenu(&mut self, id: String, position: Pos2) {
self.open_submenu = Some(id);
self.submenu_position = Some(position);
}
pub fn close_submenu(&mut self) {
self.open_submenu = None;
self.submenu_position = None;
}
}
#[derive(Debug, Clone, Default)]
pub struct ProjectRowInteraction {
pub clicked: bool,
pub right_click_pos: Option<Pos2>,
}
impl ProjectRowInteraction {
pub fn none() -> Self {
Self::default()
}
pub fn click() -> Self {
Self {
clicked: true,
right_click_pos: None,
}
}
pub fn right_click(pos: Pos2) -> Self {
Self {
clicked: false,
right_click_pos: Some(pos),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandStatus {
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CommandOutputId {
pub project: String,
pub command: String,
pub id: String,
}
impl CommandOutputId {
pub fn new(project: impl Into<String>, command: impl Into<String>) -> Self {
Self {
project: project.into(),
command: command.into(),
id: uuid::Uuid::new_v4().to_string(),
}
}
#[cfg(test)]
pub fn with_id(
project: impl Into<String>,
command: impl Into<String>,
id: impl Into<String>,
) -> Self {
Self {
project: project.into(),
command: command.into(),
id: id.into(),
}
}
pub fn cache_key(&self) -> String {
format!("{}:{}:{}", self.project, self.command, self.id)
}
pub fn tab_label(&self) -> String {
let command_display = if self.command.is_empty() {
"Command".to_string()
} else {
let mut chars = self.command.chars();
match chars.next() {
None => "Command".to_string(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
};
format!("{}: {}", command_display, self.project)
}
}
#[derive(Debug, Clone)]
pub struct CommandExecution {
pub id: CommandOutputId,
pub status: CommandStatus,
pub stdout: Vec<String>,
pub stderr: Vec<String>,
pub exit_code: Option<i32>,
pub auto_scroll: bool,
}
impl CommandExecution {
pub fn new(id: CommandOutputId) -> Self {
Self {
id,
status: CommandStatus::Running,
stdout: Vec::new(),
stderr: Vec::new(),
exit_code: None,
auto_scroll: true,
}
}
pub fn add_stdout(&mut self, line: String) {
self.stdout.push(line);
}
pub fn add_stderr(&mut self, line: String) {
self.stderr.push(line);
}
pub fn complete(&mut self, exit_code: i32) {
self.exit_code = Some(exit_code);
self.status = if exit_code == 0 {
CommandStatus::Completed
} else {
CommandStatus::Failed
};
}
pub fn fail(&mut self, error_message: String) {
self.stderr.push(error_message);
self.status = CommandStatus::Failed;
}
pub fn is_running(&self) -> bool {
self.status == CommandStatus::Running
}
pub fn is_finished(&self) -> bool {
self.status != CommandStatus::Running
}
pub fn combined_output(&self) -> Vec<&str> {
let mut output: Vec<&str> = self.stdout.iter().map(|s| s.as_str()).collect();
if !self.stderr.is_empty() {
output.extend(self.stderr.iter().map(|s| s.as_str()));
}
output
}
}
#[derive(Debug, Clone)]
pub enum CommandMessage {
Stdout { cache_key: String, line: String },
Stderr { cache_key: String, line: String },
Completed { cache_key: String, exit_code: i32 },
Failed { cache_key: String, error: String },
ProjectRemoved { project_name: String },
CleanupCompleted { result: CleanupResult },
}
#[derive(Debug, Clone, PartialEq)]
pub enum PendingCleanOperation {
Worktrees { project_name: String },
Orphaned { project_name: String },
Data {
project_name: String,
specs_count: usize,
runs_count: usize,
},
RemoveProject { project_name: String },
}
impl PendingCleanOperation {
fn title(&self) -> &'static str {
match self {
Self::Worktrees { .. } => "Clean Worktrees",
Self::Orphaned { .. } => "Clean Orphaned Sessions",
Self::Data { .. } => "Clean Project Data",
Self::RemoveProject { .. } => "Remove Project",
}
}
fn confirm_button_label(&self) -> &'static str {
match self {
Self::Data { .. } => "Delete",
_ => "Confirm",
}
}
fn message(&self) -> String {
match self {
Self::Worktrees { project_name } => {
format!(
"This will remove completed worktrees and their session state for '{}'.\n\n\
Are you sure you want to continue?",
project_name
)
}
Self::Orphaned { project_name } => {
format!(
"This will remove session state for orphaned sessions (where the worktree \
has been deleted) for '{}'.\n\n\
Are you sure you want to continue?",
project_name
)
}
Self::Data {
project_name,
specs_count,
runs_count,
} => {
let mut items = Vec::new();
if *runs_count > 0 {
items.push(format!(
"{} archived run{}",
runs_count,
if *runs_count == 1 { "" } else { "s" }
));
}
if *specs_count > 0 {
items.push(format!(
"{} spec{}",
specs_count,
if *specs_count == 1 { "" } else { "s" }
));
}
let items_str = items.join(", ");
format!(
"This will delete {} for '{}'.\n\n\
Are you sure you want to continue?",
items_str, project_name
)
}
Self::RemoveProject { project_name } => {
format!(
"This will remove all worktrees (except those with active runs) and delete \
the autom8 configuration for '{}'.\n\n\
This cannot be undone.",
project_name
)
}
}
}
fn project_name(&self) -> &str {
match self {
Self::Worktrees { project_name }
| Self::Orphaned { project_name }
| Self::Data { project_name, .. }
| Self::RemoveProject { project_name } => project_name,
}
}
}
#[derive(Debug, Clone)]
pub enum CleanupResult {
Worktrees {
project_name: String,
worktrees_removed: usize,
sessions_removed: usize,
bytes_freed: u64,
skipped_count: usize,
error_count: usize,
},
Orphaned {
project_name: String,
sessions_removed: usize,
bytes_freed: u64,
error_count: usize,
},
RemoveProject {
project_name: String,
worktrees_removed: usize,
config_deleted: bool,
bytes_freed: u64,
skipped_count: usize,
error_count: usize,
},
Data {
project_name: String,
specs_removed: usize,
runs_removed: usize,
bytes_freed: u64,
error_count: usize,
},
}
impl CleanupResult {
pub fn title(&self) -> &'static str {
match self {
Self::Worktrees { .. } => "Cleanup Complete",
Self::Orphaned { .. } => "Cleanup Complete",
Self::Data { .. } => "Cleanup Complete",
Self::RemoveProject { .. } => "Project Removed",
}
}
pub fn message(&self) -> String {
use crate::commands::format_bytes_display;
match self {
Self::Worktrees {
worktrees_removed,
sessions_removed,
bytes_freed,
skipped_count,
error_count,
..
} => {
let mut parts = Vec::new();
if *worktrees_removed > 0 || *sessions_removed > 0 {
let freed = format_bytes_display(*bytes_freed);
parts.push(format!(
"Removed {} worktree{} and {} session{}, freed {}.",
worktrees_removed,
if *worktrees_removed == 1 { "" } else { "s" },
sessions_removed,
if *sessions_removed == 1 { "" } else { "s" },
freed
));
} else {
parts.push("No worktrees or sessions were removed.".to_string());
}
if *skipped_count > 0 {
parts.push(format!(
"{} session{} skipped (active runs or uncommitted changes).",
skipped_count,
if *skipped_count == 1 {
" was"
} else {
"s were"
}
));
}
if *error_count > 0 {
parts.push(format!(
"{} error{} occurred. Check the command output tab for details.",
error_count,
if *error_count == 1 { "" } else { "s" }
));
}
parts.join("\n\n")
}
Self::Orphaned {
sessions_removed,
bytes_freed,
error_count,
..
} => {
let mut parts = Vec::new();
if *sessions_removed > 0 {
let freed = format_bytes_display(*bytes_freed);
parts.push(format!(
"Removed {} orphaned session{}, freed {}.",
sessions_removed,
if *sessions_removed == 1 { "" } else { "s" },
freed
));
} else {
parts.push("No orphaned sessions were found.".to_string());
}
if *error_count > 0 {
parts.push(format!(
"{} error{} occurred. Check the command output tab for details.",
error_count,
if *error_count == 1 { "" } else { "s" }
));
}
parts.join("\n\n")
}
Self::RemoveProject {
project_name,
worktrees_removed,
config_deleted,
bytes_freed,
skipped_count,
error_count,
} => {
let mut parts = Vec::new();
if *config_deleted {
let freed = format_bytes_display(*bytes_freed);
let mut summary = format!("Project '{}' has been removed.", project_name);
if *worktrees_removed > 0 {
summary.push_str(&format!(
"\n\nRemoved {} worktree{}, freed {}.",
worktrees_removed,
if *worktrees_removed == 1 { "" } else { "s" },
freed
));
}
parts.push(summary);
} else {
parts.push(format!(
"Failed to fully remove project '{}'.",
project_name
));
}
if *skipped_count > 0 {
parts.push(format!(
"{} worktree{} skipped (active runs).",
skipped_count,
if *skipped_count == 1 {
" was"
} else {
"s were"
}
));
}
if *error_count > 0 {
parts.push(format!(
"{} error{} occurred. Check the command output tab for details.",
error_count,
if *error_count == 1 { "" } else { "s" }
));
}
parts.join("\n\n")
}
Self::Data {
specs_removed,
runs_removed,
bytes_freed,
error_count,
..
} => {
let mut parts = Vec::new();
if *specs_removed > 0 || *runs_removed > 0 {
let freed = format_bytes_display(*bytes_freed);
let mut items = Vec::new();
if *specs_removed > 0 {
items.push(format!(
"{} spec{}",
specs_removed,
if *specs_removed == 1 { "" } else { "s" }
));
}
if *runs_removed > 0 {
items.push(format!(
"{} archived run{}",
runs_removed,
if *runs_removed == 1 { "" } else { "s" }
));
}
parts.push(format!("Removed {}, freed {}.", items.join(" and "), freed));
} else {
parts.push("No data was removed.".to_string());
}
if *error_count > 0 {
parts.push(format!(
"{} error{} occurred. Check the command output tab for details.",
error_count,
if *error_count == 1 { "" } else { "s" }
));
}
parts.join("\n\n")
}
}
}
pub fn has_errors(&self) -> bool {
match self {
Self::Worktrees { error_count, .. }
| Self::Orphaned { error_count, .. }
| Self::RemoveProject { error_count, .. }
| Self::Data { error_count, .. } => *error_count > 0,
}
}
}
pub trait RunHistoryEntryExt {
fn status_color(&self) -> Color32;
}
impl RunHistoryEntryExt for RunHistoryEntry {
fn status_color(&self) -> Color32 {
match self.status {
crate::state::RunStatus::Completed => colors::STATUS_SUCCESS,
crate::state::RunStatus::Failed => colors::STATUS_ERROR,
crate::state::RunStatus::Running => colors::STATUS_RUNNING,
crate::state::RunStatus::Interrupted => colors::STATUS_WARNING,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum TabId {
#[default]
ActiveRuns,
Projects,
Config,
CreateSpec,
RunDetail(String),
CommandOutput(String),
}
#[derive(Debug, Clone)]
pub struct TabInfo {
pub id: TabId,
pub label: String,
pub closable: bool,
}
impl TabInfo {
pub fn permanent(id: TabId, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
closable: false,
}
}
pub fn closable(id: TabId, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
closable: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
ActiveRuns,
Projects,
Config,
CreateSpec,
}
impl Tab {
pub fn label(self) -> &'static str {
match self {
Tab::ActiveRuns => "Active Runs",
Tab::Projects => "Projects",
Tab::Config => "Config",
Tab::CreateSpec => "Create Spec",
}
}
pub fn all() -> &'static [Tab] {
&[Tab::ActiveRuns, Tab::Projects, Tab::Config, Tab::CreateSpec]
}
pub fn to_tab_id(self) -> TabId {
match self {
Tab::ActiveRuns => TabId::ActiveRuns,
Tab::Projects => TabId::Projects,
Tab::Config => TabId::Config,
Tab::CreateSpec => TabId::CreateSpec,
}
}
}
const TAB_BAR_MAX_SCROLL_WIDTH: f32 = 800.0;
const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
const TAB_CLOSE_PADDING: f32 = 4.0;
const TAB_LABEL_CLOSE_GAP: f32 = 8.0;
const CONTENT_TAB_BAR_HEIGHT: f32 = 32.0;
const CHAT_BUBBLE_MAX_WIDTH_RATIO: f32 = 0.75;
const CHAT_BUBBLE_PADDING: f32 = 12.0;
const CHAT_BUBBLE_ROUNDING: f32 = 16.0;
const CHAT_MESSAGE_SPACING: f32 = 12.0;
const USER_BUBBLE_COLOR: Color32 = Color32::from_rgb(238, 235, 229);
const CLAUDE_BUBBLE_COLOR: Color32 = Color32::from_rgb(255, 255, 255);
const INPUT_BAR_HEIGHT: f32 = 56.0;
const INPUT_FIELD_ROUNDING: f32 = 12.0;
const SEND_BUTTON_COLOR: Color32 = Color32::from_rgb(0, 122, 255);
const SEND_BUTTON_HOVER_COLOR: Color32 = Color32::from_rgb(0, 100, 210);
const SEND_BUTTON_DISABLED_COLOR: Color32 = Color32::from_rgb(200, 200, 200);
const SEND_BUTTON_SIZE: f32 = 36.0;
#[derive(Debug, Clone)]
pub enum ClaudeMessage {
Spawning,
Started,
Output(String),
ResponsePaused,
Finished {
success: bool,
error: Option<String>,
},
SpawnError(String),
}
pub struct ClaudeStdinHandle {
writer: std::sync::Mutex<Option<std::process::ChildStdin>>,
}
impl ClaudeStdinHandle {
pub fn new(stdin: std::process::ChildStdin) -> Self {
Self {
writer: std::sync::Mutex::new(Some(stdin)),
}
}
pub fn send(&self, message: &str) -> bool {
use std::io::Write;
if let Ok(mut guard) = self.writer.lock() {
if let Some(ref mut stdin) = *guard {
if let Err(e) = writeln!(stdin, "{}", message) {
eprintln!("Failed to write to Claude stdin: {}", e);
return false;
}
if let Err(e) = stdin.flush() {
eprintln!("Failed to flush Claude stdin: {}", e);
return false;
}
return true;
}
}
false
}
pub fn close(&self) {
if let Ok(mut guard) = self.writer.lock() {
*guard = None;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatMessageSender {
User,
Claude,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub sender: ChatMessageSender,
pub content: String,
pub timestamp: Instant,
}
impl ChatMessage {
pub fn new(sender: ChatMessageSender, content: impl Into<String>) -> Self {
Self {
sender,
content: content.into(),
timestamp: Instant::now(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self::new(ChatMessageSender::User, content)
}
pub fn claude(content: impl Into<String>) -> Self {
Self::new(ChatMessageSender::Claude, content)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum StoryStatus {
Completed,
Active,
Pending,
Failed,
}
impl StoryStatus {
fn color(self) -> Color32 {
match self {
StoryStatus::Completed => colors::STATUS_SUCCESS,
StoryStatus::Active => colors::STATUS_RUNNING,
StoryStatus::Pending => colors::TEXT_MUTED,
StoryStatus::Failed => colors::STATUS_ERROR,
}
}
fn background(self) -> Color32 {
match self {
StoryStatus::Completed => colors::STATUS_SUCCESS_BG,
StoryStatus::Active => colors::STATUS_RUNNING_BG,
StoryStatus::Pending => colors::SURFACE_HOVER,
StoryStatus::Failed => colors::STATUS_ERROR_BG,
}
}
fn indicator(self) -> &'static str {
match self {
StoryStatus::Completed => "[done]",
StoryStatus::Active => "[...]",
StoryStatus::Pending => "[ ]",
StoryStatus::Failed => "[x]",
}
}
}
#[derive(Debug, Clone)]
struct StoryItem {
id: String,
title: String,
status: StoryStatus,
work_summary: Option<String>,
}
fn load_story_items(session: &SessionData) -> Vec<StoryItem> {
let Some(ref run) = session.run else {
return Vec::new();
};
let Some(ref user_stories) = session.cached_user_stories else {
return Vec::new();
};
let current_story_id = run.current_story.as_deref();
let failed_stories: std::collections::HashSet<&str> = run
.iterations
.iter()
.filter(|iter| iter.status == IterationStatus::Failed)
.map(|iter| iter.story_id.as_str())
.collect();
let mut work_summaries: std::collections::HashMap<&str, &str> =
std::collections::HashMap::new();
for iter in &run.iterations {
if iter.status == IterationStatus::Success {
if let Some(ref summary) = iter.work_summary {
work_summaries.insert(&iter.story_id, summary);
}
}
}
let mut items: Vec<StoryItem> = user_stories
.iter()
.map(|story| {
let status = if Some(story.id.as_str()) == current_story_id {
StoryStatus::Active
} else if story.passes {
StoryStatus::Completed
} else if failed_stories.contains(story.id.as_str()) {
StoryStatus::Failed
} else {
StoryStatus::Pending
};
let work_summary = if status == StoryStatus::Completed {
work_summaries.get(story.id.as_str()).map(|s| s.to_string())
} else {
None
};
StoryItem {
id: story.id.clone(),
title: story.title.clone(),
status,
work_summary,
}
})
.collect();
items.sort_by(|a, b| {
let order = |s: &StoryItem| match s.status {
StoryStatus::Active => 0,
StoryStatus::Completed => 1,
StoryStatus::Failed => 2,
StoryStatus::Pending => 3,
};
order(a).cmp(&order(b))
});
items
}
pub struct Autom8App {
current_tab: Tab,
tabs: Vec<TabInfo>,
active_tab_id: TabId,
previous_tab_id: Option<TabId>,
projects: Vec<ProjectData>,
sessions: Vec<SessionData>,
has_active_runs: bool,
selected_project: Option<String>,
run_history: Vec<RunHistoryEntry>,
run_detail_cache: std::collections::HashMap<String, crate::state::RunState>,
run_history_loading: bool,
run_history_error: Option<String>,
initial_load_complete: bool,
last_refresh: Instant,
refresh_interval: Duration,
sidebar_collapsed: bool,
selected_session_id: Option<String>,
closed_session_tabs: std::collections::HashSet<String>,
seen_sessions: std::collections::HashMap<String, SessionData>,
pub config_state: ConfigTabState,
context_menu: Option<ContextMenuState>,
command_executions: std::collections::HashMap<String, CommandExecution>,
command_rx: std::sync::mpsc::Receiver<CommandMessage>,
command_tx: std::sync::mpsc::Sender<CommandMessage>,
pending_clean_confirmation: Option<PendingCleanOperation>,
pending_result_modal: Option<CleanupResult>,
section_collapsed_state: std::collections::HashMap<String, bool>,
create_spec_selected_project: Option<String>,
chat_messages: Vec<ChatMessage>,
chat_scroll_to_bottom: bool,
chat_input_text: String,
is_waiting_for_claude: bool,
claude_rx: std::sync::mpsc::Receiver<ClaudeMessage>,
claude_tx: std::sync::mpsc::Sender<ClaudeMessage>,
claude_stdin: Option<Arc<ClaudeStdinHandle>>,
claude_child: Arc<std::sync::Mutex<Option<std::process::Child>>>,
claude_response_buffer: String,
claude_error: Option<String>,
claude_starting: bool,
last_claude_output_time: Option<Instant>,
claude_response_in_progress: bool,
generated_spec_path: Option<std::path::PathBuf>,
spec_confirmed: bool,
claude_finished: bool,
pending_project_change: Option<String>,
pending_start_new_spec: bool,
}
impl Default for Autom8App {
fn default() -> Self {
Self::new()
}
}
impl Autom8App {
pub fn new() -> Self {
Self::with_refresh_interval(Duration::from_millis(DEFAULT_REFRESH_INTERVAL_MS))
}
pub fn with_refresh_interval(refresh_interval: Duration) -> Self {
let tabs = vec![
TabInfo::permanent(TabId::ActiveRuns, "Active Runs"),
TabInfo::permanent(TabId::Projects, "Projects"),
TabInfo::permanent(TabId::Config, "Config"),
];
let (command_tx, command_rx) = std::sync::mpsc::channel();
let (claude_tx, claude_rx) = std::sync::mpsc::channel();
let mut app = Self {
current_tab: Tab::default(),
tabs,
active_tab_id: TabId::default(),
previous_tab_id: None,
projects: Vec::new(),
sessions: Vec::new(),
has_active_runs: false,
selected_project: None,
run_history: Vec::new(),
run_detail_cache: std::collections::HashMap::new(),
run_history_loading: false,
run_history_error: None,
initial_load_complete: false,
last_refresh: Instant::now(),
refresh_interval,
sidebar_collapsed: false,
selected_session_id: None,
closed_session_tabs: std::collections::HashSet::new(),
seen_sessions: std::collections::HashMap::new(),
config_state: ConfigTabState::new(),
context_menu: None,
command_executions: std::collections::HashMap::new(),
command_rx,
command_tx,
pending_clean_confirmation: None,
pending_result_modal: None,
section_collapsed_state: std::collections::HashMap::new(),
create_spec_selected_project: None,
chat_messages: Vec::new(),
chat_scroll_to_bottom: false,
chat_input_text: String::new(),
is_waiting_for_claude: false,
claude_rx,
claude_tx,
claude_stdin: None,
claude_child: Arc::new(std::sync::Mutex::new(None)),
claude_response_buffer: String::new(),
claude_error: None,
claude_starting: false,
last_claude_output_time: None,
claude_response_in_progress: false,
generated_spec_path: None,
spec_confirmed: false,
claude_finished: false,
pending_project_change: None,
pending_start_new_spec: false,
};
app.refresh_data();
app.initial_load_complete = true;
app
}
pub fn is_initial_load_complete(&self) -> bool {
self.initial_load_complete
}
pub fn current_tab(&self) -> Tab {
self.current_tab
}
pub fn projects(&self) -> &[ProjectData] {
&self.projects
}
pub fn sessions(&self) -> &[SessionData] {
&self.sessions
}
pub fn has_active_runs(&self) -> bool {
self.has_active_runs
}
pub fn refresh_interval(&self) -> Duration {
self.refresh_interval
}
pub fn set_refresh_interval(&mut self, interval: Duration) {
self.refresh_interval = interval;
}
pub fn is_sidebar_collapsed(&self) -> bool {
self.sidebar_collapsed
}
pub fn set_sidebar_collapsed(&mut self, collapsed: bool) {
self.sidebar_collapsed = collapsed;
}
pub fn toggle_sidebar(&mut self) {
self.sidebar_collapsed = !self.sidebar_collapsed;
}
pub fn selected_config_scope(&self) -> &ConfigScope {
self.config_state.selected_scope()
}
pub fn set_selected_config_scope(&mut self, scope: ConfigScope) {
self.config_state.set_selected_scope(scope);
}
pub fn config_scope_projects(&self) -> &[String] {
self.config_state.scope_projects()
}
pub fn project_has_config(&self, project_name: &str) -> bool {
self.config_state.project_has_config(project_name)
}
fn refresh_config_scope_data(&mut self) {
self.config_state.refresh_scope_data();
}
pub fn cached_global_config(&self) -> Option<&crate::config::Config> {
self.config_state.cached_global_config()
}
pub fn global_config_error(&self) -> Option<&str> {
self.config_state.global_config_error()
}
pub fn cached_project_config(&self, project_name: &str) -> Option<&crate::config::Config> {
self.config_state.cached_project_config(project_name)
}
pub fn project_config_error(&self) -> Option<&str> {
self.config_state.project_config_error()
}
fn create_project_config_from_global(
&mut self,
project_name: &str,
) -> std::result::Result<(), String> {
self.config_state
.create_project_config_from_global(project_name)
}
fn apply_config_bool_changes(
&mut self,
is_global: bool,
project_name: Option<&str>,
changes: &[(ConfigBoolField, bool)],
) {
self.config_state
.apply_bool_changes(is_global, project_name, changes);
}
fn apply_config_text_changes(
&mut self,
is_global: bool,
project_name: Option<&str>,
changes: &[(ConfigTextField, String)],
) {
self.config_state
.apply_text_changes(is_global, project_name, changes);
}
fn reset_config_to_defaults(&mut self, is_global: bool, project_name: Option<&str>) {
self.config_state.reset_to_defaults(is_global, project_name);
}
pub fn is_context_menu_open(&self) -> bool {
self.context_menu.is_some()
}
pub fn context_menu(&self) -> Option<&ContextMenuState> {
self.context_menu.as_ref()
}
pub fn open_context_menu(&mut self, position: Pos2, project_name: String) {
let items = self.build_context_menu_items(&project_name);
self.context_menu = Some(ContextMenuState::new(position, project_name, items));
}
pub fn close_context_menu(&mut self) {
self.context_menu = None;
}
fn get_resumable_sessions(&self, project_name: &str) -> Vec<ResumableSessionInfo> {
let sm = match StateManager::for_project(project_name) {
Ok(sm) => sm,
Err(_) => return Vec::new(),
};
let sessions = match sm.list_sessions_with_status() {
Ok(sessions) => sessions,
Err(_) => return Vec::new(),
};
sessions
.into_iter()
.filter(is_resumable_session)
.filter_map(|s| {
let machine_state = s.machine_state?;
Some(ResumableSessionInfo::new(
s.metadata.session_id,
s.metadata.branch_name,
s.metadata.worktree_path,
machine_state,
))
})
.collect()
}
fn get_resumable_session_by_id(
&self,
project_name: &str,
session_id: &str,
) -> Option<ResumableSessionInfo> {
self.get_resumable_sessions(project_name)
.into_iter()
.find(|s| s.session_id == session_id)
}
fn get_cleanable_info(&self, project_name: &str) -> CleanableInfo {
let sm = match StateManager::for_project(project_name) {
Ok(sm) => sm,
Err(_) => return CleanableInfo::default(),
};
let sessions = match sm.list_sessions_with_status() {
Ok(sessions) => sessions,
Err(_) => return CleanableInfo::default(),
};
let mut info = CleanableInfo::default();
let mut active_spec_paths: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
for session in &sessions {
if session.metadata.session_id == "main" {
if session.metadata.is_running {
if let Some(session_sm) = sm.get_session(&session.metadata.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
active_spec_paths.insert(state.spec_json_path.clone());
if let Some(md_path) = &state.spec_md_path {
active_spec_paths.insert(md_path.clone());
}
}
}
}
continue;
}
if session.is_stale {
info.orphaned_sessions += 1;
} else if !session.metadata.is_running {
info.cleanable_worktrees += 1;
} else {
if let Some(session_sm) = sm.get_session(&session.metadata.session_id) {
if let Ok(Some(state)) = session_sm.load_current() {
active_spec_paths.insert(state.spec_json_path.clone());
if let Some(md_path) = &state.spec_md_path {
active_spec_paths.insert(md_path.clone());
}
}
}
}
}
info.cleanable_specs = count_cleanable_specs(&sm.spec_dir(), &active_spec_paths);
info.cleanable_runs = count_cleanable_runs(&sm.runs_dir());
info
}
fn build_context_menu_items(&self, project_name: &str) -> Vec<ContextMenuItem> {
let resumable_sessions = self.get_resumable_sessions(project_name);
let resume_item = match resumable_sessions.len() {
0 => {
ContextMenuItem::action_disabled("Resume", ContextMenuAction::Resume(None))
}
1 => {
let session = &resumable_sessions[0];
let label = format!("Resume ({})", session.branch_name);
ContextMenuItem::action(
label,
ContextMenuAction::Resume(Some(session.session_id.clone())),
)
}
_ => {
let submenu_items: Vec<ContextMenuItem> = resumable_sessions
.iter()
.map(|session| {
ContextMenuItem::action(
session.menu_label(),
ContextMenuAction::Resume(Some(session.session_id.clone())),
)
})
.collect();
ContextMenuItem::submenu("Resume", "resume", submenu_items)
}
};
let cleanable_info = self.get_cleanable_info(project_name);
let clean_item = if !cleanable_info.has_cleanable() {
ContextMenuItem::submenu_disabled("Clean", "clean", "Nothing to clean")
} else {
let mut submenu_items = Vec::new();
if cleanable_info.cleanable_worktrees > 0 {
let label = format!("Worktrees ({})", cleanable_info.cleanable_worktrees);
submenu_items.push(ContextMenuItem::action(
label,
ContextMenuAction::CleanWorktrees,
));
}
if cleanable_info.orphaned_sessions > 0 {
let label = format!("Orphaned ({})", cleanable_info.orphaned_sessions);
submenu_items.push(ContextMenuItem::action(
label,
ContextMenuAction::CleanOrphaned,
));
}
let data_count = cleanable_info.cleanable_specs + cleanable_info.cleanable_runs;
if data_count > 0 {
let label = format!("Data ({})", data_count);
submenu_items.push(ContextMenuItem::action(label, ContextMenuAction::CleanData));
}
ContextMenuItem::submenu("Clean", "clean", submenu_items)
};
vec![
ContextMenuItem::action("Status", ContextMenuAction::Status),
ContextMenuItem::action("Describe", ContextMenuAction::Describe),
ContextMenuItem::Separator,
resume_item,
ContextMenuItem::Separator,
clean_item,
ContextMenuItem::Separator,
ContextMenuItem::action("Remove Project", ContextMenuAction::RemoveProject),
]
}
pub fn selected_project(&self) -> Option<&str> {
self.selected_project.as_deref()
}
pub fn toggle_project_selection(&mut self, project_name: &str) {
if self.selected_project.as_deref() == Some(project_name) {
self.selected_project = None;
self.run_history.clear();
self.run_history_loading = false;
self.run_history_error = None;
} else {
self.selected_project = Some(project_name.to_string());
self.load_run_history(project_name);
}
}
fn load_run_history(&mut self, project_name: &str) {
self.run_history.clear();
self.run_history_error = None;
self.run_history_loading = true;
match load_project_run_history(project_name) {
Ok(history) => {
self.run_history = history;
}
Err(e) => {
self.run_history_error = Some(format!("Failed to load run history: {}", e));
}
}
self.run_history_loading = false;
}
pub fn run_history(&self) -> &[RunHistoryEntry] {
&self.run_history
}
pub fn is_run_history_loading(&self) -> bool {
self.run_history_loading
}
pub fn run_history_error(&self) -> Option<&str> {
self.run_history_error.as_deref()
}
pub fn is_project_selected(&self, project_name: &str) -> bool {
self.selected_project.as_deref() == Some(project_name)
}
pub fn tabs(&self) -> &[TabInfo] {
&self.tabs
}
pub fn active_tab_id(&self) -> &TabId {
&self.active_tab_id
}
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
pub fn closable_tab_count(&self) -> usize {
self.tabs.iter().filter(|t| t.closable).count()
}
pub fn set_active_tab(&mut self, tab_id: TabId) {
if self.active_tab_id != tab_id {
self.previous_tab_id = Some(self.active_tab_id.clone());
}
match &tab_id {
TabId::ActiveRuns => self.current_tab = Tab::ActiveRuns,
TabId::Projects => self.current_tab = Tab::Projects,
TabId::Config => self.current_tab = Tab::Config,
TabId::CreateSpec => self.current_tab = Tab::CreateSpec,
TabId::RunDetail(_) | TabId::CommandOutput(_) => {
}
}
self.active_tab_id = tab_id;
}
pub fn has_tab(&self, tab_id: &TabId) -> bool {
self.tabs.iter().any(|t| t.id == *tab_id)
}
pub fn open_run_detail_tab(&mut self, run_id: &str, run_label: &str) -> bool {
let tab_id = TabId::RunDetail(run_id.to_string());
if self.has_tab(&tab_id) {
self.set_active_tab(tab_id);
return false;
}
let tab = TabInfo::closable(tab_id.clone(), run_label);
self.tabs.push(tab);
self.set_active_tab(tab_id);
true
}
pub fn open_run_detail_from_entry(
&mut self,
entry: &RunHistoryEntry,
run_state: Option<crate::state::RunState>,
) {
let label = format!(
"Run - {}",
entry
.started_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %I:%M %p")
);
if let Some(state) = run_state {
self.run_detail_cache.insert(entry.run_id.clone(), state);
}
self.open_run_detail_tab(&entry.run_id, &label);
}
pub fn open_command_output_tab(&mut self, project: &str, command: &str) -> CommandOutputId {
let id = CommandOutputId::new(project, command);
let cache_key = id.cache_key();
let tab_id = TabId::CommandOutput(cache_key.clone());
let label = id.tab_label();
let execution = CommandExecution::new(id.clone());
self.command_executions.insert(cache_key, execution);
let tab = TabInfo::closable(tab_id.clone(), label);
self.tabs.push(tab);
self.set_active_tab(tab_id);
id
}
pub fn get_command_execution(&self, cache_key: &str) -> Option<&CommandExecution> {
self.command_executions.get(cache_key)
}
pub fn get_command_execution_mut(&mut self, cache_key: &str) -> Option<&mut CommandExecution> {
self.command_executions.get_mut(cache_key)
}
pub fn add_command_stdout(&mut self, cache_key: &str, line: String) {
if let Some(exec) = self.command_executions.get_mut(cache_key) {
exec.add_stdout(line);
}
}
pub fn add_command_stderr(&mut self, cache_key: &str, line: String) {
if let Some(exec) = self.command_executions.get_mut(cache_key) {
exec.add_stderr(line);
}
}
pub fn complete_command(&mut self, cache_key: &str, exit_code: i32) {
if let Some(exec) = self.command_executions.get_mut(cache_key) {
exec.complete(exit_code);
}
}
pub fn fail_command(&mut self, cache_key: &str, error_message: String) {
if let Some(exec) = self.command_executions.get_mut(cache_key) {
exec.fail(error_message);
}
}
pub fn spawn_status_command(&mut self, project_name: &str) {
let id = self.open_command_output_tab(project_name, "status");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
let project = project_name.to_string();
std::thread::spawn(move || {
match StateManager::for_project(&project) {
Ok(state_manager) => {
match state_manager.list_sessions_with_status() {
Ok(sessions) => {
let lines = format_sessions_as_text(&sessions);
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code: 0,
});
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to list sessions: {}", e),
});
}
}
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to load project: {}", e),
});
}
}
});
}
pub fn spawn_describe_command(&mut self, project_name: &str) {
let id = self.open_command_output_tab(project_name, "describe");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
let project = project_name.to_string();
std::thread::spawn(move || {
match crate::config::get_project_description(&project) {
Ok(Some(desc)) => {
let lines = format_project_description_as_text(&desc);
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code: 0,
});
}
Ok(None) => {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line: format!("Project '{}' not found.", project),
});
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code: 1,
});
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to get project description: {}", e),
});
}
}
});
}
pub fn show_resume_info(&mut self, project_name: &str, session_id: &str) {
let id = self.open_command_output_tab(project_name, "resume");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
match self.get_resumable_session_by_id(project_name, session_id) {
Some(session) => {
let lines = format_resume_info_as_text(&session);
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code: 0,
});
}
None => {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line: format!("Session '{}' not found or no longer resumable.", session_id),
});
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code: 1,
});
}
}
}
pub fn spawn_clean_worktrees_command(&mut self, project_name: &str) {
let id = self.open_command_output_tab(project_name, "clean-worktrees");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
let project = project_name.to_string();
std::thread::spawn(move || {
use crate::commands::{clean_worktrees_direct, DirectCleanOptions};
let options = DirectCleanOptions {
worktrees: true,
force: false,
};
match clean_worktrees_direct(&project, options) {
Ok(summary) => {
let lines = format_cleanup_summary_as_text(&summary, "Clean Worktrees");
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code,
});
let _ = tx.send(CommandMessage::CleanupCompleted {
result: CleanupResult::Worktrees {
project_name: project,
worktrees_removed: summary.worktrees_removed,
sessions_removed: summary.sessions_removed,
bytes_freed: summary.bytes_freed,
skipped_count: summary.sessions_skipped.len(),
error_count: summary.errors.len(),
},
});
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to clean sessions: {}", e),
});
}
}
});
}
pub fn spawn_clean_orphaned_command(&mut self, project_name: &str) {
let id = self.open_command_output_tab(project_name, "clean-orphaned");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
let project = project_name.to_string();
std::thread::spawn(move || {
use crate::commands::clean_orphaned_direct;
match clean_orphaned_direct(&project) {
Ok(summary) => {
let lines = format_cleanup_summary_as_text(&summary, "Clean Orphaned");
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code,
});
let _ = tx.send(CommandMessage::CleanupCompleted {
result: CleanupResult::Orphaned {
project_name: project,
sessions_removed: summary.sessions_removed,
bytes_freed: summary.bytes_freed,
error_count: summary.errors.len(),
},
});
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to clean orphaned sessions: {}", e),
});
}
}
});
}
pub fn spawn_clean_data_command(&mut self, project_name: &str) {
let id = self.open_command_output_tab(project_name, "clean-data");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
let project = project_name.to_string();
std::thread::spawn(move || {
use crate::commands::clean_data_direct;
match clean_data_direct(&project) {
Ok(summary) => {
let lines = format_data_cleanup_summary_as_text(&summary);
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
let _ = tx.send(CommandMessage::Completed {
cache_key,
exit_code,
});
let _ = tx.send(CommandMessage::CleanupCompleted {
result: CleanupResult::Data {
project_name: project,
specs_removed: summary.specs_removed,
runs_removed: summary.runs_removed,
bytes_freed: summary.bytes_freed,
error_count: summary.errors.len(),
},
});
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to clean data: {}", e),
});
}
}
});
}
pub fn spawn_remove_project_command(&mut self, project_name: &str) {
let id = self.open_command_output_tab(project_name, "remove-project");
let cache_key = id.cache_key();
let tx = self.command_tx.clone();
let project = project_name.to_string();
std::thread::spawn(move || {
use crate::commands::remove_project_direct;
match remove_project_direct(&project) {
Ok(summary) => {
let lines = format_removal_summary_as_text(&summary, &project);
for line in lines {
let _ = tx.send(CommandMessage::Stdout {
cache_key: cache_key.clone(),
line,
});
}
let exit_code = if summary.errors.is_empty() { 0 } else { 1 };
let _ = tx.send(CommandMessage::Completed {
cache_key: cache_key.clone(),
exit_code,
});
if summary.config_deleted {
let _ = tx.send(CommandMessage::ProjectRemoved {
project_name: project.clone(),
});
}
let _ = tx.send(CommandMessage::CleanupCompleted {
result: CleanupResult::RemoveProject {
project_name: project,
worktrees_removed: summary.worktrees_removed,
config_deleted: summary.config_deleted,
bytes_freed: summary.bytes_freed,
skipped_count: summary.worktrees_skipped.len(),
error_count: summary.errors.len(),
},
});
}
Err(e) => {
let _ = tx.send(CommandMessage::Failed {
cache_key,
error: format!("Failed to remove project: {}", e),
});
}
}
});
}
fn poll_command_messages(&mut self) {
while let Ok(msg) = self.command_rx.try_recv() {
match msg {
CommandMessage::Stdout { cache_key, line } => {
self.add_command_stdout(&cache_key, line);
}
CommandMessage::Stderr { cache_key, line } => {
self.add_command_stderr(&cache_key, line);
}
CommandMessage::Completed {
cache_key,
exit_code,
} => {
self.complete_command(&cache_key, exit_code);
}
CommandMessage::Failed { cache_key, error } => {
self.fail_command(&cache_key, error);
}
CommandMessage::ProjectRemoved { project_name } => {
self.remove_project_from_sidebar(&project_name);
}
CommandMessage::CleanupCompleted { result } => {
self.pending_result_modal = Some(result);
self.refresh_data();
}
}
}
}
fn remove_project_from_sidebar(&mut self, project_name: &str) {
self.projects.retain(|p| p.info.name != project_name);
}
pub fn close_tab(&mut self, tab_id: &TabId) -> bool {
let tab_index = match self.tabs.iter().position(|t| t.id == *tab_id) {
Some(idx) => idx,
None => return false,
};
if !self.tabs[tab_index].closable {
return false;
}
let was_active = self.active_tab_id == *tab_id;
if self.previous_tab_id.as_ref() == Some(tab_id) {
self.previous_tab_id = None;
}
self.tabs.remove(tab_index);
if let TabId::RunDetail(run_id) = tab_id {
self.run_detail_cache.remove(run_id);
}
if let TabId::CommandOutput(cache_key) = tab_id {
self.command_executions.remove(cache_key);
}
if was_active {
if let Some(prev_id) = self.previous_tab_id.take() {
if self.has_tab(&prev_id) {
self.set_active_tab(prev_id);
return true;
}
}
if tab_index > 0 && tab_index <= self.tabs.len() {
self.set_active_tab(self.tabs[tab_index - 1].id.clone());
} else if !self.tabs.is_empty() {
self.set_active_tab(TabId::Projects);
}
}
true
}
pub fn close_all_dynamic_tabs(&mut self) -> usize {
let to_close: Vec<TabId> = self
.tabs
.iter()
.filter(|t| t.closable)
.map(|t| t.id.clone())
.collect();
let count = to_close.len();
for tab_id in to_close {
self.close_tab(&tab_id);
}
count
}
pub fn get_cached_run_state(&self, run_id: &str) -> Option<&crate::state::RunState> {
self.run_detail_cache.get(run_id)
}
pub fn maybe_refresh(&mut self) {
if self.last_refresh.elapsed() >= self.refresh_interval {
self.refresh_data();
}
}
pub fn refresh_data(&mut self) {
self.last_refresh = Instant::now();
let ui_data = load_ui_data(None).unwrap_or_default();
self.projects = ui_data.projects;
self.sessions = ui_data.sessions;
self.has_active_runs = ui_data.has_active_runs;
let current_ids: std::collections::HashSet<&str> = self
.sessions
.iter()
.map(|s| s.metadata.session_id.as_str())
.collect();
for session in &self.sessions {
let session_id = &session.metadata.session_id;
if !self.closed_session_tabs.contains(session_id) {
self.seen_sessions
.insert(session_id.clone(), session.clone());
}
}
let to_reload: Vec<(String, String)> = self
.seen_sessions
.iter()
.filter(|(id, _)| !current_ids.contains(id.as_str()))
.filter(|(id, _)| !self.closed_session_tabs.contains(*id))
.map(|(id, s)| (s.project_name.clone(), id.clone()))
.collect();
for (project_name, session_id) in to_reload {
if let Some(updated) = load_session_by_id(&project_name, &session_id) {
self.seen_sessions.insert(session_id, updated);
} else {
if let Some(existing) = self.seen_sessions.get(&session_id).cloned() {
if let Some(ref run) = existing.run {
if let Some(archived_run) =
crate::ui::shared::load_archived_run(&project_name, &run.run_id)
{
let mut updated = existing;
updated.run = Some(archived_run);
updated.metadata.is_running = false;
self.seen_sessions.insert(session_id, updated);
}
}
}
}
}
if let Some(ref project) = self.selected_project {
let project_name = project.clone();
self.load_run_history(&project_name);
}
}
fn get_visible_sessions(&self) -> Vec<SessionData> {
self.seen_sessions
.values()
.filter(|s| !self.closed_session_tabs.contains(&s.metadata.session_id))
.cloned()
.collect()
}
fn find_session_by_id(&self, session_id: &str) -> Option<SessionData> {
self.seen_sessions.get(session_id).cloned()
}
}
impl eframe::App for Autom8App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.maybe_refresh();
self.poll_command_messages();
self.poll_claude_messages();
ctx.request_repaint_after(self.refresh_interval);
self.render_title_bar(ctx);
let sidebar_width = if self.sidebar_collapsed {
SIDEBAR_COLLAPSED_WIDTH
} else {
SIDEBAR_WIDTH
};
if !self.sidebar_collapsed {
egui::SidePanel::left("sidebar")
.exact_width(sidebar_width)
.resizable(false)
.frame(
egui::Frame::none()
.fill(colors::BACKGROUND)
.inner_margin(egui::Margin {
left: spacing::MD,
right: spacing::MD,
top: spacing::LG,
bottom: spacing::LG,
})
.stroke(Stroke::new(1.0, colors::SEPARATOR)),
)
.show(ctx, |ui| {
self.render_sidebar(ui);
});
}
egui::CentralPanel::default()
.frame(
egui::Frame::none()
.fill(colors::BACKGROUND)
.inner_margin(egui::Margin::same(spacing::LG)),
)
.show(ctx, |ui| {
self.render_content(ui);
});
if self.context_menu.is_some() {
if ctx.input(|i| i.key_pressed(Key::Escape)) {
self.close_context_menu();
}
}
self.render_context_menu(ctx);
self.render_confirmation_dialog(ctx);
self.render_result_modal(ctx);
self.render_project_change_confirmation(ctx);
self.render_start_new_spec_confirmation(ctx);
}
}
impl Drop for Autom8App {
fn drop(&mut self) {
if let Ok(mut guard) = self.claude_child.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
if let Some(ref stdin_handle) = self.claude_stdin {
stdin_handle.close();
}
}
}
impl Autom8App {
fn render_title_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("title_bar")
.exact_height(TITLE_BAR_HEIGHT)
.frame(
egui::Frame::none()
.fill(colors::SURFACE)
.inner_margin(egui::Margin::ZERO),
)
.show(ctx, |ui| {
let title_bar_rect = ui.max_rect();
let response = ui.interact(
title_bar_rect,
ui.id().with("title_bar_drag"),
Sense::click_and_drag(),
);
if response.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if response.double_clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(
!ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)),
));
}
ui.add_space(5.0);
ui.horizontal(|ui| {
ui.add_space(TITLE_BAR_LEFT_OFFSET);
let separator_height = SIDEBAR_TOGGLE_SIZE;
let (separator_rect, _) =
ui.allocate_exact_size(egui::vec2(1.0, separator_height), Sense::hover());
ui.painter().vline(
separator_rect.center().x,
separator_rect.y_range(),
Stroke::new(1.0, colors::SEPARATOR),
);
ui.add_space(SIDEBAR_TOGGLE_PADDING);
let toggle_response =
self.render_sidebar_toggle_button(ui, self.sidebar_collapsed);
if toggle_response.clicked() {
self.sidebar_collapsed = !self.sidebar_collapsed;
}
});
});
}
fn render_context_menu(&mut self, ctx: &egui::Context) {
let menu_state = match &self.context_menu {
Some(state) => state.clone(),
None => return,
};
let screen_rect = ctx.screen_rect();
let menu_width = calculate_context_menu_width(ctx, &menu_state.items);
let item_count = menu_state
.items
.iter()
.filter(|item| !matches!(item, ContextMenuItem::Separator))
.count();
let separator_count = menu_state
.items
.iter()
.filter(|item| matches!(item, ContextMenuItem::Separator))
.count();
let menu_height = (item_count as f32 * CONTEXT_MENU_ITEM_HEIGHT)
+ (separator_count as f32 * (spacing::SM + 1.0))
+ (CONTEXT_MENU_PADDING_V * 2.0);
let mut menu_pos = menu_state.position;
menu_pos.x += CONTEXT_MENU_CURSOR_OFFSET;
menu_pos.y += CONTEXT_MENU_CURSOR_OFFSET;
if menu_pos.x + menu_width > screen_rect.max.x - spacing::SM {
menu_pos.x = screen_rect.max.x - menu_width - spacing::SM;
}
if menu_pos.y + menu_height > screen_rect.max.y - spacing::SM {
menu_pos.y = screen_rect.max.y - menu_height - spacing::SM;
}
menu_pos.x = menu_pos.x.max(spacing::SM);
menu_pos.y = menu_pos.y.max(spacing::SM);
let mut should_close = false;
let mut selected_action: Option<ContextMenuAction> = None;
let mut hovered_submenu: Option<(String, Vec<ContextMenuItem>, Rect)> = None;
let pointer_pos = ctx.input(|i| i.pointer.hover_pos());
let primary_clicked = ctx.input(|i| i.pointer.primary_clicked());
egui::Area::new(egui::Id::new("context_menu"))
.order(Order::Foreground)
.fixed_pos(menu_pos)
.show(ctx, |ui| {
egui::Frame::none()
.fill(colors::SURFACE)
.rounding(Rounding::same(rounding::CARD))
.shadow(crate::ui::gui::theme::shadow::elevated())
.stroke(Stroke::new(1.0, colors::BORDER))
.inner_margin(egui::Margin::symmetric(0.0, CONTEXT_MENU_PADDING_V))
.show(ui, |ui| {
ui.set_min_width(menu_width);
ui.set_max_width(menu_width);
for item in &menu_state.items {
match item {
ContextMenuItem::Action {
label,
action,
enabled,
} => {
let response =
self.render_context_menu_item(ui, label, *enabled, false);
if response.clicked {
selected_action = Some(action.clone());
should_close = true;
}
}
ContextMenuItem::Separator => {
ui.add_space(spacing::XS);
let rect = ui.available_rect_before_wrap();
let separator_rect =
Rect::from_min_size(rect.min, Vec2::new(menu_width, 1.0));
ui.painter().rect_filled(
separator_rect,
Rounding::ZERO,
colors::SEPARATOR,
);
ui.allocate_space(Vec2::new(menu_width, 1.0));
ui.add_space(spacing::XS);
}
ContextMenuItem::Submenu {
label,
id,
enabled,
items,
hint,
} => {
let response =
self.render_context_menu_item(ui, label, *enabled, true);
if response.hovered && *enabled && !items.is_empty() {
hovered_submenu =
Some((id.clone(), items.clone(), response.rect));
}
if response.hovered_raw && !*enabled {
if let Some(hint_text) = hint {
egui::show_tooltip_at_pointer(
ui.ctx(),
ui.layer_id(),
egui::Id::new("submenu_hint").with(id),
|ui| {
ui.label(hint_text);
},
);
}
}
}
}
}
});
});
let menu_rect = Rect::from_min_size(menu_pos, Vec2::new(menu_width, menu_height));
let mut submenu_rect: Option<Rect> = None;
let submenu_to_render = if let Some((id, items, trigger_rect)) = hovered_submenu {
if let Some(menu) = &mut self.context_menu {
let submenu_pos = Pos2::new(
menu_pos.x + menu_width + CONTEXT_MENU_SUBMENU_GAP,
trigger_rect.min.y,
);
menu.open_submenu(id.clone(), submenu_pos);
}
Some((items, trigger_rect))
} else if let (Some(open_id), Some(open_pos)) =
(&menu_state.open_submenu, menu_state.submenu_position)
{
let items = menu_state.items.iter().find_map(|item| {
if let ContextMenuItem::Submenu { id, items, .. } = item {
if id == open_id {
return Some(items.clone());
}
}
None
});
let trigger_rect = Rect::from_min_size(
Pos2::new(menu_pos.x, open_pos.y),
Vec2::new(menu_width, CONTEXT_MENU_ITEM_HEIGHT),
);
items.map(|i| (i, trigger_rect))
} else {
if let Some(menu) = &mut self.context_menu {
menu.close_submenu();
}
None
};
if let Some((submenu_items, trigger_rect)) = submenu_to_render {
if !submenu_items.is_empty() {
let submenu_width = calculate_context_menu_width(ctx, &submenu_items);
let submenu_item_count = submenu_items
.iter()
.filter(|item| !matches!(item, ContextMenuItem::Separator))
.count();
let submenu_separator_count = submenu_items
.iter()
.filter(|item| matches!(item, ContextMenuItem::Separator))
.count();
let submenu_height = (submenu_item_count as f32 * CONTEXT_MENU_ITEM_HEIGHT)
+ (submenu_separator_count as f32 * (spacing::SM + 1.0))
+ (CONTEXT_MENU_PADDING_V * 2.0);
let mut submenu_pos = Pos2::new(
menu_pos.x + menu_width + CONTEXT_MENU_SUBMENU_GAP,
trigger_rect.min.y - CONTEXT_MENU_PADDING_V,
);
if submenu_pos.x + submenu_width > screen_rect.max.x - spacing::SM {
submenu_pos.x = menu_pos.x - submenu_width - CONTEXT_MENU_SUBMENU_GAP;
}
if submenu_pos.y + submenu_height > screen_rect.max.y - spacing::SM {
submenu_pos.y = screen_rect.max.y - submenu_height - spacing::SM;
}
submenu_pos.y = submenu_pos.y.max(spacing::SM);
submenu_rect = Some(Rect::from_min_size(
submenu_pos,
Vec2::new(submenu_width, submenu_height),
));
egui::Area::new(egui::Id::new("context_submenu"))
.order(Order::Foreground)
.fixed_pos(submenu_pos)
.show(ctx, |ui| {
egui::Frame::none()
.fill(colors::SURFACE)
.rounding(Rounding::same(rounding::CARD))
.shadow(crate::ui::gui::theme::shadow::elevated())
.stroke(Stroke::new(1.0, colors::BORDER))
.inner_margin(egui::Margin::symmetric(0.0, CONTEXT_MENU_PADDING_V))
.show(ui, |ui| {
ui.set_min_width(submenu_width);
ui.set_max_width(submenu_width);
for item in &submenu_items {
match item {
ContextMenuItem::Action {
label,
action,
enabled,
} => {
let response = self.render_context_menu_item(
ui, label, *enabled, false,
);
if response.clicked {
selected_action = Some(action.clone());
should_close = true;
}
}
ContextMenuItem::Separator => {
ui.add_space(spacing::XS);
let rect = ui.available_rect_before_wrap();
let separator_rect = Rect::from_min_size(
rect.min,
Vec2::new(submenu_width, 1.0),
);
ui.painter().rect_filled(
separator_rect,
Rounding::ZERO,
colors::SEPARATOR,
);
ui.allocate_space(Vec2::new(submenu_width, 1.0));
ui.add_space(spacing::XS);
}
ContextMenuItem::Submenu { .. } => {
}
}
}
});
});
}
}
if primary_clicked {
if let Some(pos) = pointer_pos {
let in_menu = menu_rect.contains(pos);
let in_submenu = submenu_rect.map(|r| r.contains(pos)).unwrap_or(false);
if !in_menu && !in_submenu {
should_close = true;
}
}
}
if let Some(action) = selected_action {
let project_name = menu_state.project_name.clone();
match action {
ContextMenuAction::Status => {
self.spawn_status_command(&project_name);
}
ContextMenuAction::Describe => {
self.spawn_describe_command(&project_name);
}
ContextMenuAction::Resume(session_id) => {
if let Some(id) = session_id {
self.show_resume_info(&project_name, &id);
}
}
ContextMenuAction::CleanWorktrees => {
self.pending_clean_confirmation = Some(PendingCleanOperation::Worktrees {
project_name: project_name.clone(),
});
}
ContextMenuAction::CleanOrphaned => {
self.pending_clean_confirmation = Some(PendingCleanOperation::Orphaned {
project_name: project_name.clone(),
});
}
ContextMenuAction::CleanData => {
let cleanable_info = self.get_cleanable_info(&project_name);
self.pending_clean_confirmation = Some(PendingCleanOperation::Data {
project_name: project_name.clone(),
specs_count: cleanable_info.cleanable_specs,
runs_count: cleanable_info.cleanable_runs,
});
}
ContextMenuAction::RemoveProject => {
self.pending_clean_confirmation = Some(PendingCleanOperation::RemoveProject {
project_name: project_name.clone(),
});
}
}
}
if should_close {
self.close_context_menu();
}
}
fn render_context_menu_item(
&self,
ui: &mut egui::Ui,
label: &str,
enabled: bool,
has_submenu: bool,
) -> ContextMenuItemResponse {
let item_size = Vec2::new(ui.available_width(), CONTEXT_MENU_ITEM_HEIGHT);
let (rect, response) = ui.allocate_exact_size(item_size, Sense::click());
let is_hovered = response.hovered() && enabled;
let painter = ui.painter();
if is_hovered {
painter.rect_filled(rect, Rounding::ZERO, colors::SURFACE_HOVER);
}
let text_x = rect.min.x + CONTEXT_MENU_PADDING_H;
let text_color = if enabled {
colors::TEXT_PRIMARY
} else {
colors::TEXT_DISABLED
};
let galley = painter.layout_no_wrap(
label.to_string(),
typography::font(FontSize::Body, FontWeight::Regular),
text_color,
);
let text_y = rect.center().y - galley.rect.height() / 2.0;
painter.galley(Pos2::new(text_x, text_y), galley, Color32::TRANSPARENT);
if has_submenu {
let arrow_x = rect.max.x - CONTEXT_MENU_PADDING_H - CONTEXT_MENU_ARROW_SIZE;
let arrow_y = rect.center().y;
let arrow_color = if enabled {
colors::TEXT_SECONDARY
} else {
colors::TEXT_DISABLED
};
let arrow_points = [
Pos2::new(arrow_x, arrow_y - CONTEXT_MENU_ARROW_SIZE / 2.0),
Pos2::new(arrow_x + CONTEXT_MENU_ARROW_SIZE / 2.0, arrow_y),
Pos2::new(arrow_x, arrow_y + CONTEXT_MENU_ARROW_SIZE / 2.0),
];
painter.line_segment(
[arrow_points[0], arrow_points[1]],
Stroke::new(1.5, arrow_color),
);
painter.line_segment(
[arrow_points[1], arrow_points[2]],
Stroke::new(1.5, arrow_color),
);
}
let screen_rect = ui.clip_rect();
let screen_item_rect = Rect::from_min_max(
Pos2::new(screen_rect.min.x, rect.min.y),
Pos2::new(screen_rect.min.x + ui.available_width(), rect.max.y),
);
if enabled && response.hovered() {
ui.ctx()
.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
}
ContextMenuItemResponse {
clicked: response.clicked() && enabled,
hovered: is_hovered,
hovered_raw: response.hovered(),
rect: screen_item_rect,
}
}
fn render_confirmation_dialog(&mut self, ctx: &egui::Context) {
let pending = match &self.pending_clean_confirmation {
Some(op) => op.clone(),
None => return,
};
let modal = Modal::new(pending.title())
.id("clean_confirmation")
.message(pending.message())
.cancel_button(ModalButton::secondary("Cancel"))
.confirm_button(ModalButton::destructive(pending.confirm_button_label()));
match modal.show(ctx) {
ModalAction::Confirmed => {
let project_name = pending.project_name().to_string();
match pending {
PendingCleanOperation::Worktrees { .. } => {
self.spawn_clean_worktrees_command(&project_name);
}
PendingCleanOperation::Orphaned { .. } => {
self.spawn_clean_orphaned_command(&project_name);
}
PendingCleanOperation::Data { .. } => {
self.spawn_clean_data_command(&project_name);
}
PendingCleanOperation::RemoveProject { .. } => {
self.spawn_remove_project_command(&project_name);
}
}
self.pending_clean_confirmation = None;
}
ModalAction::Cancelled => {
self.pending_clean_confirmation = None;
}
ModalAction::None => {
}
}
}
fn render_result_modal(&mut self, ctx: &egui::Context) {
let result = match &self.pending_result_modal {
Some(r) => r.clone(),
None => return,
};
let modal = Modal::new(result.title())
.id("cleanup_result")
.message(result.message())
.no_cancel_button()
.confirm_button(ModalButton::new("OK"));
match modal.show(ctx) {
ModalAction::Confirmed | ModalAction::Cancelled => {
self.pending_result_modal = None;
}
ModalAction::None => {
}
}
}
fn render_project_change_confirmation(&mut self, ctx: &egui::Context) {
let pending_project = match &self.pending_project_change {
Some(name) => name.clone(),
None => return,
};
let modal = Modal::new("Switch Project?")
.id("project_change_confirmation")
.message(
"You have an active spec creation session. \
Switching projects will discard your current conversation and any unsaved work.\n\n\
Do you want to continue?",
)
.cancel_button(ModalButton::secondary("Cancel"))
.confirm_button(ModalButton::destructive("Switch Project"));
match modal.show(ctx) {
ModalAction::Confirmed => {
self.reset_create_spec_session();
self.create_spec_selected_project = Some(pending_project);
self.pending_project_change = None;
}
ModalAction::Cancelled => {
self.pending_project_change = None;
}
ModalAction::None => {
}
}
}
fn render_start_new_spec_confirmation(&mut self, ctx: &egui::Context) {
if !self.pending_start_new_spec {
return;
}
let message = if let Some(ref spec_path) = self.generated_spec_path {
format!(
"Your spec has been saved to:\n\n{}\n\n\
Make sure you've copied the run command or noted the file location before starting a new spec.\n\n\
Do you want to start a new spec?",
spec_path.display()
)
} else {
"Make sure you've saved any important information from this session.\n\n\
Do you want to start a new spec?"
.to_string()
};
let modal = Modal::new("Close?")
.id("start_new_spec_confirmation")
.message(&message)
.cancel_button(ModalButton::secondary("Cancel"))
.confirm_button(ModalButton::new("Close"));
match modal.show(ctx) {
ModalAction::Confirmed => {
self.reset_create_spec_session();
self.pending_start_new_spec = false;
}
ModalAction::Cancelled => {
self.pending_start_new_spec = false;
}
ModalAction::None => {
}
}
}
fn render_sidebar_toggle_button(
&self,
ui: &mut egui::Ui,
is_collapsed: bool,
) -> egui::Response {
let button_size = egui::vec2(SIDEBAR_TOGGLE_SIZE, SIDEBAR_TOGGLE_SIZE);
let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
let is_hovered = response.hovered();
if is_hovered {
ui.painter().rect_filled(
rect,
Rounding::same(rounding::BUTTON),
colors::SURFACE_HOVER,
);
}
let icon_color = if is_hovered {
colors::TEXT_PRIMARY
} else {
colors::TEXT_SECONDARY
};
let painter = ui.painter();
let center = rect.center();
if is_collapsed {
let line_width = 12.0;
let line_spacing = 4.0;
let half_width = line_width / 2.0;
for i in -1..=1 {
let y = center.y + (i as f32) * line_spacing;
painter.line_segment(
[
egui::pos2(center.x - half_width, y),
egui::pos2(center.x + half_width, y),
],
Stroke::new(1.5, icon_color),
);
}
} else {
let icon_rect = Rect::from_center_size(center, egui::vec2(14.0, 12.0));
painter.rect_stroke(icon_rect, Rounding::same(1.0), Stroke::new(1.5, icon_color));
let divider_x = icon_rect.left() + 5.0;
painter.line_segment(
[
egui::pos2(divider_x, icon_rect.top() + 1.0),
egui::pos2(divider_x, icon_rect.bottom() - 1.0),
],
Stroke::new(1.0, icon_color),
);
let line_start_x = divider_x + 2.0;
let line_end_x = icon_rect.right() - 2.0;
for i in 0..2 {
let y = icon_rect.top() + 4.0 + (i as f32) * 4.0;
painter.line_segment(
[egui::pos2(line_start_x, y), egui::pos2(line_end_x, y)],
Stroke::new(1.0, icon_color),
);
}
}
let tooltip_text = if is_collapsed {
"Show sidebar"
} else {
"Hide sidebar"
};
response
.on_hover_text(tooltip_text)
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
ui.add_space(spacing::SM);
let mut tab_to_activate: Option<TabId> = None;
let permanent_tabs: Vec<(TabId, &'static str)> = vec![
(TabId::ActiveRuns, "Active Runs"),
(TabId::Projects, "Projects"),
(TabId::Config, "Config"),
(TabId::CreateSpec, "Create Spec"),
];
for (tab_id, label) in permanent_tabs {
let is_active = self.active_tab_id == tab_id;
if self.render_sidebar_item(ui, label, is_active) {
tab_to_activate = Some(tab_id);
}
ui.add_space(spacing::XS);
}
if let Some(tab_id) = tab_to_activate {
self.set_active_tab(tab_id);
}
let animation_height = 150.0;
let icon_section_height = SIDEBAR_ICON_SIZE + spacing::LG * 2.0; ui.add_space(ui.available_height() - animation_height - icon_section_height);
ui.add_space(spacing::LG);
ui.horizontal(|ui| {
let sidebar_width = ui.available_width();
let icon_offset = (sidebar_width - SIDEBAR_ICON_SIZE) / 2.0;
ui.add_space(icon_offset);
ui.add(
egui::Image::new(egui::include_image!("../../../assets/icon.png"))
.fit_to_exact_size(egui::vec2(SIDEBAR_ICON_SIZE, SIDEBAR_ICON_SIZE)),
);
});
ui.add_space(spacing::LG);
let sidebar_width = ui.available_width();
super::animation::render_rising_particles(ui, sidebar_width, animation_height);
super::animation::schedule_frame(ui.ctx());
});
}
fn render_sidebar_item(&self, ui: &mut egui::Ui, label: &str, is_active: bool) -> bool {
let available_width = ui.available_width();
let item_size = egui::vec2(available_width, SIDEBAR_ITEM_HEIGHT);
let (rect, response) = ui.allocate_exact_size(item_size, Sense::click());
let is_hovered = response.hovered();
let bg_color = if is_active {
colors::SURFACE_SELECTED
} else if is_hovered {
colors::SURFACE_HOVER
} else {
Color32::TRANSPARENT
};
if bg_color != Color32::TRANSPARENT {
ui.painter()
.rect_filled(rect, Rounding::same(SIDEBAR_ITEM_ROUNDING), bg_color);
}
if is_active {
let indicator_rect = Rect::from_min_size(
rect.min,
egui::vec2(SIDEBAR_ACTIVE_INDICATOR_WIDTH, rect.height()),
);
ui.painter().rect_filled(
indicator_rect,
Rounding {
nw: SIDEBAR_ITEM_ROUNDING,
sw: SIDEBAR_ITEM_ROUNDING,
ne: 0.0,
se: 0.0,
},
colors::ACCENT,
);
}
let text_color = if is_active {
colors::TEXT_PRIMARY
} else {
colors::TEXT_SECONDARY
};
let text_pos = egui::pos2(rect.left() + SIDEBAR_ITEM_PADDING_H, rect.center().y);
ui.painter().text(
text_pos,
egui::Align2::LEFT_CENTER,
label,
typography::font(
FontSize::Body,
if is_active {
FontWeight::SemiBold
} else {
FontWeight::Medium
},
),
text_color,
);
response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
}
#[allow(dead_code)]
fn render_header(&mut self, ui: &mut egui::Ui) {
let scroll_width = ui.available_width().min(TAB_BAR_MAX_SCROLL_WIDTH);
ui.horizontal_centered(|ui| {
ui.add_space(spacing::XS);
egui::ScrollArea::horizontal()
.max_width(scroll_width)
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
.show(ui, |ui| {
ui.horizontal(|ui| {
let mut tab_to_activate: Option<TabId> = None;
let mut tab_to_close: Option<TabId> = None;
let tabs_snapshot: Vec<(TabId, String, bool)> = self
.tabs
.iter()
.map(|t| (t.id.clone(), t.label.clone(), t.closable))
.collect();
for (tab_id, label, closable) in &tabs_snapshot {
let is_active = self.active_tab_id == *tab_id;
let (clicked, close_clicked) =
self.render_dynamic_tab(ui, label, *closable, is_active);
if clicked {
tab_to_activate = Some(tab_id.clone());
}
if close_clicked {
tab_to_close = Some(tab_id.clone());
}
ui.add_space(spacing::XS);
}
if let Some(tab_id) = tab_to_close {
self.close_tab(&tab_id);
} else if let Some(tab_id) = tab_to_activate {
self.set_active_tab(tab_id);
}
});
});
});
let rect = ui.max_rect();
ui.painter().hline(
rect.x_range(),
rect.bottom(),
Stroke::new(1.0, colors::BORDER),
);
}
#[allow(dead_code)]
fn render_dynamic_tab(
&self,
ui: &mut egui::Ui,
label: &str,
closable: bool,
is_active: bool,
) -> (bool, bool) {
let text_galley = ui.fonts(|f| {
f.layout_no_wrap(
label.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let text_size = text_galley.size();
let close_button_space = if closable {
TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING
} else {
0.0
};
let tab_width = text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
let tab_size = egui::vec2(tab_width, HEADER_HEIGHT - TAB_UNDERLINE_HEIGHT);
let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
let is_hovered = response.hovered();
if is_hovered && !is_active {
ui.painter().rect_filled(
rect,
Rounding::same(rounding::BUTTON),
colors::SURFACE_HOVER,
);
}
let text_color = if is_active {
colors::TEXT_PRIMARY
} else if is_hovered {
colors::TEXT_SECONDARY
} else {
colors::TEXT_MUTED
};
let text_x = if closable {
rect.left() + TAB_PADDING_H
} else {
rect.center().x - text_size.x / 2.0
};
let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
ui.painter().galley(
text_pos,
ui.fonts(|f| {
f.layout_no_wrap(
label.to_string(),
typography::font(
FontSize::Body,
if is_active {
FontWeight::SemiBold
} else {
FontWeight::Medium
},
),
text_color,
)
}),
Color32::TRANSPARENT,
);
let mut close_clicked = false;
if closable {
let close_rect = Rect::from_min_size(
egui::pos2(
rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
),
egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
);
let close_hovered = ui
.ctx()
.input(|i| i.pointer.hover_pos())
.is_some_and(|pos| close_rect.contains(pos));
if close_hovered {
ui.painter().rect_filled(
close_rect,
Rounding::same(rounding::SMALL),
colors::SURFACE_HOVER,
);
ui.ctx()
.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
}
let x_color = if close_hovered {
colors::TEXT_PRIMARY
} else {
colors::TEXT_MUTED
};
let x_center = close_rect.center();
let x_size = TAB_CLOSE_BUTTON_SIZE * 0.35 * if close_hovered { 1.15 } else { 1.0 };
ui.painter().line_segment(
[
egui::pos2(x_center.x - x_size, x_center.y - x_size),
egui::pos2(x_center.x + x_size, x_center.y + x_size),
],
Stroke::new(1.5, x_color),
);
ui.painter().line_segment(
[
egui::pos2(x_center.x + x_size, x_center.y - x_size),
egui::pos2(x_center.x - x_size, x_center.y + x_size),
],
Stroke::new(1.5, x_color),
);
if response.clicked() && close_hovered {
close_clicked = true;
}
}
if is_active {
let underline_rect = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.bottom() - TAB_UNDERLINE_HEIGHT),
egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
);
ui.painter()
.rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
}
let tab_clicked = response.clicked() && !close_clicked;
(tab_clicked, close_clicked)
}
fn render_content(&mut self, ui: &mut egui::Ui) {
let has_dynamic_tabs = self.closable_tab_count() > 0;
if has_dynamic_tabs {
self.render_content_tab_bar(ui);
let separator_rect = ui.available_rect_before_wrap();
ui.painter().hline(
separator_rect.x_range(),
separator_rect.top(),
Stroke::new(1.0, colors::SEPARATOR),
);
ui.add_space(spacing::SM);
}
match &self.active_tab_id {
TabId::ActiveRuns => self.render_active_runs(ui),
TabId::Projects => self.render_projects(ui),
TabId::Config => self.render_config(ui),
TabId::CreateSpec => self.render_create_spec(ui),
TabId::RunDetail(run_id) => {
let run_id = run_id.clone();
self.render_run_detail(ui, &run_id);
}
TabId::CommandOutput(cache_key) => {
let cache_key = cache_key.clone();
self.render_command_output(ui, &cache_key);
}
}
}
fn render_content_tab_bar(&mut self, ui: &mut egui::Ui) {
let available_width = ui.available_width();
let scroll_width = available_width.min(TAB_BAR_MAX_SCROLL_WIDTH);
ui.allocate_ui_with_layout(
egui::vec2(available_width, CONTENT_TAB_BAR_HEIGHT),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let mut tab_to_activate: Option<TabId> = None;
let mut tab_to_close: Option<TabId> = None;
egui::ScrollArea::horizontal()
.max_width(scroll_width)
.auto_shrink([false, false])
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
)
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(spacing::XS);
let dynamic_tabs: Vec<(TabId, String)> = self
.tabs
.iter()
.filter(|t| t.closable)
.map(|t| (t.id.clone(), t.label.clone()))
.collect();
for (tab_id, label) in &dynamic_tabs {
let is_active = self.active_tab_id == *tab_id;
let (clicked, close_clicked) =
self.render_content_tab(ui, label, is_active);
if clicked {
tab_to_activate = Some(tab_id.clone());
}
if close_clicked {
tab_to_close = Some(tab_id.clone());
}
ui.add_space(spacing::XS);
}
});
});
if let Some(tab_id) = tab_to_close {
self.close_tab(&tab_id);
} else if let Some(tab_id) = tab_to_activate {
self.set_active_tab(tab_id);
}
},
);
}
fn render_content_tab(&self, ui: &mut egui::Ui, label: &str, is_active: bool) -> (bool, bool) {
let text_galley = ui.fonts(|f| {
f.layout_no_wrap(
label.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let text_size = text_galley.size();
let close_button_space = TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING;
let tab_width = text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
let tab_height = CONTENT_TAB_BAR_HEIGHT - TAB_UNDERLINE_HEIGHT - spacing::XS;
let tab_size = egui::vec2(tab_width, tab_height);
let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
let is_hovered = response.hovered();
let bg_color = if is_active {
colors::SURFACE_SELECTED
} else if is_hovered {
colors::SURFACE_HOVER
} else {
Color32::TRANSPARENT
};
if bg_color != Color32::TRANSPARENT {
ui.painter()
.rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
}
let text_color = if is_active {
colors::TEXT_PRIMARY
} else if is_hovered {
colors::TEXT_SECONDARY
} else {
colors::TEXT_MUTED
};
let text_x = rect.left() + TAB_PADDING_H;
let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
ui.painter().galley(
text_pos,
ui.fonts(|f| {
f.layout_no_wrap(
label.to_string(),
typography::font(
FontSize::Body,
if is_active {
FontWeight::SemiBold
} else {
FontWeight::Medium
},
),
text_color,
)
}),
Color32::TRANSPARENT,
);
let close_rect = Rect::from_min_size(
egui::pos2(
rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
),
egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
);
let close_hovered = ui
.ctx()
.input(|i| i.pointer.hover_pos())
.is_some_and(|pos| close_rect.contains(pos));
if close_hovered {
ui.painter().rect_filled(
close_rect,
Rounding::same(rounding::SMALL),
colors::SURFACE_HOVER,
);
ui.ctx()
.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
}
let x_color = if close_hovered {
colors::TEXT_PRIMARY
} else {
colors::TEXT_MUTED
};
let x_center = close_rect.center();
let x_size = TAB_CLOSE_BUTTON_SIZE * 0.3 * if close_hovered { 1.15 } else { 1.0 };
ui.painter().line_segment(
[
egui::pos2(x_center.x - x_size, x_center.y - x_size),
egui::pos2(x_center.x + x_size, x_center.y + x_size),
],
Stroke::new(1.5, x_color),
);
ui.painter().line_segment(
[
egui::pos2(x_center.x + x_size, x_center.y - x_size),
egui::pos2(x_center.x - x_size, x_center.y + x_size),
],
Stroke::new(1.5, x_color),
);
if is_active {
let underline_rect = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.bottom()),
egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
);
ui.painter()
.rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
}
let close_clicked = response.clicked() && close_hovered;
let tab_clicked = response.clicked() && !close_hovered;
(tab_clicked, close_clicked)
}
fn render_config(&mut self, ui: &mut egui::Ui) {
self.refresh_config_scope_data();
let mut editor_actions = ConfigEditorActions::default();
let available_width = ui.available_width();
let available_height = ui.available_height();
let divider_total_width = SPLIT_DIVIDER_WIDTH + SPLIT_DIVIDER_MARGIN * 2.0;
let panel_width =
((available_width - divider_total_width) / 2.0).max(SPLIT_PANEL_MIN_WIDTH);
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(
Vec2::new(panel_width, available_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| {
self.render_config_left_panel(ui);
},
);
ui.add_space(SPLIT_DIVIDER_MARGIN);
let divider_rect = ui.available_rect_before_wrap();
let divider_line_rect = Rect::from_min_size(
divider_rect.min,
Vec2::new(SPLIT_DIVIDER_WIDTH, available_height),
);
ui.painter()
.rect_filled(divider_line_rect, Rounding::ZERO, colors::SEPARATOR);
ui.add_space(SPLIT_DIVIDER_WIDTH);
ui.add_space(SPLIT_DIVIDER_MARGIN);
let actions_response = ui.allocate_ui_with_layout(
Vec2::new(ui.available_width(), available_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| self.render_config_right_panel(ui),
);
editor_actions = actions_response.inner;
});
if let Some(project_name) = editor_actions.create_project_config {
if let Err(e) = self.create_project_config_from_global(&project_name) {
self.config_state.project_config_error = Some(e);
}
}
if !editor_actions.bool_changes.is_empty() {
self.apply_config_bool_changes(
editor_actions.is_global,
editor_actions.project_name.as_deref(),
&editor_actions.bool_changes,
);
}
if !editor_actions.text_changes.is_empty() {
self.apply_config_text_changes(
editor_actions.is_global,
editor_actions.project_name.as_deref(),
&editor_actions.text_changes,
);
}
if editor_actions.reset_to_defaults {
self.reset_config_to_defaults(
editor_actions.is_global,
editor_actions.project_name.as_deref(),
);
}
}
fn render_config_left_panel(&mut self, ui: &mut egui::Ui) {
ui.label(
egui::RichText::new("Scope")
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
egui::ScrollArea::vertical()
.id_salt("config_scope_list")
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
.show(ui, |ui| {
if self.render_config_scope_item(ui, ConfigScope::Global, true) {
self.config_state.selected_scope = ConfigScope::Global;
}
ui.add_space(spacing::SM);
let projects: Vec<String> = self.config_state.scope_projects.clone();
for project in projects {
let has_config = self.project_has_config(&project);
let scope = ConfigScope::Project(project.clone());
if self.render_config_scope_item(ui, scope.clone(), has_config) {
self.config_state.selected_scope = scope;
}
ui.add_space(spacing::XS);
}
});
}
fn render_config_scope_item(
&self,
ui: &mut egui::Ui,
scope: ConfigScope,
has_config: bool,
) -> bool {
let is_selected = self.config_state.selected_scope == scope;
let (display_text, text_color) = match &scope {
ConfigScope::Global => ("Global".to_string(), colors::TEXT_PRIMARY),
ConfigScope::Project(name) => {
if has_config {
(name.clone(), colors::TEXT_PRIMARY)
} else {
(format!("{} (global)", name), colors::TEXT_MUTED)
}
}
};
let (rect, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), CONFIG_SCOPE_ROW_HEIGHT),
Sense::click(),
);
if ui.is_rect_visible(rect) {
let bg_color = if is_selected {
colors::SURFACE_SELECTED
} else if response.hovered() {
colors::SURFACE_HOVER
} else {
Color32::TRANSPARENT
};
ui.painter()
.rect_filled(rect, Rounding::same(SIDEBAR_ITEM_ROUNDING), bg_color);
if is_selected {
let indicator_rect = Rect::from_min_size(
rect.min,
Vec2::new(SIDEBAR_ACTIVE_INDICATOR_WIDTH, rect.height()),
);
ui.painter().rect_filled(
indicator_rect,
Rounding::same(SIDEBAR_ACTIVE_INDICATOR_WIDTH / 2.0),
colors::ACCENT,
);
}
let text_rect = rect.shrink2(Vec2::new(
CONFIG_SCOPE_ROW_PADDING_H
+ (if is_selected {
SIDEBAR_ACTIVE_INDICATOR_WIDTH + 4.0
} else {
0.0
}),
CONFIG_SCOPE_ROW_PADDING_V,
));
let font_weight = if is_selected {
FontWeight::SemiBold
} else {
FontWeight::Regular
};
ui.painter().text(
text_rect.left_center(),
egui::Align2::LEFT_CENTER,
&display_text,
typography::font(FontSize::Body, font_weight),
text_color,
);
}
response.clicked()
}
fn render_config_right_panel(&self, ui: &mut egui::Ui) -> ConfigEditorActions {
let mut actions = ConfigEditorActions::default();
let (header_text, tooltip_text) = match &self.config_state.selected_scope {
ConfigScope::Global => {
actions.is_global = true;
let path = crate::config::global_config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "~/.config/autom8/config.toml".to_string());
("Global Config".to_string(), path)
}
ConfigScope::Project(name) => {
actions.is_global = false;
actions.project_name = Some(name.clone());
let path = crate::config::project_config_path_for(name)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| format!("~/.config/autom8/{}/config.toml", name));
if self.project_has_config(name) {
(format!("Project Config: {}", name), path)
} else {
(format!("Project Config: {} (using global)", name), path)
}
}
};
let header_response = ui.label(
egui::RichText::new(&header_text)
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
header_response.on_hover_text(&tooltip_text);
ui.add_space(spacing::MD);
match &self.config_state.selected_scope {
ConfigScope::Global => {
let (bool_changes, text_changes, reset_clicked) =
self.render_global_config_editor(ui);
actions.bool_changes = bool_changes;
actions.text_changes = text_changes;
actions.reset_to_defaults = reset_clicked;
}
ConfigScope::Project(name) => {
if self.project_has_config(name) {
let (bool_changes, text_changes, reset_clicked) =
self.render_project_config_editor(ui, name);
actions.bool_changes = bool_changes;
actions.text_changes = text_changes;
actions.reset_to_defaults = reset_clicked;
} else {
let project_name = name.clone();
egui::ScrollArea::vertical()
.id_salt("config_editor")
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new(
"This project does not have a config file.\nIt uses the global configuration.",
)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::LG);
if self.render_create_config_button(ui) {
actions.create_project_config = Some(project_name.clone());
}
});
});
}
}
}
if self.config_state.last_modified.is_some() {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new("Changes take effect on next run")
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
actions
}
fn render_create_config_button(&self, ui: &mut egui::Ui) -> bool {
let button_text = "Create Project Config";
let text_galley = ui.fonts(|f| {
f.layout_no_wrap(
button_text.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let text_size = text_galley.size();
let button_padding_h = spacing::LG;
let button_padding_v = spacing::SM;
let button_size = Vec2::new(
text_size.x + button_padding_h * 2.0,
text_size.y + button_padding_v * 2.0,
);
let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
let is_hovered = response.hovered();
let bg_color = if is_hovered {
colors::ACCENT
} else {
colors::ACCENT_SUBTLE
};
ui.painter()
.rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
let text_color = if is_hovered {
colors::TEXT_PRIMARY
} else {
colors::ACCENT
};
let text_pos = rect.center() - text_size / 2.0;
ui.painter().galley(
text_pos,
ui.fonts(|f| {
f.layout_no_wrap(
button_text.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
text_color,
)
}),
text_color,
);
response.clicked()
}
fn render_reset_to_defaults_button(&self, ui: &mut egui::Ui) -> bool {
let button_text = "Reset to Defaults";
let text_galley = ui.fonts(|f| {
f.layout_no_wrap(
button_text.to_string(),
typography::font(FontSize::Small, FontWeight::Regular),
colors::TEXT_MUTED,
)
});
let text_size = text_galley.size();
let button_padding_h = spacing::MD;
let button_padding_v = spacing::XS;
let button_size = Vec2::new(
text_size.x + button_padding_h * 2.0,
text_size.y + button_padding_v * 2.0,
);
let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
let is_hovered = response.hovered();
if is_hovered {
ui.painter()
.rect_filled(rect, Rounding::same(rounding::BUTTON), colors::SURFACE);
}
let text_color = if is_hovered {
colors::TEXT_SECONDARY
} else {
colors::TEXT_MUTED
};
let text_pos = rect.center() - text_size / 2.0;
ui.painter().galley(
text_pos,
ui.fonts(|f| {
f.layout_no_wrap(
button_text.to_string(),
typography::font(FontSize::Small, FontWeight::Regular),
text_color,
)
}),
text_color,
);
response.clicked()
}
fn render_global_config_editor(
&self,
ui: &mut egui::Ui,
) -> (BoolFieldChanges, TextFieldChanges, bool) {
let mut bool_changes: Vec<(ConfigBoolField, bool)> = Vec::new();
let mut text_changes: Vec<(ConfigTextField, String)> = Vec::new();
let mut reset_clicked = false;
if let Some(error) = &self.config_state.global_config_error {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new(error)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::STATUS_ERROR),
);
return (bool_changes, text_changes, reset_clicked);
}
let Some(config) = &self.config_state.cached_global_config else {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new("Loading configuration...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
return (bool_changes, text_changes, reset_clicked);
};
let mut review = config.review;
let mut commit = config.commit;
let mut pull_request = config.pull_request;
let mut pull_request_draft = config.pull_request_draft;
let mut worktree = config.worktree;
let mut worktree_cleanup = config.worktree_cleanup;
let mut worktree_path_pattern = config.worktree_path_pattern.clone();
egui::ScrollArea::vertical()
.id_salt("config_editor")
.auto_shrink([false, false])
.show(ui, |ui| {
self.render_config_group_header(ui, "Pipeline");
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"review",
&mut review,
"Code review before committing. When enabled, changes are reviewed for quality before being committed.",
) {
bool_changes.push((ConfigBoolField::Review, review));
}
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"commit",
&mut commit,
"Automatic git commits. When enabled, changes are automatically committed after implementation.",
) {
bool_changes.push((ConfigBoolField::Commit, commit));
if !commit && pull_request {
pull_request = false;
bool_changes.push((ConfigBoolField::PullRequest, false));
if pull_request_draft {
pull_request_draft = false;
bool_changes.push((ConfigBoolField::PullRequestDraft, false));
}
}
}
ui.add_space(spacing::SM);
if self.render_config_bool_field_with_disabled(
ui,
"pull_request",
&mut pull_request,
"Automatic PR creation. When enabled, a pull request is created after committing. Requires commit to be enabled.",
!commit, Some("Pull requests require commits to be enabled"),
) {
bool_changes.push((ConfigBoolField::PullRequest, pull_request));
if !pull_request && pull_request_draft {
pull_request_draft = false;
bool_changes.push((ConfigBoolField::PullRequestDraft, false));
}
}
ui.add_space(spacing::SM);
if self.render_config_bool_field_with_disabled(
ui,
"pull_request_draft",
&mut pull_request_draft,
"Create PRs as drafts. When enabled, PRs are created in draft mode (not ready for review). Requires pull_request to be enabled.",
!pull_request, Some("Draft PRs require pull requests to be enabled"),
) {
bool_changes.push((ConfigBoolField::PullRequestDraft, pull_request_draft));
}
ui.add_space(spacing::XL);
self.render_config_group_header(ui, "Worktree");
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"worktree",
&mut worktree,
"Automatic worktree creation. When enabled, creates a dedicated worktree for each run, enabling parallel sessions.",
) {
bool_changes.push((ConfigBoolField::Worktree, worktree));
}
ui.add_space(spacing::SM);
if let Some(new_value) = self.render_config_text_field(
ui,
"worktree_path_pattern",
&mut worktree_path_pattern,
"Pattern for worktree directory names. Placeholders: {repo} = repository name, {branch} = branch name.",
) {
text_changes.push((ConfigTextField::WorktreePathPattern, new_value));
}
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"worktree_cleanup",
&mut worktree_cleanup,
"Automatic worktree cleanup. When enabled, removes worktrees after successful completion. Failed runs keep their worktrees.",
) {
bool_changes.push((ConfigBoolField::WorktreeCleanup, worktree_cleanup));
}
ui.add_space(spacing::XXL);
if self.render_reset_to_defaults_button(ui) {
reset_clicked = true;
}
ui.add_space(spacing::XL);
});
(bool_changes, text_changes, reset_clicked)
}
fn render_project_config_editor(
&self,
ui: &mut egui::Ui,
project_name: &str,
) -> (BoolFieldChanges, TextFieldChanges, bool) {
let mut bool_changes: Vec<(ConfigBoolField, bool)> = Vec::new();
let mut text_changes: Vec<(ConfigTextField, String)> = Vec::new();
let mut reset_clicked = false;
if let Some(error) = &self.config_state.project_config_error {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new(error)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::STATUS_ERROR),
);
return (bool_changes, text_changes, reset_clicked);
}
let Some(config) = self.cached_project_config(project_name) else {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new("Loading configuration...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
return (bool_changes, text_changes, reset_clicked);
};
let mut review = config.review;
let mut commit = config.commit;
let mut pull_request = config.pull_request;
let mut pull_request_draft = config.pull_request_draft;
let mut worktree = config.worktree;
let mut worktree_cleanup = config.worktree_cleanup;
let mut worktree_path_pattern = config.worktree_path_pattern.clone();
egui::ScrollArea::vertical()
.id_salt("project_config_editor")
.auto_shrink([false, false])
.show(ui, |ui| {
self.render_config_group_header(ui, "Pipeline");
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"review",
&mut review,
"Code review before committing. When enabled, changes are reviewed for quality before being committed.",
) {
bool_changes.push((ConfigBoolField::Review, review));
}
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"commit",
&mut commit,
"Automatic git commits. When enabled, changes are automatically committed after implementation.",
) {
bool_changes.push((ConfigBoolField::Commit, commit));
if !commit && pull_request {
pull_request = false;
bool_changes.push((ConfigBoolField::PullRequest, false));
if pull_request_draft {
pull_request_draft = false;
bool_changes.push((ConfigBoolField::PullRequestDraft, false));
}
}
}
ui.add_space(spacing::SM);
if self.render_config_bool_field_with_disabled(
ui,
"pull_request",
&mut pull_request,
"Automatic PR creation. When enabled, a pull request is created after committing. Requires commit to be enabled.",
!commit, Some("Pull requests require commits to be enabled"),
) {
bool_changes.push((ConfigBoolField::PullRequest, pull_request));
if !pull_request && pull_request_draft {
pull_request_draft = false;
bool_changes.push((ConfigBoolField::PullRequestDraft, false));
}
}
ui.add_space(spacing::SM);
if self.render_config_bool_field_with_disabled(
ui,
"pull_request_draft",
&mut pull_request_draft,
"Create PRs as drafts. When enabled, PRs are created in draft mode (not ready for review). Requires pull_request to be enabled.",
!pull_request, Some("Draft PRs require pull requests to be enabled"),
) {
bool_changes.push((ConfigBoolField::PullRequestDraft, pull_request_draft));
}
ui.add_space(spacing::XL);
self.render_config_group_header(ui, "Worktree");
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"worktree",
&mut worktree,
"Automatic worktree creation. When enabled, creates a dedicated worktree for each run, enabling parallel sessions.",
) {
bool_changes.push((ConfigBoolField::Worktree, worktree));
}
ui.add_space(spacing::SM);
if let Some(new_value) = self.render_config_text_field(
ui,
"worktree_path_pattern",
&mut worktree_path_pattern,
"Pattern for worktree directory names. Placeholders: {repo} = repository name, {branch} = branch name.",
) {
text_changes.push((ConfigTextField::WorktreePathPattern, new_value));
}
ui.add_space(spacing::SM);
if self.render_config_bool_field(
ui,
"worktree_cleanup",
&mut worktree_cleanup,
"Automatic worktree cleanup. When enabled, removes worktrees after successful completion. Failed runs keep their worktrees.",
) {
bool_changes.push((ConfigBoolField::WorktreeCleanup, worktree_cleanup));
}
ui.add_space(spacing::XXL);
if self.render_reset_to_defaults_button(ui) {
reset_clicked = true;
}
ui.add_space(spacing::XL);
});
(bool_changes, text_changes, reset_clicked)
}
fn render_config_group_header(&self, ui: &mut egui::Ui, title: &str) {
ui.label(
egui::RichText::new(title)
.font(typography::font(FontSize::Heading, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
}
fn render_config_bool_field(
&self,
ui: &mut egui::Ui,
name: &str,
value: &mut bool,
help_text: &str,
) -> bool {
self.render_config_bool_field_with_disabled(ui, name, value, help_text, false, None)
}
fn render_config_bool_field_with_disabled(
&self,
ui: &mut egui::Ui,
name: &str,
value: &mut bool,
help_text: &str,
disabled: bool,
disabled_tooltip: Option<&str>,
) -> bool {
let original_value = *value;
ui.horizontal(|ui| {
let text_color = if disabled {
colors::TEXT_DISABLED
} else {
colors::TEXT_PRIMARY
};
ui.label(
egui::RichText::new(name)
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(text_color),
);
ui.add_space(spacing::SM);
if disabled {
let response = ui.add(Self::toggle_switch_disabled(*value));
if let Some(tooltip) = disabled_tooltip {
response.on_hover_text(tooltip);
}
} else {
ui.add(Self::toggle_switch(value));
}
});
let help_color = if disabled {
colors::TEXT_DISABLED
} else {
colors::TEXT_MUTED
};
ui.label(
egui::RichText::new(help_text)
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(help_color),
);
*value != original_value
}
fn toggle_switch(on: &mut bool) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| -> egui::Response {
let desired_size = Vec2::new(36.0, 20.0);
let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
if response.clicked() {
*on = !*on;
response.mark_changed();
}
if ui.is_rect_visible(rect) {
let how_on = ui.ctx().animate_bool_responsive(response.id, *on);
let visuals = ui.style().interact_selectable(&response, *on);
let rect = rect.expand(visuals.expansion);
let radius = 0.5 * rect.height();
let bg_color = if *on {
colors::ACCENT_SUBTLE
} else {
colors::SURFACE_HOVER
};
ui.painter()
.rect_filled(rect, Rounding::same(radius), bg_color);
let border_color = if *on { colors::ACCENT } else { colors::BORDER };
ui.painter().rect_stroke(
rect,
Rounding::same(radius),
Stroke::new(1.0, border_color),
);
let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
let center = egui::pos2(circle_x, rect.center().y);
let knob_radius = radius * 0.75;
ui.painter().circle_filled(
center + egui::vec2(0.5, 0.5),
knob_radius,
Color32::from_black_alpha(30),
);
ui.painter()
.circle_filled(center, knob_radius, colors::TEXT_PRIMARY);
}
response
}
}
fn toggle_switch_disabled(on: bool) -> impl egui::Widget {
move |ui: &mut egui::Ui| -> egui::Response {
let desired_size = Vec2::new(36.0, 20.0);
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
if ui.is_rect_visible(rect) {
let how_on = ui.ctx().animate_bool_responsive(response.id, on);
let radius = 0.5 * rect.height();
let bg_color = colors::SURFACE_HOVER;
ui.painter()
.rect_filled(rect, Rounding::same(radius), bg_color);
ui.painter().rect_stroke(
rect,
Rounding::same(radius),
Stroke::new(1.0, colors::BORDER),
);
let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
let center = egui::pos2(circle_x, rect.center().y);
let knob_radius = radius * 0.75;
ui.painter()
.circle_filled(center, knob_radius, colors::TEXT_DISABLED);
}
response
}
}
fn render_config_text_field(
&self,
ui: &mut egui::Ui,
name: &str,
value: &mut String,
help_text: &str,
) -> Option<String> {
let mut changed_value: Option<String> = None;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(name)
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
let text_edit = egui::TextEdit::singleline(value)
.font(typography::mono(FontSize::Body))
.text_color(colors::TEXT_SECONDARY)
.desired_width(250.0);
let response = ui.add(text_edit);
if response.changed() {
changed_value = Some(value.clone());
}
});
ui.label(
egui::RichText::new(help_text)
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
if name == "worktree_path_pattern" {
let mut warnings: Vec<&str> = Vec::new();
if !value.contains("{repo}") {
warnings.push("Missing {repo} placeholder");
}
if !value.contains("{branch}") {
warnings.push("Missing {branch} placeholder");
}
if !warnings.is_empty() {
ui.add_space(spacing::XS);
for warning in warnings {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("⚠")
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::STATUS_WARNING),
);
ui.add_space(spacing::XS);
ui.label(
egui::RichText::new(warning)
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::STATUS_WARNING),
);
});
}
}
}
changed_value
}
fn render_create_spec(&mut self, ui: &mut egui::Ui) {
ui.label(
egui::RichText::new("Create Spec")
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::MD);
self.render_create_spec_project_dropdown(ui);
ui.add_space(spacing::LG);
if self.projects.is_empty() {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(spacing::LG);
self.render_create_spec_no_projects(ui);
});
} else if self.create_spec_selected_project.is_none() {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(spacing::LG);
self.render_create_spec_select_prompt(ui);
});
} else {
let available_height = ui.available_height();
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;
let reserved_bottom = input_bar_height + separator_height + bottom_padding;
ui.allocate_ui(
egui::vec2(ui.available_width(), available_height - reserved_bottom),
|ui| {
self.render_create_spec_chat_area(ui);
},
);
ui.add_space(spacing::SM);
ui.separator();
ui.add_space(spacing::SM);
self.render_create_spec_input_bar(ui);
ui.add_space(bottom_padding);
}
}
fn render_create_spec_project_dropdown(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Project:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
let selected_text = self
.create_spec_selected_project
.as_deref()
.unwrap_or("Select a project...");
let combo_id = ui.make_persistent_id("create_spec_project_dropdown");
egui::ComboBox::from_id_salt(combo_id)
.selected_text(selected_text)
.width(250.0)
.show_ui(ui, |ui| {
for project in &self.projects {
let project_name = &project.info.name;
let is_selected =
self.create_spec_selected_project.as_ref() == Some(project_name);
if ui.selectable_label(is_selected, project_name).clicked() {
let is_different_project =
self.create_spec_selected_project.as_ref() != Some(project_name);
if is_different_project && self.has_active_spec_session() {
self.pending_project_change = Some(project_name.clone());
} else {
self.create_spec_selected_project = Some(project_name.clone());
}
}
}
});
if self.has_active_spec_session() {
ui.add_space(spacing::MD);
let start_over_btn = egui::Button::new(
egui::RichText::new("Start Over")
.font(typography::font(FontSize::Small, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
)
.fill(colors::SURFACE_ELEVATED)
.stroke(Stroke::new(1.0, colors::BORDER))
.rounding(Rounding::same(rounding::BUTTON));
if ui.add(start_over_btn).clicked() {
self.reset_create_spec_session();
}
}
});
if let Some(ref project_name) = self.create_spec_selected_project {
ui.add_space(spacing::XS);
ui.label(
egui::RichText::new(format!("Selected: {}", project_name))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
}
}
fn render_create_spec_no_projects(&self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(spacing::XL);
ui.label(
egui::RichText::new("No Projects Registered")
.font(typography::font(FontSize::Heading, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
let message = "No projects registered. Run `autom8` at least once in any repository to register it.";
ui.label(
egui::RichText::new(message)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
});
}
fn render_create_spec_select_prompt(&self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(spacing::XXL);
ui.label(
egui::RichText::new("Create a New Specification")
.font(typography::font(FontSize::Heading, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Select a project to begin creating a spec")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::SM);
ui.label(
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.")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::LG);
ui.label(
egui::RichText::new(
"To register a new project, run `autom8` from the project directory",
)
.font(typography::font(FontSize::Caption, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_create_spec_chat_area(&mut self, ui: &mut egui::Ui) {
let available_width = ui.available_width();
let max_bubble_width = available_width * CHAT_BUBBLE_MAX_WIDTH_RATIO;
let scroll_id = ui.make_persistent_id("create_spec_chat_scroll");
let mut scroll_area = egui::ScrollArea::vertical()
.id_salt(scroll_id)
.auto_shrink([false, false])
.stick_to_bottom(true);
if self.chat_scroll_to_bottom {
scroll_area = scroll_area
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
}
let mut should_retry = false;
let mut should_confirm_spec = false;
let mut should_start_new = false;
scroll_area.show(ui, |ui| {
ui.add_space(spacing::LG);
if self.chat_messages.is_empty() && self.claude_error.is_none() {
self.render_chat_empty_state(ui);
} else {
for (index, message) in self.chat_messages.iter().enumerate() {
self.render_chat_message(ui, message, max_bubble_width, index);
ui.add_space(CHAT_MESSAGE_SPACING);
}
if self.claude_starting {
ui.add_space(CHAT_MESSAGE_SPACING);
self.render_starting_indicator(ui);
} else if self.is_waiting_for_claude {
ui.add_space(CHAT_MESSAGE_SPACING);
self.render_typing_indicator(ui);
}
if let Some(ref error) = self.claude_error {
ui.add_space(CHAT_MESSAGE_SPACING);
should_retry = self.render_claude_error(ui, error, max_bubble_width);
}
if self.claude_finished && self.generated_spec_path.is_some() {
ui.add_space(CHAT_MESSAGE_SPACING);
let (confirm, start_new) = self.render_spec_completion_ui(ui, max_bubble_width);
should_confirm_spec = confirm;
should_start_new = start_new;
}
}
ui.add_space(spacing::XL);
});
if should_retry {
self.retry_claude();
}
if should_confirm_spec {
self.confirm_spec();
}
if should_start_new {
self.pending_start_new_spec = true;
}
if self.chat_scroll_to_bottom {
self.chat_scroll_to_bottom = false;
}
}
fn render_typing_indicator(&self, ui: &mut egui::Ui) {
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let frame = egui::Frame::none()
.fill(CLAUDE_BUBBLE_COLOR)
.rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
.inner_margin(egui::Margin::symmetric(CHAT_BUBBLE_PADDING, spacing::SM))
.stroke(Stroke::new(1.0, colors::BORDER));
frame.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spinner();
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Claude is thinking...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
});
});
}
fn render_starting_indicator(&self, ui: &mut egui::Ui) {
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let frame = egui::Frame::none()
.fill(CLAUDE_BUBBLE_COLOR)
.rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
.inner_margin(egui::Margin::symmetric(CHAT_BUBBLE_PADDING, spacing::SM))
.stroke(Stroke::new(1.0, colors::BORDER));
frame.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spinner();
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Starting Claude...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
});
});
}
fn render_claude_error(&self, ui: &mut egui::Ui, error: &str, _max_bubble_width: f32) -> bool {
let mut should_retry = false;
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let frame = egui::Frame::none()
.fill(colors::STATUS_ERROR_BG)
.rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
.inner_margin(egui::Margin::same(CHAT_BUBBLE_PADDING))
.stroke(Stroke::new(1.0, colors::STATUS_ERROR));
frame.show(ui, |ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Error")
.font(typography::font(FontSize::Body, FontWeight::SemiBold))
.color(colors::STATUS_ERROR),
);
ui.add_space(spacing::XS);
ui.label(
egui::RichText::new(error)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
let retry_button = egui::Button::new(
egui::RichText::new("Retry")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::SURFACE),
)
.fill(colors::STATUS_ERROR)
.rounding(Rounding::same(spacing::SM));
if ui.add(retry_button).clicked() {
should_retry = true;
}
});
});
});
should_retry
}
fn render_create_spec_input_bar(&mut self, ui: &mut egui::Ui) {
let placeholder = if self.chat_messages.is_empty() {
"Describe the feature you want to build..."
} else {
"Reply..."
};
let input_not_empty = !self.chat_input_text.trim().is_empty();
let can_send = input_not_empty && !self.is_waiting_for_claude;
let mut should_send = false;
let total_width = ui.available_width();
let button_area_width = spacing::SM + SEND_BUTTON_SIZE;
let input_frame_width = total_width - button_area_width;
ui.horizontal(|ui| {
let input_frame = egui::Frame::none()
.fill(colors::SURFACE)
.rounding(Rounding::same(INPUT_FIELD_ROUNDING))
.stroke(Stroke::new(1.0, colors::BORDER))
.inner_margin(egui::Margin::symmetric(spacing::MD, spacing::SM));
let frame_response = input_frame.show(ui, |ui| {
ui.set_width(input_frame_width - spacing::MD * 2.0 - 2.0);
let max_input_height = 100.0;
egui::ScrollArea::vertical()
.max_height(max_input_height)
.show(ui, |ui| {
let text_edit = egui::TextEdit::multiline(&mut self.chat_input_text)
.hint_text(
egui::RichText::new(placeholder)
.color(colors::TEXT_MUTED)
.font(typography::font(FontSize::Body, FontWeight::Regular)),
)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.text_color(colors::TEXT_PRIMARY)
.frame(false)
.desired_width(f32::INFINITY)
.desired_rows(1)
.lock_focus(true)
.interactive(!self.is_waiting_for_claude);
let response = ui.add(text_edit);
if response.has_focus() && !self.is_waiting_for_claude {
let modifiers = ui.input(|i| i.modifiers);
let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter));
if enter_pressed && !modifiers.shift && can_send {
should_send = true;
}
}
});
});
let frame_height = frame_response.response.rect.height();
ui.add_space(spacing::SM);
ui.vertical(|ui| {
let button_vertical_offset = (frame_height - SEND_BUTTON_SIZE) / 2.0;
if button_vertical_offset > 0.0 {
ui.add_space(button_vertical_offset);
}
let (rect, response) = ui.allocate_exact_size(
egui::vec2(SEND_BUTTON_SIZE, SEND_BUTTON_SIZE),
egui::Sense::click(),
);
if ui.is_rect_visible(rect) {
let actual_color = if !can_send {
SEND_BUTTON_DISABLED_COLOR
} else if response.hovered() {
SEND_BUTTON_HOVER_COLOR
} else {
SEND_BUTTON_COLOR
};
ui.painter().rect_filled(
rect,
Rounding::same(SEND_BUTTON_SIZE / 2.0),
actual_color,
);
let icon_color = Color32::WHITE;
let center = rect.center();
let arrow_points = vec![
egui::pos2(center.x - 6.0, center.y - 5.0),
egui::pos2(center.x + 6.0, center.y),
egui::pos2(center.x - 6.0, center.y + 5.0),
egui::pos2(center.x - 3.0, center.y),
];
ui.painter().add(egui::Shape::convex_polygon(
arrow_points,
icon_color,
Stroke::NONE,
));
}
if response.clicked() && can_send {
should_send = true;
}
});
if self.is_waiting_for_claude {
ui.add_space(spacing::SM);
ui.spinner();
}
});
if should_send {
self.send_chat_message();
}
}
fn send_chat_message(&mut self) {
let message = self.chat_input_text.trim().to_string();
if message.is_empty() {
return;
}
if self.is_waiting_for_claude {
return;
}
self.add_user_message(&message);
self.chat_input_text.clear();
let has_claude_response = self
.chat_messages
.iter()
.any(|m| m.sender == ChatMessageSender::Claude);
self.spawn_claude_for_message(&message, !has_claude_response);
}
fn render_chat_empty_state(&self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(spacing::XXL);
ui.add_space(spacing::XXL);
ui.label(
egui::RichText::new("Describe the feature you want to build...")
.font(typography::font(FontSize::Large, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new("Claude will help you create a detailed specification")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_DISABLED),
);
});
}
fn render_chat_message(
&self,
ui: &mut egui::Ui,
message: &ChatMessage,
max_bubble_width: f32,
message_index: usize,
) {
let is_user = message.sender == ChatMessageSender::User;
if is_user {
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
self.render_message_bubble(ui, message, max_bubble_width, message_index, true);
});
} else {
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
self.render_message_bubble(ui, message, max_bubble_width, message_index, false);
});
}
}
fn render_message_bubble(
&self,
ui: &mut egui::Ui,
message: &ChatMessage,
max_bubble_width: f32,
message_index: usize,
is_user: bool,
) {
let bubble_color = if is_user {
USER_BUBBLE_COLOR
} else {
CLAUDE_BUBBLE_COLOR
};
let text_color = colors::TEXT_PRIMARY;
let font_id = typography::font(FontSize::Body, FontWeight::Regular);
let content_max_width = max_bubble_width - CHAT_BUBBLE_PADDING * 2.0;
let mut job = egui::text::LayoutJob::single_section(
message.content.clone(),
egui::TextFormat {
font_id: font_id.clone(),
color: text_color,
..Default::default()
},
);
job.wrap = egui::text::TextWrapping {
max_width: content_max_width,
..Default::default()
};
let galley = ui.fonts(|f| f.layout_job(job.clone()));
let text_size = galley.rect.size();
let min_bubble_width = 50.0;
let bubble_content_width = text_size.x.max(min_bubble_width).min(content_max_width);
let order_text = format!("#{}", message_index + 1);
let order_galley = ui.fonts(|f| {
f.layout_no_wrap(
order_text.clone(),
typography::font(FontSize::Caption, FontWeight::Regular),
colors::TEXT_DISABLED,
)
});
let order_height = order_galley.rect.height();
let total_content_height = text_size.y + spacing::XS + order_height;
let bubble_width = bubble_content_width + CHAT_BUBBLE_PADDING * 2.0;
let bubble_height = total_content_height + CHAT_BUBBLE_PADDING * 2.0;
let (rect, _response) = ui.allocate_exact_size(
egui::vec2(bubble_width, bubble_height),
egui::Sense::hover(),
);
if ui.is_rect_visible(rect) {
let painter = ui.painter();
if !is_user {
let shadow = theme::shadow::subtle();
let shadow_rect = rect.translate(shadow.offset);
painter.rect_filled(
shadow_rect.expand(shadow.spread),
Rounding::same(CHAT_BUBBLE_ROUNDING),
shadow.color,
);
}
painter.rect_filled(rect, Rounding::same(CHAT_BUBBLE_ROUNDING), bubble_color);
if !is_user {
painter.rect_stroke(
rect,
Rounding::same(CHAT_BUBBLE_ROUNDING),
Stroke::new(1.0, colors::BORDER),
);
}
let text_pos = rect.min + egui::vec2(CHAT_BUBBLE_PADDING, CHAT_BUBBLE_PADDING);
painter.galley(text_pos, galley, text_color);
let order_y = text_pos.y + text_size.y + spacing::XS;
let order_x = if is_user {
rect.max.x - CHAT_BUBBLE_PADDING - order_galley.rect.width()
} else {
text_pos.x
};
painter.galley(
egui::pos2(order_x, order_y),
order_galley,
colors::TEXT_DISABLED,
);
}
}
#[allow(dead_code)]
pub fn add_chat_message(&mut self, message: ChatMessage) {
self.chat_messages.push(message);
self.chat_scroll_to_bottom = true;
}
#[allow(dead_code)]
pub fn add_user_message(&mut self, content: impl Into<String>) {
self.add_chat_message(ChatMessage::user(content));
}
#[allow(dead_code)]
pub fn add_claude_message(&mut self, content: impl Into<String>) {
self.add_chat_message(ChatMessage::claude(content));
}
#[allow(dead_code)]
pub fn clear_chat_messages(&mut self) {
self.chat_messages.clear();
}
fn spawn_claude_for_message(&mut self, user_message: &str, is_first_message: bool) {
use crate::claude::extract_text_from_stream_line;
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
self.claude_error = None;
self.claude_response_in_progress = false;
self.last_claude_output_time = None;
self.claude_finished = false;
self.claude_starting = true;
self.is_waiting_for_claude = true;
let tx = self.claude_tx.clone();
let prompt = if is_first_message {
format!(
"{}\n\n---\n\nUser's request:\n\n{}\n",
crate::prompts::SPEC_SKILL_PROMPT,
user_message
)
} else {
let mut context = format!(
"{}\n\n---\n\nConversation so far:\n\n",
crate::prompts::SPEC_SKILL_PROMPT
);
for msg in &self.chat_messages {
match msg.sender {
ChatMessageSender::User => {
context.push_str(&format!("User: {}\n\n", msg.content));
}
ChatMessageSender::Claude => {
context.push_str(&format!("Assistant: {}\n\n", msg.content));
}
}
}
context.push_str(&format!(
"User: {}\n\nPlease continue the conversation and help refine the specification.",
user_message
));
context
};
let child_result = Command::new("claude")
.args(["--print", "--output-format", "stream-json", "--verbose"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match child_result {
Ok(child) => child,
Err(e) => {
let error_msg = if e.kind() == std::io::ErrorKind::NotFound {
"Claude CLI not found. Please install it from https://github.com/anthropics/claude-code".to_string()
} else {
format!("Failed to spawn Claude: {}", e)
};
self.claude_error = Some(error_msg);
self.is_waiting_for_claude = false;
self.claude_starting = false;
return;
}
};
if let Some(mut stdin) = child.stdin.take() {
if let Err(e) = stdin.write_all(prompt.as_bytes()) {
self.claude_error = Some(format!("Failed to write prompt to Claude: {}", e));
self.is_waiting_for_claude = false;
self.claude_starting = false;
return;
}
}
self.claude_stdin = None;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let child_handle = self.claude_child.clone();
{
let mut guard = child_handle.lock().unwrap();
*guard = Some(child);
}
std::thread::spawn(move || {
let _ = tx.send(ClaudeMessage::Started);
if let Some(stdout) = stdout {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(json_line) => {
if let Some(text) = extract_text_from_stream_line(&json_line) {
let _ = tx.send(ClaudeMessage::Output(text));
}
}
Err(_) => {
break;
}
}
}
}
let mut stderr_content = String::new();
if let Some(stderr) = stderr {
let reader = BufReader::new(stderr);
for text in reader.lines().take(10).flatten() {
if !text.is_empty() {
stderr_content.push_str(&text);
stderr_content.push('\n');
}
}
}
let mut guard = child_handle.lock().unwrap();
if let Some(mut child) = guard.take() {
match child.wait() {
Ok(status) => {
let success = status.success();
let error = if !success {
if stderr_content.is_empty() {
Some(format!("Claude exited with status: {}", status))
} else {
Some(format!("Claude error: {}", stderr_content.trim()))
}
} else {
None
};
let _ = tx.send(ClaudeMessage::Finished { success, error });
}
Err(e) => {
let _ = tx.send(ClaudeMessage::Finished {
success: false,
error: Some(format!("Failed to wait for Claude: {}", e)),
});
}
}
}
});
}
fn spawn_claude_interactive(&mut self, initial_message: &str) {
self.spawn_claude_for_message(initial_message, true);
}
fn poll_claude_messages(&mut self) {
const RESPONSE_PAUSE_TIMEOUT: Duration = Duration::from_millis(1500);
while let Ok(msg) = self.claude_rx.try_recv() {
match msg {
ClaudeMessage::Spawning => {
}
ClaudeMessage::Started => {
self.claude_starting = false;
self.claude_response_in_progress = true;
}
ClaudeMessage::Output(text) => {
if !self.claude_response_buffer.is_empty() {
self.claude_response_buffer.push('\n');
}
self.claude_response_buffer.push_str(&text);
self.detect_spec_path_in_output(&text);
self.last_claude_output_time = Some(Instant::now());
self.claude_response_in_progress = true;
}
ClaudeMessage::ResponsePaused => {
self.flush_claude_response_buffer();
self.is_waiting_for_claude = false;
self.claude_response_in_progress = false;
}
ClaudeMessage::Finished { success, error } => {
self.flush_claude_response_buffer();
self.is_waiting_for_claude = false;
self.claude_starting = false;
self.claude_response_in_progress = false;
self.last_claude_output_time = None;
self.claude_stdin = None;
if success {
self.claude_finished = true;
} else if let Some(err) = error {
self.claude_error = Some(err);
}
}
ClaudeMessage::SpawnError(error) => {
self.claude_error = Some(error);
self.is_waiting_for_claude = false;
self.claude_starting = false;
self.claude_response_in_progress = false;
self.claude_stdin = None;
}
}
}
if self.claude_stdin.is_some() && self.claude_response_in_progress {
if let Some(last_output) = self.last_claude_output_time {
if last_output.elapsed() >= RESPONSE_PAUSE_TIMEOUT {
self.flush_claude_response_buffer();
self.is_waiting_for_claude = false;
self.claude_response_in_progress = false;
}
}
}
}
fn flush_claude_response_buffer(&mut self) {
if !self.claude_response_buffer.is_empty() {
let response = std::mem::take(&mut self.claude_response_buffer);
self.add_claude_message(response);
}
}
fn detect_spec_path_in_output(&mut self, text: &str) {
if self.generated_spec_path.is_some() {
return;
}
let is_valid_spec_path = |path_str: &str| -> bool {
if !path_str.contains("/spec/spec-") || !path_str.ends_with(".md") {
return false;
}
if path_str.chars().any(|c| c.is_control()) || path_str.len() > 500 {
return false;
}
let filename = path_str.rsplit('/').next().unwrap_or("");
if filename.contains(' ') || filename.is_empty() {
return false;
}
true
};
if let Some(start) = text.find("~/.config/autom8/") {
if let Some(rel_end) = text[start..].find(".md") {
let path_str = &text[start..start + rel_end + 3];
if is_valid_spec_path(path_str) {
if let Some(home) = dirs::home_dir() {
let expanded = path_str.replacen("~", &home.to_string_lossy(), 1);
self.generated_spec_path = Some(std::path::PathBuf::from(expanded));
return;
}
}
}
}
for word in text.split_whitespace() {
let cleaned = word.trim_matches(|c: char| {
c == '"' || c == '\'' || c == '`' || c == '(' || c == ')' || c == ',' || c == ':'
});
if cleaned.contains(".config/autom8/") && is_valid_spec_path(cleaned) {
let path = std::path::PathBuf::from(cleaned);
if path.is_absolute() {
self.generated_spec_path = Some(path);
return;
} else if cleaned.starts_with('~') {
if let Some(home) = dirs::home_dir() {
let expanded = cleaned.replacen("~", &home.to_string_lossy(), 1);
self.generated_spec_path = Some(std::path::PathBuf::from(expanded));
return;
}
}
}
}
}
#[allow(dead_code)]
fn is_claude_running(&self) -> bool {
self.is_waiting_for_claude
}
fn retry_claude(&mut self) {
self.claude_error = None;
let first_user_message = self
.chat_messages
.iter()
.find(|m| m.sender == ChatMessageSender::User)
.map(|m| m.content.clone());
if let Some(message) = first_user_message {
self.spawn_claude_interactive(&message);
}
}
fn render_spec_completion_ui(&self, ui: &mut egui::Ui, _max_bubble_width: f32) -> (bool, bool) {
let mut should_confirm = false;
let mut should_start_new = false;
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let frame = egui::Frame::none()
.fill(colors::STATUS_SUCCESS_BG)
.rounding(Rounding::same(CHAT_BUBBLE_ROUNDING))
.inner_margin(egui::Margin::same(CHAT_BUBBLE_PADDING))
.stroke(Stroke::new(1.0, colors::STATUS_SUCCESS));
frame.show(ui, |ui| {
ui.vertical(|ui| {
if self.spec_confirmed {
self.render_spec_run_command(ui);
ui.add_space(spacing::MD);
let start_new_button = egui::Button::new(
egui::RichText::new("Close")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::SURFACE),
)
.fill(colors::ACCENT)
.rounding(Rounding::same(spacing::SM));
if ui.add(start_new_button).clicked() {
should_start_new = true;
}
} else {
ui.label(
egui::RichText::new("Spec Generated!")
.font(typography::font(FontSize::Body, FontWeight::SemiBold))
.color(colors::STATUS_SUCCESS),
);
ui.add_space(spacing::SM);
if let Some(ref spec_path) = self.generated_spec_path {
let path_display = spec_path.display().to_string();
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("File:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::XS);
let mut path_text = path_display.clone();
ui.add(
egui::TextEdit::singleline(&mut path_text)
.font(typography::font(
FontSize::Small,
FontWeight::Regular,
))
.text_color(colors::TEXT_SECONDARY)
.frame(false)
.interactive(true)
.desired_width(f32::INFINITY),
);
});
}
ui.add_space(spacing::MD);
let confirm_button = egui::Button::new(
egui::RichText::new("Confirm & Get Run Command")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::SURFACE),
)
.fill(colors::STATUS_SUCCESS)
.rounding(Rounding::same(spacing::SM));
if ui.add(confirm_button).clicked() {
should_confirm = true;
}
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new(
"Want changes? Keep chatting below to refine the spec.",
)
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
});
});
});
(should_confirm, should_start_new)
}
fn render_spec_run_command(&self, ui: &mut egui::Ui) {
ui.label(
egui::RichText::new("Ready to Run!")
.font(typography::font(FontSize::Body, FontWeight::SemiBold))
.color(colors::STATUS_SUCCESS),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Open your terminal and run:")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
let command = self.build_spec_run_command();
ui.horizontal(|ui| {
let cmd_frame = egui::Frame::none()
.fill(colors::SURFACE)
.rounding(Rounding::same(spacing::XS))
.inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
.stroke(Stroke::new(1.0, colors::BORDER));
cmd_frame.show(ui, |ui| {
let mut cmd_text = command.clone();
ui.add(
egui::TextEdit::singleline(&mut cmd_text)
.font(egui::FontId::monospace(12.0))
.text_color(colors::TEXT_PRIMARY)
.frame(false)
.interactive(true)
.desired_width(400.0),
);
});
ui.add_space(spacing::SM);
let copy_button = egui::Button::new(
egui::RichText::new("Copy")
.font(typography::font(FontSize::Small, FontWeight::Medium)),
)
.fill(colors::SURFACE)
.stroke(Stroke::new(1.0, colors::BORDER))
.rounding(Rounding::same(spacing::XS));
if ui
.add(copy_button)
.on_hover_text("Copy to clipboard")
.clicked()
{
ui.output_mut(|o| o.copied_text = command);
}
});
}
fn build_spec_run_command(&self) -> String {
let spec_path = self
.generated_spec_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<spec-path>".to_string());
let project_root = self.find_project_root_for_selected_project();
match project_root {
Some(root) => format!("cd \"{}\" && autom8 \"{}\"", root.display(), spec_path),
None => format!("autom8 \"{}\"", spec_path),
}
}
fn find_project_root_for_selected_project(&self) -> Option<std::path::PathBuf> {
let selected_project = self.create_spec_selected_project.as_ref()?;
crate::config::get_project_repo_path(selected_project)
}
fn confirm_spec(&mut self) {
self.spec_confirmed = true;
self.chat_scroll_to_bottom = true;
}
fn has_active_spec_session(&self) -> bool {
!self.chat_messages.is_empty()
|| self.claude_stdin.is_some()
|| self.is_waiting_for_claude
|| self.generated_spec_path.is_some()
}
fn reset_create_spec_session(&mut self) {
if let Ok(mut guard) = self.claude_child.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
if let Some(ref stdin_handle) = self.claude_stdin {
stdin_handle.close();
}
self.chat_messages.clear();
self.chat_input_text.clear();
self.chat_scroll_to_bottom = false;
self.claude_stdin = None;
self.claude_response_buffer.clear();
self.claude_error = None;
self.is_waiting_for_claude = false;
self.claude_starting = false;
self.last_claude_output_time = None;
self.claude_response_in_progress = false;
self.generated_spec_path = None;
self.spec_confirmed = false;
self.claude_finished = false;
}
fn render_run_detail(&self, ui: &mut egui::Ui, run_id: &str) {
ui.label(
egui::RichText::new(format!("Run Details: {}", run_id))
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::MD);
if let Some(run_state) = self.run_detail_cache.get(run_id) {
self.render_run_state_details(ui, run_state);
} else {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("Run details not available")
.font(typography::font(FontSize::Heading, FontWeight::Medium))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new(
"This run may have been archived or the data is unavailable.",
)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
});
}
}
fn render_command_output(&self, ui: &mut egui::Ui, cache_key: &str) {
let execution = match self.command_executions.get(cache_key) {
Some(exec) => exec,
None => {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("Command output not available")
.font(typography::font(FontSize::Heading, FontWeight::Medium))
.color(colors::TEXT_MUTED),
);
});
});
return;
}
};
self.render_command_output_header(ui, execution);
ui.add_space(spacing::MD);
self.render_command_output_content(ui, execution, cache_key);
}
fn render_command_output_header(&self, ui: &mut egui::Ui, execution: &CommandExecution) {
ui.horizontal(|ui| {
let (status_text, status_color) = match execution.status {
CommandStatus::Running => ("Running", colors::STATUS_RUNNING),
CommandStatus::Completed => ("Completed", colors::STATUS_SUCCESS),
CommandStatus::Failed => ("Failed", colors::STATUS_ERROR),
};
let badge_galley = ui.fonts(|f| {
f.layout_no_wrap(
status_text.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
let (badge_rect, _) =
ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
ui.painter().rect_filled(
badge_rect,
Rounding::same(rounding::SMALL),
badge_background_color(status_color),
);
let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
ui.painter().galley(text_pos, badge_galley, status_color);
ui.add_space(spacing::MD);
if execution.status == CommandStatus::Running {
self.render_inline_spinner(ui);
ui.add_space(spacing::SM);
}
ui.label(
egui::RichText::new(execution.id.tab_label())
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
});
if let Some(exit_code) = execution.exit_code {
ui.add_space(spacing::SM);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Exit code:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::XS);
let exit_color = if exit_code == 0 {
colors::STATUS_SUCCESS
} else {
colors::STATUS_ERROR
};
ui.label(
egui::RichText::new(exit_code.to_string())
.font(typography::mono(FontSize::Body))
.color(exit_color),
);
});
}
}
fn render_inline_spinner(&self, ui: &mut egui::Ui) {
let spinner_size = 16.0;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(spinner_size), Sense::hover());
if ui.is_rect_visible(rect) {
let center = rect.center();
let radius = spinner_size / 2.0 - 2.0;
let time = ui.input(|i| i.time);
let start_angle = (time * 2.0) as f32 % std::f32::consts::TAU;
let arc_length = std::f32::consts::PI * 1.5;
let n_points = 32;
let points: Vec<_> = (0..=n_points)
.map(|i| {
let angle = start_angle + arc_length * (i as f32 / n_points as f32);
egui::pos2(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
)
})
.collect();
ui.painter()
.add(egui::Shape::line(points, Stroke::new(2.0, colors::ACCENT)));
ui.ctx().request_repaint();
}
}
fn render_command_output_content(
&self,
ui: &mut egui::Ui,
execution: &CommandExecution,
_cache_key: &str,
) {
let scroll_id = egui::Id::new("command_output_scroll").with(execution.id.cache_key());
let scroll_area = egui::ScrollArea::vertical()
.id_salt(scroll_id)
.auto_shrink([false, false])
.stick_to_bottom(execution.auto_scroll);
if execution.is_running() {
ui.ctx().request_repaint();
}
scroll_area.show(ui, |ui| {
let available_rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(
available_rect,
Rounding::same(rounding::BUTTON),
colors::SURFACE_HOVER,
);
ui.add_space(spacing::SM);
egui::Frame::none()
.inner_margin(spacing::MD)
.show(ui, |ui| {
if !execution.stdout.is_empty() {
for line in &execution.stdout {
ui.add(
egui::Label::new(
egui::RichText::new(line)
.font(typography::mono(FontSize::Small))
.color(colors::TEXT_PRIMARY),
)
.selectable(true)
.wrap_mode(egui::TextWrapMode::Wrap),
);
}
}
if !execution.stderr.is_empty() {
if !execution.stdout.is_empty() {
ui.add_space(spacing::SM);
ui.separator();
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Errors:")
.font(typography::font(FontSize::Small, FontWeight::Medium))
.color(colors::STATUS_ERROR),
);
ui.add_space(spacing::XS);
}
for line in &execution.stderr {
ui.add(
egui::Label::new(
egui::RichText::new(line)
.font(typography::mono(FontSize::Small))
.color(colors::STATUS_ERROR),
)
.selectable(true)
.wrap_mode(egui::TextWrapMode::Wrap),
);
}
}
if execution.stdout.is_empty()
&& execution.stderr.is_empty()
&& execution.is_running()
{
ui.label(
egui::RichText::new("Waiting for output...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED)
.italics(),
);
}
if execution.stdout.is_empty()
&& execution.stderr.is_empty()
&& execution.is_finished()
{
ui.label(
egui::RichText::new("Command completed with no output.")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED)
.italics(),
);
}
});
});
}
fn render_run_state_details(&self, ui: &mut egui::Ui, run_state: &crate::state::RunState) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
self.render_run_summary_card(ui, run_state);
ui.add_space(spacing::LG);
ui.separator();
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new("Stories")
.font(typography::font(FontSize::Heading, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
if run_state.iterations.is_empty() {
ui.label(
egui::RichText::new("No stories processed yet")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
} else {
let mut story_order: Vec<String> = Vec::new();
let mut story_iterations: std::collections::HashMap<
String,
Vec<&crate::state::IterationRecord>,
> = std::collections::HashMap::new();
for iter in &run_state.iterations {
if !story_iterations.contains_key(&iter.story_id) {
story_order.push(iter.story_id.clone());
}
story_iterations
.entry(iter.story_id.clone())
.or_default()
.push(iter);
}
for story_id in &story_order {
let iterations = story_iterations.get(story_id).unwrap();
self.render_story_detail_card(ui, story_id, iterations);
ui.add_space(spacing::MD);
}
}
});
}
fn render_run_summary_card(&self, ui: &mut egui::Ui, run_state: &crate::state::RunState) {
ui.horizontal(|ui| {
let status_text = match run_state.status {
crate::state::RunStatus::Completed => "Completed",
crate::state::RunStatus::Failed => "Failed",
crate::state::RunStatus::Running => "Running",
crate::state::RunStatus::Interrupted => "Interrupted",
};
let status_color = match run_state.status {
crate::state::RunStatus::Completed => colors::STATUS_SUCCESS,
crate::state::RunStatus::Failed => colors::STATUS_ERROR,
crate::state::RunStatus::Running => colors::STATUS_RUNNING,
crate::state::RunStatus::Interrupted => colors::STATUS_WARNING,
};
let badge_galley = ui.fonts(|f| {
f.layout_no_wrap(
status_text.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
let (badge_rect, _) =
ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
ui.painter().rect_filled(
badge_rect,
Rounding::same(rounding::SMALL),
badge_background_color(status_color),
);
let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
ui.painter().galley(text_pos, badge_galley, status_color);
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new(format!(
"Run ID: {}",
&run_state.run_id[..8.min(run_state.run_id.len())]
))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
ui.add_space(spacing::MD);
egui::Grid::new("run_timing_grid")
.num_columns(2)
.spacing([spacing::LG, spacing::XS])
.show(ui, |ui| {
ui.label(
egui::RichText::new("Start Time:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
ui.label(
egui::RichText::new(
run_state
.started_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %I:%M:%S %p")
.to_string(),
)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.end_row();
ui.label(
egui::RichText::new("End Time:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
if let Some(finished) = run_state.finished_at {
ui.label(
egui::RichText::new(
finished
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %I:%M:%S %p")
.to_string(),
)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
} else {
ui.label(
egui::RichText::new("In progress...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::STATUS_RUNNING),
);
}
ui.end_row();
ui.label(
egui::RichText::new("Duration:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
let duration_str = if let Some(finished) = run_state.finished_at {
let duration = finished - run_state.started_at;
Self::format_duration_detailed(duration)
} else {
let duration = chrono::Utc::now() - run_state.started_at;
format!("{} (ongoing)", Self::format_duration_detailed(duration))
};
ui.label(
egui::RichText::new(duration_str)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.end_row();
ui.label(
egui::RichText::new("Branch:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
ui.label(
egui::RichText::new(&run_state.branch)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::ACCENT),
);
ui.end_row();
let completed_count = run_state
.iterations
.iter()
.filter(|i| i.status == crate::state::IterationStatus::Success)
.map(|i| &i.story_id)
.collect::<std::collections::HashSet<_>>()
.len();
let total_stories = run_state
.iterations
.iter()
.map(|i| &i.story_id)
.collect::<std::collections::HashSet<_>>()
.len();
if total_stories > 0 {
ui.label(
egui::RichText::new("Stories:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
ui.label(
egui::RichText::new(format!(
"{}/{} completed",
completed_count, total_stories
))
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.end_row();
}
ui.label(
egui::RichText::new("Total Tokens:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
if let Some(ref usage) = run_state.total_usage {
let total = Self::format_tokens(usage.total_tokens());
let input = Self::format_tokens(usage.input_tokens);
let output = Self::format_tokens(usage.output_tokens);
ui.label(
egui::RichText::new(format!("{} ({} in / {} out)", total, input, output))
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
} else {
ui.label(
egui::RichText::new("N/A")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
ui.end_row();
if let Some(ref usage) = run_state.total_usage {
if usage.cache_read_tokens > 0 || usage.cache_creation_tokens > 0 {
ui.label(
egui::RichText::new("Cache:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
let cache_read = Self::format_tokens(usage.cache_read_tokens);
let cache_created = Self::format_tokens(usage.cache_creation_tokens);
ui.label(
egui::RichText::new(format!(
"{} read / {} created",
cache_read, cache_created
))
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.end_row();
}
if let Some(ref model) = usage.model {
ui.label(
egui::RichText::new("Model:")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
ui.label(
egui::RichText::new(model)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.end_row();
}
}
});
let pseudo_phases = ["Planning", "Final Review", "PR & Commit"];
let has_pseudo_phase_usage = pseudo_phases
.iter()
.any(|phase| run_state.phase_usage.contains_key(*phase));
if has_pseudo_phase_usage {
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Phase Breakdown")
.font(typography::font(FontSize::Small, FontWeight::SemiBold))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::XS);
egui::Grid::new("phase_usage_grid")
.num_columns(2)
.spacing([spacing::LG, spacing::XS])
.show(ui, |ui| {
for phase in pseudo_phases {
if let Some(usage) = run_state.phase_usage.get(phase) {
ui.label(
egui::RichText::new(format!("{}:", phase))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
ui.label(
egui::RichText::new(format!(
"{} tokens",
Self::format_tokens(usage.total_tokens())
))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_PRIMARY),
);
ui.end_row();
}
}
});
}
}
fn render_story_detail_card(
&self,
ui: &mut egui::Ui,
story_id: &str,
iterations: &[&crate::state::IterationRecord],
) {
let last_iter = iterations.last().unwrap();
let status_color = match last_iter.status {
crate::state::IterationStatus::Success => colors::STATUS_SUCCESS,
crate::state::IterationStatus::Failed => colors::STATUS_ERROR,
crate::state::IterationStatus::Running => colors::STATUS_RUNNING,
};
let available_width = ui.available_width();
egui::Frame::none()
.fill(colors::SURFACE_HOVER)
.rounding(Rounding::same(rounding::CARD))
.inner_margin(egui::Margin::same(spacing::MD))
.show(ui, |ui| {
ui.set_min_width(available_width - spacing::MD * 2.0);
ui.horizontal(|ui| {
let (dot_rect, _) =
ui.allocate_exact_size(Vec2::splat(spacing::MD), Sense::hover());
ui.painter()
.circle_filled(dot_rect.center(), 5.0, status_color);
ui.label(
egui::RichText::new(story_id)
.font(typography::font(FontSize::Body, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
let status_text = match last_iter.status {
crate::state::IterationStatus::Success => "Success",
crate::state::IterationStatus::Failed => "Failed",
crate::state::IterationStatus::Running => "Running",
};
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let badge_galley = ui.fonts(|f| {
f.layout_no_wrap(
status_text.to_string(),
typography::font(FontSize::Small, FontWeight::Medium),
status_color,
)
});
let badge_width = badge_galley.rect.width() + spacing::SM * 2.0;
let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
let (badge_rect, _) = ui.allocate_exact_size(
Vec2::new(badge_width, badge_height),
Sense::hover(),
);
ui.painter().rect_filled(
badge_rect,
Rounding::same(rounding::SMALL),
badge_background_color(status_color),
);
let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
ui.painter().galley(text_pos, badge_galley, status_color);
});
});
let work_summary = iterations
.iter()
.rev()
.find_map(|iter| iter.work_summary.as_ref());
if let Some(summary) = work_summary {
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new(truncate_with_ellipsis(summary, 200))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
}
if iterations.len() > 1 {
ui.add_space(spacing::SM);
ui.separator();
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new(format!("Iterations ({} total)", iterations.len()))
.font(typography::font(FontSize::Small, FontWeight::SemiBold))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::XS);
for (idx, iter) in iterations.iter().enumerate() {
let iter_status_color = match iter.status {
crate::state::IterationStatus::Success => colors::STATUS_SUCCESS,
crate::state::IterationStatus::Failed => colors::STATUS_ERROR,
crate::state::IterationStatus::Running => colors::STATUS_RUNNING,
};
ui.horizontal(|ui| {
let (dot_rect, _) =
ui.allocate_exact_size(Vec2::splat(spacing::SM), Sense::hover());
ui.painter()
.circle_filled(dot_rect.center(), 3.0, iter_status_color);
ui.label(
egui::RichText::new(format!("#{}", idx + 1))
.font(typography::font(FontSize::Caption, FontWeight::Medium))
.color(colors::TEXT_PRIMARY),
);
let status_str = match iter.status {
crate::state::IterationStatus::Success => "Success",
crate::state::IterationStatus::Failed => "Failed (review cycle)",
crate::state::IterationStatus::Running => "Running",
};
ui.label(
egui::RichText::new(status_str)
.font(typography::font(FontSize::Caption, FontWeight::Regular))
.color(iter_status_color),
);
if let Some(finished) = iter.finished_at {
let duration = finished - iter.started_at;
let duration_str = Self::format_duration_short(duration);
ui.label(
egui::RichText::new(format!("({})", duration_str))
.font(typography::font(
FontSize::Caption,
FontWeight::Regular,
))
.color(colors::TEXT_MUTED),
);
}
if let Some(ref usage) = iter.usage {
ui.label(
egui::RichText::new(format!(
"• {} tokens",
Self::format_tokens(usage.total_tokens())
))
.font(typography::font(FontSize::Caption, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
});
}
} else {
let iter = iterations[0];
ui.add_space(spacing::XS);
let mut info_parts = Vec::new();
if let Some(finished) = iter.finished_at {
let duration = finished - iter.started_at;
info_parts.push(format!(
"Duration: {}",
Self::format_duration_detailed(duration)
));
}
if let Some(ref usage) = iter.usage {
info_parts.push(format!(
"Tokens: {}",
Self::format_tokens(usage.total_tokens())
));
}
if !info_parts.is_empty() {
ui.label(
egui::RichText::new(info_parts.join(" • "))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
}
});
}
fn format_duration_detailed(duration: chrono::Duration) -> String {
let total_seconds = duration.num_seconds().max(0);
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
fn format_duration_short(duration: chrono::Duration) -> String {
let total_seconds = duration.num_seconds().max(0);
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
if hours > 0 {
format!("{}h{}m", hours, minutes)
} else if minutes > 0 {
format!("{}m{}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
fn format_tokens(tokens: u64) -> String {
let s = tokens.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
result.chars().rev().collect()
}
fn render_active_runs(&mut self, ui: &mut egui::Ui) {
let available_width = ui.available_width();
let available_height = ui.available_height();
ui.allocate_ui_with_layout(
egui::vec2(available_width, available_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| {
ui.label(
egui::RichText::new("Active Runs")
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
let visible_sessions = self.get_visible_sessions();
if visible_sessions.is_empty() {
self.render_empty_active_runs(ui);
} else {
let current_selection_valid =
self.selected_session_id.as_ref().is_some_and(|id| {
visible_sessions
.iter()
.any(|s| s.metadata.session_id == *id)
});
if !current_selection_valid {
self.selected_session_id = visible_sessions
.first()
.map(|s| s.metadata.session_id.clone());
}
self.render_active_session_tab_bar(ui);
ui.add_space(spacing::SM);
if let Some(selected_id) = self.selected_session_id.clone() {
if let Some(session) = self.find_session_by_id(&selected_id) {
self.render_expanded_session_view(ui, &session);
}
}
}
},
);
}
fn render_active_session_tab_bar(&mut self, ui: &mut egui::Ui) {
let available_width = ui.available_width();
let scroll_width = available_width.min(TAB_BAR_MAX_SCROLL_WIDTH);
let mut tab_to_select: Option<String> = None;
let mut tab_to_close: Option<String> = None;
let visible_sessions: Vec<(String, String, Option<MachineState>)> = self
.get_visible_sessions()
.iter()
.map(|s| {
let branch_label = strip_worktree_prefix(&s.metadata.branch_name, &s.project_name);
let state = s.run.as_ref().map(|r| r.machine_state);
(s.metadata.session_id.clone(), branch_label, state)
})
.collect();
ui.allocate_ui_with_layout(
egui::vec2(available_width, CONTENT_TAB_BAR_HEIGHT),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
egui::ScrollArea::horizontal()
.max_width(scroll_width)
.auto_shrink([false, false])
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
)
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
ui.add_space(spacing::XS);
for (session_id, branch_label, state) in &visible_sessions {
let is_active = self
.selected_session_id
.as_ref()
.is_some_and(|id| id == session_id);
let (tab_clicked, close_clicked) = self.render_active_session_tab(
ui,
branch_label,
is_active,
*state,
);
if tab_clicked {
tab_to_select = Some(session_id.clone());
}
if close_clicked {
tab_to_close = Some(session_id.clone());
}
ui.add_space(spacing::XS);
}
});
});
},
);
if let Some(session_id) = tab_to_select {
self.selected_session_id = Some(session_id);
}
if let Some(session_id) = tab_to_close {
self.close_session_tab(&session_id);
}
}
fn close_session_tab(&mut self, session_id: &str) {
self.closed_session_tabs.insert(session_id.to_string());
self.seen_sessions.remove(session_id);
if self
.selected_session_id
.as_ref()
.is_some_and(|id| id == session_id)
{
self.selected_session_id = None;
}
}
fn render_active_session_tab(
&self,
ui: &mut egui::Ui,
label: &str,
is_active: bool,
state: Option<MachineState>,
) -> (bool, bool) {
let show_close_button = state.is_none_or(is_terminal_state);
let text_galley = ui.fonts(|f| {
f.layout_no_wrap(
label.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let text_size = text_galley.size();
let status_dot_radius = 4.0;
let status_dot_spacing = spacing::SM;
let status_dot_space = status_dot_radius * 2.0 + status_dot_spacing;
let close_button_space = if show_close_button {
TAB_LABEL_CLOSE_GAP + TAB_CLOSE_BUTTON_SIZE + TAB_CLOSE_PADDING
} else {
0.0
};
let tab_width = status_dot_space + text_size.x + TAB_PADDING_H * 2.0 + close_button_space;
let tab_height = CONTENT_TAB_BAR_HEIGHT - TAB_UNDERLINE_HEIGHT - spacing::XS;
let tab_size = egui::vec2(tab_width, tab_height);
let (rect, response) = ui.allocate_exact_size(tab_size, Sense::click());
let is_hovered = response.hovered();
let bg_color = if is_active {
colors::SURFACE_SELECTED
} else if is_hovered {
colors::SURFACE_HOVER
} else {
Color32::TRANSPARENT
};
if bg_color != Color32::TRANSPARENT {
ui.painter()
.rect_filled(rect, Rounding::same(rounding::BUTTON), bg_color);
}
let status_color = state.map(state_to_color).unwrap_or(colors::STATUS_IDLE);
let indicator_center = egui::pos2(
rect.left() + TAB_PADDING_H + status_dot_radius,
rect.center().y,
);
let is_terminal = state.is_none_or(is_terminal_state);
if is_terminal {
let check_size = status_dot_radius * 0.9; let stroke = Stroke::new(2.0, status_color);
let start = egui::pos2(indicator_center.x - check_size, indicator_center.y);
let mid = egui::pos2(
indicator_center.x - check_size * 0.3,
indicator_center.y + check_size * 0.7,
);
let end = egui::pos2(
indicator_center.x + check_size,
indicator_center.y - check_size * 0.6,
);
ui.painter().line_segment([start, mid], stroke);
ui.painter().line_segment([mid, end], stroke);
} else {
ui.painter()
.circle_filled(indicator_center, status_dot_radius, status_color);
}
let text_color = if is_active {
colors::TEXT_PRIMARY
} else if is_hovered {
colors::TEXT_SECONDARY
} else {
colors::TEXT_MUTED
};
let text_x = rect.left() + TAB_PADDING_H + status_dot_space;
let text_pos = egui::pos2(text_x, rect.center().y - text_size.y / 2.0);
ui.painter().galley(
text_pos,
ui.fonts(|f| {
f.layout_no_wrap(
label.to_string(),
typography::font(
FontSize::Body,
if is_active {
FontWeight::SemiBold
} else {
FontWeight::Medium
},
),
text_color,
)
}),
Color32::TRANSPARENT,
);
let close_hovered = if show_close_button {
let close_rect = Rect::from_min_size(
egui::pos2(
rect.right() - TAB_PADDING_H - TAB_CLOSE_BUTTON_SIZE,
rect.center().y - TAB_CLOSE_BUTTON_SIZE / 2.0,
),
egui::vec2(TAB_CLOSE_BUTTON_SIZE, TAB_CLOSE_BUTTON_SIZE),
);
let hovered = ui
.ctx()
.input(|i| i.pointer.hover_pos())
.is_some_and(|pos| close_rect.contains(pos));
if hovered {
ui.painter().rect_filled(
close_rect,
Rounding::same(rounding::SMALL),
colors::SURFACE_HOVER,
);
}
let x_color = if hovered {
colors::TEXT_PRIMARY
} else {
colors::TEXT_MUTED
};
let x_center = close_rect.center();
let x_size = TAB_CLOSE_BUTTON_SIZE * 0.3;
ui.painter().line_segment(
[
egui::pos2(x_center.x - x_size, x_center.y - x_size),
egui::pos2(x_center.x + x_size, x_center.y + x_size),
],
Stroke::new(1.5, x_color),
);
ui.painter().line_segment(
[
egui::pos2(x_center.x + x_size, x_center.y - x_size),
egui::pos2(x_center.x - x_size, x_center.y + x_size),
],
Stroke::new(1.5, x_color),
);
hovered
} else {
false
};
if is_active {
let underline_rect = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.bottom()),
egui::vec2(rect.width(), TAB_UNDERLINE_HEIGHT),
);
ui.painter()
.rect_filled(underline_rect, Rounding::ZERO, colors::ACCENT);
}
let close_clicked = response.clicked() && close_hovered;
let tab_clicked = response.clicked() && !close_hovered;
(tab_clicked, close_clicked)
}
fn render_expanded_session_view(&mut self, ui: &mut egui::Ui, session: &SessionData) {
let available_width = ui.available_width();
let available_height = ui.available_height();
let content_padding = spacing::LG;
let content_width = available_width - content_padding * 2.0;
let section_gap = spacing::LG;
egui::ScrollArea::vertical()
.id_salt(format!("expanded_view_{}", session.metadata.session_id))
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(content_padding);
ui.horizontal(|ui| {
ui.add_space(content_padding);
ui.vertical(|ui| {
ui.set_width(content_width);
let branch_display = strip_worktree_prefix(
&session.metadata.branch_name,
&session.project_name,
);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&branch_display)
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::MD);
let badge_text = if session.is_main_session {
"main"
} else {
&session.metadata.session_id
};
let badge_color = if session.is_main_session {
colors::ACCENT
} else {
colors::TEXT_SECONDARY
};
let badge_bg = if session.is_main_session {
colors::ACCENT_SUBTLE
} else {
colors::SURFACE_HOVER
};
egui::Frame::none()
.fill(badge_bg)
.rounding(rounding::SMALL)
.inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
.show(ui, |ui| {
ui.label(
egui::RichText::new(badge_text)
.font(typography::font(
FontSize::Small,
FontWeight::Medium,
))
.color(badge_color),
);
});
});
ui.add_space(spacing::XS);
ui.label(
egui::RichText::new(&session.project_name)
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::MD);
let appears_stuck = session.appears_stuck();
let (state, state_color) = if let Some(ref run) = session.run {
let base_color = state_to_color(run.machine_state);
let color = if appears_stuck {
colors::STATUS_WARNING
} else {
base_color
};
(run.machine_state, color)
} else {
(MachineState::Idle, colors::STATUS_IDLE)
};
ui.horizontal(|ui| {
let dot_size = 8.0;
let (rect, _) = ui.allocate_exact_size(
egui::vec2(dot_size, dot_size),
Sense::hover(),
);
ui.painter()
.circle_filled(rect.center(), dot_size / 2.0, state_color);
ui.add_space(spacing::SM);
let state_text = if appears_stuck {
format!("{} (Not responding)", format_state(state))
} else {
format_state(state).to_string()
};
ui.label(
egui::RichText::new(state_text)
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_PRIMARY),
);
if let Some(ref progress) = session.progress {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new(progress.as_fraction())
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
if let Some(ref run) = session.run {
if let Some(ref story_id) = run.current_story {
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new(story_id)
.font(typography::font(
FontSize::Body,
FontWeight::Regular,
))
.color(colors::TEXT_MUTED),
);
}
}
}
if let Some(ref run) = session.run {
ui.add_space(spacing::MD);
ui.label(
egui::RichText::new(format_run_duration(
run.started_at,
run.finished_at,
))
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
if state != MachineState::Idle
&& !is_terminal_state(state)
&& session.progress.is_some()
{
ui.add_space(spacing::MD);
let max_animation_width = (content_width / 3.0).min(150.0);
if max_animation_width > 30.0 {
let animation_height = 12.0;
let (rect, _) = ui.allocate_exact_size(
egui::vec2(max_animation_width, animation_height),
Sense::hover(),
);
let time = ui.ctx().input(|i| i.time) as f32;
super::animation::render_infinity(
ui.painter(),
time,
rect,
state_color,
1.0,
);
super::animation::schedule_frame(ui.ctx());
}
}
});
ui.add_space(spacing::LG);
ui.label(
egui::RichText::new("Output")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::SM);
let output_height = (available_height * 0.4).max(200.0);
egui::Frame::none()
.fill(colors::SURFACE_HOVER)
.rounding(rounding::CARD)
.inner_margin(egui::Margin::same(spacing::MD))
.show(ui, |ui| {
ui.set_min_height(output_height);
ui.set_max_height(output_height);
ui.set_width(content_width - spacing::MD * 2.0);
egui::ScrollArea::vertical()
.id_salt(format!("output_{}", session.metadata.session_id))
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
let output_source = get_output_for_session(session);
Self::render_output_content(ui, &output_source);
});
});
ui.add_space(section_gap);
let story_items = load_story_items(session);
Self::render_stories_section(
ui,
&session.metadata.session_id,
&story_items,
content_width,
&mut self.section_collapsed_state,
);
ui.add_space(content_padding);
});
});
});
}
fn render_story_items_content(ui: &mut egui::Ui, story_items: &[StoryItem]) {
if story_items.is_empty() {
ui.label(
egui::RichText::new("No stories found")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_DISABLED),
);
} else {
for (index, story) in story_items.iter().enumerate() {
if index > 0 {
ui.add_space(spacing::SM);
}
let is_active = story.status == StoryStatus::Active;
egui::Frame::none()
.fill(story.status.background())
.rounding(rounding::SMALL)
.inner_margin(egui::Margin::symmetric(spacing::SM, spacing::XS))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(story.status.indicator())
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(story.status.color()),
);
ui.add_space(spacing::SM);
let id_weight = if is_active {
FontWeight::SemiBold
} else {
FontWeight::Medium
};
let id_color = if is_active {
colors::ACCENT
} else {
colors::TEXT_PRIMARY
};
ui.label(
egui::RichText::new(&story.id)
.font(typography::font(FontSize::Small, id_weight))
.color(id_color),
);
});
let title_weight = if is_active {
FontWeight::Medium
} else {
FontWeight::Regular
};
let title_color = if is_active {
colors::TEXT_PRIMARY
} else {
colors::TEXT_SECONDARY
};
ui.label(
egui::RichText::new(&story.title)
.font(typography::font(FontSize::Small, title_weight))
.color(title_color),
);
if let Some(ref summary) = story.work_summary {
ui.add_space(spacing::XS);
ui.label(
egui::RichText::new(summary)
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
}
});
}
}
}
fn render_output_content(ui: &mut egui::Ui, output_source: &OutputSource) {
match output_source {
OutputSource::Live(lines) | OutputSource::Iteration(lines) => {
for line in lines {
ui.label(
egui::RichText::new(line.trim())
.font(typography::mono(FontSize::Small))
.color(colors::TEXT_SECONDARY),
);
}
}
OutputSource::StatusMessage(message) => {
ui.label(
egui::RichText::new(message)
.font(typography::mono(FontSize::Small))
.color(colors::TEXT_DISABLED),
);
}
OutputSource::NoData => {
ui.label(
egui::RichText::new("No live output")
.font(typography::mono(FontSize::Small))
.color(colors::TEXT_DISABLED),
);
}
}
}
fn render_stories_section(
ui: &mut egui::Ui,
session_id: &str,
story_items: &[StoryItem],
panel_width: f32,
collapsed_state: &mut std::collections::HashMap<String, bool>,
) {
let stories_id = format!("{}_stories", session_id);
CollapsibleSection::new(&stories_id, "Stories")
.default_expanded(true)
.show(ui, collapsed_state, |ui| {
egui::Frame::none()
.fill(colors::SURFACE_HOVER)
.rounding(rounding::CARD)
.inner_margin(egui::Margin::same(spacing::MD))
.show(ui, |ui| {
ui.set_width(panel_width - spacing::MD * 2.0);
Self::render_story_items_content(ui, story_items);
});
});
}
fn render_empty_active_runs(&self, ui: &mut egui::Ui) {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.add_space(spacing::XXL + spacing::LG);
ui.label(
egui::RichText::new("No active runs")
.font(typography::font(FontSize::Heading, FontWeight::Medium))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Run autom8 to start implementing a feature")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_projects(&mut self, ui: &mut egui::Ui) {
let available_width = ui.available_width();
let available_height = ui.available_height();
let divider_total_width = SPLIT_DIVIDER_WIDTH + SPLIT_DIVIDER_MARGIN * 2.0;
let panel_width =
((available_width - divider_total_width) / 2.0).max(SPLIT_PANEL_MIN_WIDTH);
let mut clicked_run_id: Option<String> = None;
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(
Vec2::new(panel_width, available_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| {
self.render_projects_left_panel(ui);
},
);
ui.add_space(SPLIT_DIVIDER_MARGIN);
let divider_rect = ui.available_rect_before_wrap();
let divider_line_rect = Rect::from_min_size(
divider_rect.min,
Vec2::new(SPLIT_DIVIDER_WIDTH, available_height),
);
ui.painter()
.rect_filled(divider_line_rect, Rounding::ZERO, colors::SEPARATOR);
ui.add_space(SPLIT_DIVIDER_WIDTH);
ui.add_space(SPLIT_DIVIDER_MARGIN);
ui.allocate_ui_with_layout(
Vec2::new(ui.available_width(), available_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| {
clicked_run_id = self.render_projects_right_panel(ui);
},
);
});
if let Some(run_id) = clicked_run_id {
if let Some(entry) = self.run_history.iter().find(|e| e.run_id == run_id) {
let entry_clone = entry.clone();
if let Some(ref project_name) = self.selected_project {
let run_state = StateManager::for_project(project_name).ok().and_then(|sm| {
sm.list_archived()
.ok()
.and_then(|runs| runs.into_iter().find(|r| r.run_id == run_id))
});
self.open_run_detail_from_entry(&entry_clone, run_state);
}
}
}
}
fn render_projects_left_panel(&mut self, ui: &mut egui::Ui) {
ui.label(
egui::RichText::new("Projects")
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::SM);
if self.projects.is_empty() {
self.render_empty_projects(ui);
} else {
self.render_projects_list(ui);
}
}
fn render_projects_right_panel(&self, ui: &mut egui::Ui) -> Option<String> {
let mut clicked_run_id: Option<String> = None;
if let Some(ref selected_name) = self.selected_project {
ui.label(
egui::RichText::new(format!("Run History: {}", selected_name))
.font(typography::font(FontSize::Title, FontWeight::SemiBold))
.color(colors::TEXT_PRIMARY),
);
ui.add_space(spacing::MD);
if let Some(ref error) = self.run_history_error {
self.render_run_history_error(ui, error);
} else if self.run_history_loading {
self.render_run_history_loading(ui);
} else if self.run_history.is_empty() {
self.render_run_history_empty(ui);
} else {
egui::ScrollArea::vertical()
.id_salt("projects_right_panel")
.auto_shrink([false, false])
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
)
.show(ui, |ui| {
for entry in &self.run_history {
if self.render_run_history_entry(ui, entry) {
clicked_run_id = Some(entry.run_id.clone());
}
ui.add_space(spacing::SM);
}
});
}
} else {
self.render_no_project_selected(ui);
}
clicked_run_id
}
fn render_run_history_loading(&self, ui: &mut egui::Ui) {
ui.add_space(spacing::LG);
ui.vertical_centered(|ui| {
let spinner_size = 24.0;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(spinner_size), egui::Sense::hover());
if ui.is_rect_visible(rect) {
let center = rect.center();
let radius = spinner_size / 2.0 - 2.0;
let time = ui.input(|i| i.time);
let start_angle = (time * 2.0) as f32 % std::f32::consts::TAU;
let arc_length = std::f32::consts::PI * 1.5;
let n_points = 32;
let points: Vec<_> = (0..=n_points)
.map(|i| {
let angle = start_angle + (i as f32 / n_points as f32) * arc_length;
egui::pos2(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
)
})
.collect();
ui.painter()
.add(egui::Shape::line(points, Stroke::new(2.5, colors::ACCENT)));
ui.ctx().request_repaint();
}
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Loading run history...")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_run_history_error(&self, ui: &mut egui::Ui, error: &str) {
ui.add_space(spacing::LG);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("Failed to load run history")
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::STATUS_ERROR),
);
ui.add_space(spacing::XS);
ui.label(
egui::RichText::new(truncate_with_ellipsis(error, 60))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_run_history_empty(&self, ui: &mut egui::Ui) {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.add_space(spacing::LG);
ui.label(
egui::RichText::new("No run history")
.font(typography::font(FontSize::Heading, FontWeight::Medium))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Completed runs will appear here")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_no_project_selected(&self, ui: &mut egui::Ui) {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("Select a project")
.font(typography::font(FontSize::Heading, FontWeight::Medium))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Click on a project to view its run history")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_run_history_entry(&self, ui: &mut egui::Ui, entry: &RunHistoryEntry) -> bool {
let available_width = ui.available_width();
let card_height = 72.0;
let (rect, response) =
ui.allocate_exact_size(Vec2::new(available_width, card_height), Sense::click());
let is_hovered = response.hovered();
let bg_color = if is_hovered {
colors::SURFACE_HOVER
} else {
colors::SURFACE
};
let border = if is_hovered {
Stroke::new(1.0, colors::BORDER_FOCUSED)
} else {
Stroke::new(1.0, colors::BORDER)
};
ui.painter()
.rect(rect, Rounding::same(rounding::CARD), bg_color, border);
let inner_rect = rect.shrink(spacing::MD);
let mut child_ui = ui.new_child(
egui::UiBuilder::new()
.max_rect(inner_rect)
.layout(egui::Layout::top_down(egui::Align::LEFT)),
);
child_ui.horizontal(|ui| {
let datetime_text = entry
.started_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %I:%M %p")
.to_string();
ui.label(
egui::RichText::new(datetime_text)
.font(typography::font(FontSize::Body, FontWeight::Medium))
.color(colors::TEXT_PRIMARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let status_color = entry.status_color();
let status_text = entry.status_text();
let badge_galley = ui.fonts(|f| {
f.layout_no_wrap(
status_text.to_string(),
typography::font(FontSize::Small, FontWeight::Medium),
colors::TEXT_PRIMARY,
)
});
let badge_width = badge_galley.rect.width() + spacing::MD * 2.0;
let badge_height = badge_galley.rect.height() + spacing::XS * 2.0;
let (badge_rect, _) =
ui.allocate_exact_size(Vec2::new(badge_width, badge_height), Sense::hover());
ui.painter().rect_filled(
badge_rect,
Rounding::same(rounding::SMALL),
badge_background_color(status_color),
);
let text_pos = badge_rect.center() - badge_galley.rect.center().to_vec2();
ui.painter().galley(text_pos, badge_galley, status_color);
});
});
child_ui.add_space(spacing::XS);
child_ui.horizontal(|ui| {
ui.label(
egui::RichText::new(entry.story_count_text())
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_SECONDARY),
);
ui.add_space(spacing::MD);
let branch_display = truncate_with_ellipsis(&entry.branch, MAX_BRANCH_LENGTH);
ui.label(
egui::RichText::new(format!("⎇ {}", branch_display))
.font(typography::font(FontSize::Small, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
response.clicked()
}
fn render_empty_projects(&self, ui: &mut egui::Ui) {
ui.add_space(spacing::XXL);
ui.vertical_centered(|ui| {
ui.add_space(spacing::XXL + spacing::LG);
ui.label(
egui::RichText::new("No projects found")
.font(typography::font(FontSize::Heading, FontWeight::Medium))
.color(colors::TEXT_MUTED),
);
ui.add_space(spacing::SM);
ui.label(
egui::RichText::new("Projects will appear here after running autom8")
.font(typography::font(FontSize::Body, FontWeight::Regular))
.color(colors::TEXT_MUTED),
);
});
}
fn render_projects_list(&mut self, ui: &mut egui::Ui) {
let project_names: Vec<String> =
self.projects.iter().map(|p| p.info.name.clone()).collect();
let selected = self.selected_project.clone();
let mut interactions: Vec<(String, ProjectRowInteraction)> = Vec::new();
egui::ScrollArea::vertical()
.id_salt("projects_left_panel")
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
.show(ui, |ui| {
for (idx, project_name) in project_names.iter().enumerate() {
let project = &self.projects[idx];
let is_selected = selected.as_deref() == Some(project_name.as_str());
let interaction = self.render_project_row(ui, project, is_selected);
if interaction.clicked || interaction.right_click_pos.is_some() {
interactions.push((project_name.clone(), interaction));
}
ui.add_space(spacing::XS);
}
});
for (project_name, interaction) in interactions {
if interaction.clicked {
self.toggle_project_selection(&project_name);
} else if let Some(pos) = interaction.right_click_pos {
self.open_context_menu(pos, project_name);
}
}
}
fn count_active_sessions_for_project(&self, project_name: &str) -> usize {
self.sessions
.iter()
.filter(|s| s.project_name == project_name && !s.is_stale)
.count()
}
fn project_status_color(&self, project: &ProjectData) -> Color32 {
if let Some(ref error) = project.load_error {
if !error.is_empty() {
return colors::STATUS_ERROR;
}
}
if project.info.has_active_run {
colors::STATUS_RUNNING
} else {
colors::STATUS_IDLE
}
}
fn project_status_text(&self, project: &ProjectData) -> String {
if let Some(ref error) = project.load_error {
if !error.is_empty() {
return truncate_with_ellipsis(error, 30);
}
}
let active_count = self.count_active_sessions_for_project(&project.info.name);
if active_count > 1 {
format!("{} sessions active", active_count)
} else if project.info.has_active_run || active_count == 1 {
"Running".to_string()
} else if let Some(last_run) = project.info.last_run_date {
format!("Last run: {}", format_relative_time(last_run))
} else {
"Idle".to_string()
}
}
fn render_project_row(
&self,
ui: &mut egui::Ui,
project: &ProjectData,
is_selected: bool,
) -> ProjectRowInteraction {
let row_size = Vec2::new(ui.available_width(), PROJECT_ROW_HEIGHT);
let (rect, response) = ui.allocate_exact_size(row_size, Sense::click());
if !ui.is_rect_visible(rect) {
return ProjectRowInteraction::none();
}
let painter = ui.painter();
let is_hovered = response.hovered();
let was_clicked = response.clicked();
let was_secondary_clicked = response.secondary_clicked();
response.on_hover_cursor(egui::CursorIcon::PointingHand);
let bg_color = if is_selected {
colors::SURFACE_SELECTED
} else if is_hovered {
colors::SURFACE_HOVER
} else {
colors::SURFACE
};
let border_color = if is_selected {
colors::ACCENT
} else if is_hovered {
colors::BORDER_FOCUSED
} else {
colors::BORDER
};
let border_width = if is_selected { 2.0 } else { 1.0 };
painter.rect(
rect,
Rounding::same(rounding::BUTTON),
bg_color,
Stroke::new(border_width, border_color),
);
let content_rect = rect.shrink2(Vec2::new(PROJECT_ROW_PADDING_H, PROJECT_ROW_PADDING_V));
let mut cursor_x = content_rect.min.x;
let center_y = content_rect.center().y;
let status_color = self.project_status_color(project);
let dot_center = egui::pos2(cursor_x + PROJECT_STATUS_DOT_RADIUS, center_y);
painter.circle_filled(dot_center, PROJECT_STATUS_DOT_RADIUS, status_color);
cursor_x += PROJECT_STATUS_DOT_RADIUS * 2.0 + spacing::MD;
let name_text = truncate_with_ellipsis(&project.info.name, 30);
let name_galley = painter.layout_no_wrap(
name_text,
typography::font(FontSize::Body, FontWeight::SemiBold),
colors::TEXT_PRIMARY,
);
let name_y = center_y - name_galley.rect.height() / 2.0 - 6.0;
painter.galley(
egui::pos2(cursor_x, name_y),
name_galley.clone(),
Color32::TRANSPARENT,
);
let status_text = self.project_status_text(project);
let status_text_color = if project.load_error.is_some() {
colors::STATUS_ERROR
} else if project.info.has_active_run
|| self.count_active_sessions_for_project(&project.info.name) > 0
{
colors::STATUS_RUNNING
} else {
colors::TEXT_MUTED
};
let status_galley = painter.layout_no_wrap(
status_text,
typography::font(FontSize::Caption, FontWeight::Regular),
status_text_color,
);
let status_y = name_y + name_galley.rect.height() + spacing::XS;
painter.galley(
egui::pos2(cursor_x, status_y),
status_galley,
Color32::TRANSPARENT,
);
if let Some(last_run) = project.info.last_run_date {
let activity_text = format_relative_time(last_run);
let activity_galley = painter.layout_no_wrap(
activity_text,
typography::font(FontSize::Caption, FontWeight::Regular),
colors::TEXT_MUTED,
);
let activity_x = content_rect.max.x - activity_galley.rect.width();
let activity_y = center_y - activity_galley.rect.height() / 2.0;
painter.galley(
egui::pos2(activity_x, activity_y),
activity_galley,
Color32::TRANSPARENT,
);
}
if was_secondary_clicked {
let menu_pos = ui
.ctx()
.input(|i| i.pointer.hover_pos())
.unwrap_or(rect.center());
ProjectRowInteraction::right_click(menu_pos)
} else if was_clicked {
ProjectRowInteraction::click()
} else {
ProjectRowInteraction::none()
}
}
}
fn load_window_icon() -> Option<Arc<egui::IconData>> {
let icon_bytes = include_bytes!("../../../assets/icon.png");
match eframe::icon_data::from_png_bytes(icon_bytes) {
Ok(icon_data) => Some(Arc::new(icon_data)),
Err(_) => {
None
}
}
}
fn build_viewport() -> egui::ViewportBuilder {
let mut builder = egui::ViewportBuilder::default()
.with_title("autom8")
.with_inner_size([DEFAULT_WIDTH, DEFAULT_HEIGHT])
.with_min_inner_size([MIN_WIDTH, MIN_HEIGHT])
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false);
if let Some(icon) = load_window_icon() {
builder = builder.with_icon(icon);
}
builder
}
pub fn run_gui() -> Result<()> {
let options = eframe::NativeOptions {
viewport: build_viewport(),
..Default::default()
};
eframe::run_native(
"autom8",
options,
Box::new(|cc| {
egui_extras::install_image_loaders(&cc.egui_ctx);
typography::init(&cc.egui_ctx);
theme::init(&cc.egui_ctx);
Ok(Box::new(Autom8App::new()))
}),
)
.map_err(|e| Autom8Error::GuiError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::PathBuf;
fn make_test_session_data(
run: Option<crate::state::RunState>,
live_output: Option<crate::state::LiveState>,
) -> SessionData {
use crate::state::SessionMetadata;
SessionData {
project_name: "test-project".to_string(),
metadata: SessionMetadata {
session_id: "main".to_string(),
worktree_path: PathBuf::from("/test/path"),
branch_name: "test-branch".to_string(),
created_at: Utc::now(),
last_active_at: Utc::now(),
is_running: true,
spec_json_path: None,
},
run,
progress: None,
load_error: None,
is_main_session: true,
is_stale: false,
live_output,
cached_user_stories: None,
}
}
fn make_test_run_state(machine_state: MachineState) -> crate::state::RunState {
crate::state::RunState {
run_id: "test-run".to_string(),
status: crate::state::RunStatus::Running,
machine_state,
spec_json_path: PathBuf::from("/test/spec.json"),
spec_md_path: None,
branch: "test-branch".to_string(),
current_story: None,
iteration: 1,
review_iteration: 0,
started_at: Utc::now(),
finished_at: None,
iterations: vec![],
config: None,
knowledge: Default::default(),
pre_story_commit: None,
session_id: Some("main".to_string()),
total_usage: None,
phase_usage: std::collections::HashMap::new(),
}
}
#[test]
fn test_app_initialization() {
let app = Autom8App::new();
assert_eq!(app.current_tab(), Tab::ActiveRuns);
assert_eq!(app.tab_count(), 3);
let interval = Duration::from_millis(100);
let app2 = Autom8App::with_refresh_interval(interval);
assert_eq!(app2.refresh_interval(), interval);
}
#[test]
fn test_tab_open_close() {
let mut app = Autom8App::new();
assert!(app.open_run_detail_tab("run-1", "Run 1"));
assert!(!app.open_run_detail_tab("run-1", "Run 1")); app.open_run_detail_tab("run-2", "Run 2");
assert_eq!(app.tab_count(), 5); assert_eq!(app.closable_tab_count(), 2);
assert!(app.close_tab(&TabId::RunDetail("run-1".to_string())));
assert_eq!(app.closable_tab_count(), 1);
assert!(!app.close_tab(&TabId::ActiveRuns));
assert!(!app.close_tab(&TabId::Config));
}
#[test]
fn test_run_history_entry_creation() {
use crate::state::{IterationRecord, IterationStatus, RunState, RunStatus};
let mut run = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
run.status = RunStatus::Completed;
run.iterations.push(IterationRecord {
number: 1,
story_id: "US-001".to_string(),
started_at: Utc::now(),
finished_at: Some(Utc::now()),
status: IterationStatus::Success,
output_snippet: String::new(),
work_summary: None,
usage: None,
});
run.iterations.push(IterationRecord {
number: 2,
story_id: "US-002".to_string(),
started_at: Utc::now(),
finished_at: None,
status: IterationStatus::Failed,
output_snippet: String::new(),
work_summary: None,
usage: None,
});
let entry = RunHistoryEntry::from_run_state("test-project".to_string(), &run);
assert_eq!(entry.branch, "feature/test");
assert_eq!(entry.completed_stories, 1);
assert_eq!(entry.total_stories, 2);
assert_eq!(entry.story_count_text(), "1/2 stories");
assert_eq!(entry.status_color(), colors::STATUS_SUCCESS);
}
#[test]
fn test_config_scope_display() {
assert_eq!(ConfigScope::Global.display_name(), "Global");
assert_eq!(
ConfigScope::Project("my-project".to_string()).display_name(),
"my-project"
);
assert!(ConfigScope::Global.is_global());
assert!(!ConfigScope::Project("test".to_string()).is_global());
}
#[test]
fn test_output_source_fresh_live_preferred() {
let mut live = crate::state::LiveState::new(MachineState::RunningClaude);
live.output_lines = vec!["Line 1".to_string(), "Line 2".to_string()];
let run = make_test_run_state(MachineState::RunningClaude);
let session = make_test_session_data(Some(run), Some(live));
let output = get_output_for_session(&session);
assert!(matches!(output, OutputSource::Live(_)));
if let OutputSource::Live(lines) = output {
assert_eq!(lines.len(), 2);
}
}
#[test]
fn test_output_source_no_live_returns_no_data() {
let run = make_test_run_state(MachineState::RunningClaude);
let session = make_test_session_data(Some(run), None);
let output = get_output_for_session(&session);
assert!(matches!(output, OutputSource::NoData));
}
#[test]
fn test_output_source_enum_variants() {
let live = OutputSource::Live(vec!["test".to_string()]);
let iter = OutputSource::Iteration(vec!["test".to_string()]);
let status = OutputSource::StatusMessage("test".to_string());
let no_data = OutputSource::NoData;
assert_ne!(live, iter);
assert_ne!(status.clone(), no_data.clone());
assert_eq!(status, OutputSource::StatusMessage("test".to_string()));
}
#[test]
fn test_iteration_output_preserved_when_live_empty() {
use crate::state::{IterationRecord, IterationStatus, LiveState};
let mut run = make_test_run_state(MachineState::RunningClaude);
run.iterations.push(IterationRecord {
number: 1,
story_id: "US-001".to_string(),
started_at: Utc::now(),
finished_at: None,
status: IterationStatus::Running,
output_snippet: "Previous iteration output\nLine 2\nLine 3".to_string(),
work_summary: None,
usage: None,
});
let live = LiveState {
output_lines: vec![], updated_at: Utc::now(),
machine_state: MachineState::RunningClaude,
last_heartbeat: Utc::now(),
};
let session = make_test_session_data(Some(run), Some(live));
let output = get_output_for_session(&session);
match output {
OutputSource::Iteration(lines) => {
assert!(!lines.is_empty());
assert!(lines
.iter()
.any(|l| l.contains("Previous iteration output")));
}
OutputSource::StatusMessage(msg) => {
panic!(
"Bug: Should have shown iteration output, not status message: {}",
msg
);
}
other => panic!("Unexpected output source: {:?}", other),
}
}
#[test]
fn test_waiting_shown_only_when_no_output() {
use crate::state::LiveState;
let run = make_test_run_state(MachineState::RunningClaude);
let live = LiveState {
output_lines: vec![],
updated_at: Utc::now(),
machine_state: MachineState::RunningClaude,
last_heartbeat: Utc::now(),
};
let session = make_test_session_data(Some(run), Some(live));
let output = get_output_for_session(&session);
match output {
OutputSource::StatusMessage(msg) => {
assert_eq!(msg, "Waiting for output...");
}
other => panic!("Expected StatusMessage, got {:?}", other),
}
}
#[test]
fn test_output_persists_across_state_transitions() {
use crate::state::{IterationRecord, IterationStatus, LiveState};
let mut run = make_test_run_state(MachineState::Reviewing);
run.iterations.push(IterationRecord {
number: 1,
story_id: "US-001".to_string(),
started_at: Utc::now(),
finished_at: Some(Utc::now()),
status: IterationStatus::Success,
output_snippet: "Previous iteration completed\nImplemented feature X".to_string(),
work_summary: Some("Implemented feature X".to_string()),
usage: None,
});
let mut live = LiveState::new(MachineState::RunningClaude);
live.updated_at = Utc::now() - chrono::Duration::seconds(10);
let session = make_test_session_data(Some(run), Some(live));
let output = get_output_for_session(&session);
match output {
OutputSource::Iteration(lines) => {
assert!(!lines.is_empty());
assert!(lines
.iter()
.any(|l| l.contains("Previous iteration completed")));
}
OutputSource::StatusMessage(msg) => {
panic!(
"Bug: Should have shown iteration output during state transition, not: {}",
msg
);
}
other => panic!("Unexpected output source: {:?}", other),
}
}
#[test]
fn test_previous_iteration_shown_when_current_has_no_output() {
use crate::state::{IterationRecord, IterationStatus, LiveState};
let mut run = make_test_run_state(MachineState::RunningClaude);
run.iterations.push(IterationRecord {
number: 1,
story_id: "US-001".to_string(),
started_at: Utc::now(),
finished_at: Some(Utc::now()),
status: IterationStatus::Success,
output_snippet: "First iteration output\nDid something useful".to_string(),
work_summary: Some("Did something useful".to_string()),
usage: None,
});
run.iterations.push(IterationRecord {
number: 2,
story_id: "US-002".to_string(),
started_at: Utc::now(),
finished_at: None,
status: IterationStatus::Running,
output_snippet: String::new(), work_summary: None,
usage: None,
});
let live = LiveState {
output_lines: vec![], updated_at: Utc::now(),
machine_state: MachineState::RunningClaude,
last_heartbeat: Utc::now(),
};
let session = make_test_session_data(Some(run), Some(live));
let output = get_output_for_session(&session);
match output {
OutputSource::Iteration(lines) => {
assert!(!lines.is_empty());
assert!(lines.iter().any(|l| l.contains("First iteration output")));
}
OutputSource::StatusMessage(msg) => {
panic!(
"Bug: Should have shown previous iteration output, not: {}",
msg
);
}
other => panic!("Unexpected output source: {:?}", other),
}
}
}