Skip to main content

git_iris/studio/app/
mod.rs

1//! Main application for Iris Studio
2//!
3//! Event loop and rendering coordination.
4
5mod agent_tasks;
6
7use anyhow::{Result, anyhow};
8use crossterm::event::{
9    self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind,
10};
11use crossterm::execute;
12use crossterm::terminal::{
13    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14};
15use ratatui::Frame;
16use ratatui::Terminal;
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::Rect;
19use ratatui::style::{Modifier, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::Paragraph;
22use std::collections::VecDeque;
23use std::io::{self, Stdout};
24use std::sync::Arc;
25use std::time::Duration;
26use tokio::sync::mpsc;
27
28use crate::agents::{IrisAgentService, StatusContext};
29use crate::config::Config;
30use crate::git::GitRepo;
31use crate::services::GitCommitService;
32use crate::types::{GeneratedMessage, Review};
33
34use super::components::{DiffHunk, DiffLine, FileDiff, FileGitStatus, parse_diff};
35use super::events::{
36    AgentResult, ContentPayload, ContentType, SemanticBlameResult, SideEffect, StudioEvent,
37    TaskType,
38};
39use super::history::History;
40use super::layout::{LayoutAreas, calculate_layout, get_mode_layout};
41use super::reducer::reduce;
42use super::render::{
43    render_changelog_panel, render_commit_panel, render_companion_status_bar, render_explore_panel,
44    render_modal, render_pr_panel, render_release_notes_panel, render_review_panel,
45};
46use super::state::{GitStatus, IrisStatus, Mode, Notification, PanelId, StudioState};
47use super::theme;
48
49// ═══════════════════════════════════════════════════════════════════════════════
50// Async Task Results
51// ═══════════════════════════════════════════════════════════════════════════════
52
53/// Result from an async Iris task
54pub enum IrisTaskResult {
55    /// Generated commit messages
56    CommitMessages(Vec<GeneratedMessage>),
57    /// Generated code review
58    ReviewContent(Review),
59    /// Generated PR description (markdown)
60    PRContent(String),
61    /// Generated changelog (markdown)
62    ChangelogContent(String),
63    /// Generated release notes (markdown)
64    ReleaseNotesContent(String),
65    /// Chat response from Iris
66    ChatResponse(String),
67    /// Chat-triggered update to current content
68    ChatUpdate(ChatUpdateType),
69    /// Tool call status update (for streaming tool calls to chat)
70    ToolStatus { tool_name: String, message: String },
71    /// Streaming text chunk received
72    StreamingChunk {
73        task_type: TaskType,
74        chunk: String,
75        aggregated: String,
76    },
77    /// Streaming completed
78    StreamingComplete { task_type: TaskType },
79    /// Semantic blame result
80    SemanticBlame(SemanticBlameResult),
81    /// Dynamic status message from fast model
82    StatusMessage(crate::agents::StatusMessage),
83    /// Completion message from fast model (replaces generic completion)
84    CompletionMessage(String),
85    /// Error from the task (includes which task failed)
86    Error { task_type: TaskType, error: String },
87    /// File log loaded for explore mode
88    FileLogLoaded {
89        file: std::path::PathBuf,
90        entries: Vec<crate::studio::state::FileLogEntry>,
91    },
92    /// Global commit log loaded
93    GlobalLogLoaded {
94        entries: Vec<crate::studio::state::FileLogEntry>,
95    },
96    /// Git status loaded (async initialization)
97    GitStatusLoaded(Box<GitStatusData>),
98    /// Companion service initialized (async)
99    CompanionReady(Box<CompanionInitData>),
100}
101
102/// Data from async git status loading
103#[derive(Debug, Clone)]
104pub struct GitStatusData {
105    pub branch: String,
106    pub staged_files: Vec<std::path::PathBuf>,
107    pub modified_files: Vec<std::path::PathBuf>,
108    pub untracked_files: Vec<std::path::PathBuf>,
109    pub commits_ahead: usize,
110    pub commits_behind: usize,
111    pub staged_diff: Option<String>,
112}
113
114/// Data from async companion initialization
115pub struct CompanionInitData {
116    /// The companion service
117    pub service: crate::companion::CompanionService,
118    /// Display data for the UI
119    pub display: super::state::CompanionSessionDisplay,
120}
121
122/// Type of content update triggered by chat
123#[derive(Debug, Clone)]
124pub enum ChatUpdateType {
125    /// Update commit message
126    CommitMessage(GeneratedMessage),
127    /// Update PR description
128    PRDescription(String),
129    /// Update review content
130    Review(String),
131}
132
133fn agent_complete_event(task_type: TaskType, result: AgentResult) -> StudioEvent {
134    StudioEvent::AgentComplete { task_type, result }
135}
136
137fn chat_update_event(update: ChatUpdateType) -> StudioEvent {
138    let (content_type, content) = match update {
139        ChatUpdateType::CommitMessage(msg) => {
140            (ContentType::CommitMessage, ContentPayload::Commit(msg))
141        }
142        ChatUpdateType::PRDescription(content) => (
143            ContentType::PRDescription,
144            ContentPayload::Markdown(content),
145        ),
146        ChatUpdateType::Review(content) => {
147            (ContentType::CodeReview, ContentPayload::Markdown(content))
148        }
149    };
150
151    StudioEvent::UpdateContent {
152        content_type,
153        content,
154    }
155}
156
157fn first_line_hint(content: &str, max_chars: usize) -> Option<String> {
158    content
159        .lines()
160        .next()
161        .map(|line| line.chars().take(max_chars).collect())
162}
163
164fn status_file_name(file: &std::path::Path) -> String {
165    file.file_name().map_or_else(
166        || file.to_string_lossy().to_string(),
167        |name| name.to_string_lossy().to_string(),
168    )
169}
170
171// ═══════════════════════════════════════════════════════════════════════════════
172// Studio Application
173// ═══════════════════════════════════════════════════════════════════════════════
174
175/// Main Iris Studio application
176pub struct StudioApp {
177    /// Application state
178    pub state: StudioState,
179    /// History for all content, chat, and events
180    pub history: History,
181    /// Event queue for processing
182    event_queue: VecDeque<StudioEvent>,
183    /// Git commit service for operations
184    commit_service: Option<Arc<GitCommitService>>,
185    /// Iris agent service for AI operations
186    agent_service: Option<Arc<IrisAgentService>>,
187    /// Channel receiver for async Iris results
188    iris_result_rx: mpsc::UnboundedReceiver<IrisTaskResult>,
189    /// Channel sender for async Iris results (kept for spawning tasks)
190    iris_result_tx: mpsc::UnboundedSender<IrisTaskResult>,
191    /// Last calculated layout for mouse hit testing
192    last_layout: Option<LayoutAreas>,
193    /// Whether an explicit initial mode was set
194    explicit_mode_set: bool,
195    /// Last mouse click info for double-click detection (time, x, y)
196    last_click: Option<(std::time::Instant, u16, u16)>,
197    /// Drag selection start info (panel, line number) for code view selection
198    drag_start: Option<(PanelId, usize)>,
199    /// Background task handles to abort on exit
200    background_tasks: Vec<tokio::task::JoinHandle<()>>,
201}
202
203impl StudioApp {
204    /// Create a new Studio application
205    #[must_use]
206    pub fn new(
207        config: Config,
208        repo: Option<Arc<GitRepo>>,
209        commit_service: Option<Arc<GitCommitService>>,
210        agent_service: Option<Arc<IrisAgentService>>,
211    ) -> Self {
212        // Build history with repo context if available
213        let history = if let Some(ref r) = repo {
214            let repo_path = r.repo_path().clone();
215            let branch = r.get_current_branch().ok();
216            History::with_repo(repo_path, branch)
217        } else {
218            History::new()
219        };
220
221        let state = StudioState::new(config, repo);
222        let (iris_result_tx, iris_result_rx) = mpsc::unbounded_channel();
223
224        Self {
225            state,
226            history,
227            event_queue: VecDeque::new(),
228            commit_service,
229            agent_service,
230            iris_result_rx,
231            iris_result_tx,
232            last_layout: None,
233            explicit_mode_set: false,
234            last_click: None,
235            drag_start: None,
236            background_tasks: Vec::new(),
237        }
238    }
239
240    /// Set explicit initial mode
241    pub fn set_initial_mode(&mut self, mode: Mode) {
242        self.state.switch_mode(mode);
243        self.explicit_mode_set = true;
244    }
245
246    /// Push an event to the queue
247    fn push_event(&mut self, event: StudioEvent) {
248        self.event_queue.push_back(event);
249    }
250
251    /// Process all queued events through the reducer
252    fn process_events(&mut self) -> Option<ExitResult> {
253        while let Some(event) = self.event_queue.pop_front() {
254            // Run through reducer which mutates state and returns effects
255            let effects = reduce(&mut self.state, event, &mut self.history);
256
257            // Execute side effects
258            if let Some(result) = self.execute_effects(effects) {
259                return Some(result);
260            }
261        }
262        None
263    }
264
265    /// Execute side effects from reducer
266    fn execute_effects(&mut self, effects: Vec<SideEffect>) -> Option<ExitResult> {
267        use super::events::{AgentTask, DataType};
268
269        for effect in effects {
270            match effect {
271                SideEffect::Quit => return Some(ExitResult::Quit),
272
273                SideEffect::ExecuteCommit { message } => {
274                    return Some(self.perform_commit(&message));
275                }
276
277                SideEffect::ExecuteAmend { message } => {
278                    return Some(self.perform_amend(&message));
279                }
280
281                SideEffect::Redraw => {
282                    self.state.mark_dirty();
283                }
284
285                SideEffect::RefreshGitStatus => {
286                    let _ = self.refresh_git_status();
287                }
288
289                SideEffect::GitStage(path) => {
290                    self.stage_file(&path.to_string_lossy());
291                }
292
293                SideEffect::GitUnstage(path) => {
294                    self.unstage_file(&path.to_string_lossy());
295                }
296
297                SideEffect::GitStageAll => {
298                    self.stage_all();
299                }
300
301                SideEffect::GitUnstageAll => {
302                    self.unstage_all();
303                }
304
305                SideEffect::SaveSettings => {
306                    self.save_settings();
307                }
308
309                SideEffect::CopyToClipboard(text) => match arboard::Clipboard::new() {
310                    Ok(mut clipboard) => {
311                        if let Err(e) = clipboard.set_text(&text) {
312                            self.state
313                                .notify(Notification::error(format!("Failed to copy: {e}")));
314                        } else {
315                            self.state
316                                .notify(Notification::success("Copied to clipboard"));
317                        }
318                    }
319                    Err(e) => {
320                        self.state
321                            .notify(Notification::error(format!("Clipboard unavailable: {e}")));
322                    }
323                },
324
325                SideEffect::ShowNotification {
326                    level,
327                    message,
328                    duration_ms: _,
329                } => {
330                    let notif = match level {
331                        super::events::NotificationLevel::Info => Notification::info(&message),
332                        super::events::NotificationLevel::Success => {
333                            Notification::success(&message)
334                        }
335                        super::events::NotificationLevel::Warning => {
336                            Notification::warning(&message)
337                        }
338                        super::events::NotificationLevel::Error => Notification::error(&message),
339                    };
340                    self.state.notify(notif);
341                }
342
343                SideEffect::SpawnAgent { task } => {
344                    // Status messages are now spawned inside each spawn_*_generation method
345                    match task {
346                        AgentTask::Commit {
347                            instructions,
348                            preset,
349                            use_gitmoji,
350                            amend,
351                        } => {
352                            self.spawn_commit_generation(instructions, preset, use_gitmoji, amend);
353                        }
354                        AgentTask::Review { from_ref, to_ref } => {
355                            self.spawn_review_generation(from_ref, to_ref);
356                        }
357                        AgentTask::PR {
358                            base_branch,
359                            to_ref,
360                        } => {
361                            self.spawn_pr_generation(base_branch, &to_ref);
362                        }
363                        AgentTask::Changelog { from_ref, to_ref } => {
364                            self.spawn_changelog_generation(from_ref, to_ref);
365                        }
366                        AgentTask::ReleaseNotes { from_ref, to_ref } => {
367                            self.spawn_release_notes_generation(from_ref, to_ref);
368                        }
369                        AgentTask::Chat { message, context } => {
370                            self.spawn_chat_query(message, context);
371                        }
372                        AgentTask::SemanticBlame { blame_info } => {
373                            self.spawn_semantic_blame(blame_info);
374                        }
375                    }
376                }
377
378                SideEffect::GatherBlameAndSpawnAgent {
379                    file,
380                    start_line,
381                    end_line,
382                } => {
383                    self.gather_blame_and_spawn(&file, start_line, end_line);
384                }
385
386                SideEffect::LoadData {
387                    data_type,
388                    from_ref,
389                    to_ref,
390                } => {
391                    // Trigger data refresh for the mode
392                    match data_type {
393                        DataType::GitStatus | DataType::CommitDiff => {
394                            let _ = self.refresh_git_status();
395                        }
396                        DataType::ReviewDiff => {
397                            self.update_review_data(from_ref, to_ref);
398                        }
399                        DataType::PRDiff => {
400                            self.update_pr_data(from_ref, to_ref);
401                        }
402                        DataType::ChangelogCommits => {
403                            self.update_changelog_data(from_ref, to_ref);
404                        }
405                        DataType::ReleaseNotesCommits => {
406                            self.update_release_notes_data(from_ref, to_ref);
407                        }
408                        DataType::ExploreFiles => {
409                            self.update_explore_file_tree();
410                        }
411                    }
412                }
413
414                SideEffect::LoadFileLog(path) => {
415                    self.load_file_log(&path);
416                }
417
418                SideEffect::LoadGlobalLog => {
419                    self.load_global_log();
420                }
421            }
422        }
423        None
424    }
425
426    /// Update git status from repository
427    ///
428    /// # Errors
429    ///
430    /// Returns an error when repository status cannot be refreshed.
431    pub fn refresh_git_status(&mut self) -> Result<()> {
432        let preferred_commit_path = self.current_commit_selection_path();
433
434        if let Some(repo) = &self.state.repo {
435            // Get file info which includes staged files
436            let files_info = repo.extract_files_info(false).ok();
437            let unstaged = repo.get_unstaged_files().ok();
438
439            let staged_files: Vec<std::path::PathBuf> = files_info
440                .as_ref()
441                .map(|f| {
442                    f.staged_files
443                        .iter()
444                        .map(|s| s.path.clone().into())
445                        .collect()
446                })
447                .unwrap_or_default();
448
449            let modified_files: Vec<std::path::PathBuf> = unstaged
450                .as_ref()
451                .map(|f| f.iter().map(|s| s.path.clone().into()).collect())
452                .unwrap_or_default();
453
454            // Get untracked files
455            let untracked_files: Vec<std::path::PathBuf> = repo
456                .get_untracked_files()
457                .unwrap_or_default()
458                .into_iter()
459                .map(std::path::PathBuf::from)
460                .collect();
461
462            // Get ahead/behind counts
463            let (commits_ahead, commits_behind) = repo.get_ahead_behind();
464
465            let status = GitStatus {
466                branch: repo.get_current_branch().unwrap_or_default(),
467                staged_count: staged_files.len(),
468                staged_files,
469                modified_count: modified_files.len(),
470                modified_files,
471                untracked_count: untracked_files.len(),
472                untracked_files,
473                commits_ahead,
474                commits_behind,
475            };
476            self.state.git_status = status;
477
478            // Update file trees for components (explore tree is lazy-loaded on mode switch)
479            self.update_commit_file_tree(preferred_commit_path.as_deref());
480            self.update_review_file_tree();
481
482            // Load diffs into diff view
483            self.load_staged_diffs(files_info.as_ref());
484
485            // Restore the current commit selection when possible.
486            if let Some(path) = preferred_commit_path.as_deref() {
487                self.state.modes.commit.diff_view.select_file_by_path(path);
488            } else if let Some(path) = self.state.modes.commit.file_tree.selected_path() {
489                self.state.modes.commit.diff_view.select_file_by_path(&path);
490            }
491        }
492        Ok(())
493    }
494
495    /// Load staged file diffs into the diff view component
496    fn load_staged_diffs(&mut self, files_info: Option<&crate::git::RepoFilesInfo>) {
497        let Some(info) = files_info else { return };
498        let Some(repo) = &self.state.repo else { return };
499
500        // Get a proper unified diff with all headers using git
501        if let Ok(diff_text) = repo.get_staged_diff_full() {
502            let diffs = parse_diff(&diff_text);
503            self.state.modes.commit.diff_view.set_diffs(diffs);
504        } else {
505            // Fallback: Build synthetic diff from file info
506            let mut diffs = Vec::new();
507            for f in &info.staged_files {
508                let mut file_diff = FileDiff::new(&f.path);
509                file_diff.is_new = matches!(f.change_type, crate::context::ChangeType::Added);
510                file_diff.is_deleted = matches!(f.change_type, crate::context::ChangeType::Deleted);
511
512                // Create a synthetic hunk from the diff lines
513                if !f.diff.is_empty() && f.diff != "[Content excluded]" {
514                    let hunk = DiffHunk {
515                        header: "@@ Changes @@".to_string(),
516                        lines: f
517                            .diff
518                            .lines()
519                            .enumerate()
520                            .map(|(i, line)| {
521                                let content = line.strip_prefix(['+', '-', ' ']).unwrap_or(line);
522                                if line.starts_with('+') {
523                                    DiffLine::added(content, i + 1)
524                                } else if line.starts_with('-') {
525                                    DiffLine::removed(content, i + 1)
526                                } else {
527                                    DiffLine::context(content, i + 1, i + 1)
528                                }
529                            })
530                            .collect(),
531                        old_start: 1,
532                        old_count: 0,
533                        new_start: 1,
534                        new_count: 0,
535                    };
536                    file_diff.hunks.push(hunk);
537                }
538                diffs.push(file_diff);
539            }
540            self.state.modes.commit.diff_view.set_diffs(diffs);
541        }
542    }
543
544    /// Update explore mode file tree from repository
545    fn update_explore_file_tree(&mut self) {
546        // Get all tracked files from the repository
547        let Some(repo) = &self.state.repo else { return };
548        let all_files: Vec<std::path::PathBuf> = match repo.get_all_tracked_files() {
549            Ok(files) => files.into_iter().map(std::path::PathBuf::from).collect(),
550            Err(e) => {
551                eprintln!("Failed to get tracked files: {}", e);
552                return;
553            }
554        };
555
556        // Build status lookup from git status
557        let mut statuses = Vec::new();
558        for path in &self.state.git_status.staged_files {
559            statuses.push((path.clone(), FileGitStatus::Staged));
560        }
561        for path in &self.state.git_status.modified_files {
562            statuses.push((path.clone(), FileGitStatus::Modified));
563        }
564        for path in &self.state.git_status.untracked_files {
565            statuses.push((path.clone(), FileGitStatus::Untracked));
566        }
567
568        if !all_files.is_empty() {
569            let tree_state = super::components::FileTreeState::from_paths(&all_files, &statuses);
570            self.state.modes.explore.file_tree = tree_state;
571
572            // Initialize selected file (content only - file log loads via event system)
573            if let Some(entry) = self.state.modes.explore.file_tree.selected_entry()
574                && !entry.is_dir
575            {
576                let path = entry.path.clone();
577                self.state.modes.explore.current_file = Some(path.clone());
578                // Load file content into code view
579                if let Err(e) = self.state.modes.explore.code_view.load_file(&path) {
580                    tracing::warn!("Failed to load initial file: {}", e);
581                }
582                // Store path for deferred file log loading (will be triggered after event loop starts)
583                self.state.modes.explore.pending_file_log = Some(path);
584            }
585        }
586    }
587
588    /// Load git log for a specific file (async)
589    fn load_file_log(&self, path: &std::path::Path) {
590        use crate::studio::state::FileLogEntry;
591
592        let Some(repo) = &self.state.repo else {
593            return;
594        };
595
596        let tx = self.iris_result_tx.clone();
597        let file = path.to_path_buf();
598        let repo_path = repo.repo_path().clone();
599
600        tokio::spawn(async move {
601            let file_for_result = file.clone();
602            let result = tokio::task::spawn_blocking(move || {
603                use std::process::Command;
604
605                // Make file path relative to repo root
606                let relative_path = file
607                    .strip_prefix(&repo_path)
608                    .unwrap_or(&file)
609                    .to_string_lossy()
610                    .to_string();
611
612                // Run git log for the specific file with format:
613                // %H = full hash, %h = short hash, %s = subject, %an = author, %ar = relative time
614                let output = Command::new("git")
615                    .args([
616                        "-C",
617                        repo_path.to_str().unwrap_or("."),
618                        "log",
619                        "--follow",
620                        "--pretty=format:%H|%h|%s|%an|%ar",
621                        "--numstat",
622                        "-n",
623                        "50", // Limit to 50 entries
624                        "--",
625                        &relative_path,
626                    ])
627                    .output()?;
628
629                if !output.status.success() {
630                    return Ok(Vec::new());
631                }
632
633                let stdout = String::from_utf8_lossy(&output.stdout);
634                let mut entries = Vec::new();
635                let mut current_entry: Option<FileLogEntry> = None;
636
637                for line in stdout.lines() {
638                    if line.contains('|') && line.len() > 40 {
639                        // This is a commit line
640                        if let Some(entry) = current_entry.take() {
641                            entries.push(entry);
642                        }
643
644                        let parts: Vec<&str> = line.splitn(5, '|').collect();
645                        if parts.len() >= 5 {
646                            current_entry = Some(FileLogEntry {
647                                hash: parts[0].to_string(),
648                                short_hash: parts[1].to_string(),
649                                message: parts[2].to_string(),
650                                author: parts[3].to_string(),
651                                relative_time: parts[4].to_string(),
652                                additions: None,
653                                deletions: None,
654                            });
655                        }
656                    } else if let Some(ref mut entry) = current_entry {
657                        // This is a numstat line (additions\tdeletions\tfilename)
658                        let stat_parts: Vec<&str> = line.split('\t').collect();
659                        if stat_parts.len() >= 2 {
660                            entry.additions = stat_parts[0].parse().ok();
661                            entry.deletions = stat_parts[1].parse().ok();
662                        }
663                    }
664                }
665
666                // Don't forget the last entry
667                if let Some(entry) = current_entry {
668                    entries.push(entry);
669                }
670
671                Ok::<_, std::io::Error>(entries)
672            })
673            .await;
674
675            match result {
676                Ok(Ok(entries)) => {
677                    let _ = tx.send(IrisTaskResult::FileLogLoaded {
678                        file: file_for_result,
679                        entries,
680                    });
681                }
682                Ok(Err(e)) => {
683                    tracing::warn!("Failed to load file log: {}", e);
684                }
685                Err(e) => {
686                    tracing::warn!("File log task panicked: {}", e);
687                }
688            }
689        });
690    }
691
692    /// Load global commit log (not file-specific)
693    fn load_global_log(&self) {
694        use crate::studio::state::FileLogEntry;
695
696        let Some(repo) = &self.state.repo else {
697            return;
698        };
699
700        let tx = self.iris_result_tx.clone();
701        let repo_path = repo.repo_path().clone();
702
703        tokio::spawn(async move {
704            let result = tokio::task::spawn_blocking(move || {
705                use std::process::Command;
706
707                // Run git log for the whole repo
708                let output = Command::new("git")
709                    .args([
710                        "-C",
711                        repo_path.to_str().unwrap_or("."),
712                        "log",
713                        "--pretty=format:%H|%h|%s|%an|%ar",
714                        "--shortstat",
715                        "-n",
716                        "100", // Limit to 100 entries
717                    ])
718                    .output()?;
719
720                if !output.status.success() {
721                    return Ok(Vec::new());
722                }
723
724                let stdout = String::from_utf8_lossy(&output.stdout);
725                let mut entries = Vec::new();
726                let mut current_entry: Option<FileLogEntry> = None;
727
728                for line in stdout.lines() {
729                    if line.contains('|') && line.len() > 40 {
730                        // This is a commit line
731                        if let Some(entry) = current_entry.take() {
732                            entries.push(entry);
733                        }
734
735                        let parts: Vec<&str> = line.splitn(5, '|').collect();
736                        if parts.len() >= 5 {
737                            current_entry = Some(FileLogEntry {
738                                hash: parts[0].to_string(),
739                                short_hash: parts[1].to_string(),
740                                message: parts[2].to_string(),
741                                author: parts[3].to_string(),
742                                relative_time: parts[4].to_string(),
743                                additions: None,
744                                deletions: None,
745                            });
746                        }
747                    } else if line.contains("insertion") || line.contains("deletion") {
748                        // This is a shortstat line
749                        if let Some(ref mut entry) = current_entry {
750                            // Parse "N files changed, M insertions(+), K deletions(-)"
751                            for part in line.split(',') {
752                                let part = part.trim();
753                                if part.contains("insertion") {
754                                    entry.additions =
755                                        part.split_whitespace().next().and_then(|n| n.parse().ok());
756                                } else if part.contains("deletion") {
757                                    entry.deletions =
758                                        part.split_whitespace().next().and_then(|n| n.parse().ok());
759                                }
760                            }
761                        }
762                    }
763                }
764
765                // Don't forget the last entry
766                if let Some(entry) = current_entry {
767                    entries.push(entry);
768                }
769
770                Ok::<_, std::io::Error>(entries)
771            })
772            .await;
773
774            match result {
775                Ok(Ok(entries)) => {
776                    let _ = tx.send(IrisTaskResult::GlobalLogLoaded { entries });
777                }
778                Ok(Err(e)) => {
779                    tracing::warn!("Failed to load global log: {}", e);
780                }
781                Err(e) => {
782                    tracing::warn!("Global log task panicked: {}", e);
783                }
784            }
785        });
786    }
787
788    /// Load git status asynchronously (for fast TUI startup)
789    fn load_git_status_async(&self) {
790        let Some(repo) = &self.state.repo else {
791            return;
792        };
793
794        let tx = self.iris_result_tx.clone();
795        let repo_path = repo.repo_path().clone();
796
797        tokio::spawn(async move {
798            let result = tokio::task::spawn_blocking(move || {
799                use crate::git::GitRepo;
800
801                // Open repo once and gather all data
802                let repo = GitRepo::new(&repo_path)?;
803
804                let branch = repo.get_current_branch().unwrap_or_default();
805                let files_info = repo.extract_files_info(false).ok();
806                let unstaged = repo.get_unstaged_files().ok();
807                let untracked = repo.get_untracked_files().unwrap_or_default();
808                let (commits_ahead, commits_behind) = repo.get_ahead_behind();
809                let staged_diff = repo.get_staged_diff_full().ok();
810
811                let staged_files: Vec<std::path::PathBuf> = files_info
812                    .as_ref()
813                    .map(|f| {
814                        f.staged_files
815                            .iter()
816                            .map(|s| s.path.clone().into())
817                            .collect()
818                    })
819                    .unwrap_or_default();
820
821                let modified_files: Vec<std::path::PathBuf> = unstaged
822                    .as_ref()
823                    .map(|f| f.iter().map(|s| s.path.clone().into()).collect())
824                    .unwrap_or_default();
825
826                let untracked_files: Vec<std::path::PathBuf> = untracked
827                    .into_iter()
828                    .map(std::path::PathBuf::from)
829                    .collect();
830
831                Ok::<_, anyhow::Error>(GitStatusData {
832                    branch,
833                    staged_files,
834                    modified_files,
835                    untracked_files,
836                    commits_ahead,
837                    commits_behind,
838                    staged_diff,
839                })
840            })
841            .await;
842
843            match result {
844                Ok(Ok(data)) => {
845                    let _ = tx.send(IrisTaskResult::GitStatusLoaded(Box::new(data)));
846                }
847                Ok(Err(e)) => {
848                    tracing::warn!("Failed to load git status: {}", e);
849                }
850                Err(e) => {
851                    tracing::warn!("Git status task panicked: {}", e);
852                }
853            }
854        });
855    }
856
857    /// Load companion service asynchronously for fast TUI startup
858    fn load_companion_async(&mut self) {
859        let Some(repo) = &self.state.repo else {
860            return;
861        };
862
863        let tx = self.iris_result_tx.clone();
864        let repo_path = repo.repo_path().clone();
865        let branch = repo
866            .get_current_branch()
867            .or_else(|_| repo.get_default_base_ref())
868            .unwrap_or_else(|_| "HEAD".to_string());
869
870        let handle = tokio::spawn(async move {
871            let result = tokio::task::spawn_blocking(move || {
872                use super::state::CompanionSessionDisplay;
873                use crate::companion::{BranchMemory, CompanionService};
874
875                // Create companion service (this is the slow part - file watcher setup)
876                let service = CompanionService::new(repo_path, &branch)?;
877
878                // Load or create branch memory
879                let mut branch_mem = service
880                    .load_branch_memory(&branch)
881                    .ok()
882                    .flatten()
883                    .unwrap_or_else(|| BranchMemory::new(branch.clone()));
884
885                // Get welcome message before recording visit
886                let welcome = branch_mem.welcome_message();
887
888                // Record this visit
889                branch_mem.record_visit();
890
891                // Save updated branch memory
892                if let Err(e) = service.save_branch_memory(&branch_mem) {
893                    tracing::warn!("Failed to save branch memory: {}", e);
894                }
895
896                let display = CompanionSessionDisplay {
897                    watcher_active: service.has_watcher(),
898                    welcome_message: welcome.clone(),
899                    welcome_shown_at: welcome.map(|_| std::time::Instant::now()),
900                    ..Default::default()
901                };
902
903                Ok::<_, anyhow::Error>(CompanionInitData { service, display })
904            })
905            .await;
906
907            match result {
908                Ok(Ok(data)) => {
909                    let _ = tx.send(IrisTaskResult::CompanionReady(Box::new(data)));
910                }
911                Ok(Err(e)) => {
912                    tracing::warn!("Failed to initialize companion: {}", e);
913                }
914                Err(e) => {
915                    tracing::warn!("Companion init task panicked: {}", e);
916                }
917            }
918        });
919
920        // Store handle so we can abort on exit
921        self.background_tasks.push(handle);
922    }
923
924    /// Run the TUI application
925    ///
926    /// # Errors
927    ///
928    /// Returns an error when terminal setup, the main loop, or cleanup fails.
929    pub fn run(&mut self) -> Result<ExitResult> {
930        // Install panic hook to ensure terminal is restored on panic
931        let original_hook = std::panic::take_hook();
932        std::panic::set_hook(Box::new(move |panic_info| {
933            // Try to restore terminal
934            let _ = disable_raw_mode();
935            let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
936            // Print panic info to stderr
937            eprintln!("\n\n=== PANIC ===\n{}\n", panic_info);
938            original_hook(panic_info);
939        }));
940
941        // Setup terminal
942        enable_raw_mode()?;
943        let mut stdout = io::stdout();
944        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
945        let backend = CrosstermBackend::new(stdout);
946        let mut terminal = Terminal::new(backend)?;
947
948        // Run main loop
949        let result = self.main_loop(&mut terminal);
950
951        // Cleanup terminal
952        disable_raw_mode()?;
953        execute!(
954            terminal.backend_mut(),
955            LeaveAlternateScreen,
956            DisableMouseCapture
957        )?;
958        terminal.show_cursor()?;
959
960        result
961    }
962
963    fn main_loop(
964        &mut self,
965        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
966    ) -> Result<ExitResult> {
967        // Start async git status loading for fast TUI startup
968        self.state.git_status_loading = true;
969        self.load_git_status_async();
970
971        // Start async companion initialization (file watcher setup is slow)
972        self.load_companion_async();
973
974        // Note: Auto-generation happens in apply_git_status_data() after async load completes
975
976        loop {
977            // Process any pending file log load (deferred from initialization)
978            if let Some(path) = self.state.modes.explore.pending_file_log.take() {
979                self.push_event(StudioEvent::FileLogLoading(path));
980            }
981
982            // Check for completed Iris tasks
983            self.check_iris_results();
984
985            // Poll companion events (file watcher)
986            self.check_companion_events();
987
988            // Process any queued events through reducer
989            if let Some(result) = self.process_events() {
990                return Ok(result);
991            }
992
993            // Render if dirty
994            if self.state.check_dirty() {
995                terminal.draw(|frame| self.render(frame))?;
996            }
997
998            // Poll for events with timeout for animations
999            if event::poll(Duration::from_millis(50))? {
1000                match event::read()? {
1001                    Event::Key(key) if key.kind == KeyEventKind::Press => {
1002                        // Push to event queue - reducer will handle via existing handlers
1003                        self.push_event(StudioEvent::KeyPressed(key));
1004                    }
1005                    Event::Mouse(mouse) => {
1006                        match mouse.kind {
1007                            MouseEventKind::Down(MouseButton::Left) => {
1008                                let now = std::time::Instant::now();
1009                                let is_double_click =
1010                                    self.last_click.is_some_and(|(time, lx, ly)| {
1011                                        now.duration_since(time).as_millis() < 400
1012                                            && mouse.column.abs_diff(lx) <= 2
1013                                            && mouse.row.abs_diff(ly) <= 1
1014                                    });
1015
1016                                // Handle click based on what was clicked
1017                                if let Some(panel) = self.panel_at(mouse.column, mouse.row) {
1018                                    // Focus panel if not focused
1019                                    if self.state.focused_panel != panel {
1020                                        self.state.focused_panel = panel;
1021                                        self.state.mark_dirty();
1022                                    }
1023
1024                                    // Start drag selection for code view
1025                                    if let Some(line) =
1026                                        self.code_view_line_at(panel, mouse.column, mouse.row)
1027                                    {
1028                                        self.drag_start = Some((panel, line));
1029                                        // Clear any existing selection and set cursor
1030                                        self.update_code_selection(panel, line, line);
1031                                    } else {
1032                                        self.drag_start = None;
1033                                    }
1034
1035                                    // Handle file tree clicks
1036                                    self.handle_file_tree_click(
1037                                        panel,
1038                                        mouse.column,
1039                                        mouse.row,
1040                                        is_double_click,
1041                                    );
1042                                }
1043
1044                                // Update last click for double-click detection
1045                                self.last_click = Some((now, mouse.column, mouse.row));
1046                            }
1047                            MouseEventKind::Drag(MouseButton::Left) => {
1048                                // Extend selection while dragging
1049                                if let Some((start_panel, start_line)) = self.drag_start
1050                                    && let Some(panel) = self.panel_at(mouse.column, mouse.row)
1051                                    && panel == start_panel
1052                                    && let Some(current_line) =
1053                                        self.code_view_line_at(panel, mouse.column, mouse.row)
1054                                {
1055                                    let (sel_start, sel_end) = if current_line < start_line {
1056                                        (current_line, start_line)
1057                                    } else {
1058                                        (start_line, current_line)
1059                                    };
1060                                    self.update_code_selection(panel, sel_start, sel_end);
1061                                }
1062                            }
1063                            MouseEventKind::Up(MouseButton::Left) => {
1064                                // Finalize drag selection
1065                                self.drag_start = None;
1066                            }
1067                            _ => {}
1068                        }
1069                        // Push scroll events to queue
1070                        self.push_event(StudioEvent::Mouse(mouse));
1071                    }
1072                    Event::Resize(_, _) => {
1073                        // Terminal resized, trigger redraw
1074                        self.state.mark_dirty();
1075                    }
1076                    _ => {}
1077                }
1078            }
1079
1080            // Push tick event for animations
1081            self.push_event(StudioEvent::Tick);
1082        }
1083    }
1084
1085    /// Check for companion events (file watcher)
1086    /// Convert companion events to `StudioEvents` and push to queue
1087    fn check_companion_events(&mut self) {
1088        use crate::companion::CompanionEvent;
1089
1090        // Collect events first to avoid borrow conflicts
1091        let events: Vec<_> = {
1092            let Some(companion) = &mut self.state.companion else {
1093                return;
1094            };
1095
1096            let mut collected = Vec::new();
1097            while let Some(event) = companion.try_recv_event() {
1098                collected.push(event);
1099            }
1100            collected
1101        };
1102
1103        // Now convert and push events
1104        for event in events {
1105            let studio_event = match event {
1106                CompanionEvent::FileCreated(path) => StudioEvent::CompanionFileCreated(path),
1107                CompanionEvent::FileModified(path) => StudioEvent::CompanionFileModified(path),
1108                CompanionEvent::FileDeleted(path) => StudioEvent::CompanionFileDeleted(path),
1109                CompanionEvent::FileRenamed(_old, new) => StudioEvent::CompanionFileModified(new),
1110                CompanionEvent::GitRefChanged => StudioEvent::CompanionGitRefChanged,
1111                CompanionEvent::WatcherError(err) => StudioEvent::CompanionWatcherError(err),
1112            };
1113            self.push_event(studio_event);
1114        }
1115    }
1116
1117    /// Check for completed Iris task results
1118    /// Convert async Iris results to events and push to queue
1119    fn check_iris_results(&mut self) {
1120        while let Ok(result) = self.iris_result_rx.try_recv() {
1121            if let Some(event) = self.iris_result_event(result) {
1122                self.push_event(event);
1123            }
1124        }
1125    }
1126
1127    fn iris_result_event(&mut self, result: IrisTaskResult) -> Option<StudioEvent> {
1128        match result {
1129            IrisTaskResult::CommitMessages(messages) => Some(self.commit_messages_event(messages)),
1130            IrisTaskResult::ReviewContent(review) => Some(agent_complete_event(
1131                TaskType::Review,
1132                AgentResult::ReviewContent(review),
1133            )),
1134            IrisTaskResult::PRContent(content) => {
1135                Some(self.markdown_agent_event(TaskType::PR, "pr", content, AgentResult::PRContent))
1136            }
1137            IrisTaskResult::ChangelogContent(content) => Some(self.markdown_agent_event(
1138                TaskType::Changelog,
1139                "changelog",
1140                content,
1141                AgentResult::ChangelogContent,
1142            )),
1143            IrisTaskResult::ReleaseNotesContent(content) => Some(self.markdown_agent_event(
1144                TaskType::ReleaseNotes,
1145                "release_notes",
1146                content,
1147                AgentResult::ReleaseNotesContent,
1148            )),
1149            IrisTaskResult::ChatResponse(response) => Some(agent_complete_event(
1150                TaskType::Chat,
1151                AgentResult::ChatResponse(response),
1152            )),
1153            IrisTaskResult::ChatUpdate(update) => Some(chat_update_event(update)),
1154            IrisTaskResult::SemanticBlame(result) => Some(agent_complete_event(
1155                TaskType::SemanticBlame,
1156                AgentResult::SemanticBlame(result),
1157            )),
1158            IrisTaskResult::ToolStatus { tool_name, message } => {
1159                self.handle_tool_status(&tool_name, &message);
1160                None
1161            }
1162            IrisTaskResult::StreamingChunk {
1163                task_type,
1164                chunk,
1165                aggregated,
1166            } => Some(StudioEvent::StreamingChunk {
1167                task_type,
1168                chunk,
1169                aggregated,
1170            }),
1171            IrisTaskResult::StreamingComplete { task_type } => {
1172                Some(StudioEvent::StreamingComplete { task_type })
1173            }
1174            IrisTaskResult::StatusMessage(message) => {
1175                tracing::info!("Received status message via channel: {:?}", message.message);
1176                Some(StudioEvent::StatusMessage(message))
1177            }
1178            IrisTaskResult::CompletionMessage(message) => {
1179                self.apply_completion_message(message);
1180                None
1181            }
1182            IrisTaskResult::Error { task_type, error } => {
1183                Some(StudioEvent::AgentError { task_type, error })
1184            }
1185            IrisTaskResult::FileLogLoaded { file, entries } => {
1186                Some(StudioEvent::FileLogLoaded { file, entries })
1187            }
1188            IrisTaskResult::GlobalLogLoaded { entries } => {
1189                Some(StudioEvent::GlobalLogLoaded { entries })
1190            }
1191            IrisTaskResult::GitStatusLoaded(data) => {
1192                self.apply_git_status_data(*data);
1193                None
1194            }
1195            IrisTaskResult::CompanionReady(data) => {
1196                self.apply_companion_ready(*data);
1197                None
1198            }
1199        }
1200    }
1201
1202    fn commit_messages_event(&mut self, messages: Vec<GeneratedMessage>) -> StudioEvent {
1203        if let Some(msg) = messages.first().and_then(|m| m.completion_message.clone()) {
1204            tracing::info!("Using agent completion_message: {:?}", msg);
1205            self.state.set_iris_complete(msg);
1206        } else {
1207            tracing::info!(
1208                "No completion_message from agent, spawning generation. First msg: {:?}",
1209                messages.first().map(|m| &m.title)
1210            );
1211            self.spawn_completion_message("commit", messages.first().map(|m| m.title.clone()));
1212        }
1213
1214        agent_complete_event(TaskType::Commit, AgentResult::CommitMessages(messages))
1215    }
1216
1217    fn markdown_agent_event(
1218        &mut self,
1219        task_type: TaskType,
1220        completion_type: &str,
1221        content: String,
1222        result: fn(String) -> AgentResult,
1223    ) -> StudioEvent {
1224        self.spawn_completion_message(completion_type, first_line_hint(&content, 60));
1225        agent_complete_event(task_type, result(content))
1226    }
1227
1228    fn handle_tool_status(&mut self, tool_name: &str, message: &str) {
1229        let tool_desc = format!("{tool_name} - {message}");
1230        if let Some(prev) = self.state.chat_state.current_tool.take() {
1231            self.state.chat_state.add_tool_to_history(prev);
1232        }
1233        self.state.chat_state.current_tool = Some(tool_desc);
1234        self.state.mark_dirty();
1235    }
1236
1237    fn apply_completion_message(&mut self, message: String) {
1238        tracing::info!("Received completion message: {:?}", message);
1239        self.state.set_iris_complete(message);
1240        self.state.mark_dirty();
1241    }
1242
1243    fn apply_companion_ready(&mut self, data: CompanionInitData) {
1244        self.state.companion = Some(data.service);
1245        self.state.companion_display = data.display;
1246        self.state.mark_dirty();
1247        tracing::info!("Companion service initialized asynchronously");
1248    }
1249
1250    /// Apply git status data from async loading
1251    fn apply_git_status_data(&mut self, data: GitStatusData) {
1252        use super::components::diff_view::parse_diff;
1253
1254        // Update git status
1255        self.state.git_status = super::state::GitStatus {
1256            branch: data.branch,
1257            staged_count: data.staged_files.len(),
1258            staged_files: data.staged_files,
1259            modified_count: data.modified_files.len(),
1260            modified_files: data.modified_files,
1261            untracked_count: data.untracked_files.len(),
1262            untracked_files: data.untracked_files,
1263            commits_ahead: data.commits_ahead,
1264            commits_behind: data.commits_behind,
1265        };
1266        self.state.git_status_loading = false;
1267
1268        let preferred_commit_path = self.current_commit_selection_path();
1269
1270        // Update file trees
1271        self.update_commit_file_tree(preferred_commit_path.as_deref());
1272        self.update_review_file_tree();
1273
1274        // Load diffs from staged diff text
1275        if let Some(diff_text) = data.staged_diff {
1276            let diffs = parse_diff(&diff_text);
1277            self.state.modes.commit.diff_view.set_diffs(diffs);
1278        }
1279
1280        // Restore the current commit selection when possible.
1281        if let Some(path) = preferred_commit_path.as_deref() {
1282            self.state.modes.commit.diff_view.select_file_by_path(path);
1283        } else if let Some(path) = self.state.modes.commit.file_tree.selected_path() {
1284            self.state.modes.commit.diff_view.select_file_by_path(&path);
1285        }
1286
1287        // If no explicit mode was set, switch to suggested mode
1288        if !self.explicit_mode_set {
1289            let suggested = self.state.suggest_initial_mode();
1290            if suggested != self.state.active_mode {
1291                self.state.switch_mode(suggested);
1292            }
1293        }
1294
1295        // Auto-generate based on mode
1296        match self.state.active_mode {
1297            Mode::Commit => {
1298                if self.state.git_status.has_staged() {
1299                    self.auto_generate_commit();
1300                }
1301            }
1302            Mode::Review => {
1303                self.update_review_data(None, None);
1304                self.auto_generate_review();
1305            }
1306            Mode::PR => {
1307                self.update_pr_data(None, None);
1308                self.auto_generate_pr();
1309            }
1310            Mode::Changelog => {
1311                self.update_changelog_data(None, None);
1312                self.auto_generate_changelog();
1313            }
1314            Mode::ReleaseNotes => {
1315                self.update_release_notes_data(None, None);
1316                self.auto_generate_release_notes();
1317            }
1318            Mode::Explore => {
1319                self.update_explore_file_tree();
1320            }
1321        }
1322
1323        self.state.mark_dirty();
1324    }
1325
1326    /// Spawn fire-and-forget status message generation using the fast model
1327    ///
1328    /// This spawns an async task that generates witty status messages while
1329    /// the user waits for the main agent task to complete. Messages are
1330    /// sent via the result channel and displayed in the status bar.
1331    fn spawn_status_messages(&self, task: &super::events::AgentTask) {
1332        use crate::agents::StatusMessageGenerator;
1333
1334        tracing::info!("spawn_status_messages called for task: {:?}", task);
1335
1336        let Some(agent) = self.agent_service.as_ref() else {
1337            tracing::warn!("No agent service available for status messages");
1338            return;
1339        };
1340
1341        tracing::info!(
1342            "Status generator using provider={}, fast_model={}",
1343            agent.provider(),
1344            agent.fast_model()
1345        );
1346
1347        let context = self.status_context_for_task(task);
1348        let tx = self.iris_result_tx.clone();
1349        let additional_params = agent
1350            .config()
1351            .get_provider_config(agent.provider())
1352            .map(|provider_config| provider_config.additional_params.clone());
1353        let status_gen = StatusMessageGenerator::new(
1354            agent.provider(),
1355            agent.fast_model(),
1356            agent.api_key(),
1357            additional_params,
1358        );
1359
1360        tokio::spawn(async move {
1361            tracing::info!("Status message starting for task: {}", context.task_type);
1362            let start = std::time::Instant::now();
1363            match tokio::time::timeout(
1364                std::time::Duration::from_millis(2500),
1365                status_gen.generate(&context),
1366            )
1367            .await
1368            {
1369                Ok(msg) => {
1370                    let elapsed = start.elapsed();
1371                    tracing::info!(
1372                        "Status message generated in {:?}: {:?}",
1373                        elapsed,
1374                        msg.message
1375                    );
1376                    if let Err(e) = tx.send(IrisTaskResult::StatusMessage(msg)) {
1377                        tracing::error!("Failed to send status message: {}", e);
1378                    }
1379                }
1380                Err(e) => {
1381                    tracing::warn!(
1382                        "Status message timed out after {:?}: {}",
1383                        start.elapsed(),
1384                        e
1385                    );
1386                }
1387            }
1388        });
1389    }
1390
1391    fn status_context_for_task(&self, task: &super::events::AgentTask) -> StatusContext {
1392        let (task_type, activity) = Self::task_status_activity(task);
1393        let context = StatusContext::new(task_type, activity);
1394        let context = self.with_status_branch(context);
1395        let context = self.with_status_files(context);
1396        self.with_regeneration_context(context, task)
1397    }
1398
1399    fn task_status_activity(task: &super::events::AgentTask) -> (&'static str, &'static str) {
1400        let (task_type, activity) = match task {
1401            super::events::AgentTask::Commit { amend, .. } => {
1402                if *amend {
1403                    ("commit", "amending previous commit")
1404                } else {
1405                    ("commit", "crafting your commit message")
1406                }
1407            }
1408            super::events::AgentTask::Review { .. } => ("review", "analyzing code changes"),
1409            super::events::AgentTask::PR { .. } => ("pr", "drafting PR description"),
1410            super::events::AgentTask::Changelog { .. } => ("changelog", "generating changelog"),
1411            super::events::AgentTask::ReleaseNotes { .. } => {
1412                ("release_notes", "composing release notes")
1413            }
1414            super::events::AgentTask::Chat { .. } => ("chat", "thinking about your question"),
1415            super::events::AgentTask::SemanticBlame { .. } => {
1416                ("semantic_blame", "tracing code origins")
1417            }
1418        };
1419        (task_type, activity)
1420    }
1421
1422    fn with_status_branch(&self, mut context: StatusContext) -> StatusContext {
1423        if let Some(repo) = &self.state.repo
1424            && let Ok(branch) = repo.get_current_branch()
1425        {
1426            context = context.with_branch(branch);
1427        }
1428        context
1429    }
1430
1431    fn with_status_files(&self, context: StatusContext) -> StatusContext {
1432        let files = self.status_file_names();
1433        let file_count = files.len();
1434        if file_count > 0 {
1435            context.with_file_count(file_count).with_files(files)
1436        } else {
1437            context
1438        }
1439    }
1440
1441    fn status_file_names(&self) -> Vec<String> {
1442        let mut files: Vec<String> = Vec::new();
1443        for file in &self.state.git_status.staged_files {
1444            files.push(status_file_name(file));
1445        }
1446        for file in &self.state.git_status.modified_files {
1447            let name = status_file_name(file);
1448            if !files.contains(&name) {
1449                files.push(name);
1450            }
1451        }
1452        files
1453    }
1454
1455    fn with_regeneration_context(
1456        &self,
1457        mut context: StatusContext,
1458        task: &super::events::AgentTask,
1459    ) -> StatusContext {
1460        let (is_regen, content_hint) = match task {
1461            super::events::AgentTask::Commit { .. } => {
1462                let has_content = !self.state.modes.commit.messages.is_empty();
1463                let hint = self
1464                    .state
1465                    .modes
1466                    .commit
1467                    .messages
1468                    .first()
1469                    .map(|m| m.title.clone());
1470                (has_content, hint)
1471            }
1472            super::events::AgentTask::Review { .. } => {
1473                let existing = &self.state.modes.review.review_content;
1474                let hint = existing
1475                    .lines()
1476                    .next()
1477                    .map(|l| l.chars().take(50).collect());
1478                (!existing.is_empty(), hint)
1479            }
1480            super::events::AgentTask::PR { .. } => {
1481                let existing = &self.state.modes.pr.pr_content;
1482                let hint = existing
1483                    .lines()
1484                    .next()
1485                    .map(|l| l.chars().take(50).collect());
1486                (!existing.is_empty(), hint)
1487            }
1488            super::events::AgentTask::Changelog { .. } => {
1489                let existing = &self.state.modes.changelog.changelog_content;
1490                (!existing.is_empty(), None)
1491            }
1492            super::events::AgentTask::ReleaseNotes { .. } => {
1493                let existing = &self.state.modes.release_notes.release_notes_content;
1494                (!existing.is_empty(), None)
1495            }
1496            _ => (false, None),
1497        };
1498        context = context.with_regeneration(is_regen);
1499        if let Some(hint) = content_hint {
1500            context = context.with_content_hint(hint);
1501        }
1502        context
1503    }
1504
1505    /// Spawn completion message generation using the fast model
1506    /// This generates a clever completion message based on the content that was just generated.
1507    fn spawn_completion_message(&self, task_type: &str, content_hint: Option<String>) {
1508        use crate::agents::{StatusContext, StatusMessageGenerator};
1509
1510        let Some(agent) = self.agent_service.as_ref() else {
1511            return;
1512        };
1513
1514        let mut context = StatusContext::new(task_type, "completed");
1515
1516        // Add branch if available
1517        if let Some(repo) = &self.state.repo
1518            && let Ok(branch) = repo.get_current_branch()
1519        {
1520            context = context.with_branch(branch);
1521        }
1522
1523        // Add content hint for context
1524        if let Some(hint) = content_hint {
1525            context = context.with_content_hint(hint);
1526        }
1527
1528        let tx = self.iris_result_tx.clone();
1529        let additional_params = agent
1530            .config()
1531            .get_provider_config(agent.provider())
1532            .map(|provider_config| provider_config.additional_params.clone());
1533        let status_gen = StatusMessageGenerator::new(
1534            agent.provider(),
1535            agent.fast_model(),
1536            agent.api_key(),
1537            additional_params,
1538        );
1539
1540        tokio::spawn(async move {
1541            match tokio::time::timeout(
1542                std::time::Duration::from_secs(2),
1543                status_gen.generate_completion(&context),
1544            )
1545            .await
1546            {
1547                Ok(msg) => {
1548                    tracing::info!("Completion message generated: {:?}", msg.message);
1549                    let _ = tx.send(IrisTaskResult::CompletionMessage(msg.message));
1550                }
1551                Err(_) => {
1552                    tracing::warn!("Completion message generation timed out");
1553                }
1554            }
1555        });
1556    }
1557
1558    /// Auto-generate commit message on app start
1559    fn auto_generate_commit(&mut self) {
1560        // Don't regenerate if we already have messages
1561        if !self.state.modes.commit.messages.is_empty() {
1562            return;
1563        }
1564
1565        self.state.set_iris_thinking("Analyzing changes...");
1566        self.state.modes.commit.generating = true;
1567        let preset = self.state.modes.commit.preset.clone();
1568        let use_gitmoji = self.state.modes.commit.use_gitmoji;
1569        let amend = self.state.modes.commit.amend_mode;
1570        self.spawn_commit_generation(None, preset, use_gitmoji, amend);
1571    }
1572
1573    /// Auto-generate code review on mode entry
1574    fn auto_generate_review(&mut self) {
1575        // Don't regenerate if we already have content
1576        if !self.state.modes.review.review_content.is_empty() {
1577            return;
1578        }
1579
1580        // Need diffs to review
1581        if self.state.modes.review.diff_view.file_paths().is_empty() {
1582            return;
1583        }
1584
1585        self.state.set_iris_thinking("Reviewing code changes...");
1586        self.state.modes.review.generating = true;
1587        let from_ref = self.state.modes.review.from_ref.clone();
1588        let to_ref = self.state.modes.review.to_ref.clone();
1589        self.spawn_review_generation(from_ref, to_ref);
1590    }
1591
1592    /// Auto-generate PR description on mode entry
1593    fn auto_generate_pr(&mut self) {
1594        // Don't regenerate if we already have content
1595        if !self.state.modes.pr.pr_content.is_empty() {
1596            return;
1597        }
1598
1599        // Need commits to describe
1600        if self.state.modes.pr.commits.is_empty() {
1601            return;
1602        }
1603
1604        self.state.set_iris_thinking("Drafting PR description...");
1605        self.state.modes.pr.generating = true;
1606        let base_branch = self.state.modes.pr.base_branch.clone();
1607        let to_ref = self.state.modes.pr.to_ref.clone();
1608        self.spawn_pr_generation(base_branch, &to_ref);
1609    }
1610
1611    /// Auto-generate changelog on mode entry
1612    fn auto_generate_changelog(&mut self) {
1613        // Don't regenerate if we already have content
1614        if !self.state.modes.changelog.changelog_content.is_empty() {
1615            return;
1616        }
1617
1618        // Need commits to generate from
1619        if self.state.modes.changelog.commits.is_empty() {
1620            return;
1621        }
1622
1623        let from_ref = self.state.modes.changelog.from_ref.clone();
1624        let to_ref = self.state.modes.changelog.to_ref.clone();
1625
1626        self.state.set_iris_thinking("Generating changelog...");
1627        self.state.modes.changelog.generating = true;
1628        self.spawn_changelog_generation(from_ref, to_ref);
1629    }
1630
1631    /// Auto-generate release notes on mode entry
1632    fn auto_generate_release_notes(&mut self) {
1633        // Don't regenerate if we already have content
1634        if !self
1635            .state
1636            .modes
1637            .release_notes
1638            .release_notes_content
1639            .is_empty()
1640        {
1641            return;
1642        }
1643
1644        // Need commits to generate from
1645        if self.state.modes.release_notes.commits.is_empty() {
1646            return;
1647        }
1648
1649        let from_ref = self.state.modes.release_notes.from_ref.clone();
1650        let to_ref = self.state.modes.release_notes.to_ref.clone();
1651
1652        self.state.set_iris_thinking("Generating release notes...");
1653        self.state.modes.release_notes.generating = true;
1654        self.spawn_release_notes_generation(from_ref, to_ref);
1655    }
1656
1657    /// Determine which panel contains the given coordinates
1658    fn panel_at(&self, x: u16, y: u16) -> Option<PanelId> {
1659        let Some(layout) = &self.last_layout else {
1660            return None;
1661        };
1662
1663        for (i, panel_rect) in layout.panels.iter().enumerate() {
1664            if x >= panel_rect.x
1665                && x < panel_rect.x + panel_rect.width
1666                && y >= panel_rect.y
1667                && y < panel_rect.y + panel_rect.height
1668            {
1669                return match i {
1670                    0 => Some(PanelId::Left),
1671                    1 => Some(PanelId::Center),
1672                    2 => Some(PanelId::Right),
1673                    _ => None,
1674                };
1675            }
1676        }
1677        None
1678    }
1679
1680    /// Handle mouse click in panels (file tree, code view, etc.)
1681    fn handle_file_tree_click(&mut self, panel: PanelId, _x: u16, y: u16, is_double_click: bool) {
1682        let Some(layout) = &self.last_layout else {
1683            return;
1684        };
1685
1686        // Get the panel rect
1687        let panel_idx = match panel {
1688            PanelId::Left => 0,
1689            PanelId::Center => 1,
1690            PanelId::Right => 2,
1691        };
1692
1693        let Some(panel_rect) = layout.panels.get(panel_idx) else {
1694            return;
1695        };
1696
1697        // Calculate row within panel (accounting for border and title)
1698        // Panel has 1 row border on each side
1699        let inner_y = y.saturating_sub(panel_rect.y + 1);
1700
1701        // Determine which component to update based on mode and panel
1702        match (self.state.active_mode, panel) {
1703            // ─────────────────────────────────────────────────────────────────
1704            // Explore Mode
1705            // ─────────────────────────────────────────────────────────────────
1706            (Mode::Explore, PanelId::Left) => {
1707                let file_tree = &mut self.state.modes.explore.file_tree;
1708                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1709
1710                if is_double_click && is_dir {
1711                    file_tree.toggle_expand();
1712                } else if is_double_click && !is_dir {
1713                    // Double-click on file: load it and focus code view
1714                    if let Some(path) = file_tree.selected_path() {
1715                        self.state.modes.explore.current_file = Some(path.clone());
1716                        if let Err(e) = self.state.modes.explore.code_view.load_file(&path) {
1717                            self.state.notify(Notification::warning(format!(
1718                                "Could not load file: {}",
1719                                e
1720                            )));
1721                        }
1722                        self.state.focused_panel = PanelId::Center;
1723                    }
1724                } else if changed && !is_dir {
1725                    // Single click on file: load it into code view
1726                    if let Some(path) = file_tree.selected_path() {
1727                        self.state.modes.explore.current_file = Some(path.clone());
1728                        if let Err(e) = self.state.modes.explore.code_view.load_file(&path) {
1729                            self.state.notify(Notification::warning(format!(
1730                                "Could not load file: {}",
1731                                e
1732                            )));
1733                        }
1734                    }
1735                }
1736                self.state.mark_dirty();
1737            }
1738            (Mode::Explore, PanelId::Center) => {
1739                // Code view: click to select line
1740                let code_view = &mut self.state.modes.explore.code_view;
1741                if code_view.select_by_row(inner_y as usize) {
1742                    self.state.mark_dirty();
1743                }
1744            }
1745            // ─────────────────────────────────────────────────────────────────
1746            // Commit Mode
1747            // ─────────────────────────────────────────────────────────────────
1748            (Mode::Commit, PanelId::Left) => {
1749                let file_tree = &mut self.state.modes.commit.file_tree;
1750                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1751
1752                if is_double_click && is_dir {
1753                    file_tree.toggle_expand();
1754                } else if is_double_click && !is_dir {
1755                    // Double-click on file: focus on diff panel
1756                    if let Some(path) = file_tree.selected_path() {
1757                        self.state.modes.commit.diff_view.select_file_by_path(&path);
1758                        self.state.focused_panel = PanelId::Right;
1759                    }
1760                } else if changed {
1761                    // Single click: sync diff view
1762                    if let Some(path) = file_tree.selected_path() {
1763                        self.state.modes.commit.diff_view.select_file_by_path(&path);
1764                    }
1765                }
1766                self.state.mark_dirty();
1767            }
1768            // ─────────────────────────────────────────────────────────────────
1769            // Review Mode
1770            // ─────────────────────────────────────────────────────────────────
1771            (Mode::Review, PanelId::Left) => {
1772                let file_tree = &mut self.state.modes.review.file_tree;
1773                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1774
1775                if is_double_click && is_dir {
1776                    file_tree.toggle_expand();
1777                } else if is_double_click && !is_dir {
1778                    // Double-click on file: focus on diff panel
1779                    if let Some(path) = file_tree.selected_path() {
1780                        self.state.modes.review.diff_view.select_file_by_path(&path);
1781                        self.state.focused_panel = PanelId::Center;
1782                    }
1783                } else if changed {
1784                    // Single click: sync diff view
1785                    if let Some(path) = file_tree.selected_path() {
1786                        self.state.modes.review.diff_view.select_file_by_path(&path);
1787                    }
1788                }
1789                self.state.mark_dirty();
1790            }
1791            // ─────────────────────────────────────────────────────────────────
1792            // PR Mode
1793            // ─────────────────────────────────────────────────────────────────
1794            (Mode::PR, PanelId::Left) => {
1795                let file_tree = &mut self.state.modes.pr.file_tree;
1796                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1797
1798                if is_double_click && is_dir {
1799                    file_tree.toggle_expand();
1800                } else if (changed || is_double_click)
1801                    && let Some(path) = file_tree.selected_path()
1802                {
1803                    self.state.modes.pr.diff_view.select_file_by_path(&path);
1804                }
1805                self.state.mark_dirty();
1806            }
1807            _ => {}
1808        }
1809    }
1810
1811    /// Get the line number at a mouse position in the code view (1-indexed)
1812    /// Returns None if not in a code view area
1813    fn code_view_line_at(&self, panel: PanelId, _x: u16, y: u16) -> Option<usize> {
1814        let layout = self.last_layout.as_ref()?;
1815
1816        // Get the panel rect
1817        let panel_idx = match panel {
1818            PanelId::Left => 0,
1819            PanelId::Center => 1,
1820            PanelId::Right => 2,
1821        };
1822
1823        let panel_rect = layout.panels.get(panel_idx)?;
1824
1825        // Calculate row within panel (accounting for border)
1826        let inner_y = y.saturating_sub(panel_rect.y + 1) as usize;
1827
1828        // Only handle code view panels based on mode
1829        match (self.state.active_mode, panel) {
1830            (Mode::Explore, PanelId::Center) => {
1831                let code_view = &self.state.modes.explore.code_view;
1832                let target_line = code_view.scroll_offset() + inner_y + 1;
1833                if target_line <= code_view.line_count() {
1834                    Some(target_line)
1835                } else {
1836                    None
1837                }
1838            }
1839            _ => None,
1840        }
1841    }
1842
1843    /// Update code view selection range (for mouse drag)
1844    fn update_code_selection(&mut self, panel: PanelId, start: usize, end: usize) {
1845        if let (Mode::Explore, PanelId::Center) = (self.state.active_mode, panel) {
1846            // Update code view state
1847            self.state.modes.explore.code_view.set_selected_line(start);
1848            if start == end {
1849                // Single line - set anchor for potential drag extension
1850                self.state.modes.explore.selection_anchor = Some(start);
1851                self.state.modes.explore.code_view.clear_selection();
1852                self.state.modes.explore.selection = None;
1853            } else {
1854                // Multi-line selection from drag
1855                self.state.modes.explore.code_view.set_selection(start, end);
1856                self.state.modes.explore.selection = Some((start, end));
1857            }
1858            // Update current line for semantic blame
1859            self.state.modes.explore.current_line = start;
1860            self.state.mark_dirty();
1861        }
1862    }
1863
1864    fn perform_commit(&mut self, message: &str) -> ExitResult {
1865        if let Some(service) = &self.commit_service {
1866            match service.perform_commit(message) {
1867                Ok(result) => {
1868                    // Record commit in companion
1869                    self.state
1870                        .companion_record_commit(result.commit_hash.clone());
1871
1872                    // Also update branch memory commit count
1873                    self.update_branch_commit_count(&result.branch);
1874
1875                    let output = crate::output::format_commit_result(&result, message);
1876                    ExitResult::Committed(output)
1877                }
1878                Err(e) => ExitResult::Error(e.to_string()),
1879            }
1880        } else {
1881            ExitResult::Error("Commit service not available".to_string())
1882        }
1883    }
1884
1885    fn perform_amend(&mut self, message: &str) -> ExitResult {
1886        if let Some(service) = &self.commit_service {
1887            match service.perform_amend(message) {
1888                Ok(result) => {
1889                    // Record amend in companion (still counts as commit activity)
1890                    self.state
1891                        .companion_record_commit(result.commit_hash.clone());
1892
1893                    let output = crate::output::format_commit_result(&result, message);
1894                    ExitResult::Amended(output)
1895                }
1896                Err(e) => ExitResult::Error(e.to_string()),
1897            }
1898        } else {
1899            ExitResult::Error("Commit service not available".to_string())
1900        }
1901    }
1902
1903    /// Update branch memory commit count
1904    fn update_branch_commit_count(&self, branch: &str) {
1905        if let Some(ref companion) = self.state.companion {
1906            let mut branch_mem = companion
1907                .load_branch_memory(branch)
1908                .ok()
1909                .flatten()
1910                .unwrap_or_else(|| crate::companion::BranchMemory::new(branch.to_string()));
1911
1912            branch_mem.record_commit();
1913
1914            if let Err(e) = companion.save_branch_memory(&branch_mem) {
1915                tracing::warn!("Failed to save branch memory after commit: {}", e);
1916            }
1917        }
1918    }
1919
1920    // ═══════════════════════════════════════════════════════════════════════════
1921    // Rendering
1922    // ═══════════════════════════════════════════════════════════════════════════
1923
1924    fn render(&mut self, frame: &mut Frame) {
1925        let areas = calculate_layout(frame.area(), self.state.active_mode);
1926
1927        self.render_header(frame, areas.header);
1928        self.render_tabs(frame, areas.tabs);
1929        self.render_panels(frame, &areas);
1930
1931        // Render companion status bar for explore mode
1932        if let Some(companion_area) = areas.companion_bar {
1933            render_companion_status_bar(frame, companion_area, &self.state);
1934        }
1935
1936        self.render_status(frame, areas.status);
1937
1938        // Store layout for mouse hit testing
1939        self.last_layout = Some(areas);
1940
1941        // Render modal overlay on top of everything
1942        if self.state.modal.is_some() {
1943            render_modal(&self.state, frame, self.state.last_render);
1944        }
1945    }
1946
1947    fn render_header(&self, frame: &mut Frame, area: Rect) {
1948        let branch = &self.state.git_status.branch;
1949        let staged = self.state.git_status.staged_count;
1950        let modified = self.state.git_status.modified_count;
1951
1952        // Create gradient title "◆ Iris Studio"
1953        let mut spans: Vec<Span> = Vec::new();
1954        spans.push(Span::styled(
1955            " ◆ ",
1956            Style::default().fg(theme::accent_primary()),
1957        ));
1958
1959        // Gradient text for "Iris Studio"
1960        let title_text = "Iris Studio";
1961        #[allow(clippy::cast_precision_loss)]
1962        for (i, c) in title_text.chars().enumerate() {
1963            let position = i as f32 / (title_text.len() - 1).max(1) as f32;
1964            spans.push(Span::styled(
1965                c.to_string(),
1966                Style::default()
1967                    .fg(theme::gradient_purple_cyan(position))
1968                    .add_modifier(Modifier::BOLD),
1969            ));
1970        }
1971
1972        spans.push(Span::raw(" "));
1973
1974        // Branch info with git icon
1975        if !branch.is_empty() {
1976            spans.push(Span::styled(
1977                "⎇ ",
1978                Style::default().fg(theme::text_dim_color()),
1979            ));
1980            spans.push(Span::styled(
1981                format!("{} ", branch),
1982                Style::default()
1983                    .fg(theme::accent_secondary())
1984                    .add_modifier(Modifier::BOLD),
1985            ));
1986        }
1987
1988        // Staged count
1989        if staged > 0 {
1990            spans.push(Span::styled(
1991                format!("✓{} ", staged),
1992                Style::default().fg(theme::success_color()),
1993            ));
1994        }
1995
1996        // Modified count
1997        if modified > 0 {
1998            spans.push(Span::styled(
1999                format!("○{} ", modified),
2000                Style::default().fg(theme::warning_color()),
2001            ));
2002        }
2003
2004        let line = Line::from(spans);
2005        let header = Paragraph::new(line);
2006        frame.render_widget(header, area);
2007    }
2008
2009    fn render_tabs(&self, frame: &mut Frame, area: Rect) {
2010        let mut spans = Vec::new();
2011        spans.push(Span::raw(" "));
2012
2013        for (idx, mode) in Mode::all().iter().enumerate() {
2014            let is_active = *mode == self.state.active_mode;
2015            let is_available = mode.is_available();
2016
2017            if is_active {
2018                // Active tab with gradient underline effect
2019                spans.push(Span::styled(
2020                    format!(" {} ", mode.shortcut()),
2021                    Style::default()
2022                        .fg(theme::accent_primary())
2023                        .add_modifier(Modifier::BOLD),
2024                ));
2025                // Mode name with gradient
2026                let name = mode.display_name();
2027                #[allow(clippy::cast_precision_loss)]
2028                for (i, c) in name.chars().enumerate() {
2029                    let position = i as f32 / (name.len() - 1).max(1) as f32;
2030                    spans.push(Span::styled(
2031                        c.to_string(),
2032                        Style::default()
2033                            .fg(theme::gradient_purple_cyan(position))
2034                            .add_modifier(Modifier::BOLD),
2035                    ));
2036                }
2037                spans.push(Span::raw(" "));
2038                // Underline with gradient
2039                spans.push(Span::styled(
2040                    "━",
2041                    Style::default().fg(theme::accent_primary()),
2042                ));
2043                spans.push(Span::styled(
2044                    "━",
2045                    Style::default().fg(theme::gradient_purple_cyan(0.5)),
2046                ));
2047                spans.push(Span::styled(
2048                    "━",
2049                    Style::default().fg(theme::accent_secondary()),
2050                ));
2051            } else if is_available {
2052                spans.push(Span::styled(
2053                    format!(" {} ", mode.shortcut()),
2054                    Style::default().fg(theme::text_muted_color()),
2055                ));
2056                spans.push(Span::styled(
2057                    mode.display_name().to_string(),
2058                    theme::mode_inactive(),
2059                ));
2060            } else {
2061                spans.push(Span::styled(
2062                    format!(" {} {} ", mode.shortcut(), mode.display_name()),
2063                    Style::default().fg(theme::text_muted_color()),
2064                ));
2065            }
2066
2067            // Separator between tabs
2068            if idx < Mode::all().len() - 1 {
2069                spans.push(Span::styled(
2070                    " │ ",
2071                    Style::default().fg(theme::text_muted_color()),
2072                ));
2073            }
2074        }
2075
2076        let tabs = Paragraph::new(Line::from(spans));
2077        frame.render_widget(tabs, area);
2078    }
2079
2080    fn render_panels(&mut self, frame: &mut Frame, areas: &LayoutAreas) {
2081        let layout = get_mode_layout(self.state.active_mode);
2082        let panel_ids: Vec<_> = layout.panels.iter().map(|c| c.id).collect();
2083        let panel_areas: Vec<_> = areas.panels.clone();
2084
2085        for (i, panel_area) in panel_areas.iter().enumerate() {
2086            if let Some(&panel_id) = panel_ids.get(i) {
2087                self.render_panel_content(frame, *panel_area, panel_id);
2088            }
2089        }
2090    }
2091
2092    fn render_panel_content(&mut self, frame: &mut Frame, area: Rect, panel_id: PanelId) {
2093        match self.state.active_mode {
2094            Mode::Explore => render_explore_panel(&mut self.state, frame, area, panel_id),
2095            Mode::Commit => render_commit_panel(&mut self.state, frame, area, panel_id),
2096            Mode::Review => render_review_panel(&mut self.state, frame, area, panel_id),
2097            Mode::PR => render_pr_panel(&mut self.state, frame, area, panel_id),
2098            Mode::Changelog => render_changelog_panel(&mut self.state, frame, area, panel_id),
2099            Mode::ReleaseNotes => {
2100                render_release_notes_panel(&mut self.state, frame, area, panel_id);
2101            }
2102        }
2103    }
2104
2105    fn current_commit_selection_path(&mut self) -> Option<std::path::PathBuf> {
2106        self.state
2107            .modes
2108            .commit
2109            .diff_view
2110            .current_diff()
2111            .map(|diff| diff.path.clone())
2112            .or_else(|| self.state.modes.commit.file_tree.selected_path())
2113    }
2114
2115    /// Update commit mode file tree from git status
2116    /// Shows either changed files (staged/unstaged) or all tracked files based on toggle
2117    fn update_commit_file_tree(&mut self, preferred_path: Option<&std::path::Path>) {
2118        let mut statuses = Vec::new();
2119
2120        // Build status map for known changed files
2121        for path in &self.state.git_status.staged_files {
2122            statuses.push((path.clone(), FileGitStatus::Staged));
2123        }
2124        for path in &self.state.git_status.modified_files {
2125            if !self.state.git_status.staged_files.contains(path) {
2126                statuses.push((path.clone(), FileGitStatus::Modified));
2127            }
2128        }
2129        for path in &self.state.git_status.untracked_files {
2130            statuses.push((path.clone(), FileGitStatus::Untracked));
2131        }
2132
2133        let all_files: Vec<std::path::PathBuf> = if self.state.modes.commit.show_all_files {
2134            // Show all tracked files from the repository
2135            let Some(repo) = &self.state.repo else {
2136                return;
2137            };
2138            match repo.get_all_tracked_files() {
2139                Ok(files) => files.into_iter().map(std::path::PathBuf::from).collect(),
2140                Err(e) => {
2141                    eprintln!("Failed to get tracked files: {}", e);
2142                    return;
2143                }
2144            }
2145        } else {
2146            // Show only changed files (staged + modified + untracked)
2147            let mut files = Vec::new();
2148            for path in &self.state.git_status.staged_files {
2149                files.push(path.clone());
2150            }
2151            for path in &self.state.git_status.modified_files {
2152                if !files.contains(path) {
2153                    files.push(path.clone());
2154                }
2155            }
2156            for path in &self.state.git_status.untracked_files {
2157                if !files.contains(path) {
2158                    files.push(path.clone());
2159                }
2160            }
2161            files
2162        };
2163
2164        let mut tree_state = super::components::FileTreeState::from_paths(&all_files, &statuses);
2165
2166        // Expand all by default (usually not too many files)
2167        tree_state.expand_all();
2168
2169        if let Some(path) = preferred_path {
2170            let _ = tree_state.select_path(path);
2171        }
2172
2173        self.state.modes.commit.file_tree = tree_state;
2174    }
2175
2176    /// Update review mode file tree from git status (staged + modified)
2177    fn update_review_file_tree(&mut self) {
2178        let mut all_files = Vec::new();
2179        let mut statuses = Vec::new();
2180
2181        // Include both staged and modified files for review
2182        for path in &self.state.git_status.staged_files {
2183            all_files.push(path.clone());
2184            statuses.push((path.clone(), FileGitStatus::Staged));
2185        }
2186        for path in &self.state.git_status.modified_files {
2187            if !all_files.contains(path) {
2188                all_files.push(path.clone());
2189                statuses.push((path.clone(), FileGitStatus::Modified));
2190            }
2191        }
2192
2193        let tree_state = super::components::FileTreeState::from_paths(&all_files, &statuses);
2194        self.state.modes.review.file_tree = tree_state;
2195        self.state.modes.review.file_tree.expand_all();
2196
2197        // Also load diffs for review mode
2198        self.load_review_diffs();
2199    }
2200
2201    /// Load diffs into review mode diff view
2202    fn load_review_diffs(&mut self) {
2203        let Some(repo) = &self.state.repo else { return };
2204
2205        // Get staged diff first, then unstaged
2206        if let Ok(diff_text) = repo.get_staged_diff_full() {
2207            let diffs = parse_diff(&diff_text);
2208            self.state.modes.review.diff_view.set_diffs(diffs);
2209        }
2210
2211        // Sync initial file selection
2212        if let Some(path) = self.state.modes.review.file_tree.selected_path() {
2213            self.state.modes.review.diff_view.select_file_by_path(&path);
2214        }
2215    }
2216
2217    // ═══════════════════════════════════════════════════════════════════════════
2218    // Shared Data Loading Helpers
2219    // ═══════════════════════════════════════════════════════════════════════════
2220
2221    /// Load diff between two refs and return parsed diffs.
2222    fn load_diff_between_refs(
2223        &mut self,
2224        repo: &crate::git::GitRepo,
2225        from: &str,
2226        to: &str,
2227    ) -> Option<Vec<FileDiff>> {
2228        match repo.get_ref_diff_full(from, to) {
2229            Ok(diff_text) => Some(parse_diff(&diff_text)),
2230            Err(e) => {
2231                self.state
2232                    .notify(Notification::warning(format!("Could not load diff: {e}")));
2233                None
2234            }
2235        }
2236    }
2237
2238    /// Load commits between two refs as `ChangelogCommit`.
2239    fn load_changelog_commits(
2240        &mut self,
2241        repo: &crate::git::GitRepo,
2242        from: &str,
2243        to: &str,
2244    ) -> Option<Vec<super::state::ChangelogCommit>> {
2245        use super::state::ChangelogCommit;
2246        match repo.get_commits_between_with_callback(from, to, |commit| {
2247            Ok(ChangelogCommit {
2248                hash: commit.hash[..7.min(commit.hash.len())].to_string(),
2249                message: commit.message.lines().next().unwrap_or("").to_string(),
2250                author: commit.author.clone(),
2251            })
2252        }) {
2253            Ok(commits) => Some(commits),
2254            Err(e) => {
2255                self.state.notify(Notification::warning(format!(
2256                    "Could not load commits: {e}"
2257                )));
2258                None
2259            }
2260        }
2261    }
2262
2263    // ═══════════════════════════════════════════════════════════════════════════
2264    // Mode-Specific Data Updates
2265    // ═══════════════════════════════════════════════════════════════════════════
2266
2267    pub fn update_pr_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2268        use super::state::PrCommit;
2269
2270        let Some(repo) = self.state.repo.clone() else {
2271            return;
2272        };
2273
2274        let base = from_ref.unwrap_or_else(|| self.state.modes.pr.base_branch.clone());
2275        let to = to_ref.unwrap_or_else(|| self.state.modes.pr.to_ref.clone());
2276
2277        // Load commits (PrCommit has same shape as ChangelogCommit)
2278        match repo.get_commits_between_with_callback(&base, &to, |commit| {
2279            Ok(PrCommit {
2280                hash: commit.hash[..7.min(commit.hash.len())].to_string(),
2281                message: commit.message.lines().next().unwrap_or("").to_string(),
2282                author: commit.author.clone(),
2283            })
2284        }) {
2285            Ok(commits) => {
2286                self.state.modes.pr.commits = commits;
2287                self.state.modes.pr.selected_commit = 0;
2288                self.state.modes.pr.commit_scroll = 0;
2289            }
2290            Err(e) => {
2291                self.state.notify(Notification::warning(format!(
2292                    "Could not load commits: {e}"
2293                )));
2294            }
2295        }
2296
2297        if let Some(diffs) = self.load_diff_between_refs(&repo, &base, &to) {
2298            self.state.modes.pr.diff_view.set_diffs(diffs);
2299        }
2300
2301        self.state.mark_dirty();
2302    }
2303
2304    /// Update Review mode data - load diff between `from_ref` and `to_ref`
2305    pub fn update_review_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2306        let Some(repo) = self.state.repo.clone() else {
2307            return;
2308        };
2309
2310        let from = from_ref.unwrap_or_else(|| self.state.modes.review.from_ref.clone());
2311        let to = to_ref.unwrap_or_else(|| self.state.modes.review.to_ref.clone());
2312
2313        if let Some(diffs) = self.load_diff_between_refs(&repo, &from, &to) {
2314            // Update file tree from the diff files
2315            let files: Vec<std::path::PathBuf> = diffs
2316                .iter()
2317                .map(|d| std::path::PathBuf::from(&d.path))
2318                .collect();
2319            let statuses: Vec<_> = files
2320                .iter()
2321                .map(|p| (p.clone(), FileGitStatus::Modified))
2322                .collect();
2323            let tree_state = super::components::FileTreeState::from_paths(&files, &statuses);
2324            self.state.modes.review.file_tree = tree_state;
2325            self.state.modes.review.file_tree.expand_all();
2326            self.state.modes.review.diff_view.set_diffs(diffs);
2327        }
2328
2329        self.state.mark_dirty();
2330    }
2331
2332    /// Update Changelog mode data - load commits and diff between `from_ref` and `to_ref`
2333    pub fn update_changelog_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2334        let Some(repo) = self.state.repo.clone() else {
2335            return;
2336        };
2337
2338        let from = from_ref.unwrap_or_else(|| self.state.modes.changelog.from_ref.clone());
2339        let to = to_ref.unwrap_or_else(|| self.state.modes.changelog.to_ref.clone());
2340
2341        if let Some(commits) = self.load_changelog_commits(&repo, &from, &to) {
2342            self.state.modes.changelog.commits = commits;
2343            self.state.modes.changelog.selected_commit = 0;
2344            self.state.modes.changelog.commit_scroll = 0;
2345        }
2346
2347        if let Some(diffs) = self.load_diff_between_refs(&repo, &from, &to) {
2348            self.state.modes.changelog.diff_view.set_diffs(diffs);
2349        }
2350
2351        self.state.mark_dirty();
2352    }
2353
2354    /// Update release notes mode data when refs change
2355    pub fn update_release_notes_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2356        let Some(repo) = self.state.repo.clone() else {
2357            return;
2358        };
2359
2360        let from = from_ref.unwrap_or_else(|| self.state.modes.release_notes.from_ref.clone());
2361        let to = to_ref.unwrap_or_else(|| self.state.modes.release_notes.to_ref.clone());
2362
2363        if let Some(commits) = self.load_changelog_commits(&repo, &from, &to) {
2364            self.state.modes.release_notes.commits = commits;
2365            self.state.modes.release_notes.selected_commit = 0;
2366            self.state.modes.release_notes.commit_scroll = 0;
2367        }
2368
2369        if let Some(diffs) = self.load_diff_between_refs(&repo, &from, &to) {
2370            self.state.modes.release_notes.diff_view.set_diffs(diffs);
2371        }
2372
2373        self.state.mark_dirty();
2374    }
2375
2376    // ═══════════════════════════════════════════════════════════════════════════
2377    // Staging Operations
2378    // ═══════════════════════════════════════════════════════════════════════════
2379
2380    /// Stage a single file
2381    fn stage_file(&mut self, path: &str) {
2382        let Some(repo) = &self.state.repo else {
2383            self.state
2384                .notify(Notification::error("No repository available"));
2385            return;
2386        };
2387
2388        match repo.stage_file(std::path::Path::new(path)) {
2389            Ok(()) => {
2390                // Track in companion
2391                self.state
2392                    .companion_touch_file(std::path::PathBuf::from(path));
2393                self.state
2394                    .notify(Notification::success(format!("Staged: {}", path)));
2395                let _ = self.refresh_git_status();
2396                self.state.update_companion_display();
2397            }
2398            Err(e) => {
2399                self.state
2400                    .notify(Notification::error(format!("Failed to stage: {}", e)));
2401            }
2402        }
2403        self.state.mark_dirty();
2404    }
2405
2406    /// Unstage a single file
2407    fn unstage_file(&mut self, path: &str) {
2408        let Some(repo) = &self.state.repo else {
2409            self.state
2410                .notify(Notification::error("No repository available"));
2411            return;
2412        };
2413
2414        match repo.unstage_file(std::path::Path::new(path)) {
2415            Ok(()) => {
2416                // Track in companion
2417                self.state
2418                    .companion_touch_file(std::path::PathBuf::from(path));
2419                self.state
2420                    .notify(Notification::success(format!("Unstaged: {}", path)));
2421                let _ = self.refresh_git_status();
2422                self.state.update_companion_display();
2423            }
2424            Err(e) => {
2425                self.state
2426                    .notify(Notification::error(format!("Failed to unstage: {}", e)));
2427            }
2428        }
2429        self.state.mark_dirty();
2430    }
2431
2432    /// Stage all files
2433    fn stage_all(&mut self) {
2434        let Some(repo) = &self.state.repo else {
2435            self.state
2436                .notify(Notification::error("No repository available"));
2437            return;
2438        };
2439
2440        // Track all modified files before staging
2441        let files_to_track: Vec<_> = self
2442            .state
2443            .git_status
2444            .modified_files
2445            .iter()
2446            .cloned()
2447            .chain(self.state.git_status.untracked_files.iter().cloned())
2448            .collect();
2449
2450        match repo.stage_all() {
2451            Ok(()) => {
2452                // Track all in companion
2453                for path in files_to_track {
2454                    self.state.companion_touch_file(path);
2455                }
2456                self.state.notify(Notification::success("Staged all files"));
2457                let _ = self.refresh_git_status();
2458                self.state.update_companion_display();
2459            }
2460            Err(e) => {
2461                self.state
2462                    .notify(Notification::error(format!("Failed to stage all: {}", e)));
2463            }
2464        }
2465        self.state.mark_dirty();
2466    }
2467
2468    /// Unstage all files
2469    fn unstage_all(&mut self) {
2470        let Some(repo) = &self.state.repo else {
2471            self.state
2472                .notify(Notification::error("No repository available"));
2473            return;
2474        };
2475
2476        // Track all staged files before unstaging
2477        let files_to_track: Vec<_> = self.state.git_status.staged_files.clone();
2478
2479        match repo.unstage_all() {
2480            Ok(()) => {
2481                // Track all in companion
2482                for path in files_to_track {
2483                    self.state.companion_touch_file(path);
2484                }
2485                self.state
2486                    .notify(Notification::success("Unstaged all files"));
2487                let _ = self.refresh_git_status();
2488                self.state.update_companion_display();
2489            }
2490            Err(e) => {
2491                self.state
2492                    .notify(Notification::error(format!("Failed to unstage all: {}", e)));
2493            }
2494        }
2495        self.state.mark_dirty();
2496    }
2497
2498    /// Save settings from the settings modal to config file
2499    fn save_settings(&mut self) {
2500        use crate::studio::state::Modal;
2501
2502        let settings = if let Some(Modal::Settings(s)) = &self.state.modal {
2503            s.clone()
2504        } else {
2505            return;
2506        };
2507
2508        if !settings.modified {
2509            self.state.notify(Notification::info("No changes to save"));
2510            return;
2511        }
2512
2513        // Update config
2514        let mut config = self.state.config.clone();
2515        config.default_provider.clone_from(&settings.provider);
2516        config.use_gitmoji = settings.use_gitmoji;
2517        config
2518            .instruction_preset
2519            .clone_from(&settings.instruction_preset);
2520        config
2521            .instructions
2522            .clone_from(&settings.custom_instructions);
2523        config.theme.clone_from(&settings.theme);
2524
2525        // Update provider config
2526        if let Some(provider_config) = config.providers.get_mut(&settings.provider) {
2527            provider_config.model.clone_from(&settings.model);
2528            if let Some(api_key) = &settings.api_key_actual {
2529                provider_config.api_key.clone_from(api_key);
2530            }
2531        }
2532
2533        // Save to file
2534        match config.save() {
2535            Ok(()) => {
2536                self.state.config = config;
2537                // Clear the modified flag
2538                if let Some(Modal::Settings(s)) = &mut self.state.modal {
2539                    s.modified = false;
2540                    s.error = None;
2541                }
2542                self.state.notify(Notification::success("Settings saved"));
2543            }
2544            Err(e) => {
2545                if let Some(Modal::Settings(s)) = &mut self.state.modal {
2546                    s.error = Some(format!("Save failed: {}", e));
2547                }
2548                self.state
2549                    .notify(Notification::error(format!("Failed to save: {}", e)));
2550            }
2551        }
2552        self.state.mark_dirty();
2553    }
2554
2555    fn render_status(&self, frame: &mut Frame, area: Rect) {
2556        let mut spans = Vec::new();
2557
2558        // Show notification if any
2559        if let Some(notification) = self.state.current_notification() {
2560            let style = match notification.level {
2561                super::state::NotificationLevel::Info => theme::dimmed(),
2562                super::state::NotificationLevel::Success => theme::success(),
2563                super::state::NotificationLevel::Warning => theme::warning(),
2564                super::state::NotificationLevel::Error => theme::error(),
2565            };
2566            spans.push(Span::styled(&notification.message, style));
2567        } else {
2568            // Context-aware keybinding hints based on mode and panel
2569            let hints = self.get_context_hints();
2570            spans.push(Span::styled(hints, theme::dimmed()));
2571        }
2572
2573        // Right-align Iris status
2574        let iris_status = match &self.state.iris_status {
2575            IrisStatus::Idle => Span::styled("Iris: ready", theme::dimmed()),
2576            IrisStatus::Thinking { task, .. } => {
2577                let spinner = self.state.iris_status.spinner_char().unwrap_or('◎');
2578                Span::styled(
2579                    format!("{} {}", spinner, task),
2580                    Style::default().fg(theme::accent_secondary()),
2581                )
2582            }
2583            IrisStatus::Complete { message, .. } => {
2584                Span::styled(message.clone(), Style::default().fg(theme::success_color()))
2585            }
2586            IrisStatus::Error(msg) => Span::styled(format!("Error: {}", msg), theme::error()),
2587        };
2588
2589        // Calculate spacing (use saturating_sub to avoid overflow on narrow terminals)
2590        let left_len: usize = spans.iter().map(|s| s.content.len()).sum();
2591        let right_len = iris_status.content.len();
2592        let padding = (area.width as usize)
2593            .saturating_sub(left_len)
2594            .saturating_sub(right_len)
2595            .saturating_sub(2);
2596        let padding_str = " ".repeat(padding.max(1));
2597
2598        spans.push(Span::raw(padding_str));
2599        spans.push(iris_status);
2600
2601        let status = Paragraph::new(Line::from(spans));
2602        frame.render_widget(status, area);
2603    }
2604
2605    /// Get context-aware keybinding hints based on mode and focused panel
2606    fn get_context_hints(&self) -> String {
2607        let base = "[?]help [Tab]panel [q]quit";
2608
2609        match self.state.active_mode {
2610            Mode::Commit => match self.state.focused_panel {
2611                PanelId::Left => {
2612                    format!(
2613                        "{} · [↑↓]nav [s]stage [u]unstage [a]all [U]unstage all",
2614                        base
2615                    )
2616                }
2617                PanelId::Center => format!(
2618                    "{} · [e]edit [r]regen [p]preset [g]emoji [←→]msg [Enter]commit",
2619                    base
2620                ),
2621                PanelId::Right => {
2622                    format!("{} · [↑↓]scroll [n/p]file [s/u]stage []/[]hunk", base)
2623                }
2624            },
2625            Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => {
2626                match self.state.focused_panel {
2627                    PanelId::Left => format!("{} · [f/t]set refs [r]generate", base),
2628                    PanelId::Center => format!("{} · [↑↓]scroll [y]copy [r]generate", base),
2629                    PanelId::Right => format!("{} · [↑↓]scroll", base),
2630                }
2631            }
2632            Mode::Explore => match self.state.focused_panel {
2633                PanelId::Left => format!("{} · [↑↓]nav [Enter]open", base),
2634                PanelId::Center => {
2635                    format!("{} · [↑↓]nav [v]select [y]copy [Y]copy file [w]why", base)
2636                }
2637                PanelId::Right => format!("{} · [c]chat", base),
2638            },
2639        }
2640    }
2641}
2642
2643// ═══════════════════════════════════════════════════════════════════════════════
2644// Exit Result
2645// ═══════════════════════════════════════════════════════════════════════════════
2646
2647/// Result of running the Studio application
2648#[derive(Debug)]
2649pub enum ExitResult {
2650    /// User quit normally
2651    Quit,
2652    /// User committed changes (with output message)
2653    Committed(String),
2654    /// User amended the previous commit (with output message)
2655    Amended(String),
2656    /// An error occurred
2657    Error(String),
2658}
2659
2660impl Drop for StudioApp {
2661    fn drop(&mut self) {
2662        // Abort all background tasks to prevent hanging on exit
2663        for handle in self.background_tasks.drain(..) {
2664            handle.abort();
2665        }
2666    }
2667}
2668
2669// ═══════════════════════════════════════════════════════════════════════════════
2670// Public Entry Point
2671// ═══════════════════════════════════════════════════════════════════════════════
2672
2673/// Run Iris Studio
2674///
2675/// # Errors
2676///
2677/// Returns an error when Studio initialization or execution fails.
2678pub fn run_studio(
2679    config: Config,
2680    repo: Option<Arc<GitRepo>>,
2681    commit_service: Option<Arc<GitCommitService>>,
2682    agent_service: Option<Arc<IrisAgentService>>,
2683    initial_mode: Option<Mode>,
2684    from_ref: Option<String>,
2685    to_ref: Option<String>,
2686) -> Result<()> {
2687    // Enable file logging for debugging (TUI owns stdout, so logs go to file only)
2688    // Only set up default log file if one wasn't specified via CLI (-l --log-file)
2689    if !crate::logger::has_log_file()
2690        && let Err(e) = crate::logger::set_log_file(crate::cli::LOG_FILE)
2691    {
2692        eprintln!("Warning: Could not set up log file: {}", e);
2693    }
2694    // Disable stdout logging - TUI owns the terminal, but debug goes to file
2695    crate::logger::set_log_to_stdout(false);
2696    tracing::info!("Iris Studio starting");
2697
2698    let mut app = StudioApp::new(config, repo, commit_service, agent_service);
2699
2700    // Set initial mode if specified
2701    if let Some(mode) = initial_mode {
2702        app.set_initial_mode(mode);
2703    }
2704
2705    // Set comparison refs if specified (applies to Review, PR, Changelog, and Release Notes modes)
2706    if let Some(from) = from_ref {
2707        app.state.modes.review.from_ref = from.clone();
2708        app.state.modes.pr.base_branch = from.clone();
2709        app.state.modes.changelog.from_ref = from.clone();
2710        app.state.modes.release_notes.from_ref = from;
2711    }
2712    if let Some(to) = to_ref {
2713        app.state.modes.review.to_ref = to.clone();
2714        app.state.modes.pr.to_ref = to.clone();
2715        app.state.modes.changelog.to_ref = to.clone();
2716        app.state.modes.release_notes.to_ref = to;
2717    }
2718
2719    // Run the app
2720    match app.run()? {
2721        ExitResult::Quit => {
2722            // Silent exit
2723            Ok(())
2724        }
2725        ExitResult::Committed(message) => {
2726            println!("{message}");
2727            Ok(())
2728        }
2729        ExitResult::Amended(message) => {
2730            println!("{message}");
2731            Ok(())
2732        }
2733        ExitResult::Error(error) => Err(anyhow!("{}", error)),
2734    }
2735}