git_iris/studio/state/
mod.rs

1//! State management for Iris Studio
2//!
3//! Centralized state for all modes and shared data.
4
5mod chat;
6mod modes;
7
8pub use chat::{ChatMessage, ChatRole, ChatState, truncate_preview};
9pub use modes::{ChangelogCommit, FileLogEntry, ModeStates, PrCommit};
10
11use crate::agents::StatusMessageBatch;
12use crate::companion::CompanionService;
13use crate::config::Config;
14use crate::git::GitRepo;
15use crate::types::format_commit_message;
16use std::collections::VecDeque;
17use std::path::PathBuf;
18use std::sync::Arc;
19
20// ═══════════════════════════════════════════════════════════════════════════════
21// Mode Enum
22// ═══════════════════════════════════════════════════════════════════════════════
23
24/// Available modes in Iris Studio
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26pub enum Mode {
27    /// Explore mode - semantic code understanding
28    #[default]
29    Explore,
30    /// Commit mode - generate and edit commit messages
31    Commit,
32    /// Review mode - AI-powered code review
33    Review,
34    /// PR mode - pull request creation
35    PR,
36    /// Changelog mode - structured changelogs
37    Changelog,
38    /// Release Notes mode - release documentation
39    ReleaseNotes,
40}
41
42impl Mode {
43    /// Get the display name for this mode
44    pub fn display_name(&self) -> &'static str {
45        match self {
46            Mode::Explore => "Explore",
47            Mode::Commit => "Commit",
48            Mode::Review => "Review",
49            Mode::PR => "PR",
50            Mode::Changelog => "Changelog",
51            Mode::ReleaseNotes => "Release",
52        }
53    }
54
55    /// Get the keyboard shortcut for this mode
56    pub fn shortcut(&self) -> char {
57        match self {
58            Mode::Explore => 'E',
59            Mode::Commit => 'C',
60            Mode::Review => 'R',
61            Mode::PR => 'P',
62            Mode::Changelog => 'L',
63            Mode::ReleaseNotes => 'N',
64        }
65    }
66
67    /// Check if this mode is available (implemented)
68    pub fn is_available(&self) -> bool {
69        matches!(
70            self,
71            Mode::Explore
72                | Mode::Commit
73                | Mode::Review
74                | Mode::PR
75                | Mode::Changelog
76                | Mode::ReleaseNotes
77        )
78    }
79
80    /// Get all modes in order
81    pub fn all() -> &'static [Mode] {
82        &[
83            Mode::Explore,
84            Mode::Commit,
85            Mode::Review,
86            Mode::PR,
87            Mode::Changelog,
88            Mode::ReleaseNotes,
89        ]
90    }
91}
92
93// ═══════════════════════════════════════════════════════════════════════════════
94// Panel Focus
95// ═══════════════════════════════════════════════════════════════════════════════
96
97/// Generic panel identifier
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub enum PanelId {
100    /// Left panel (typically file tree or file list)
101    Left,
102    /// Center panel (typically code view or diff view)
103    Center,
104    /// Right panel (typically context or message)
105    Right,
106}
107
108impl PanelId {
109    /// Get the next panel in tab order
110    pub fn next(&self) -> Self {
111        match self {
112            PanelId::Left => PanelId::Center,
113            PanelId::Center => PanelId::Right,
114            PanelId::Right => PanelId::Left,
115        }
116    }
117
118    /// Get the previous panel in tab order
119    pub fn prev(&self) -> Self {
120        match self {
121            PanelId::Left => PanelId::Right,
122            PanelId::Center => PanelId::Left,
123            PanelId::Right => PanelId::Center,
124        }
125    }
126}
127
128// ═══════════════════════════════════════════════════════════════════════════════
129// Git Status
130// ═══════════════════════════════════════════════════════════════════════════════
131
132/// Cached git repository status
133#[derive(Debug, Clone, Default)]
134pub struct GitStatus {
135    /// Current branch name
136    pub branch: String,
137    /// Number of staged files
138    pub staged_count: usize,
139    /// Number of modified (unstaged) files
140    pub modified_count: usize,
141    /// Number of untracked files
142    pub untracked_count: usize,
143    /// Number of commits ahead of upstream
144    pub commits_ahead: usize,
145    /// Number of commits behind upstream
146    pub commits_behind: usize,
147    /// List of staged files
148    pub staged_files: Vec<PathBuf>,
149    /// List of modified files
150    pub modified_files: Vec<PathBuf>,
151    /// List of untracked files
152    pub untracked_files: Vec<PathBuf>,
153}
154
155impl GitStatus {
156    /// Check if we're on the main/master branch
157    pub fn is_main_branch(&self) -> bool {
158        self.branch == "main" || self.branch == "master"
159    }
160
161    /// Check if there are any changes
162    pub fn has_changes(&self) -> bool {
163        self.staged_count > 0 || self.modified_count > 0 || self.untracked_count > 0
164    }
165
166    /// Check if there are staged changes ready to commit
167    pub fn has_staged(&self) -> bool {
168        self.staged_count > 0
169    }
170}
171
172// ═══════════════════════════════════════════════════════════════════════════════
173// Notifications
174// ═══════════════════════════════════════════════════════════════════════════════
175
176/// Notification severity level
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum NotificationLevel {
179    Info,
180    Success,
181    Warning,
182    Error,
183}
184
185/// A notification message to display to the user
186#[derive(Debug, Clone)]
187pub struct Notification {
188    pub message: String,
189    pub level: NotificationLevel,
190    pub timestamp: std::time::Instant,
191}
192
193impl Notification {
194    pub fn info(message: impl Into<String>) -> Self {
195        Self {
196            message: message.into(),
197            level: NotificationLevel::Info,
198            timestamp: std::time::Instant::now(),
199        }
200    }
201
202    pub fn success(message: impl Into<String>) -> Self {
203        Self {
204            message: message.into(),
205            level: NotificationLevel::Success,
206            timestamp: std::time::Instant::now(),
207        }
208    }
209
210    pub fn warning(message: impl Into<String>) -> Self {
211        Self {
212            message: message.into(),
213            level: NotificationLevel::Warning,
214            timestamp: std::time::Instant::now(),
215        }
216    }
217
218    pub fn error(message: impl Into<String>) -> Self {
219        Self {
220            message: message.into(),
221            level: NotificationLevel::Error,
222            timestamp: std::time::Instant::now(),
223        }
224    }
225
226    /// Check if this notification has expired (older than 5 seconds)
227    pub fn is_expired(&self) -> bool {
228        self.timestamp.elapsed() > std::time::Duration::from_secs(5)
229    }
230}
231
232// ═══════════════════════════════════════════════════════════════════════════════
233// Modal State
234// ═══════════════════════════════════════════════════════════════════════════════
235
236/// Preset info for display
237#[derive(Debug, Clone)]
238pub struct PresetInfo {
239    /// Preset key (id)
240    pub key: String,
241    /// Display name
242    pub name: String,
243    /// Description
244    pub description: String,
245    /// Emoji
246    pub emoji: String,
247}
248
249/// Emoji info for display in selector
250#[derive(Debug, Clone)]
251pub struct EmojiInfo {
252    /// The emoji character
253    pub emoji: String,
254    /// Short key/code (e.g., "feat", "fix")
255    pub key: String,
256    /// Description
257    pub description: String,
258}
259
260/// Emoji mode for commit messages
261#[derive(Debug, Clone, PartialEq, Eq, Default)]
262pub enum EmojiMode {
263    /// No emoji
264    None,
265    /// Let AI choose the emoji
266    #[default]
267    Auto,
268    /// User-selected specific emoji
269    Custom(String),
270}
271
272/// Active modal dialog
273pub enum Modal {
274    /// Help overlay showing keybindings
275    Help,
276    /// Search modal for files/symbols
277    Search {
278        query: String,
279        results: Vec<String>,
280        selected: usize,
281    },
282    /// Confirmation dialog
283    Confirm { message: String, action: String },
284    /// Instructions input for commit message generation
285    Instructions { input: String },
286    /// Chat interface with Iris (state lives in `StudioState.chat_state`)
287    Chat,
288    /// Base branch/ref selector for PR/changelog modes
289    RefSelector {
290        /// Current input/filter
291        input: String,
292        /// Available refs (branches, tags)
293        refs: Vec<String>,
294        /// Selected index
295        selected: usize,
296        /// Target mode (which mode to update)
297        target: RefSelectorTarget,
298    },
299    /// Preset selector for commit style
300    PresetSelector {
301        /// Current input/filter
302        input: String,
303        /// Available presets
304        presets: Vec<PresetInfo>,
305        /// Selected index
306        selected: usize,
307        /// Scroll offset for long lists
308        scroll: usize,
309    },
310    /// Emoji selector for commit messages
311    EmojiSelector {
312        /// Current input/filter
313        input: String,
314        /// Available emojis (None, Auto, then all gitmojis)
315        emojis: Vec<EmojiInfo>,
316        /// Selected index
317        selected: usize,
318        /// Scroll offset for long lists
319        scroll: usize,
320    },
321    /// Settings configuration modal
322    Settings(Box<SettingsState>),
323    /// Theme selector modal
324    ThemeSelector {
325        /// Current input/filter
326        input: String,
327        /// Available themes
328        themes: Vec<ThemeOptionInfo>,
329        /// Selected index
330        selected: usize,
331        /// Scroll offset for long lists
332        scroll: usize,
333    },
334    /// Quick commit count picker for PR mode ("last N commits")
335    CommitCount {
336        /// Current input (number as string)
337        input: String,
338        /// Which mode to update
339        target: CommitCountTarget,
340    },
341}
342
343/// Target for commit count picker
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum CommitCountTarget {
346    /// Set PR "from" ref to HEAD~N
347    Pr,
348    /// Set Review "from" ref to HEAD~N
349    Review,
350    /// Set Changelog "from" ref to HEAD~N
351    Changelog,
352    /// Set Release Notes "from" ref to HEAD~N
353    ReleaseNotes,
354}
355
356// ═══════════════════════════════════════════════════════════════════════════════
357// Settings State
358// ═══════════════════════════════════════════════════════════════════════════════
359
360/// Settings section for grouped display
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum SettingsSection {
363    Provider,
364    Appearance,
365    Behavior,
366}
367
368impl SettingsSection {
369    /// Get the display name for this section
370    pub fn display_name(&self) -> &'static str {
371        match self {
372            SettingsSection::Provider => "Provider",
373            SettingsSection::Appearance => "Appearance",
374            SettingsSection::Behavior => "Behavior",
375        }
376    }
377}
378
379/// Field being edited in settings
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum SettingsField {
382    Provider,
383    Model,
384    ApiKey,
385    Theme,
386    UseGitmoji,
387    InstructionPreset,
388    CustomInstructions,
389}
390
391impl SettingsField {
392    /// Get all fields in display order
393    pub fn all() -> &'static [SettingsField] {
394        &[
395            SettingsField::Provider,
396            SettingsField::Model,
397            SettingsField::ApiKey,
398            SettingsField::Theme,
399            SettingsField::UseGitmoji,
400            SettingsField::InstructionPreset,
401            SettingsField::CustomInstructions,
402        ]
403    }
404
405    /// Get field display name
406    pub fn display_name(&self) -> &'static str {
407        match self {
408            SettingsField::Provider => "Provider",
409            SettingsField::Model => "Model",
410            SettingsField::ApiKey => "API Key",
411            SettingsField::Theme => "Theme",
412            SettingsField::UseGitmoji => "Gitmoji",
413            SettingsField::InstructionPreset => "Preset",
414            SettingsField::CustomInstructions => "Instructions",
415        }
416    }
417
418    /// Get which section this field belongs to
419    pub fn section(&self) -> SettingsSection {
420        match self {
421            SettingsField::Provider | SettingsField::Model | SettingsField::ApiKey => {
422                SettingsSection::Provider
423            }
424            SettingsField::Theme => SettingsSection::Appearance,
425            SettingsField::UseGitmoji
426            | SettingsField::InstructionPreset
427            | SettingsField::CustomInstructions => SettingsSection::Behavior,
428        }
429    }
430}
431
432/// Theme info for settings and selector display
433#[derive(Debug, Clone)]
434pub struct ThemeOptionInfo {
435    /// Theme identifier (e.g., `silkcircuit-neon`)
436    pub id: String,
437    /// Display name (e.g., `SilkCircuit Neon`)
438    pub display_name: String,
439    /// Variant indicator (dark/light)
440    pub variant: String,
441    /// Theme author
442    pub author: String,
443    /// Theme description
444    pub description: String,
445}
446
447/// State for the settings modal
448#[derive(Debug, Clone)]
449pub struct SettingsState {
450    /// Currently selected field
451    pub selected_field: usize,
452    /// Currently editing a field
453    pub editing: bool,
454    /// Text input buffer for editing
455    pub input_buffer: String,
456    /// Current provider
457    pub provider: String,
458    /// Current model
459    pub model: String,
460    /// API key (masked for display)
461    pub api_key_display: String,
462    /// Actual API key (for saving)
463    pub api_key_actual: Option<String>,
464    /// Current theme identifier
465    pub theme: String,
466    /// Use gitmoji
467    pub use_gitmoji: bool,
468    /// Instruction preset
469    pub instruction_preset: String,
470    /// Custom instructions for all operations
471    pub custom_instructions: String,
472    /// Available providers
473    pub available_providers: Vec<String>,
474    /// Available themes
475    pub available_themes: Vec<ThemeOptionInfo>,
476    /// Available presets
477    pub available_presets: Vec<String>,
478    /// Whether config was modified
479    pub modified: bool,
480    /// Error message if any
481    pub error: Option<String>,
482}
483
484impl SettingsState {
485    /// Create settings state from current config
486    pub fn from_config(config: &Config) -> Self {
487        use crate::instruction_presets::get_instruction_preset_library;
488        use crate::providers::Provider;
489        use crate::theme;
490
491        let provider = config.default_provider.clone();
492        let provider_config = config.get_provider_config(&provider);
493
494        let model = provider_config.map(|p| p.model.clone()).unwrap_or_default();
495
496        let api_key_display = provider_config
497            .map(|p| Self::mask_api_key(&p.api_key))
498            .unwrap_or_default();
499
500        let available_providers: Vec<String> =
501            Provider::ALL.iter().map(|p| p.name().to_string()).collect();
502
503        // Get available themes (sorted: dark first, then light, alphabetically within each)
504        let mut available_themes: Vec<ThemeOptionInfo> = theme::list_available_themes()
505            .into_iter()
506            .map(|info| ThemeOptionInfo {
507                id: info.name,
508                display_name: info.display_name,
509                variant: match info.variant {
510                    theme::ThemeVariant::Dark => "dark".to_string(),
511                    theme::ThemeVariant::Light => "light".to_string(),
512                },
513                author: info.author,
514                description: info.description,
515            })
516            .collect();
517        available_themes.sort_by(|a, b| {
518            // Dark themes first, then sort alphabetically
519            match (a.variant.as_str(), b.variant.as_str()) {
520                ("dark", "light") => std::cmp::Ordering::Less,
521                ("light", "dark") => std::cmp::Ordering::Greater,
522                _ => a.display_name.cmp(&b.display_name),
523            }
524        });
525
526        // Get current theme name
527        let current_theme = theme::current();
528        let theme_id = available_themes
529            .iter()
530            .find(|t| t.display_name == current_theme.meta.name)
531            .map_or_else(|| "silkcircuit-neon".to_string(), |t| t.id.clone());
532
533        let preset_library = get_instruction_preset_library();
534        let available_presets: Vec<String> = preset_library
535            .list_presets()
536            .iter()
537            .map(|(key, _)| (*key).clone())
538            .collect();
539
540        Self {
541            selected_field: 0,
542            editing: false,
543            input_buffer: String::new(),
544            provider,
545            model,
546            api_key_display,
547            api_key_actual: None, // Only set when user enters a new key
548            theme: theme_id,
549            use_gitmoji: config.use_gitmoji,
550            instruction_preset: config.instruction_preset.clone(),
551            custom_instructions: config
552                .temp_instructions
553                .clone()
554                .unwrap_or_else(|| config.instructions.clone()),
555            available_providers,
556            available_themes,
557            available_presets,
558            modified: false,
559            error: None,
560        }
561    }
562
563    /// Mask an API key for display
564    fn mask_api_key(key: &str) -> String {
565        if key.is_empty() {
566            "(not set)".to_string()
567        } else {
568            let len = key.len();
569            if len <= 8 {
570                "*".repeat(len)
571            } else {
572                format!("{}...{}", &key[..4], &key[len - 4..])
573            }
574        }
575    }
576
577    /// Get the currently selected field
578    pub fn current_field(&self) -> SettingsField {
579        SettingsField::all()[self.selected_field]
580    }
581
582    /// Move selection up
583    pub fn select_prev(&mut self) {
584        if self.selected_field > 0 {
585            self.selected_field -= 1;
586        }
587    }
588
589    /// Move selection down
590    pub fn select_next(&mut self) {
591        let max = SettingsField::all().len() - 1;
592        if self.selected_field < max {
593            self.selected_field += 1;
594        }
595    }
596
597    /// Get the current value for a field
598    pub fn get_field_value(&self, field: SettingsField) -> String {
599        match field {
600            SettingsField::Provider => self.provider.clone(),
601            SettingsField::Model => self.model.clone(),
602            SettingsField::ApiKey => self.api_key_display.clone(),
603            SettingsField::Theme => self
604                .available_themes
605                .iter()
606                .find(|t| t.id == self.theme)
607                .map_or_else(|| self.theme.clone(), |t| t.display_name.clone()),
608            SettingsField::UseGitmoji => {
609                if self.use_gitmoji {
610                    "yes".to_string()
611                } else {
612                    "no".to_string()
613                }
614            }
615            SettingsField::InstructionPreset => self.instruction_preset.clone(),
616            SettingsField::CustomInstructions => {
617                if self.custom_instructions.is_empty() {
618                    "(none)".to_string()
619                } else {
620                    // Truncate for display if too long
621                    let preview = self.custom_instructions.lines().next().unwrap_or("");
622                    if preview.len() > 30 || self.custom_instructions.lines().count() > 1 {
623                        format!("{}...", &preview.chars().take(30).collect::<String>())
624                    } else {
625                        preview.to_string()
626                    }
627                }
628            }
629        }
630    }
631
632    /// Get the current theme info
633    pub fn current_theme_info(&self) -> Option<&ThemeOptionInfo> {
634        self.available_themes.iter().find(|t| t.id == self.theme)
635    }
636
637    /// Cycle through options for the current field (forward direction)
638    pub fn cycle_current_field(&mut self) {
639        self.cycle_field_direction(true);
640    }
641
642    /// Cycle through options for the current field (backward direction)
643    pub fn cycle_current_field_back(&mut self) {
644        self.cycle_field_direction(false);
645    }
646
647    /// Cycle through options for the current field in given direction
648    fn cycle_field_direction(&mut self, forward: bool) {
649        let field = self.current_field();
650        match field {
651            SettingsField::Provider => {
652                if let Some(idx) = self
653                    .available_providers
654                    .iter()
655                    .position(|p| p == &self.provider)
656                {
657                    let next = if forward {
658                        (idx + 1) % self.available_providers.len()
659                    } else if idx == 0 {
660                        self.available_providers.len() - 1
661                    } else {
662                        idx - 1
663                    };
664                    self.provider = self.available_providers[next].clone();
665                    self.modified = true;
666                }
667            }
668            SettingsField::Theme => {
669                if let Some(idx) = self
670                    .available_themes
671                    .iter()
672                    .position(|t| t.id == self.theme)
673                {
674                    let next = if forward {
675                        (idx + 1) % self.available_themes.len()
676                    } else if idx == 0 {
677                        self.available_themes.len() - 1
678                    } else {
679                        idx - 1
680                    };
681                    self.theme = self.available_themes[next].id.clone();
682                    self.modified = true;
683                    // Apply theme immediately for live preview
684                    let _ = crate::theme::load_theme_by_name(&self.theme);
685                }
686            }
687            SettingsField::UseGitmoji => {
688                self.use_gitmoji = !self.use_gitmoji;
689                self.modified = true;
690            }
691            SettingsField::InstructionPreset => {
692                if let Some(idx) = self
693                    .available_presets
694                    .iter()
695                    .position(|p| p == &self.instruction_preset)
696                {
697                    let next = if forward {
698                        (idx + 1) % self.available_presets.len()
699                    } else if idx == 0 {
700                        self.available_presets.len() - 1
701                    } else {
702                        idx - 1
703                    };
704                    self.instruction_preset = self.available_presets[next].clone();
705                    self.modified = true;
706                }
707            }
708            _ => {}
709        }
710    }
711
712    /// Start editing the current field
713    pub fn start_editing(&mut self) {
714        let field = self.current_field();
715        match field {
716            SettingsField::Model => {
717                self.input_buffer = self.model.clone();
718                self.editing = true;
719            }
720            SettingsField::ApiKey => {
721                self.input_buffer.clear(); // Start fresh for API key
722                self.editing = true;
723            }
724            SettingsField::CustomInstructions => {
725                self.input_buffer = self.custom_instructions.clone();
726                self.editing = true;
727            }
728            _ => {
729                // For other fields, cycle instead
730                self.cycle_current_field();
731            }
732        }
733    }
734
735    /// Cancel editing
736    pub fn cancel_editing(&mut self) {
737        self.editing = false;
738        self.input_buffer.clear();
739    }
740
741    /// Confirm editing
742    pub fn confirm_editing(&mut self) {
743        if !self.editing {
744            return;
745        }
746
747        let field = self.current_field();
748        match field {
749            SettingsField::Model => {
750                if !self.input_buffer.is_empty() {
751                    self.model = self.input_buffer.clone();
752                    self.modified = true;
753                }
754            }
755            SettingsField::ApiKey => {
756                if !self.input_buffer.is_empty() {
757                    // Store actual key, update display
758                    let key = self.input_buffer.clone();
759                    self.api_key_display = Self::mask_api_key(&key);
760                    self.api_key_actual = Some(key);
761                    self.modified = true;
762                }
763            }
764            SettingsField::CustomInstructions => {
765                // Allow empty (clears instructions)
766                self.custom_instructions = self.input_buffer.clone();
767                self.modified = true;
768            }
769            _ => {}
770        }
771
772        self.editing = false;
773        self.input_buffer.clear();
774    }
775}
776
777/// Target for ref selector modal
778#[derive(Debug, Clone, Copy)]
779pub enum RefSelectorTarget {
780    /// Review from ref
781    ReviewFrom,
782    /// Review to ref
783    ReviewTo,
784    /// PR from ref (base branch)
785    PrFrom,
786    /// PR to ref
787    PrTo,
788    /// Changelog from version
789    ChangelogFrom,
790    /// Changelog to version
791    ChangelogTo,
792    /// Release notes from version
793    ReleaseNotesFrom,
794    /// Release notes to version
795    ReleaseNotesTo,
796}
797
798// ═══════════════════════════════════════════════════════════════════════════════
799// Iris Status
800// ═══════════════════════════════════════════════════════════════════════════════
801
802/// Status of the Iris agent
803#[derive(Debug, Clone, Default)]
804pub enum IrisStatus {
805    #[default]
806    Idle,
807    Thinking {
808        /// Current display message (may be static fallback or dynamic)
809        task: String,
810        /// Original fallback message (used if no dynamic messages arrive)
811        fallback: String,
812        /// Spinner animation frame
813        spinner_frame: usize,
814        /// Dynamic status message from the fast model (we keep ONE per task)
815        dynamic_messages: StatusMessageBatch,
816    },
817    /// Task completed - show completion message (stays until next task)
818    Complete {
819        /// Completion message to display
820        message: String,
821    },
822    Error(String),
823}
824
825impl IrisStatus {
826    /// Get the spinner frame character
827    pub fn spinner_char(&self) -> Option<char> {
828        match self {
829            IrisStatus::Thinking { spinner_frame, .. } => {
830                let frames = super::theme::SPINNER_BRAILLE;
831                Some(frames[*spinner_frame % frames.len()])
832            }
833            _ => None,
834        }
835    }
836
837    /// Get the current display message
838    pub fn message(&self) -> Option<&str> {
839        match self {
840            IrisStatus::Thinking { task, .. } => Some(task),
841            IrisStatus::Complete { message, .. } => Some(message),
842            IrisStatus::Error(msg) => Some(msg),
843            IrisStatus::Idle => None,
844        }
845    }
846
847    /// Check if this is a completion state
848    pub fn is_complete(&self) -> bool {
849        matches!(self, IrisStatus::Complete { .. })
850    }
851
852    /// Advance the spinner frame (Complete just stays put)
853    pub fn tick(&mut self) {
854        if let IrisStatus::Thinking { spinner_frame, .. } = self {
855            *spinner_frame = (*spinner_frame + 1) % super::theme::SPINNER_BRAILLE.len();
856        }
857    }
858
859    /// Set the dynamic status message (replaces any previous - we only keep ONE)
860    pub fn add_dynamic_message(&mut self, message: crate::agents::StatusMessage) {
861        if let IrisStatus::Thinking {
862            task,
863            dynamic_messages,
864            ..
865        } = self
866        {
867            // Replace the current message with the new one
868            task.clone_from(&message.message);
869            dynamic_messages.clear();
870            dynamic_messages.add(message);
871        }
872    }
873}
874
875// ═══════════════════════════════════════════════════════════════════════════════
876// Companion Session Display
877// ═══════════════════════════════════════════════════════════════════════════════
878
879/// A single commit entry for display
880#[derive(Debug, Clone, Default)]
881pub struct CommitEntry {
882    /// Short hash (7 chars)
883    pub short_hash: String,
884    /// Commit message (first line)
885    pub message: String,
886    /// Author name
887    pub author: String,
888    /// Relative time (e.g., "2 hours ago")
889    pub relative_time: String,
890}
891
892/// Snapshot of companion session for UI display
893#[derive(Debug, Clone, Default)]
894pub struct CompanionSessionDisplay {
895    /// Number of files touched this session
896    pub files_touched: usize,
897    /// Number of commits made this session
898    pub commits_made: usize,
899    /// Session duration in human-readable form
900    pub duration: String,
901    /// Most recently touched file (for activity indicator)
902    pub last_touched_file: Option<PathBuf>,
903    /// Welcome message if returning to branch
904    pub welcome_message: Option<String>,
905    /// When welcome message was shown (for auto-clear)
906    pub welcome_shown_at: Option<std::time::Instant>,
907    /// Whether file watcher is active
908    pub watcher_active: bool,
909
910    // ─── Git Browser Info ───
911    /// Current HEAD commit
912    pub head_commit: Option<CommitEntry>,
913    /// Recent commits (mini log, up to 5)
914    pub recent_commits: Vec<CommitEntry>,
915    /// Commits ahead of remote
916    pub ahead: usize,
917    /// Commits behind remote
918    pub behind: usize,
919    /// Current branch name
920    pub branch: String,
921    /// Number of staged files
922    pub staged_count: usize,
923    /// Number of unstaged files
924    pub unstaged_count: usize,
925}
926
927// ═══════════════════════════════════════════════════════════════════════════════
928// Main Studio State
929// ═══════════════════════════════════════════════════════════════════════════════
930
931/// Main application state for Iris Studio
932pub struct StudioState {
933    /// Git repository reference
934    pub repo: Option<Arc<GitRepo>>,
935
936    /// Cached git status
937    pub git_status: GitStatus,
938
939    /// Whether git status is currently loading
940    pub git_status_loading: bool,
941
942    /// Application configuration
943    pub config: Config,
944
945    /// Current active mode
946    pub active_mode: Mode,
947
948    /// Focused panel
949    pub focused_panel: PanelId,
950
951    /// Mode-specific states
952    pub modes: ModeStates,
953
954    /// Active modal
955    pub modal: Option<Modal>,
956
957    /// Persistent chat state (survives modal close, universal across modes)
958    pub chat_state: ChatState,
959
960    /// Notification queue
961    pub notifications: VecDeque<Notification>,
962
963    /// Iris agent status
964    pub iris_status: IrisStatus,
965
966    /// Companion service for ambient awareness (optional - may fail to init)
967    pub companion: Option<CompanionService>,
968
969    /// Companion session display data (updated periodically)
970    pub companion_display: CompanionSessionDisplay,
971
972    /// Whether the UI needs redraw
973    pub dirty: bool,
974
975    /// Last render timestamp for animations
976    pub last_render: std::time::Instant,
977}
978
979impl StudioState {
980    /// Create new studio state
981    /// Note: Companion service is initialized asynchronously via `load_companion_async()` in app for fast startup
982    pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
983        // Apply CLI overrides to commit mode
984        let mut modes = ModeStates::default();
985        if let Some(temp_instr) = &config.temp_instructions {
986            modes.commit.custom_instructions.clone_from(temp_instr);
987        }
988        if let Some(temp_preset) = &config.temp_preset {
989            modes.commit.preset.clone_from(temp_preset);
990        }
991
992        Self {
993            repo,
994            git_status: GitStatus::default(),
995            git_status_loading: false,
996            config,
997            active_mode: Mode::Explore,
998            focused_panel: PanelId::Left,
999            modes,
1000            modal: None,
1001            chat_state: ChatState::new(),
1002            notifications: VecDeque::new(),
1003            iris_status: IrisStatus::Idle,
1004            companion: None,
1005            companion_display: CompanionSessionDisplay::default(),
1006            dirty: true,
1007            last_render: std::time::Instant::now(),
1008        }
1009    }
1010
1011    /// Suggest the best initial mode based on repo state
1012    pub fn suggest_initial_mode(&self) -> Mode {
1013        let status = &self.git_status;
1014
1015        // Staged changes? Probably want to commit
1016        if status.has_staged() {
1017            return Mode::Commit;
1018        }
1019
1020        // On feature branch with commits ahead? PR time (future)
1021        // if status.commits_ahead > 0 && !status.is_main_branch() {
1022        //     return Mode::PR;
1023        // }
1024
1025        // Default to exploration
1026        Mode::Explore
1027    }
1028
1029    /// Switch to a new mode with context preservation
1030    pub fn switch_mode(&mut self, new_mode: Mode) {
1031        if !new_mode.is_available() {
1032            self.notify(Notification::warning(format!(
1033                "{} mode is not yet implemented",
1034                new_mode.display_name()
1035            )));
1036            return;
1037        }
1038
1039        let old_mode = self.active_mode;
1040
1041        // Context preservation logic
1042        match (old_mode, new_mode) {
1043            (Mode::Explore, Mode::Commit) => {
1044                // Carry current file context to commit
1045            }
1046            (Mode::Commit, Mode::Explore) => {
1047                // Carry last viewed file back
1048            }
1049            _ => {}
1050        }
1051
1052        self.active_mode = new_mode;
1053
1054        // Set default focus based on mode
1055        self.focused_panel = match new_mode {
1056            // Commit mode: focus on message editor (center panel)
1057            Mode::Commit => PanelId::Center,
1058            // Review/PR/Changelog/Release: focus on output (center panel)
1059            Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => PanelId::Center,
1060            // Explore: focus on file tree (left panel)
1061            Mode::Explore => PanelId::Left,
1062        };
1063        self.dirty = true;
1064    }
1065
1066    /// Add a notification
1067    pub fn notify(&mut self, notification: Notification) {
1068        self.notifications.push_back(notification);
1069        // Keep only last 5 notifications
1070        while self.notifications.len() > 5 {
1071            self.notifications.pop_front();
1072        }
1073        self.dirty = true;
1074    }
1075
1076    /// Get the current notification (most recent non-expired)
1077    pub fn current_notification(&self) -> Option<&Notification> {
1078        self.notifications.iter().rev().find(|n| !n.is_expired())
1079    }
1080
1081    /// Clean up expired notifications
1082    pub fn cleanup_notifications(&mut self) {
1083        let had_notifications = !self.notifications.is_empty();
1084        self.notifications.retain(|n| !n.is_expired());
1085        if had_notifications && self.notifications.is_empty() {
1086            self.dirty = true;
1087        }
1088    }
1089
1090    /// Mark state as dirty (needs redraw)
1091    pub fn mark_dirty(&mut self) {
1092        self.dirty = true;
1093    }
1094
1095    /// Check and clear dirty flag
1096    pub fn check_dirty(&mut self) -> bool {
1097        let was_dirty = self.dirty;
1098        self.dirty = false;
1099        was_dirty
1100    }
1101
1102    /// Focus the next panel
1103    pub fn focus_next_panel(&mut self) {
1104        self.focused_panel = self.focused_panel.next();
1105        self.dirty = true;
1106    }
1107
1108    /// Focus the previous panel
1109    pub fn focus_prev_panel(&mut self) {
1110        self.focused_panel = self.focused_panel.prev();
1111        self.dirty = true;
1112    }
1113
1114    /// Open help modal
1115    pub fn show_help(&mut self) {
1116        self.modal = Some(Modal::Help);
1117        self.dirty = true;
1118    }
1119
1120    /// Open chat modal (universal, persists across modes)
1121    pub fn show_chat(&mut self) {
1122        // If chat is empty, initialize with context from all generated content
1123        if self.chat_state.messages.is_empty() {
1124            let context = self.build_chat_context();
1125            self.chat_state = ChatState::with_context("git workflow", context.as_deref());
1126        }
1127
1128        // Open chat modal (state lives in self.chat_state)
1129        self.modal = Some(Modal::Chat);
1130        self.dirty = true;
1131    }
1132
1133    /// Build context summary from all generated content for chat
1134    fn build_chat_context(&self) -> Option<String> {
1135        let mut sections = Vec::new();
1136
1137        // Commit message
1138        if let Some(msg) = self
1139            .modes
1140            .commit
1141            .messages
1142            .get(self.modes.commit.current_index)
1143        {
1144            let formatted = format_commit_message(msg);
1145            if !formatted.trim().is_empty() {
1146                sections.push(format!("Commit Message:\n{}", formatted));
1147            }
1148        }
1149
1150        // Code review
1151        if !self.modes.review.review_content.is_empty() {
1152            let preview = truncate_preview(&self.modes.review.review_content, 300);
1153            sections.push(format!("Code Review:\n{}", preview));
1154        }
1155
1156        // PR description
1157        if !self.modes.pr.pr_content.is_empty() {
1158            let preview = truncate_preview(&self.modes.pr.pr_content, 300);
1159            sections.push(format!("PR Description:\n{}", preview));
1160        }
1161
1162        // Changelog
1163        if !self.modes.changelog.changelog_content.is_empty() {
1164            let preview = truncate_preview(&self.modes.changelog.changelog_content, 300);
1165            sections.push(format!("Changelog:\n{}", preview));
1166        }
1167
1168        // Release notes
1169        if !self.modes.release_notes.release_notes_content.is_empty() {
1170            let preview = truncate_preview(&self.modes.release_notes.release_notes_content, 300);
1171            sections.push(format!("Release Notes:\n{}", preview));
1172        }
1173
1174        if sections.is_empty() {
1175            None
1176        } else {
1177            Some(sections.join("\n\n"))
1178        }
1179    }
1180
1181    /// Close any open modal
1182    pub fn close_modal(&mut self) {
1183        if self.modal.is_some() {
1184            self.modal = None;
1185            self.dirty = true;
1186        }
1187    }
1188
1189    /// Update Iris status
1190    pub fn set_iris_thinking(&mut self, task: impl Into<String>) {
1191        let msg = task.into();
1192        self.iris_status = IrisStatus::Thinking {
1193            task: msg.clone(),
1194            fallback: msg,
1195            spinner_frame: 0,
1196            dynamic_messages: StatusMessageBatch::new(),
1197        };
1198        self.dirty = true;
1199    }
1200
1201    /// Add a dynamic status message (received from fast model)
1202    pub fn add_status_message(&mut self, message: crate::agents::StatusMessage) {
1203        self.iris_status.add_dynamic_message(message);
1204        self.dirty = true;
1205    }
1206
1207    /// Set Iris idle
1208    pub fn set_iris_idle(&mut self) {
1209        self.iris_status = IrisStatus::Idle;
1210        self.dirty = true;
1211    }
1212
1213    /// Set Iris complete with a message (stays until next task)
1214    pub fn set_iris_complete(&mut self, message: impl Into<String>) {
1215        let msg = message.into();
1216        // Capitalize first letter for consistent sentence case
1217        let capitalized = {
1218            let mut chars = msg.chars();
1219            match chars.next() {
1220                None => String::new(),
1221                Some(first) => first.to_uppercase().chain(chars).collect(),
1222            }
1223        };
1224        self.iris_status = IrisStatus::Complete {
1225            message: capitalized,
1226        };
1227        self.dirty = true;
1228    }
1229
1230    /// Set Iris error
1231    pub fn set_iris_error(&mut self, error: impl Into<String>) {
1232        self.iris_status = IrisStatus::Error(error.into());
1233        self.dirty = true;
1234    }
1235
1236    /// Tick animations (spinner, etc.)
1237    pub fn tick(&mut self) {
1238        self.iris_status.tick();
1239        self.cleanup_notifications();
1240
1241        // Mark dirty if we have active animations (Thinking spinner)
1242        if matches!(self.iris_status, IrisStatus::Thinking { .. }) {
1243            self.dirty = true;
1244        }
1245
1246        // Also mark dirty if chat modal is responding (for spinner animation)
1247        if matches!(self.modal, Some(Modal::Chat)) && self.chat_state.is_responding {
1248            self.dirty = true;
1249        }
1250    }
1251
1252    /// Get list of branch refs for selection
1253    pub fn get_branch_refs(&self) -> Vec<String> {
1254        let Some(git_repo) = &self.repo else {
1255            return vec!["main".to_string(), "master".to_string()];
1256        };
1257
1258        let Ok(repo) = git_repo.open_repo() else {
1259            return vec!["main".to_string(), "master".to_string()];
1260        };
1261
1262        let mut refs = Vec::new();
1263
1264        // Get local branches
1265        if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1266            for branch in branches.flatten() {
1267                if let Ok(Some(name)) = branch.0.name() {
1268                    refs.push(name.to_string());
1269                }
1270            }
1271        }
1272
1273        // Get remote branches (origin/*)
1274        if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1275            for branch in branches.flatten() {
1276                if let Ok(Some(name)) = branch.0.name() {
1277                    // Skip HEAD references
1278                    if !name.ends_with("/HEAD") {
1279                        refs.push(name.to_string());
1280                    }
1281                }
1282            }
1283        }
1284
1285        // Sort with common branches first
1286        refs.sort_by(|a, b| {
1287            let priority = |s: &str| -> i32 {
1288                match s {
1289                    "main" => 0,
1290                    "master" => 1,
1291                    s if s.starts_with("origin/main") => 2,
1292                    s if s.starts_with("origin/master") => 3,
1293                    s if s.starts_with("origin/") => 5,
1294                    _ => 4,
1295                }
1296            };
1297            priority(a).cmp(&priority(b)).then(a.cmp(b))
1298        });
1299
1300        if refs.is_empty() {
1301            refs.push("main".to_string());
1302        }
1303
1304        refs
1305    }
1306
1307    /// Get list of commit-related presets for selection
1308    pub fn get_commit_presets(&self) -> Vec<PresetInfo> {
1309        use crate::instruction_presets::{PresetType, get_instruction_preset_library};
1310
1311        let library = get_instruction_preset_library();
1312        let mut presets: Vec<PresetInfo> = library
1313            .list_presets_by_type(Some(PresetType::Commit))
1314            .into_iter()
1315            .chain(library.list_presets_by_type(Some(PresetType::Both)))
1316            .map(|(key, preset)| PresetInfo {
1317                key: key.clone(),
1318                name: preset.name.clone(),
1319                description: preset.description.clone(),
1320                emoji: preset.emoji.clone(),
1321            })
1322            .collect();
1323
1324        // Sort by name, but put "default" first
1325        presets.sort_by(|a, b| {
1326            if a.key == "default" {
1327                std::cmp::Ordering::Less
1328            } else if b.key == "default" {
1329                std::cmp::Ordering::Greater
1330            } else {
1331                a.name.cmp(&b.name)
1332            }
1333        });
1334
1335        presets
1336    }
1337
1338    /// Get list of emojis for selection (None, Auto, then all gitmojis)
1339    pub fn get_emoji_list(&self) -> Vec<EmojiInfo> {
1340        use crate::gitmoji::get_gitmoji_list;
1341
1342        let mut emojis = vec![
1343            EmojiInfo {
1344                emoji: "∅".to_string(),
1345                key: "none".to_string(),
1346                description: "No emoji".to_string(),
1347            },
1348            EmojiInfo {
1349                emoji: "✨".to_string(),
1350                key: "auto".to_string(),
1351                description: "Let AI choose".to_string(),
1352            },
1353        ];
1354
1355        // Parse gitmoji list and add all entries
1356        for line in get_gitmoji_list().lines() {
1357            // Format: "emoji - :key: - description"
1358            let parts: Vec<&str> = line.splitn(3, " - ").collect();
1359            if parts.len() >= 3 {
1360                let emoji = parts[0].trim().to_string();
1361                let key = parts[1].trim_matches(':').to_string();
1362                let description = parts[2].to_string();
1363                emojis.push(EmojiInfo {
1364                    emoji,
1365                    key,
1366                    description,
1367                });
1368            }
1369        }
1370
1371        emojis
1372    }
1373
1374    /// Update companion display from session data and git info
1375    pub fn update_companion_display(&mut self) {
1376        // Update session data from companion
1377        if let Some(ref companion) = self.companion {
1378            let session = companion.session().read();
1379
1380            // Format duration
1381            let duration = session.duration();
1382            let duration_str = if duration.num_hours() > 0 {
1383                format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
1384            } else if duration.num_minutes() > 0 {
1385                format!("{}m", duration.num_minutes())
1386            } else {
1387                "just started".to_string()
1388            };
1389
1390            // Get most recently touched file
1391            let last_touched = session.recent_files().first().map(|f| f.path.clone());
1392
1393            self.companion_display.files_touched = session.files_count();
1394            self.companion_display.commits_made = session.commits_made.len();
1395            self.companion_display.duration = duration_str;
1396            self.companion_display.last_touched_file = last_touched;
1397            self.companion_display.watcher_active = companion.has_watcher();
1398        }
1399
1400        // Update git info from repo and git_status
1401        self.companion_display.branch = self.git_status.branch.clone();
1402        self.companion_display.staged_count = self.git_status.staged_count;
1403        self.companion_display.unstaged_count =
1404            self.git_status.modified_count + self.git_status.untracked_count;
1405        self.companion_display.ahead = self.git_status.commits_ahead;
1406        self.companion_display.behind = self.git_status.commits_behind;
1407
1408        // Fetch recent commits from repo
1409        if let Some(ref repo) = self.repo
1410            && let Ok(commits) = repo.get_recent_commits(6)
1411        {
1412            let mut entries: Vec<CommitEntry> = commits
1413                .into_iter()
1414                .map(|c| {
1415                    let relative_time = Self::format_relative_time(&c.timestamp);
1416                    CommitEntry {
1417                        short_hash: c.hash[..7.min(c.hash.len())].to_string(),
1418                        message: c.message.lines().next().unwrap_or("").to_string(),
1419                        author: c
1420                            .author
1421                            .split('<')
1422                            .next()
1423                            .unwrap_or(&c.author)
1424                            .trim()
1425                            .to_string(),
1426                        relative_time,
1427                    }
1428                })
1429                .collect();
1430
1431            // First commit is HEAD
1432            self.companion_display.head_commit = entries.first().cloned();
1433
1434            // Rest are recent commits (skip HEAD, take up to 5)
1435            if !entries.is_empty() {
1436                entries.remove(0);
1437            }
1438            self.companion_display.recent_commits = entries.into_iter().take(5).collect();
1439        }
1440    }
1441
1442    /// Format a timestamp as relative time
1443    fn format_relative_time(timestamp: &str) -> String {
1444        use chrono::{DateTime, Utc};
1445
1446        // Try to parse the timestamp
1447        if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
1448            let now = Utc::now();
1449            let then: DateTime<Utc> = dt.into();
1450            let duration = now.signed_duration_since(then);
1451
1452            if duration.num_days() > 365 {
1453                format!("{}y ago", duration.num_days() / 365)
1454            } else if duration.num_days() > 30 {
1455                format!("{}mo ago", duration.num_days() / 30)
1456            } else if duration.num_days() > 0 {
1457                format!("{}d ago", duration.num_days())
1458            } else if duration.num_hours() > 0 {
1459                format!("{}h ago", duration.num_hours())
1460            } else if duration.num_minutes() > 0 {
1461                format!("{}m ago", duration.num_minutes())
1462            } else {
1463                "just now".to_string()
1464            }
1465        } else {
1466            // Fallback: try simpler format or return as-is
1467            timestamp.split('T').next().unwrap_or(timestamp).to_string()
1468        }
1469    }
1470
1471    /// Clear welcome message (after user has seen it)
1472    pub fn clear_companion_welcome(&mut self) {
1473        self.companion_display.welcome_message = None;
1474    }
1475
1476    /// Record a file touch in companion (for manual tracking when watcher isn't active)
1477    pub fn companion_touch_file(&mut self, path: PathBuf) {
1478        if let Some(ref companion) = self.companion {
1479            companion.touch_file(path);
1480        }
1481    }
1482
1483    /// Record a commit in companion
1484    pub fn companion_record_commit(&mut self, hash: String) {
1485        if let Some(ref companion) = self.companion {
1486            companion.record_commit(hash);
1487        }
1488        // Update display
1489        self.update_companion_display();
1490    }
1491}