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