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