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