Skip to main content

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