1mod 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
49pub enum IrisTaskResult {
55 CommitMessages(Vec<GeneratedMessage>),
57 ReviewContent(String),
59 PRContent(String),
61 ChangelogContent(String),
63 ReleaseNotesContent(String),
65 ChatResponse(String),
67 ChatUpdate(ChatUpdateType),
69 ToolStatus { tool_name: String, message: String },
71 StreamingChunk {
73 task_type: TaskType,
74 chunk: String,
75 aggregated: String,
76 },
77 StreamingComplete { task_type: TaskType },
79 SemanticBlame(SemanticBlameResult),
81 StatusMessage(crate::agents::StatusMessage),
83 CompletionMessage(String),
85 Error { task_type: TaskType, error: String },
87 FileLogLoaded {
89 file: std::path::PathBuf,
90 entries: Vec<crate::studio::state::FileLogEntry>,
91 },
92 GlobalLogLoaded {
94 entries: Vec<crate::studio::state::FileLogEntry>,
95 },
96 GitStatusLoaded(Box<GitStatusData>),
98 CompanionReady(Box<CompanionInitData>),
100}
101
102#[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
114pub struct CompanionInitData {
116 pub service: crate::companion::CompanionService,
118 pub display: super::state::CompanionSessionDisplay,
120}
121
122#[derive(Debug, Clone)]
124pub enum ChatUpdateType {
125 CommitMessage(GeneratedMessage),
127 PRDescription(String),
129 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
171pub struct StudioApp {
177 pub state: StudioState,
179 pub history: History,
181 event_queue: VecDeque<StudioEvent>,
183 commit_service: Option<Arc<GitCommitService>>,
185 agent_service: Option<Arc<IrisAgentService>>,
187 iris_result_rx: mpsc::UnboundedReceiver<IrisTaskResult>,
189 iris_result_tx: mpsc::UnboundedSender<IrisTaskResult>,
191 last_layout: Option<LayoutAreas>,
193 explicit_mode_set: bool,
195 last_click: Option<(std::time::Instant, u16, u16)>,
197 drag_start: Option<(PanelId, usize)>,
199 background_tasks: Vec<tokio::task::JoinHandle<()>>,
201}
202
203impl StudioApp {
204 #[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 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 pub fn set_initial_mode(&mut self, mode: Mode) {
242 self.state.switch_mode(mode);
243 self.explicit_mode_set = true;
244 }
245
246 fn push_event(&mut self, event: StudioEvent) {
248 self.event_queue.push_back(event);
249 }
250
251 fn process_events(&mut self) -> Option<ExitResult> {
253 while let Some(event) = self.event_queue.pop_front() {
254 let effects = reduce(&mut self.state, event, &mut self.history);
256
257 if let Some(result) = self.execute_effects(effects) {
259 return Some(result);
260 }
261 }
262 None
263 }
264
265 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 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 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 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 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 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 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 self.update_commit_file_tree(preferred_commit_path.as_deref());
480 self.update_review_file_tree();
481
482 self.load_staged_diffs(files_info.as_ref());
484
485 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 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 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 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 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 fn update_explore_file_tree(&mut self) {
546 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 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 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 if let Err(e) = self.state.modes.explore.code_view.load_file(&path) {
580 tracing::warn!("Failed to load initial file: {}", e);
581 }
582 self.state.modes.explore.pending_file_log = Some(path);
584 }
585 }
586 }
587
588 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 let relative_path = file
607 .strip_prefix(&repo_path)
608 .unwrap_or(&file)
609 .to_string_lossy()
610 .to_string();
611
612 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", "--",
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 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 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 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 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 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", ])
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 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 if let Some(ref mut entry) = current_entry {
750 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 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 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 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 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 let service = CompanionService::new(repo_path, &branch)?;
877
878 let mut branch_mem = service
880 .load_branch_memory(&branch)
881 .ok()
882 .flatten()
883 .unwrap_or_else(|| BranchMemory::new(branch.clone()));
884
885 let welcome = branch_mem.welcome_message();
887
888 branch_mem.record_visit();
890
891 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 self.background_tasks.push(handle);
922 }
923
924 pub fn run(&mut self) -> Result<ExitResult> {
930 let original_hook = std::panic::take_hook();
932 std::panic::set_hook(Box::new(move |panic_info| {
933 let _ = disable_raw_mode();
935 let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
936 eprintln!("\n\n=== PANIC ===\n{}\n", panic_info);
938 original_hook(panic_info);
939 }));
940
941 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 let result = self.main_loop(&mut terminal);
950
951 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 self.state.git_status_loading = true;
969 self.load_git_status_async();
970
971 self.load_companion_async();
973
974 loop {
977 if let Some(path) = self.state.modes.explore.pending_file_log.take() {
979 self.push_event(StudioEvent::FileLogLoading(path));
980 }
981
982 self.check_iris_results();
984
985 self.check_companion_events();
987
988 if let Some(result) = self.process_events() {
990 return Ok(result);
991 }
992
993 if self.state.check_dirty() {
995 terminal.draw(|frame| self.render(frame))?;
996 }
997
998 if event::poll(Duration::from_millis(50))? {
1000 match event::read()? {
1001 Event::Key(key) if key.kind == KeyEventKind::Press => {
1002 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 if let Some(panel) = self.panel_at(mouse.column, mouse.row) {
1018 if self.state.focused_panel != panel {
1020 self.state.focused_panel = panel;
1021 self.state.mark_dirty();
1022 }
1023
1024 if let Some(line) =
1026 self.code_view_line_at(panel, mouse.column, mouse.row)
1027 {
1028 self.drag_start = Some((panel, line));
1029 self.update_code_selection(panel, line, line);
1031 } else {
1032 self.drag_start = None;
1033 }
1034
1035 self.handle_file_tree_click(
1037 panel,
1038 mouse.column,
1039 mouse.row,
1040 is_double_click,
1041 );
1042 }
1043
1044 self.last_click = Some((now, mouse.column, mouse.row));
1046 }
1047 MouseEventKind::Drag(MouseButton::Left) => {
1048 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 self.drag_start = None;
1066 }
1067 _ => {}
1068 }
1069 self.push_event(StudioEvent::Mouse(mouse));
1071 }
1072 Event::Resize(_, _) => {
1073 self.state.mark_dirty();
1075 }
1076 _ => {}
1077 }
1078 }
1079
1080 self.push_event(StudioEvent::Tick);
1082 }
1083 }
1084
1085 fn check_companion_events(&mut self) {
1088 use crate::companion::CompanionEvent;
1089
1090 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 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 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 fn apply_git_status_data(&mut self, data: GitStatusData) {
1254 use super::components::diff_view::parse_diff;
1255
1256 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 self.update_commit_file_tree(preferred_commit_path.as_deref());
1274 self.update_review_file_tree();
1275
1276 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 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 !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 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 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 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 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 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 fn auto_generate_commit(&mut self) {
1562 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 fn auto_generate_review(&mut self) {
1577 if !self.state.modes.review.review_content.is_empty() {
1579 return;
1580 }
1581
1582 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 fn auto_generate_pr(&mut self) {
1596 if !self.state.modes.pr.pr_content.is_empty() {
1598 return;
1599 }
1600
1601 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 fn auto_generate_changelog(&mut self) {
1615 if !self.state.modes.changelog.changelog_content.is_empty() {
1617 return;
1618 }
1619
1620 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 fn auto_generate_release_notes(&mut self) {
1635 if !self
1637 .state
1638 .modes
1639 .release_notes
1640 .release_notes_content
1641 .is_empty()
1642 {
1643 return;
1644 }
1645
1646 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 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 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 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 let inner_y = y.saturating_sub(panel_rect.y + 1);
1702
1703 match (self.state.active_mode, panel) {
1705 (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 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 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 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 (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 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 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 (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 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 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 (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 fn code_view_line_at(&self, panel: PanelId, _x: u16, y: u16) -> Option<usize> {
1816 let layout = self.last_layout.as_ref()?;
1817
1818 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 let inner_y = y.saturating_sub(panel_rect.y + 1) as usize;
1829
1830 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 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 self.state.modes.explore.code_view.set_selected_line(start);
1850 if start == end {
1851 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 self.state.modes.explore.code_view.set_selection(start, end);
1858 self.state.modes.explore.selection = Some((start, end));
1859 }
1860 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 self.state
1872 .companion_record_commit(result.commit_hash.clone());
1873
1874 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 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 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 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 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 self.last_layout = Some(areas);
1942
1943 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 let mut spans: Vec<Span> = Vec::new();
1956 spans.push(Span::styled(
1957 " ◆ ",
1958 Style::default().fg(theme::accent_primary()),
1959 ));
1960
1961 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 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 if staged > 0 {
1992 spans.push(Span::styled(
1993 format!("✓{} ", staged),
1994 Style::default().fg(theme::success_color()),
1995 ));
1996 }
1997
1998 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 spans.push(Span::styled(
2022 format!(" {} ", mode.shortcut()),
2023 Style::default()
2024 .fg(theme::accent_primary())
2025 .add_modifier(Modifier::BOLD),
2026 ));
2027 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 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 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 fn update_commit_file_tree(&mut self, preferred_path: Option<&std::path::Path>) {
2120 let mut statuses = Vec::new();
2121
2122 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 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 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 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 fn update_review_file_tree(&mut self) {
2180 let mut all_files = Vec::new();
2181 let mut statuses = Vec::new();
2182
2183 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 self.load_review_diffs();
2201 }
2202
2203 fn load_review_diffs(&mut self) {
2205 let Some(repo) = &self.state.repo else { return };
2206
2207 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let files_to_track: Vec<_> = self.state.git_status.staged_files.clone();
2480
2481 match repo.unstage_all() {
2482 Ok(()) => {
2483 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 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 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 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 match config.save() {
2537 Ok(()) => {
2538 self.state.config = config;
2539 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 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(¬ification.message, style));
2569 } else {
2570 let hints = self.get_context_hints();
2572 spans.push(Span::styled(hints, theme::dimmed()));
2573 }
2574
2575 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 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 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#[derive(Debug)]
2651pub enum ExitResult {
2652 Quit,
2654 Committed(String),
2656 Amended(String),
2658 Error(String),
2660}
2661
2662impl Drop for StudioApp {
2663 fn drop(&mut self) {
2664 for handle in self.background_tasks.drain(..) {
2666 handle.abort();
2667 }
2668 }
2669}
2670
2671pub 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 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 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 if let Some(mode) = initial_mode {
2704 app.set_initial_mode(mode);
2705 }
2706
2707 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 match app.run()? {
2723 ExitResult::Quit => {
2724 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}