Skip to main content

git_iris/studio/app/
mod.rs

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