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