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