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 => {
758                if !self.input_buffer.is_empty() {
759                    self.model = self.input_buffer.clone();
760                    self.modified = true;
761                }
762            }
763            SettingsField::ApiKey => {
764                if !self.input_buffer.is_empty() {
765                    // Store actual key, update display
766                    let key = self.input_buffer.clone();
767                    self.api_key_display = Self::mask_api_key(&key);
768                    self.api_key_actual = Some(key);
769                    self.modified = true;
770                }
771            }
772            SettingsField::CustomInstructions => {
773                // Allow empty (clears instructions)
774                self.custom_instructions = self.input_buffer.clone();
775                self.modified = true;
776            }
777            _ => {}
778        }
779
780        self.editing = false;
781        self.input_buffer.clear();
782    }
783}
784
785/// Target for ref selector modal
786#[derive(Debug, Clone, Copy)]
787pub enum RefSelectorTarget {
788    /// Review from ref
789    ReviewFrom,
790    /// Review to ref
791    ReviewTo,
792    /// PR from ref (base branch)
793    PrFrom,
794    /// PR to ref
795    PrTo,
796    /// Changelog from version
797    ChangelogFrom,
798    /// Changelog to version
799    ChangelogTo,
800    /// Release notes from version
801    ReleaseNotesFrom,
802    /// Release notes to version
803    ReleaseNotesTo,
804}
805
806// ═══════════════════════════════════════════════════════════════════════════════
807// Iris Status
808// ═══════════════════════════════════════════════════════════════════════════════
809
810/// Status of the Iris agent
811#[derive(Debug, Clone, Default)]
812pub enum IrisStatus {
813    #[default]
814    Idle,
815    Thinking {
816        /// Current display message (may be static fallback or dynamic)
817        task: String,
818        /// Original fallback message (used if no dynamic messages arrive)
819        fallback: String,
820        /// Spinner animation frame
821        spinner_frame: usize,
822        /// Dynamic status message from the fast model (we keep ONE per task)
823        dynamic_messages: StatusMessageBatch,
824    },
825    /// Task completed - show completion message (stays until next task)
826    Complete {
827        /// Completion message to display
828        message: String,
829    },
830    Error(String),
831}
832
833impl IrisStatus {
834    /// Get the spinner frame character
835    pub fn spinner_char(&self) -> Option<char> {
836        match self {
837            IrisStatus::Thinking { spinner_frame, .. } => {
838                let frames = super::theme::SPINNER_BRAILLE;
839                Some(frames[*spinner_frame % frames.len()])
840            }
841            _ => None,
842        }
843    }
844
845    /// Get the current display message
846    pub fn message(&self) -> Option<&str> {
847        match self {
848            IrisStatus::Thinking { task, .. } => Some(task),
849            IrisStatus::Complete { message, .. } => Some(message),
850            IrisStatus::Error(msg) => Some(msg),
851            IrisStatus::Idle => None,
852        }
853    }
854
855    /// Check if this is a completion state
856    pub fn is_complete(&self) -> bool {
857        matches!(self, IrisStatus::Complete { .. })
858    }
859
860    /// Advance the spinner frame (Complete just stays put)
861    pub fn tick(&mut self) {
862        if let IrisStatus::Thinking { spinner_frame, .. } = self {
863            *spinner_frame = (*spinner_frame + 1) % super::theme::SPINNER_BRAILLE.len();
864        }
865    }
866
867    /// Set the dynamic status message (replaces any previous - we only keep ONE)
868    pub fn add_dynamic_message(&mut self, message: crate::agents::StatusMessage) {
869        if let IrisStatus::Thinking {
870            task,
871            dynamic_messages,
872            ..
873        } = self
874        {
875            // Replace the current message with the new one
876            task.clone_from(&message.message);
877            dynamic_messages.clear();
878            dynamic_messages.add(message);
879        }
880    }
881}
882
883// ═══════════════════════════════════════════════════════════════════════════════
884// Companion Session Display
885// ═══════════════════════════════════════════════════════════════════════════════
886
887/// A single commit entry for display
888#[derive(Debug, Clone, Default)]
889pub struct CommitEntry {
890    /// Short hash (7 chars)
891    pub short_hash: String,
892    /// Commit message (first line)
893    pub message: String,
894    /// Author name
895    pub author: String,
896    /// Relative time (e.g., "2 hours ago")
897    pub relative_time: String,
898}
899
900/// Snapshot of companion session for UI display
901#[derive(Debug, Clone, Default)]
902pub struct CompanionSessionDisplay {
903    /// Number of files touched this session
904    pub files_touched: usize,
905    /// Number of commits made this session
906    pub commits_made: usize,
907    /// Session duration in human-readable form
908    pub duration: String,
909    /// Most recently touched file (for activity indicator)
910    pub last_touched_file: Option<PathBuf>,
911    /// Welcome message if returning to branch
912    pub welcome_message: Option<String>,
913    /// When welcome message was shown (for auto-clear)
914    pub welcome_shown_at: Option<std::time::Instant>,
915    /// Whether file watcher is active
916    pub watcher_active: bool,
917
918    // ─── Git Browser Info ───
919    /// Current HEAD commit
920    pub head_commit: Option<CommitEntry>,
921    /// Recent commits (mini log, up to 5)
922    pub recent_commits: Vec<CommitEntry>,
923    /// Commits ahead of remote
924    pub ahead: usize,
925    /// Commits behind remote
926    pub behind: usize,
927    /// Current branch name
928    pub branch: String,
929    /// Number of staged files
930    pub staged_count: usize,
931    /// Number of unstaged files
932    pub unstaged_count: usize,
933}
934
935// ═══════════════════════════════════════════════════════════════════════════════
936// Main Studio State
937// ═══════════════════════════════════════════════════════════════════════════════
938
939/// Main application state for Iris Studio
940pub struct StudioState {
941    /// Git repository reference
942    pub repo: Option<Arc<GitRepo>>,
943
944    /// Cached git status
945    pub git_status: GitStatus,
946
947    /// Whether git status is currently loading
948    pub git_status_loading: bool,
949
950    /// Application configuration
951    pub config: Config,
952
953    /// Current active mode
954    pub active_mode: Mode,
955
956    /// Focused panel
957    pub focused_panel: PanelId,
958
959    /// Mode-specific states
960    pub modes: ModeStates,
961
962    /// Active modal
963    pub modal: Option<Modal>,
964
965    /// Persistent chat state (survives modal close, universal across modes)
966    pub chat_state: ChatState,
967
968    /// Notification queue
969    pub notifications: VecDeque<Notification>,
970
971    /// Iris agent status
972    pub iris_status: IrisStatus,
973
974    /// Companion service for ambient awareness (optional - may fail to init)
975    pub companion: Option<CompanionService>,
976
977    /// Companion session display data (updated periodically)
978    pub companion_display: CompanionSessionDisplay,
979
980    /// Whether the UI needs redraw
981    pub dirty: bool,
982
983    /// Last render timestamp for animations
984    pub last_render: std::time::Instant,
985}
986
987impl StudioState {
988    /// Create new studio state
989    /// Note: Companion service is initialized asynchronously via `load_companion_async()` in app for fast startup
990    #[must_use]
991    pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
992        // Apply CLI overrides to commit mode
993        let mut modes = ModeStates::default();
994        let default_base_ref = repo
995            .as_ref()
996            .and_then(|repo| repo.get_default_base_ref().ok());
997        let current_branch = repo
998            .as_ref()
999            .and_then(|repo| repo.get_current_branch().ok());
1000
1001        if let Some(temp_instr) = &config.temp_instructions {
1002            modes.commit.custom_instructions.clone_from(temp_instr);
1003        }
1004        if let Some(temp_preset) = &config.temp_preset {
1005            modes.commit.preset.clone_from(temp_preset);
1006        }
1007        if let Some(default_base_ref) = &default_base_ref {
1008            modes.pr.base_branch.clone_from(default_base_ref);
1009            if !same_branch_ref(current_branch.as_deref(), Some(default_base_ref.as_str())) {
1010                modes.review.from_ref.clone_from(default_base_ref);
1011            }
1012        }
1013
1014        Self {
1015            repo,
1016            git_status: GitStatus::default(),
1017            git_status_loading: false,
1018            config,
1019            active_mode: Mode::Explore,
1020            focused_panel: PanelId::Left,
1021            modes,
1022            modal: None,
1023            chat_state: ChatState::new(),
1024            notifications: VecDeque::new(),
1025            iris_status: IrisStatus::Idle,
1026            companion: None,
1027            companion_display: CompanionSessionDisplay::default(),
1028            dirty: true,
1029            last_render: std::time::Instant::now(),
1030        }
1031    }
1032
1033    /// Suggest the best initial mode based on repo state
1034    pub fn suggest_initial_mode(&self) -> Mode {
1035        let status = &self.git_status;
1036        let default_base_ref = self
1037            .repo
1038            .as_ref()
1039            .and_then(|repo| repo.get_default_base_ref().ok());
1040
1041        // Staged changes? Probably want to commit
1042        if status.has_staged() {
1043            return Mode::Commit;
1044        }
1045
1046        // On a feature branch with commits ahead? Default to PR mode.
1047        if status.commits_ahead > 0 && !status.is_primary_branch(default_base_ref.as_deref()) {
1048            return Mode::PR;
1049        }
1050
1051        // Default to exploration
1052        Mode::Explore
1053    }
1054
1055    /// Switch to a new mode with context preservation
1056    pub fn switch_mode(&mut self, new_mode: Mode) {
1057        if !new_mode.is_available() {
1058            self.notify(Notification::warning(format!(
1059                "{} mode is not yet implemented",
1060                new_mode.display_name()
1061            )));
1062            return;
1063        }
1064
1065        let old_mode = self.active_mode;
1066
1067        // Context preservation logic
1068        match (old_mode, new_mode) {
1069            (Mode::Explore, Mode::Commit) => {
1070                // Carry current file context to commit
1071            }
1072            (Mode::Commit, Mode::Explore) => {
1073                // Carry last viewed file back
1074            }
1075            _ => {}
1076        }
1077
1078        self.active_mode = new_mode;
1079
1080        // Set default focus based on mode
1081        self.focused_panel = match new_mode {
1082            // Commit mode: focus on message editor (center panel)
1083            Mode::Commit => PanelId::Center,
1084            // Review/PR/Changelog/Release: focus on output (center panel)
1085            Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => PanelId::Center,
1086            // Explore: focus on file tree (left panel)
1087            Mode::Explore => PanelId::Left,
1088        };
1089        self.dirty = true;
1090    }
1091
1092    /// Add a notification
1093    pub fn notify(&mut self, notification: Notification) {
1094        self.notifications.push_back(notification);
1095        // Keep only last 5 notifications
1096        while self.notifications.len() > 5 {
1097            self.notifications.pop_front();
1098        }
1099        self.dirty = true;
1100    }
1101
1102    /// Get the current notification (most recent non-expired)
1103    pub fn current_notification(&self) -> Option<&Notification> {
1104        self.notifications.iter().rev().find(|n| !n.is_expired())
1105    }
1106
1107    /// Clean up expired notifications
1108    pub fn cleanup_notifications(&mut self) {
1109        let had_notifications = !self.notifications.is_empty();
1110        self.notifications.retain(|n| !n.is_expired());
1111        if had_notifications && self.notifications.is_empty() {
1112            self.dirty = true;
1113        }
1114    }
1115
1116    /// Mark state as dirty (needs redraw)
1117    pub fn mark_dirty(&mut self) {
1118        self.dirty = true;
1119    }
1120
1121    /// Check and clear dirty flag
1122    pub fn check_dirty(&mut self) -> bool {
1123        let was_dirty = self.dirty;
1124        self.dirty = false;
1125        was_dirty
1126    }
1127
1128    /// Focus the next panel
1129    pub fn focus_next_panel(&mut self) {
1130        self.focused_panel = self.focused_panel.next();
1131        self.dirty = true;
1132    }
1133
1134    /// Focus the previous panel
1135    pub fn focus_prev_panel(&mut self) {
1136        self.focused_panel = self.focused_panel.prev();
1137        self.dirty = true;
1138    }
1139
1140    /// Open help modal
1141    pub fn show_help(&mut self) {
1142        self.modal = Some(Modal::Help);
1143        self.dirty = true;
1144    }
1145
1146    /// Open chat modal (universal, persists across modes)
1147    pub fn show_chat(&mut self) {
1148        // If chat is empty, initialize with context from all generated content
1149        if self.chat_state.messages.is_empty() {
1150            let context = self.build_chat_context();
1151            self.chat_state = ChatState::with_context("git workflow", context.as_deref());
1152        }
1153
1154        // Open chat modal (state lives in self.chat_state)
1155        self.modal = Some(Modal::Chat);
1156        self.dirty = true;
1157    }
1158
1159    /// Build context summary from all generated content for chat
1160    fn build_chat_context(&self) -> Option<String> {
1161        let mut sections = Vec::new();
1162
1163        // Commit message
1164        if let Some(msg) = self
1165            .modes
1166            .commit
1167            .messages
1168            .get(self.modes.commit.current_index)
1169        {
1170            let formatted = format_commit_message(msg);
1171            if !formatted.trim().is_empty() {
1172                sections.push(format!("Commit Message:\n{}", formatted));
1173            }
1174        }
1175
1176        // Code review
1177        if !self.modes.review.review_content.is_empty() {
1178            let preview = truncate_preview(&self.modes.review.review_content, 300);
1179            sections.push(format!("Code Review:\n{}", preview));
1180        }
1181
1182        // PR description
1183        if !self.modes.pr.pr_content.is_empty() {
1184            let preview = truncate_preview(&self.modes.pr.pr_content, 300);
1185            sections.push(format!("PR Description:\n{}", preview));
1186        }
1187
1188        // Changelog
1189        if !self.modes.changelog.changelog_content.is_empty() {
1190            let preview = truncate_preview(&self.modes.changelog.changelog_content, 300);
1191            sections.push(format!("Changelog:\n{}", preview));
1192        }
1193
1194        // Release notes
1195        if !self.modes.release_notes.release_notes_content.is_empty() {
1196            let preview = truncate_preview(&self.modes.release_notes.release_notes_content, 300);
1197            sections.push(format!("Release Notes:\n{}", preview));
1198        }
1199
1200        if sections.is_empty() {
1201            None
1202        } else {
1203            Some(sections.join("\n\n"))
1204        }
1205    }
1206
1207    /// Close any open modal
1208    pub fn close_modal(&mut self) {
1209        if self.modal.is_some() {
1210            self.modal = None;
1211            self.dirty = true;
1212        }
1213    }
1214
1215    /// Update Iris status
1216    pub fn set_iris_thinking(&mut self, task: impl Into<String>) {
1217        let msg = task.into();
1218        self.iris_status = IrisStatus::Thinking {
1219            task: msg.clone(),
1220            fallback: msg,
1221            spinner_frame: 0,
1222            dynamic_messages: StatusMessageBatch::new(),
1223        };
1224        self.dirty = true;
1225    }
1226
1227    /// Add a dynamic status message (received from fast model)
1228    pub fn add_status_message(&mut self, message: crate::agents::StatusMessage) {
1229        self.iris_status.add_dynamic_message(message);
1230        self.dirty = true;
1231    }
1232
1233    /// Set Iris idle
1234    pub fn set_iris_idle(&mut self) {
1235        self.iris_status = IrisStatus::Idle;
1236        self.dirty = true;
1237    }
1238
1239    /// Set Iris complete with a message (stays until next task)
1240    pub fn set_iris_complete(&mut self, message: impl Into<String>) {
1241        let msg = message.into();
1242        // Capitalize first letter for consistent sentence case
1243        let capitalized = {
1244            let mut chars = msg.chars();
1245            match chars.next() {
1246                None => String::new(),
1247                Some(first) => first.to_uppercase().chain(chars).collect(),
1248            }
1249        };
1250        self.iris_status = IrisStatus::Complete {
1251            message: capitalized,
1252        };
1253        self.dirty = true;
1254    }
1255
1256    /// Set Iris error
1257    pub fn set_iris_error(&mut self, error: impl Into<String>) {
1258        self.iris_status = IrisStatus::Error(error.into());
1259        self.dirty = true;
1260    }
1261
1262    /// Tick animations (spinner, etc.)
1263    pub fn tick(&mut self) {
1264        self.iris_status.tick();
1265        self.cleanup_notifications();
1266
1267        // Mark dirty if we have active animations (Thinking spinner)
1268        if matches!(self.iris_status, IrisStatus::Thinking { .. }) {
1269            self.dirty = true;
1270        }
1271
1272        // Also mark dirty if chat modal is responding (for spinner animation)
1273        if matches!(self.modal, Some(Modal::Chat)) && self.chat_state.is_responding {
1274            self.dirty = true;
1275        }
1276    }
1277
1278    /// Get list of branch refs for selection
1279    pub fn get_branch_refs(&self) -> Vec<String> {
1280        let fallback_refs = || {
1281            vec![
1282                "main".to_string(),
1283                "master".to_string(),
1284                "trunk".to_string(),
1285                "develop".to_string(),
1286            ]
1287        };
1288
1289        let Some(git_repo) = &self.repo else {
1290            return fallback_refs();
1291        };
1292
1293        let Ok(repo) = git_repo.open_repo() else {
1294            return fallback_refs();
1295        };
1296        let default_base_ref = git_repo.get_default_base_ref().ok();
1297
1298        let mut refs = Vec::new();
1299
1300        // Get local branches
1301        if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1302            for branch in branches.flatten() {
1303                if let Ok(Some(name)) = branch.0.name() {
1304                    refs.push(name.to_string());
1305                }
1306            }
1307        }
1308
1309        // Get remote branches (origin/*)
1310        if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1311            for branch in branches.flatten() {
1312                if let Ok(Some(name)) = branch.0.name() {
1313                    // Skip HEAD references
1314                    if !name.ends_with("/HEAD") {
1315                        refs.push(name.to_string());
1316                    }
1317                }
1318            }
1319        }
1320
1321        // Sort with common branches first
1322        refs.sort_by(|a, b| {
1323            branch_ref_priority(a, default_base_ref.as_deref())
1324                .cmp(&branch_ref_priority(b, default_base_ref.as_deref()))
1325                .then(a.cmp(b))
1326        });
1327        refs.dedup();
1328
1329        if refs.is_empty() {
1330            if let Some(default_base_ref) = default_base_ref {
1331                refs.push(default_base_ref);
1332            } else {
1333                refs = fallback_refs();
1334            }
1335        }
1336
1337        refs
1338    }
1339
1340    /// Get list of commit-related presets for selection
1341    pub fn get_commit_presets(&self) -> Vec<PresetInfo> {
1342        use crate::instruction_presets::{PresetType, get_instruction_preset_library};
1343
1344        let library = get_instruction_preset_library();
1345        let mut presets: Vec<PresetInfo> = library
1346            .list_presets_by_type(Some(PresetType::Commit))
1347            .into_iter()
1348            .chain(library.list_presets_by_type(Some(PresetType::Both)))
1349            .map(|(key, preset)| PresetInfo {
1350                key: key.clone(),
1351                name: preset.name.clone(),
1352                description: preset.description.clone(),
1353                emoji: preset.emoji.clone(),
1354            })
1355            .collect();
1356
1357        // Sort by name, but put "default" first
1358        presets.sort_by(|a, b| {
1359            if a.key == "default" {
1360                std::cmp::Ordering::Less
1361            } else if b.key == "default" {
1362                std::cmp::Ordering::Greater
1363            } else {
1364                a.name.cmp(&b.name)
1365            }
1366        });
1367
1368        presets
1369    }
1370
1371    /// Get list of emojis for selection (None, Auto, then all gitmojis)
1372    pub fn get_emoji_list(&self) -> Vec<EmojiInfo> {
1373        use crate::gitmoji::get_gitmoji_list;
1374
1375        let mut emojis = vec![
1376            EmojiInfo {
1377                emoji: "∅".to_string(),
1378                key: "none".to_string(),
1379                description: "No emoji".to_string(),
1380            },
1381            EmojiInfo {
1382                emoji: "✨".to_string(),
1383                key: "auto".to_string(),
1384                description: "Let AI choose".to_string(),
1385            },
1386        ];
1387
1388        // Parse gitmoji list and add all entries
1389        for line in get_gitmoji_list().lines() {
1390            // Format: "emoji - :key: - description"
1391            let parts: Vec<&str> = line.splitn(3, " - ").collect();
1392            if parts.len() >= 3 {
1393                let emoji = parts[0].trim().to_string();
1394                let key = parts[1].trim_matches(':').to_string();
1395                let description = parts[2].to_string();
1396                emojis.push(EmojiInfo {
1397                    emoji,
1398                    key,
1399                    description,
1400                });
1401            }
1402        }
1403
1404        emojis
1405    }
1406
1407    /// Update companion display from session data and git info
1408    pub fn update_companion_display(&mut self) {
1409        // Update session data from companion
1410        if let Some(ref companion) = self.companion {
1411            let session = companion.session().read();
1412
1413            // Format duration
1414            let duration = session.duration();
1415            let duration_str = if duration.num_hours() > 0 {
1416                format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
1417            } else if duration.num_minutes() > 0 {
1418                format!("{}m", duration.num_minutes())
1419            } else {
1420                "just started".to_string()
1421            };
1422
1423            // Get most recently touched file
1424            let last_touched = session.recent_files().first().map(|f| f.path.clone());
1425
1426            self.companion_display.files_touched = session.files_count();
1427            self.companion_display.commits_made = session.commits_made.len();
1428            self.companion_display.duration = duration_str;
1429            self.companion_display.last_touched_file = last_touched;
1430            self.companion_display.watcher_active = companion.has_watcher();
1431            self.companion_display.branch = session.branch.clone();
1432        } else {
1433            self.companion_display.branch = self.git_status.branch.clone();
1434        }
1435
1436        // Update git info from repo and git_status
1437        self.companion_display.staged_count = self.git_status.staged_count;
1438        self.companion_display.unstaged_count =
1439            self.git_status.modified_count + self.git_status.untracked_count;
1440        self.companion_display.ahead = self.git_status.commits_ahead;
1441        self.companion_display.behind = self.git_status.commits_behind;
1442
1443        // Fetch recent commits from repo
1444        if let Some(ref repo) = self.repo
1445            && let Ok(commits) = repo.get_recent_commits(6)
1446        {
1447            let mut entries: Vec<CommitEntry> = commits
1448                .into_iter()
1449                .map(|c| {
1450                    let relative_time = Self::format_relative_time(&c.timestamp);
1451                    CommitEntry {
1452                        short_hash: c.hash[..7.min(c.hash.len())].to_string(),
1453                        message: c.message.lines().next().unwrap_or("").to_string(),
1454                        author: c
1455                            .author
1456                            .split('<')
1457                            .next()
1458                            .unwrap_or(&c.author)
1459                            .trim()
1460                            .to_string(),
1461                        relative_time,
1462                    }
1463                })
1464                .collect();
1465
1466            // First commit is HEAD
1467            self.companion_display.head_commit = entries.first().cloned();
1468
1469            // Rest are recent commits (skip HEAD, take up to 5)
1470            if !entries.is_empty() {
1471                entries.remove(0);
1472            }
1473            self.companion_display.recent_commits = entries.into_iter().take(5).collect();
1474        }
1475    }
1476
1477    /// Format a timestamp as relative time
1478    fn format_relative_time(timestamp: &str) -> String {
1479        use chrono::{DateTime, Utc};
1480
1481        // Try to parse the timestamp
1482        if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
1483            let now = Utc::now();
1484            let then: DateTime<Utc> = dt.into();
1485            let duration = now.signed_duration_since(then);
1486
1487            if duration.num_days() > 365 {
1488                format!("{}y ago", duration.num_days() / 365)
1489            } else if duration.num_days() > 30 {
1490                format!("{}mo ago", duration.num_days() / 30)
1491            } else if duration.num_days() > 0 {
1492                format!("{}d ago", duration.num_days())
1493            } else if duration.num_hours() > 0 {
1494                format!("{}h ago", duration.num_hours())
1495            } else if duration.num_minutes() > 0 {
1496                format!("{}m ago", duration.num_minutes())
1497            } else {
1498                "just now".to_string()
1499            }
1500        } else {
1501            // Fallback: try simpler format or return as-is
1502            timestamp.split('T').next().unwrap_or(timestamp).to_string()
1503        }
1504    }
1505
1506    /// Clear welcome message (after user has seen it)
1507    pub fn clear_companion_welcome(&mut self) {
1508        self.companion_display.welcome_message = None;
1509    }
1510
1511    /// Record a file touch in companion (for manual tracking when watcher isn't active)
1512    pub fn companion_touch_file(&mut self, path: PathBuf) {
1513        if let Some(ref companion) = self.companion {
1514            companion.touch_file(path);
1515        }
1516    }
1517
1518    /// Record a commit in companion
1519    pub fn companion_record_commit(&mut self, hash: String) {
1520        if let Some(ref companion) = self.companion {
1521            companion.record_commit(hash);
1522        }
1523        // Update display
1524        self.update_companion_display();
1525    }
1526}
1527
1528fn same_branch_ref(left: Option<&str>, right: Option<&str>) -> bool {
1529    match (left, right) {
1530        (Some(left), Some(right)) => normalize_branch_ref(left) == normalize_branch_ref(right),
1531        _ => false,
1532    }
1533}
1534
1535fn normalize_branch_ref(value: &str) -> &str {
1536    value.strip_prefix("origin/").unwrap_or(value)
1537}
1538
1539fn branch_ref_priority(candidate: &str, default_base_ref: Option<&str>) -> i32 {
1540    if same_branch_ref(Some(candidate), default_base_ref) {
1541        if default_base_ref == Some(candidate) {
1542            return 0;
1543        }
1544        return 1;
1545    }
1546
1547    if !candidate.contains('/') {
1548        return 2;
1549    }
1550
1551    if candidate.starts_with("origin/") {
1552        return 3;
1553    }
1554
1555    4
1556}