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