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 = StatusMessageGenerator::new(agent.provider(), agent.fast_model());
1426
1427        tokio::spawn(async move {
1428            tracing::info!("Status message starting for task: {}", context.task_type);
1429            let start = std::time::Instant::now();
1430            match tokio::time::timeout(
1431                std::time::Duration::from_millis(2500),
1432                status_gen.generate(&context),
1433            )
1434            .await
1435            {
1436                Ok(msg) => {
1437                    let elapsed = start.elapsed();
1438                    tracing::info!(
1439                        "Status message generated in {:?}: {:?}",
1440                        elapsed,
1441                        msg.message
1442                    );
1443                    if let Err(e) = tx.send(IrisTaskResult::StatusMessage(msg)) {
1444                        tracing::error!("Failed to send status message: {}", e);
1445                    }
1446                }
1447                Err(e) => {
1448                    tracing::warn!(
1449                        "Status message timed out after {:?}: {}",
1450                        start.elapsed(),
1451                        e
1452                    );
1453                }
1454            }
1455        });
1456    }
1457
1458    /// Spawn completion message generation using the fast model
1459    /// This generates a clever completion message based on the content that was just generated.
1460    fn spawn_completion_message(&self, task_type: &str, content_hint: Option<String>) {
1461        use crate::agents::{StatusContext, StatusMessageGenerator};
1462
1463        let Some(agent) = self.agent_service.as_ref() else {
1464            return;
1465        };
1466
1467        let mut context = StatusContext::new(task_type, "completed");
1468
1469        // Add branch if available
1470        if let Some(repo) = &self.state.repo
1471            && let Ok(branch) = repo.get_current_branch()
1472        {
1473            context = context.with_branch(branch);
1474        }
1475
1476        // Add content hint for context
1477        if let Some(hint) = content_hint {
1478            context = context.with_content_hint(hint);
1479        }
1480
1481        let tx = self.iris_result_tx.clone();
1482        let status_gen = StatusMessageGenerator::new(agent.provider(), agent.fast_model());
1483
1484        tokio::spawn(async move {
1485            match tokio::time::timeout(
1486                std::time::Duration::from_millis(2000),
1487                status_gen.generate_completion(&context),
1488            )
1489            .await
1490            {
1491                Ok(msg) => {
1492                    tracing::info!("Completion message generated: {:?}", msg.message);
1493                    let _ = tx.send(IrisTaskResult::CompletionMessage(msg.message));
1494                }
1495                Err(_) => {
1496                    tracing::warn!("Completion message generation timed out");
1497                }
1498            }
1499        });
1500    }
1501
1502    /// Auto-generate commit message on app start
1503    fn auto_generate_commit(&mut self) {
1504        // Don't regenerate if we already have messages
1505        if !self.state.modes.commit.messages.is_empty() {
1506            return;
1507        }
1508
1509        self.state.set_iris_thinking("Analyzing changes...");
1510        self.state.modes.commit.generating = true;
1511        let preset = self.state.modes.commit.preset.clone();
1512        let use_gitmoji = self.state.modes.commit.use_gitmoji;
1513        let amend = self.state.modes.commit.amend_mode;
1514        self.spawn_commit_generation(None, preset, use_gitmoji, amend);
1515    }
1516
1517    /// Auto-generate code review on mode entry
1518    fn auto_generate_review(&mut self) {
1519        // Don't regenerate if we already have content
1520        if !self.state.modes.review.review_content.is_empty() {
1521            return;
1522        }
1523
1524        // Need diffs to review
1525        if self.state.modes.review.diff_view.file_paths().is_empty() {
1526            return;
1527        }
1528
1529        self.state.set_iris_thinking("Reviewing code changes...");
1530        self.state.modes.review.generating = true;
1531        let from_ref = self.state.modes.review.from_ref.clone();
1532        let to_ref = self.state.modes.review.to_ref.clone();
1533        self.spawn_review_generation(from_ref, to_ref);
1534    }
1535
1536    /// Auto-generate PR description on mode entry
1537    fn auto_generate_pr(&mut self) {
1538        // Don't regenerate if we already have content
1539        if !self.state.modes.pr.pr_content.is_empty() {
1540            return;
1541        }
1542
1543        // Need commits to describe
1544        if self.state.modes.pr.commits.is_empty() {
1545            return;
1546        }
1547
1548        self.state.set_iris_thinking("Drafting PR description...");
1549        self.state.modes.pr.generating = true;
1550        let base_branch = self.state.modes.pr.base_branch.clone();
1551        let to_ref = self.state.modes.pr.to_ref.clone();
1552        self.spawn_pr_generation(base_branch, &to_ref);
1553    }
1554
1555    /// Auto-generate changelog on mode entry
1556    fn auto_generate_changelog(&mut self) {
1557        // Don't regenerate if we already have content
1558        if !self.state.modes.changelog.changelog_content.is_empty() {
1559            return;
1560        }
1561
1562        // Need commits to generate from
1563        if self.state.modes.changelog.commits.is_empty() {
1564            return;
1565        }
1566
1567        let from_ref = self.state.modes.changelog.from_ref.clone();
1568        let to_ref = self.state.modes.changelog.to_ref.clone();
1569
1570        self.state.set_iris_thinking("Generating changelog...");
1571        self.state.modes.changelog.generating = true;
1572        self.spawn_changelog_generation(from_ref, to_ref);
1573    }
1574
1575    /// Auto-generate release notes on mode entry
1576    fn auto_generate_release_notes(&mut self) {
1577        // Don't regenerate if we already have content
1578        if !self
1579            .state
1580            .modes
1581            .release_notes
1582            .release_notes_content
1583            .is_empty()
1584        {
1585            return;
1586        }
1587
1588        // Need commits to generate from
1589        if self.state.modes.release_notes.commits.is_empty() {
1590            return;
1591        }
1592
1593        let from_ref = self.state.modes.release_notes.from_ref.clone();
1594        let to_ref = self.state.modes.release_notes.to_ref.clone();
1595
1596        self.state.set_iris_thinking("Generating release notes...");
1597        self.state.modes.release_notes.generating = true;
1598        self.spawn_release_notes_generation(from_ref, to_ref);
1599    }
1600
1601    /// Determine which panel contains the given coordinates
1602    fn panel_at(&self, x: u16, y: u16) -> Option<PanelId> {
1603        let Some(layout) = &self.last_layout else {
1604            return None;
1605        };
1606
1607        for (i, panel_rect) in layout.panels.iter().enumerate() {
1608            if x >= panel_rect.x
1609                && x < panel_rect.x + panel_rect.width
1610                && y >= panel_rect.y
1611                && y < panel_rect.y + panel_rect.height
1612            {
1613                return match i {
1614                    0 => Some(PanelId::Left),
1615                    1 => Some(PanelId::Center),
1616                    2 => Some(PanelId::Right),
1617                    _ => None,
1618                };
1619            }
1620        }
1621        None
1622    }
1623
1624    /// Handle mouse click in panels (file tree, code view, etc.)
1625    fn handle_file_tree_click(&mut self, panel: PanelId, _x: u16, y: u16, is_double_click: bool) {
1626        let Some(layout) = &self.last_layout else {
1627            return;
1628        };
1629
1630        // Get the panel rect
1631        let panel_idx = match panel {
1632            PanelId::Left => 0,
1633            PanelId::Center => 1,
1634            PanelId::Right => 2,
1635        };
1636
1637        let Some(panel_rect) = layout.panels.get(panel_idx) else {
1638            return;
1639        };
1640
1641        // Calculate row within panel (accounting for border and title)
1642        // Panel has 1 row border on each side
1643        let inner_y = y.saturating_sub(panel_rect.y + 1);
1644
1645        // Determine which component to update based on mode and panel
1646        match (self.state.active_mode, panel) {
1647            // ─────────────────────────────────────────────────────────────────
1648            // Explore Mode
1649            // ─────────────────────────────────────────────────────────────────
1650            (Mode::Explore, PanelId::Left) => {
1651                let file_tree = &mut self.state.modes.explore.file_tree;
1652                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1653
1654                if is_double_click && is_dir {
1655                    file_tree.toggle_expand();
1656                } else if is_double_click && !is_dir {
1657                    // Double-click on file: load it and focus code view
1658                    if let Some(path) = file_tree.selected_path() {
1659                        self.state.modes.explore.current_file = Some(path.clone());
1660                        if let Err(e) = self.state.modes.explore.code_view.load_file(&path) {
1661                            self.state.notify(Notification::warning(format!(
1662                                "Could not load file: {}",
1663                                e
1664                            )));
1665                        }
1666                        self.state.focused_panel = PanelId::Center;
1667                    }
1668                } else if changed && !is_dir {
1669                    // Single click on file: load it into code view
1670                    if let Some(path) = file_tree.selected_path() {
1671                        self.state.modes.explore.current_file = Some(path.clone());
1672                        if let Err(e) = self.state.modes.explore.code_view.load_file(&path) {
1673                            self.state.notify(Notification::warning(format!(
1674                                "Could not load file: {}",
1675                                e
1676                            )));
1677                        }
1678                    }
1679                }
1680                self.state.mark_dirty();
1681            }
1682            (Mode::Explore, PanelId::Center) => {
1683                // Code view: click to select line
1684                let code_view = &mut self.state.modes.explore.code_view;
1685                if code_view.select_by_row(inner_y as usize) {
1686                    self.state.mark_dirty();
1687                }
1688            }
1689            // ─────────────────────────────────────────────────────────────────
1690            // Commit Mode
1691            // ─────────────────────────────────────────────────────────────────
1692            (Mode::Commit, PanelId::Left) => {
1693                let file_tree = &mut self.state.modes.commit.file_tree;
1694                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1695
1696                if is_double_click && is_dir {
1697                    file_tree.toggle_expand();
1698                } else if is_double_click && !is_dir {
1699                    // Double-click on file: focus on diff panel
1700                    if let Some(path) = file_tree.selected_path() {
1701                        self.state.modes.commit.diff_view.select_file_by_path(&path);
1702                        self.state.focused_panel = PanelId::Right;
1703                    }
1704                } else if changed {
1705                    // Single click: sync diff view
1706                    if let Some(path) = file_tree.selected_path() {
1707                        self.state.modes.commit.diff_view.select_file_by_path(&path);
1708                    }
1709                }
1710                self.state.mark_dirty();
1711            }
1712            // ─────────────────────────────────────────────────────────────────
1713            // Review Mode
1714            // ─────────────────────────────────────────────────────────────────
1715            (Mode::Review, PanelId::Left) => {
1716                let file_tree = &mut self.state.modes.review.file_tree;
1717                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1718
1719                if is_double_click && is_dir {
1720                    file_tree.toggle_expand();
1721                } else if is_double_click && !is_dir {
1722                    // Double-click on file: focus on diff panel
1723                    if let Some(path) = file_tree.selected_path() {
1724                        self.state.modes.review.diff_view.select_file_by_path(&path);
1725                        self.state.focused_panel = PanelId::Center;
1726                    }
1727                } else if changed {
1728                    // Single click: sync diff view
1729                    if let Some(path) = file_tree.selected_path() {
1730                        self.state.modes.review.diff_view.select_file_by_path(&path);
1731                    }
1732                }
1733                self.state.mark_dirty();
1734            }
1735            // ─────────────────────────────────────────────────────────────────
1736            // PR Mode
1737            // ─────────────────────────────────────────────────────────────────
1738            (Mode::PR, PanelId::Left) => {
1739                let file_tree = &mut self.state.modes.pr.file_tree;
1740                let (changed, is_dir) = file_tree.handle_click(inner_y as usize);
1741
1742                if is_double_click && is_dir {
1743                    file_tree.toggle_expand();
1744                } else if (changed || is_double_click)
1745                    && let Some(path) = file_tree.selected_path()
1746                {
1747                    self.state.modes.pr.diff_view.select_file_by_path(&path);
1748                }
1749                self.state.mark_dirty();
1750            }
1751            _ => {}
1752        }
1753    }
1754
1755    /// Get the line number at a mouse position in the code view (1-indexed)
1756    /// Returns None if not in a code view area
1757    fn code_view_line_at(&self, panel: PanelId, _x: u16, y: u16) -> Option<usize> {
1758        let layout = self.last_layout.as_ref()?;
1759
1760        // Get the panel rect
1761        let panel_idx = match panel {
1762            PanelId::Left => 0,
1763            PanelId::Center => 1,
1764            PanelId::Right => 2,
1765        };
1766
1767        let panel_rect = layout.panels.get(panel_idx)?;
1768
1769        // Calculate row within panel (accounting for border)
1770        let inner_y = y.saturating_sub(panel_rect.y + 1) as usize;
1771
1772        // Only handle code view panels based on mode
1773        match (self.state.active_mode, panel) {
1774            (Mode::Explore, PanelId::Center) => {
1775                let code_view = &self.state.modes.explore.code_view;
1776                let target_line = code_view.scroll_offset() + inner_y + 1;
1777                if target_line <= code_view.line_count() {
1778                    Some(target_line)
1779                } else {
1780                    None
1781                }
1782            }
1783            _ => None,
1784        }
1785    }
1786
1787    /// Update code view selection range (for mouse drag)
1788    fn update_code_selection(&mut self, panel: PanelId, start: usize, end: usize) {
1789        if let (Mode::Explore, PanelId::Center) = (self.state.active_mode, panel) {
1790            // Update code view state
1791            self.state.modes.explore.code_view.set_selected_line(start);
1792            if start == end {
1793                // Single line - set anchor for potential drag extension
1794                self.state.modes.explore.selection_anchor = Some(start);
1795                self.state.modes.explore.code_view.clear_selection();
1796                self.state.modes.explore.selection = None;
1797            } else {
1798                // Multi-line selection from drag
1799                self.state.modes.explore.code_view.set_selection(start, end);
1800                self.state.modes.explore.selection = Some((start, end));
1801            }
1802            // Update current line for semantic blame
1803            self.state.modes.explore.current_line = start;
1804            self.state.mark_dirty();
1805        }
1806    }
1807
1808    fn perform_commit(&mut self, message: &str) -> ExitResult {
1809        if let Some(service) = &self.commit_service {
1810            match service.perform_commit(message) {
1811                Ok(result) => {
1812                    // Record commit in companion
1813                    self.state
1814                        .companion_record_commit(result.commit_hash.clone());
1815
1816                    // Also update branch memory commit count
1817                    self.update_branch_commit_count(&result.branch);
1818
1819                    let output = crate::output::format_commit_result(&result, message);
1820                    ExitResult::Committed(output)
1821                }
1822                Err(e) => ExitResult::Error(e.to_string()),
1823            }
1824        } else {
1825            ExitResult::Error("Commit service not available".to_string())
1826        }
1827    }
1828
1829    fn perform_amend(&mut self, message: &str) -> ExitResult {
1830        if let Some(service) = &self.commit_service {
1831            match service.perform_amend(message) {
1832                Ok(result) => {
1833                    // Record amend in companion (still counts as commit activity)
1834                    self.state
1835                        .companion_record_commit(result.commit_hash.clone());
1836
1837                    let output = crate::output::format_commit_result(&result, message);
1838                    ExitResult::Amended(output)
1839                }
1840                Err(e) => ExitResult::Error(e.to_string()),
1841            }
1842        } else {
1843            ExitResult::Error("Commit service not available".to_string())
1844        }
1845    }
1846
1847    /// Update branch memory commit count
1848    fn update_branch_commit_count(&self, branch: &str) {
1849        if let Some(ref companion) = self.state.companion {
1850            let mut branch_mem = companion
1851                .load_branch_memory(branch)
1852                .ok()
1853                .flatten()
1854                .unwrap_or_else(|| crate::companion::BranchMemory::new(branch.to_string()));
1855
1856            branch_mem.record_commit();
1857
1858            if let Err(e) = companion.save_branch_memory(&branch_mem) {
1859                tracing::warn!("Failed to save branch memory after commit: {}", e);
1860            }
1861        }
1862    }
1863
1864    // ═══════════════════════════════════════════════════════════════════════════
1865    // Rendering
1866    // ═══════════════════════════════════════════════════════════════════════════
1867
1868    fn render(&mut self, frame: &mut Frame) {
1869        let areas = calculate_layout(frame.area(), self.state.active_mode);
1870
1871        self.render_header(frame, areas.header);
1872        self.render_tabs(frame, areas.tabs);
1873        self.render_panels(frame, &areas);
1874
1875        // Render companion status bar for explore mode
1876        if let Some(companion_area) = areas.companion_bar {
1877            render_companion_status_bar(frame, companion_area, &self.state);
1878        }
1879
1880        self.render_status(frame, areas.status);
1881
1882        // Store layout for mouse hit testing
1883        self.last_layout = Some(areas);
1884
1885        // Render modal overlay on top of everything
1886        if self.state.modal.is_some() {
1887            render_modal(&self.state, frame, self.state.last_render);
1888        }
1889    }
1890
1891    fn render_header(&self, frame: &mut Frame, area: Rect) {
1892        let branch = &self.state.git_status.branch;
1893        let staged = self.state.git_status.staged_count;
1894        let modified = self.state.git_status.modified_count;
1895
1896        // Create gradient title "◆ Iris Studio"
1897        let mut spans: Vec<Span> = Vec::new();
1898        spans.push(Span::styled(
1899            " ◆ ",
1900            Style::default().fg(theme::accent_primary()),
1901        ));
1902
1903        // Gradient text for "Iris Studio"
1904        let title_text = "Iris Studio";
1905        #[allow(clippy::cast_precision_loss)]
1906        for (i, c) in title_text.chars().enumerate() {
1907            let position = i as f32 / (title_text.len() - 1).max(1) as f32;
1908            spans.push(Span::styled(
1909                c.to_string(),
1910                Style::default()
1911                    .fg(theme::gradient_purple_cyan(position))
1912                    .add_modifier(Modifier::BOLD),
1913            ));
1914        }
1915
1916        spans.push(Span::raw(" "));
1917
1918        // Branch info with git icon
1919        if !branch.is_empty() {
1920            spans.push(Span::styled(
1921                "⎇ ",
1922                Style::default().fg(theme::text_dim_color()),
1923            ));
1924            spans.push(Span::styled(
1925                format!("{} ", branch),
1926                Style::default()
1927                    .fg(theme::accent_secondary())
1928                    .add_modifier(Modifier::BOLD),
1929            ));
1930        }
1931
1932        // Staged count
1933        if staged > 0 {
1934            spans.push(Span::styled(
1935                format!("✓{} ", staged),
1936                Style::default().fg(theme::success_color()),
1937            ));
1938        }
1939
1940        // Modified count
1941        if modified > 0 {
1942            spans.push(Span::styled(
1943                format!("○{} ", modified),
1944                Style::default().fg(theme::warning_color()),
1945            ));
1946        }
1947
1948        let line = Line::from(spans);
1949        let header = Paragraph::new(line);
1950        frame.render_widget(header, area);
1951    }
1952
1953    fn render_tabs(&self, frame: &mut Frame, area: Rect) {
1954        let mut spans = Vec::new();
1955        spans.push(Span::raw(" "));
1956
1957        for (idx, mode) in Mode::all().iter().enumerate() {
1958            let is_active = *mode == self.state.active_mode;
1959            let is_available = mode.is_available();
1960
1961            if is_active {
1962                // Active tab with gradient underline effect
1963                spans.push(Span::styled(
1964                    format!(" {} ", mode.shortcut()),
1965                    Style::default()
1966                        .fg(theme::accent_primary())
1967                        .add_modifier(Modifier::BOLD),
1968                ));
1969                // Mode name with gradient
1970                let name = mode.display_name();
1971                #[allow(clippy::cast_precision_loss)]
1972                for (i, c) in name.chars().enumerate() {
1973                    let position = i as f32 / (name.len() - 1).max(1) as f32;
1974                    spans.push(Span::styled(
1975                        c.to_string(),
1976                        Style::default()
1977                            .fg(theme::gradient_purple_cyan(position))
1978                            .add_modifier(Modifier::BOLD),
1979                    ));
1980                }
1981                spans.push(Span::raw(" "));
1982                // Underline with gradient
1983                spans.push(Span::styled(
1984                    "━",
1985                    Style::default().fg(theme::accent_primary()),
1986                ));
1987                spans.push(Span::styled(
1988                    "━",
1989                    Style::default().fg(theme::gradient_purple_cyan(0.5)),
1990                ));
1991                spans.push(Span::styled(
1992                    "━",
1993                    Style::default().fg(theme::accent_secondary()),
1994                ));
1995            } else if is_available {
1996                spans.push(Span::styled(
1997                    format!(" {} ", mode.shortcut()),
1998                    Style::default().fg(theme::text_muted_color()),
1999                ));
2000                spans.push(Span::styled(
2001                    mode.display_name().to_string(),
2002                    theme::mode_inactive(),
2003                ));
2004            } else {
2005                spans.push(Span::styled(
2006                    format!(" {} {} ", mode.shortcut(), mode.display_name()),
2007                    Style::default().fg(theme::text_muted_color()),
2008                ));
2009            }
2010
2011            // Separator between tabs
2012            if idx < Mode::all().len() - 1 {
2013                spans.push(Span::styled(
2014                    " │ ",
2015                    Style::default().fg(theme::text_muted_color()),
2016                ));
2017            }
2018        }
2019
2020        let tabs = Paragraph::new(Line::from(spans));
2021        frame.render_widget(tabs, area);
2022    }
2023
2024    fn render_panels(&mut self, frame: &mut Frame, areas: &LayoutAreas) {
2025        let layout = get_mode_layout(self.state.active_mode);
2026        let panel_ids: Vec<_> = layout.panels.iter().map(|c| c.id).collect();
2027        let panel_areas: Vec<_> = areas.panels.clone();
2028
2029        for (i, panel_area) in panel_areas.iter().enumerate() {
2030            if let Some(&panel_id) = panel_ids.get(i) {
2031                self.render_panel_content(frame, *panel_area, panel_id);
2032            }
2033        }
2034    }
2035
2036    fn render_panel_content(&mut self, frame: &mut Frame, area: Rect, panel_id: PanelId) {
2037        match self.state.active_mode {
2038            Mode::Explore => render_explore_panel(&mut self.state, frame, area, panel_id),
2039            Mode::Commit => render_commit_panel(&mut self.state, frame, area, panel_id),
2040            Mode::Review => render_review_panel(&mut self.state, frame, area, panel_id),
2041            Mode::PR => render_pr_panel(&mut self.state, frame, area, panel_id),
2042            Mode::Changelog => render_changelog_panel(&mut self.state, frame, area, panel_id),
2043            Mode::ReleaseNotes => {
2044                render_release_notes_panel(&mut self.state, frame, area, panel_id);
2045            }
2046        }
2047    }
2048
2049    /// Update commit mode file tree from git status
2050    /// Shows either changed files (staged/unstaged) or all tracked files based on toggle
2051    fn update_commit_file_tree(&mut self) {
2052        let mut statuses = Vec::new();
2053
2054        // Build status map for known changed files
2055        for path in &self.state.git_status.staged_files {
2056            statuses.push((path.clone(), FileGitStatus::Staged));
2057        }
2058        for path in &self.state.git_status.modified_files {
2059            if !self.state.git_status.staged_files.contains(path) {
2060                statuses.push((path.clone(), FileGitStatus::Modified));
2061            }
2062        }
2063        for path in &self.state.git_status.untracked_files {
2064            statuses.push((path.clone(), FileGitStatus::Untracked));
2065        }
2066
2067        let all_files: Vec<std::path::PathBuf> = if self.state.modes.commit.show_all_files {
2068            // Show all tracked files from the repository
2069            let Some(repo) = &self.state.repo else {
2070                return;
2071            };
2072            match repo.get_all_tracked_files() {
2073                Ok(files) => files.into_iter().map(std::path::PathBuf::from).collect(),
2074                Err(e) => {
2075                    eprintln!("Failed to get tracked files: {}", e);
2076                    return;
2077                }
2078            }
2079        } else {
2080            // Show only changed files (staged + modified + untracked)
2081            let mut files = Vec::new();
2082            for path in &self.state.git_status.staged_files {
2083                files.push(path.clone());
2084            }
2085            for path in &self.state.git_status.modified_files {
2086                if !files.contains(path) {
2087                    files.push(path.clone());
2088                }
2089            }
2090            for path in &self.state.git_status.untracked_files {
2091                if !files.contains(path) {
2092                    files.push(path.clone());
2093                }
2094            }
2095            files
2096        };
2097
2098        let tree_state = super::components::FileTreeState::from_paths(&all_files, &statuses);
2099        self.state.modes.commit.file_tree = tree_state;
2100
2101        // Expand all by default (usually not too many files)
2102        self.state.modes.commit.file_tree.expand_all();
2103    }
2104
2105    /// Update review mode file tree from git status (staged + modified)
2106    fn update_review_file_tree(&mut self) {
2107        let mut all_files = Vec::new();
2108        let mut statuses = Vec::new();
2109
2110        // Include both staged and modified files for review
2111        for path in &self.state.git_status.staged_files {
2112            all_files.push(path.clone());
2113            statuses.push((path.clone(), FileGitStatus::Staged));
2114        }
2115        for path in &self.state.git_status.modified_files {
2116            if !all_files.contains(path) {
2117                all_files.push(path.clone());
2118                statuses.push((path.clone(), FileGitStatus::Modified));
2119            }
2120        }
2121
2122        let tree_state = super::components::FileTreeState::from_paths(&all_files, &statuses);
2123        self.state.modes.review.file_tree = tree_state;
2124        self.state.modes.review.file_tree.expand_all();
2125
2126        // Also load diffs for review mode
2127        self.load_review_diffs();
2128    }
2129
2130    /// Load diffs into review mode diff view
2131    fn load_review_diffs(&mut self) {
2132        let Some(repo) = &self.state.repo else { return };
2133
2134        // Get staged diff first, then unstaged
2135        if let Ok(diff_text) = repo.get_staged_diff_full() {
2136            let diffs = parse_diff(&diff_text);
2137            self.state.modes.review.diff_view.set_diffs(diffs);
2138        }
2139
2140        // Sync initial file selection
2141        if let Some(path) = self.state.modes.review.file_tree.selected_path() {
2142            self.state.modes.review.diff_view.select_file_by_path(&path);
2143        }
2144    }
2145
2146    /// Update PR mode data - load commits and diff between refs
2147    pub fn update_pr_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2148        use super::state::PrCommit;
2149
2150        // Clone the Arc to avoid borrow conflicts with self.state mutations
2151        let Some(repo) = self.state.repo.clone() else {
2152            return;
2153        };
2154
2155        // Use provided refs or fall back to state
2156        let base = from_ref.unwrap_or_else(|| self.state.modes.pr.base_branch.clone());
2157        let to = to_ref.unwrap_or_else(|| self.state.modes.pr.to_ref.clone());
2158
2159        // Load commits between the refs
2160        match repo.get_commits_between_with_callback(&base, &to, |commit| {
2161            Ok(PrCommit {
2162                hash: commit.hash[..7.min(commit.hash.len())].to_string(),
2163                message: commit.message.lines().next().unwrap_or("").to_string(),
2164                author: commit.author.clone(),
2165            })
2166        }) {
2167            Ok(commits) => {
2168                self.state.modes.pr.commits = commits;
2169                self.state.modes.pr.selected_commit = 0;
2170                self.state.modes.pr.commit_scroll = 0;
2171            }
2172            Err(e) => {
2173                self.state.notify(Notification::warning(format!(
2174                    "Could not load commits: {}",
2175                    e
2176                )));
2177            }
2178        }
2179
2180        // Load diff between the refs
2181        match repo.get_ref_diff_full(&base, &to) {
2182            Ok(diff_text) => {
2183                let diffs = parse_diff(&diff_text);
2184                self.state.modes.pr.diff_view.set_diffs(diffs);
2185            }
2186            Err(e) => {
2187                self.state
2188                    .notify(Notification::warning(format!("Could not load diff: {}", e)));
2189            }
2190        }
2191
2192        self.state.mark_dirty();
2193    }
2194
2195    /// Update Review mode data - load diff between `from_ref` and `to_ref`
2196    pub fn update_review_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2197        // Clone the Arc to avoid borrow conflicts with self.state mutations
2198        let Some(repo) = self.state.repo.clone() else {
2199            return;
2200        };
2201
2202        // Use provided refs or fall back to state
2203        let from = from_ref.unwrap_or_else(|| self.state.modes.review.from_ref.clone());
2204        let to = to_ref.unwrap_or_else(|| self.state.modes.review.to_ref.clone());
2205
2206        // Load diff between the refs
2207        match repo.get_ref_diff_full(&from, &to) {
2208            Ok(diff_text) => {
2209                let diffs = parse_diff(&diff_text);
2210                self.state.modes.review.diff_view.set_diffs(diffs.clone());
2211
2212                // Also update file tree from the diff files
2213                let files: Vec<std::path::PathBuf> = diffs
2214                    .iter()
2215                    .map(|d| std::path::PathBuf::from(&d.path))
2216                    .collect();
2217                let statuses: Vec<_> = files
2218                    .iter()
2219                    .map(|p| (p.clone(), FileGitStatus::Modified))
2220                    .collect();
2221                let tree_state = super::components::FileTreeState::from_paths(&files, &statuses);
2222                self.state.modes.review.file_tree = tree_state;
2223                self.state.modes.review.file_tree.expand_all();
2224            }
2225            Err(e) => {
2226                self.state
2227                    .notify(Notification::warning(format!("Could not load diff: {}", e)));
2228            }
2229        }
2230
2231        self.state.mark_dirty();
2232    }
2233
2234    /// Update Changelog mode data - load commits and diff between `from_ref` and `to_ref`
2235    pub fn update_changelog_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2236        use super::state::ChangelogCommit;
2237
2238        // Clone the Arc to avoid borrow conflicts with self.state mutations
2239        let Some(repo) = self.state.repo.clone() else {
2240            return;
2241        };
2242
2243        // Use provided refs or fall back to state
2244        let from = from_ref.unwrap_or_else(|| self.state.modes.changelog.from_ref.clone());
2245        let to = to_ref.unwrap_or_else(|| self.state.modes.changelog.to_ref.clone());
2246
2247        // Load commits between the refs
2248        match repo.get_commits_between_with_callback(&from, &to, |commit| {
2249            Ok(ChangelogCommit {
2250                hash: commit.hash[..7.min(commit.hash.len())].to_string(),
2251                message: commit.message.lines().next().unwrap_or("").to_string(),
2252                author: commit.author.clone(),
2253            })
2254        }) {
2255            Ok(commits) => {
2256                self.state.modes.changelog.commits = commits;
2257                self.state.modes.changelog.selected_commit = 0;
2258                self.state.modes.changelog.commit_scroll = 0;
2259            }
2260            Err(e) => {
2261                self.state.notify(Notification::warning(format!(
2262                    "Could not load commits: {}",
2263                    e
2264                )));
2265            }
2266        }
2267
2268        // Load diff between the refs
2269        match repo.get_ref_diff_full(&from, &to) {
2270            Ok(diff_text) => {
2271                let diffs = parse_diff(&diff_text);
2272                self.state.modes.changelog.diff_view.set_diffs(diffs);
2273            }
2274            Err(e) => {
2275                self.state
2276                    .notify(Notification::warning(format!("Could not load diff: {}", e)));
2277            }
2278        }
2279
2280        self.state.mark_dirty();
2281    }
2282
2283    /// Update release notes mode data when refs change
2284    pub fn update_release_notes_data(&mut self, from_ref: Option<String>, to_ref: Option<String>) {
2285        use super::state::ChangelogCommit;
2286
2287        // Clone the Arc to avoid borrow conflicts with self.state mutations
2288        let Some(repo) = self.state.repo.clone() else {
2289            return;
2290        };
2291
2292        // Use provided refs or fall back to state
2293        let from = from_ref.unwrap_or_else(|| self.state.modes.release_notes.from_ref.clone());
2294        let to = to_ref.unwrap_or_else(|| self.state.modes.release_notes.to_ref.clone());
2295
2296        // Load commits between the refs
2297        match repo.get_commits_between_with_callback(&from, &to, |commit| {
2298            Ok(ChangelogCommit {
2299                hash: commit.hash[..7.min(commit.hash.len())].to_string(),
2300                message: commit.message.lines().next().unwrap_or("").to_string(),
2301                author: commit.author.clone(),
2302            })
2303        }) {
2304            Ok(commits) => {
2305                self.state.modes.release_notes.commits = commits;
2306                self.state.modes.release_notes.selected_commit = 0;
2307                self.state.modes.release_notes.commit_scroll = 0;
2308            }
2309            Err(e) => {
2310                self.state.notify(Notification::warning(format!(
2311                    "Could not load commits: {}",
2312                    e
2313                )));
2314            }
2315        }
2316
2317        // Load diff between the refs
2318        match repo.get_ref_diff_full(&from, &to) {
2319            Ok(diff_text) => {
2320                let diffs = parse_diff(&diff_text);
2321                self.state.modes.release_notes.diff_view.set_diffs(diffs);
2322            }
2323            Err(e) => {
2324                self.state
2325                    .notify(Notification::warning(format!("Could not load diff: {}", e)));
2326            }
2327        }
2328
2329        self.state.mark_dirty();
2330    }
2331
2332    // ═══════════════════════════════════════════════════════════════════════════
2333    // Staging Operations
2334    // ═══════════════════════════════════════════════════════════════════════════
2335
2336    /// Stage a single file
2337    fn stage_file(&mut self, path: &str) {
2338        let Some(repo) = &self.state.repo else {
2339            self.state
2340                .notify(Notification::error("No repository available"));
2341            return;
2342        };
2343
2344        match repo.stage_file(std::path::Path::new(path)) {
2345            Ok(()) => {
2346                // Track in companion
2347                self.state
2348                    .companion_touch_file(std::path::PathBuf::from(path));
2349                self.state
2350                    .notify(Notification::success(format!("Staged: {}", path)));
2351                let _ = self.refresh_git_status();
2352                self.state.update_companion_display();
2353            }
2354            Err(e) => {
2355                self.state
2356                    .notify(Notification::error(format!("Failed to stage: {}", e)));
2357            }
2358        }
2359        self.state.mark_dirty();
2360    }
2361
2362    /// Unstage a single file
2363    fn unstage_file(&mut self, path: &str) {
2364        let Some(repo) = &self.state.repo else {
2365            self.state
2366                .notify(Notification::error("No repository available"));
2367            return;
2368        };
2369
2370        match repo.unstage_file(std::path::Path::new(path)) {
2371            Ok(()) => {
2372                // Track in companion
2373                self.state
2374                    .companion_touch_file(std::path::PathBuf::from(path));
2375                self.state
2376                    .notify(Notification::success(format!("Unstaged: {}", path)));
2377                let _ = self.refresh_git_status();
2378                self.state.update_companion_display();
2379            }
2380            Err(e) => {
2381                self.state
2382                    .notify(Notification::error(format!("Failed to unstage: {}", e)));
2383            }
2384        }
2385        self.state.mark_dirty();
2386    }
2387
2388    /// Stage all files
2389    fn stage_all(&mut self) {
2390        let Some(repo) = &self.state.repo else {
2391            self.state
2392                .notify(Notification::error("No repository available"));
2393            return;
2394        };
2395
2396        // Track all modified files before staging
2397        let files_to_track: Vec<_> = self
2398            .state
2399            .git_status
2400            .modified_files
2401            .iter()
2402            .cloned()
2403            .chain(self.state.git_status.untracked_files.iter().cloned())
2404            .collect();
2405
2406        match repo.stage_all() {
2407            Ok(()) => {
2408                // Track all in companion
2409                for path in files_to_track {
2410                    self.state.companion_touch_file(path);
2411                }
2412                self.state.notify(Notification::success("Staged all files"));
2413                let _ = self.refresh_git_status();
2414                self.state.update_companion_display();
2415            }
2416            Err(e) => {
2417                self.state
2418                    .notify(Notification::error(format!("Failed to stage all: {}", e)));
2419            }
2420        }
2421        self.state.mark_dirty();
2422    }
2423
2424    /// Unstage all files
2425    fn unstage_all(&mut self) {
2426        let Some(repo) = &self.state.repo else {
2427            self.state
2428                .notify(Notification::error("No repository available"));
2429            return;
2430        };
2431
2432        // Track all staged files before unstaging
2433        let files_to_track: Vec<_> = self.state.git_status.staged_files.clone();
2434
2435        match repo.unstage_all() {
2436            Ok(()) => {
2437                // Track all in companion
2438                for path in files_to_track {
2439                    self.state.companion_touch_file(path);
2440                }
2441                self.state
2442                    .notify(Notification::success("Unstaged all files"));
2443                let _ = self.refresh_git_status();
2444                self.state.update_companion_display();
2445            }
2446            Err(e) => {
2447                self.state
2448                    .notify(Notification::error(format!("Failed to unstage all: {}", e)));
2449            }
2450        }
2451        self.state.mark_dirty();
2452    }
2453
2454    /// Save settings from the settings modal to config file
2455    fn save_settings(&mut self) {
2456        use crate::studio::state::Modal;
2457
2458        let settings = if let Some(Modal::Settings(s)) = &self.state.modal {
2459            s.clone()
2460        } else {
2461            return;
2462        };
2463
2464        if !settings.modified {
2465            self.state.notify(Notification::info("No changes to save"));
2466            return;
2467        }
2468
2469        // Update config
2470        let mut config = self.state.config.clone();
2471        config.default_provider.clone_from(&settings.provider);
2472        config.use_gitmoji = settings.use_gitmoji;
2473        config
2474            .instruction_preset
2475            .clone_from(&settings.instruction_preset);
2476        config
2477            .instructions
2478            .clone_from(&settings.custom_instructions);
2479        config.theme.clone_from(&settings.theme);
2480
2481        // Update provider config
2482        if let Some(provider_config) = config.providers.get_mut(&settings.provider) {
2483            provider_config.model.clone_from(&settings.model);
2484            if let Some(api_key) = &settings.api_key_actual {
2485                provider_config.api_key.clone_from(api_key);
2486            }
2487        }
2488
2489        // Save to file
2490        match config.save() {
2491            Ok(()) => {
2492                self.state.config = config;
2493                // Clear the modified flag
2494                if let Some(Modal::Settings(s)) = &mut self.state.modal {
2495                    s.modified = false;
2496                    s.error = None;
2497                }
2498                self.state.notify(Notification::success("Settings saved"));
2499            }
2500            Err(e) => {
2501                if let Some(Modal::Settings(s)) = &mut self.state.modal {
2502                    s.error = Some(format!("Save failed: {}", e));
2503                }
2504                self.state
2505                    .notify(Notification::error(format!("Failed to save: {}", e)));
2506            }
2507        }
2508        self.state.mark_dirty();
2509    }
2510
2511    fn render_status(&self, frame: &mut Frame, area: Rect) {
2512        let mut spans = Vec::new();
2513
2514        // Show notification if any
2515        if let Some(notification) = self.state.current_notification() {
2516            let style = match notification.level {
2517                super::state::NotificationLevel::Info => theme::dimmed(),
2518                super::state::NotificationLevel::Success => theme::success(),
2519                super::state::NotificationLevel::Warning => theme::warning(),
2520                super::state::NotificationLevel::Error => theme::error(),
2521            };
2522            spans.push(Span::styled(&notification.message, style));
2523        } else {
2524            // Context-aware keybinding hints based on mode and panel
2525            let hints = self.get_context_hints();
2526            spans.push(Span::styled(hints, theme::dimmed()));
2527        }
2528
2529        // Right-align Iris status
2530        let iris_status = match &self.state.iris_status {
2531            IrisStatus::Idle => Span::styled("Iris: ready", theme::dimmed()),
2532            IrisStatus::Thinking { task, .. } => {
2533                let spinner = self.state.iris_status.spinner_char().unwrap_or('◎');
2534                Span::styled(
2535                    format!("{} {}", spinner, task),
2536                    Style::default().fg(theme::accent_secondary()),
2537                )
2538            }
2539            IrisStatus::Complete { message, .. } => {
2540                Span::styled(message.clone(), Style::default().fg(theme::success_color()))
2541            }
2542            IrisStatus::Error(msg) => Span::styled(format!("Error: {}", msg), theme::error()),
2543        };
2544
2545        // Calculate spacing (use saturating_sub to avoid overflow on narrow terminals)
2546        let left_len: usize = spans.iter().map(|s| s.content.len()).sum();
2547        let right_len = iris_status.content.len();
2548        let padding = (area.width as usize)
2549            .saturating_sub(left_len)
2550            .saturating_sub(right_len)
2551            .saturating_sub(2);
2552        let padding_str = " ".repeat(padding.max(1));
2553
2554        spans.push(Span::raw(padding_str));
2555        spans.push(iris_status);
2556
2557        let status = Paragraph::new(Line::from(spans));
2558        frame.render_widget(status, area);
2559    }
2560
2561    /// Get context-aware keybinding hints based on mode and focused panel
2562    fn get_context_hints(&self) -> String {
2563        let base = "[?]help [Tab]panel [q]quit";
2564
2565        match self.state.active_mode {
2566            Mode::Commit => match self.state.focused_panel {
2567                PanelId::Left => format!("{} · [↑↓]nav [s]stage [u]unstage [a]all [U]reset", base),
2568                PanelId::Center => format!(
2569                    "{} · [e]edit [r]regen [p]preset [g]emoji [←→]msg [Enter]commit",
2570                    base
2571                ),
2572                PanelId::Right => format!("{} · [↑↓]scroll [n/p]file []/[]hunk", base),
2573            },
2574            Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => {
2575                match self.state.focused_panel {
2576                    PanelId::Left => format!("{} · [f/t]set refs [r]generate", base),
2577                    PanelId::Center => format!("{} · [↑↓]scroll [y]copy [r]generate", base),
2578                    PanelId::Right => format!("{} · [↑↓]scroll", base),
2579                }
2580            }
2581            Mode::Explore => match self.state.focused_panel {
2582                PanelId::Left => format!("{} · [↑↓]nav [Enter]open", base),
2583                PanelId::Center => {
2584                    format!("{} · [↑↓]nav [v]select [y]copy [Y]copy file [w]why", base)
2585                }
2586                PanelId::Right => format!("{} · [c]chat", base),
2587            },
2588        }
2589    }
2590}
2591
2592// ═══════════════════════════════════════════════════════════════════════════════
2593// Exit Result
2594// ═══════════════════════════════════════════════════════════════════════════════
2595
2596/// Result of running the Studio application
2597#[derive(Debug)]
2598pub enum ExitResult {
2599    /// User quit normally
2600    Quit,
2601    /// User committed changes (with output message)
2602    Committed(String),
2603    /// User amended the previous commit (with output message)
2604    Amended(String),
2605    /// An error occurred
2606    Error(String),
2607}
2608
2609impl Drop for StudioApp {
2610    fn drop(&mut self) {
2611        // Abort all background tasks to prevent hanging on exit
2612        for handle in self.background_tasks.drain(..) {
2613            handle.abort();
2614        }
2615    }
2616}
2617
2618// ═══════════════════════════════════════════════════════════════════════════════
2619// Public Entry Point
2620// ═══════════════════════════════════════════════════════════════════════════════
2621
2622/// Run Iris Studio
2623pub fn run_studio(
2624    config: Config,
2625    repo: Option<Arc<GitRepo>>,
2626    commit_service: Option<Arc<GitCommitService>>,
2627    agent_service: Option<Arc<IrisAgentService>>,
2628    initial_mode: Option<Mode>,
2629    from_ref: Option<String>,
2630    to_ref: Option<String>,
2631) -> Result<()> {
2632    // Enable file logging for debugging (TUI owns stdout, so logs go to file only)
2633    // Only set up default log file if one wasn't specified via CLI (-l --log-file)
2634    if !crate::logger::has_log_file()
2635        && let Err(e) = crate::logger::set_log_file(crate::cli::LOG_FILE)
2636    {
2637        eprintln!("Warning: Could not set up log file: {}", e);
2638    }
2639    // Disable stdout logging - TUI owns the terminal, but debug goes to file
2640    crate::logger::set_log_to_stdout(false);
2641    tracing::info!("Iris Studio starting");
2642
2643    let mut app = StudioApp::new(config, repo, commit_service, agent_service);
2644
2645    // Set initial mode if specified
2646    if let Some(mode) = initial_mode {
2647        app.set_initial_mode(mode);
2648    }
2649
2650    // Set comparison refs if specified (applies to Review, PR, Changelog, and Release Notes modes)
2651    if let Some(from) = from_ref {
2652        app.state.modes.review.from_ref = from.clone();
2653        app.state.modes.pr.base_branch = from.clone();
2654        app.state.modes.changelog.from_ref = from.clone();
2655        app.state.modes.release_notes.from_ref = from;
2656    }
2657    if let Some(to) = to_ref {
2658        app.state.modes.review.to_ref = to.clone();
2659        app.state.modes.pr.to_ref = to.clone();
2660        app.state.modes.changelog.to_ref = to.clone();
2661        app.state.modes.release_notes.to_ref = to;
2662    }
2663
2664    // Run the app
2665    match app.run()? {
2666        ExitResult::Quit => {
2667            // Silent exit
2668            Ok(())
2669        }
2670        ExitResult::Committed(message) => {
2671            println!("{message}");
2672            Ok(())
2673        }
2674        ExitResult::Amended(message) => {
2675            println!("{message}");
2676            Ok(())
2677        }
2678        ExitResult::Error(error) => Err(anyhow!("{}", error)),
2679    }
2680}