1mod chat;
6mod modes;
7
8pub use chat::{ChatMessage, ChatRole, ChatState, truncate_preview};
9pub use modes::{ChangelogCommit, FileLogEntry, ModeStates, PrCommit};
10
11use crate::agents::StatusMessageBatch;
12use crate::companion::CompanionService;
13use crate::config::Config;
14use crate::git::GitRepo;
15use crate::types::format_commit_message;
16use std::collections::VecDeque;
17use std::path::PathBuf;
18use std::sync::Arc;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26pub enum Mode {
27 #[default]
29 Explore,
30 Commit,
32 Review,
34 PR,
36 Changelog,
38 ReleaseNotes,
40}
41
42impl Mode {
43 pub fn display_name(&self) -> &'static str {
45 match self {
46 Mode::Explore => "Explore",
47 Mode::Commit => "Commit",
48 Mode::Review => "Review",
49 Mode::PR => "PR",
50 Mode::Changelog => "Changelog",
51 Mode::ReleaseNotes => "Release",
52 }
53 }
54
55 pub fn shortcut(&self) -> char {
57 match self {
58 Mode::Explore => 'E',
59 Mode::Commit => 'C',
60 Mode::Review => 'R',
61 Mode::PR => 'P',
62 Mode::Changelog => 'L',
63 Mode::ReleaseNotes => 'N',
64 }
65 }
66
67 pub fn is_available(&self) -> bool {
69 matches!(
70 self,
71 Mode::Explore
72 | Mode::Commit
73 | Mode::Review
74 | Mode::PR
75 | Mode::Changelog
76 | Mode::ReleaseNotes
77 )
78 }
79
80 pub fn all() -> &'static [Mode] {
82 &[
83 Mode::Explore,
84 Mode::Commit,
85 Mode::Review,
86 Mode::PR,
87 Mode::Changelog,
88 Mode::ReleaseNotes,
89 ]
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub enum PanelId {
100 Left,
102 Center,
104 Right,
106}
107
108impl PanelId {
109 pub fn next(&self) -> Self {
111 match self {
112 PanelId::Left => PanelId::Center,
113 PanelId::Center => PanelId::Right,
114 PanelId::Right => PanelId::Left,
115 }
116 }
117
118 pub fn prev(&self) -> Self {
120 match self {
121 PanelId::Left => PanelId::Right,
122 PanelId::Center => PanelId::Left,
123 PanelId::Right => PanelId::Center,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Default)]
134pub struct GitStatus {
135 pub branch: String,
137 pub staged_count: usize,
139 pub modified_count: usize,
141 pub untracked_count: usize,
143 pub commits_ahead: usize,
145 pub commits_behind: usize,
147 pub staged_files: Vec<PathBuf>,
149 pub modified_files: Vec<PathBuf>,
151 pub untracked_files: Vec<PathBuf>,
153}
154
155impl GitStatus {
156 pub fn is_main_branch(&self) -> bool {
158 self.branch == "main" || self.branch == "master"
159 }
160
161 pub fn has_changes(&self) -> bool {
163 self.staged_count > 0 || self.modified_count > 0 || self.untracked_count > 0
164 }
165
166 pub fn has_staged(&self) -> bool {
168 self.staged_count > 0
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum NotificationLevel {
179 Info,
180 Success,
181 Warning,
182 Error,
183}
184
185#[derive(Debug, Clone)]
187pub struct Notification {
188 pub message: String,
189 pub level: NotificationLevel,
190 pub timestamp: std::time::Instant,
191}
192
193impl Notification {
194 pub fn info(message: impl Into<String>) -> Self {
195 Self {
196 message: message.into(),
197 level: NotificationLevel::Info,
198 timestamp: std::time::Instant::now(),
199 }
200 }
201
202 pub fn success(message: impl Into<String>) -> Self {
203 Self {
204 message: message.into(),
205 level: NotificationLevel::Success,
206 timestamp: std::time::Instant::now(),
207 }
208 }
209
210 pub fn warning(message: impl Into<String>) -> Self {
211 Self {
212 message: message.into(),
213 level: NotificationLevel::Warning,
214 timestamp: std::time::Instant::now(),
215 }
216 }
217
218 pub fn error(message: impl Into<String>) -> Self {
219 Self {
220 message: message.into(),
221 level: NotificationLevel::Error,
222 timestamp: std::time::Instant::now(),
223 }
224 }
225
226 pub fn is_expired(&self) -> bool {
228 self.timestamp.elapsed() > std::time::Duration::from_secs(5)
229 }
230}
231
232#[derive(Debug, Clone)]
238pub struct PresetInfo {
239 pub key: String,
241 pub name: String,
243 pub description: String,
245 pub emoji: String,
247}
248
249#[derive(Debug, Clone)]
251pub struct EmojiInfo {
252 pub emoji: String,
254 pub key: String,
256 pub description: String,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Default)]
262pub enum EmojiMode {
263 None,
265 #[default]
267 Auto,
268 Custom(String),
270}
271
272pub enum Modal {
274 Help,
276 Search {
278 query: String,
279 results: Vec<String>,
280 selected: usize,
281 },
282 Confirm { message: String, action: String },
284 Instructions { input: String },
286 Chat,
288 RefSelector {
290 input: String,
292 refs: Vec<String>,
294 selected: usize,
296 target: RefSelectorTarget,
298 },
299 PresetSelector {
301 input: String,
303 presets: Vec<PresetInfo>,
305 selected: usize,
307 scroll: usize,
309 },
310 EmojiSelector {
312 input: String,
314 emojis: Vec<EmojiInfo>,
316 selected: usize,
318 scroll: usize,
320 },
321 Settings(Box<SettingsState>),
323 ThemeSelector {
325 input: String,
327 themes: Vec<ThemeOptionInfo>,
329 selected: usize,
331 scroll: usize,
333 },
334 CommitCount {
336 input: String,
338 target: CommitCountTarget,
340 },
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum CommitCountTarget {
346 Pr,
348 Review,
350 Changelog,
352 ReleaseNotes,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum SettingsSection {
363 Provider,
364 Appearance,
365 Behavior,
366}
367
368impl SettingsSection {
369 pub fn display_name(&self) -> &'static str {
371 match self {
372 SettingsSection::Provider => "Provider",
373 SettingsSection::Appearance => "Appearance",
374 SettingsSection::Behavior => "Behavior",
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum SettingsField {
382 Provider,
383 Model,
384 ApiKey,
385 Theme,
386 UseGitmoji,
387 InstructionPreset,
388 CustomInstructions,
389}
390
391impl SettingsField {
392 pub fn all() -> &'static [SettingsField] {
394 &[
395 SettingsField::Provider,
396 SettingsField::Model,
397 SettingsField::ApiKey,
398 SettingsField::Theme,
399 SettingsField::UseGitmoji,
400 SettingsField::InstructionPreset,
401 SettingsField::CustomInstructions,
402 ]
403 }
404
405 pub fn display_name(&self) -> &'static str {
407 match self {
408 SettingsField::Provider => "Provider",
409 SettingsField::Model => "Model",
410 SettingsField::ApiKey => "API Key",
411 SettingsField::Theme => "Theme",
412 SettingsField::UseGitmoji => "Gitmoji",
413 SettingsField::InstructionPreset => "Preset",
414 SettingsField::CustomInstructions => "Instructions",
415 }
416 }
417
418 pub fn section(&self) -> SettingsSection {
420 match self {
421 SettingsField::Provider | SettingsField::Model | SettingsField::ApiKey => {
422 SettingsSection::Provider
423 }
424 SettingsField::Theme => SettingsSection::Appearance,
425 SettingsField::UseGitmoji
426 | SettingsField::InstructionPreset
427 | SettingsField::CustomInstructions => SettingsSection::Behavior,
428 }
429 }
430}
431
432#[derive(Debug, Clone)]
434pub struct ThemeOptionInfo {
435 pub id: String,
437 pub display_name: String,
439 pub variant: String,
441 pub author: String,
443 pub description: String,
445}
446
447#[derive(Debug, Clone)]
449pub struct SettingsState {
450 pub selected_field: usize,
452 pub editing: bool,
454 pub input_buffer: String,
456 pub provider: String,
458 pub model: String,
460 pub api_key_display: String,
462 pub api_key_actual: Option<String>,
464 pub theme: String,
466 pub use_gitmoji: bool,
468 pub instruction_preset: String,
470 pub custom_instructions: String,
472 pub available_providers: Vec<String>,
474 pub available_themes: Vec<ThemeOptionInfo>,
476 pub available_presets: Vec<String>,
478 pub modified: bool,
480 pub error: Option<String>,
482}
483
484impl SettingsState {
485 pub fn from_config(config: &Config) -> Self {
487 use crate::instruction_presets::get_instruction_preset_library;
488 use crate::providers::Provider;
489 use crate::theme;
490
491 let provider = config.default_provider.clone();
492 let provider_config = config.get_provider_config(&provider);
493
494 let model = provider_config.map(|p| p.model.clone()).unwrap_or_default();
495
496 let api_key_display = provider_config
497 .map(|p| Self::mask_api_key(&p.api_key))
498 .unwrap_or_default();
499
500 let available_providers: Vec<String> =
501 Provider::ALL.iter().map(|p| p.name().to_string()).collect();
502
503 let mut available_themes: Vec<ThemeOptionInfo> = theme::list_available_themes()
505 .into_iter()
506 .map(|info| ThemeOptionInfo {
507 id: info.name,
508 display_name: info.display_name,
509 variant: match info.variant {
510 theme::ThemeVariant::Dark => "dark".to_string(),
511 theme::ThemeVariant::Light => "light".to_string(),
512 },
513 author: info.author,
514 description: info.description,
515 })
516 .collect();
517 available_themes.sort_by(|a, b| {
518 match (a.variant.as_str(), b.variant.as_str()) {
520 ("dark", "light") => std::cmp::Ordering::Less,
521 ("light", "dark") => std::cmp::Ordering::Greater,
522 _ => a.display_name.cmp(&b.display_name),
523 }
524 });
525
526 let current_theme = theme::current();
528 let theme_id = available_themes
529 .iter()
530 .find(|t| t.display_name == current_theme.meta.name)
531 .map_or_else(|| "silkcircuit-neon".to_string(), |t| t.id.clone());
532
533 let preset_library = get_instruction_preset_library();
534 let available_presets: Vec<String> = preset_library
535 .list_presets()
536 .iter()
537 .map(|(key, _)| (*key).clone())
538 .collect();
539
540 Self {
541 selected_field: 0,
542 editing: false,
543 input_buffer: String::new(),
544 provider,
545 model,
546 api_key_display,
547 api_key_actual: None, theme: theme_id,
549 use_gitmoji: config.use_gitmoji,
550 instruction_preset: config.instruction_preset.clone(),
551 custom_instructions: config
552 .temp_instructions
553 .clone()
554 .unwrap_or_else(|| config.instructions.clone()),
555 available_providers,
556 available_themes,
557 available_presets,
558 modified: false,
559 error: None,
560 }
561 }
562
563 fn mask_api_key(key: &str) -> String {
565 if key.is_empty() {
566 "(not set)".to_string()
567 } else {
568 let len = key.len();
569 if len <= 8 {
570 "*".repeat(len)
571 } else {
572 format!("{}...{}", &key[..4], &key[len - 4..])
573 }
574 }
575 }
576
577 pub fn current_field(&self) -> SettingsField {
579 SettingsField::all()[self.selected_field]
580 }
581
582 pub fn select_prev(&mut self) {
584 if self.selected_field > 0 {
585 self.selected_field -= 1;
586 }
587 }
588
589 pub fn select_next(&mut self) {
591 let max = SettingsField::all().len() - 1;
592 if self.selected_field < max {
593 self.selected_field += 1;
594 }
595 }
596
597 pub fn get_field_value(&self, field: SettingsField) -> String {
599 match field {
600 SettingsField::Provider => self.provider.clone(),
601 SettingsField::Model => self.model.clone(),
602 SettingsField::ApiKey => self.api_key_display.clone(),
603 SettingsField::Theme => self
604 .available_themes
605 .iter()
606 .find(|t| t.id == self.theme)
607 .map_or_else(|| self.theme.clone(), |t| t.display_name.clone()),
608 SettingsField::UseGitmoji => {
609 if self.use_gitmoji {
610 "yes".to_string()
611 } else {
612 "no".to_string()
613 }
614 }
615 SettingsField::InstructionPreset => self.instruction_preset.clone(),
616 SettingsField::CustomInstructions => {
617 if self.custom_instructions.is_empty() {
618 "(none)".to_string()
619 } else {
620 let preview = self.custom_instructions.lines().next().unwrap_or("");
622 if preview.len() > 30 || self.custom_instructions.lines().count() > 1 {
623 format!("{}...", &preview.chars().take(30).collect::<String>())
624 } else {
625 preview.to_string()
626 }
627 }
628 }
629 }
630 }
631
632 pub fn current_theme_info(&self) -> Option<&ThemeOptionInfo> {
634 self.available_themes.iter().find(|t| t.id == self.theme)
635 }
636
637 pub fn cycle_current_field(&mut self) {
639 self.cycle_field_direction(true);
640 }
641
642 pub fn cycle_current_field_back(&mut self) {
644 self.cycle_field_direction(false);
645 }
646
647 fn cycle_field_direction(&mut self, forward: bool) {
649 let field = self.current_field();
650 match field {
651 SettingsField::Provider => {
652 if let Some(idx) = self
653 .available_providers
654 .iter()
655 .position(|p| p == &self.provider)
656 {
657 let next = if forward {
658 (idx + 1) % self.available_providers.len()
659 } else if idx == 0 {
660 self.available_providers.len() - 1
661 } else {
662 idx - 1
663 };
664 self.provider = self.available_providers[next].clone();
665 self.modified = true;
666 }
667 }
668 SettingsField::Theme => {
669 if let Some(idx) = self
670 .available_themes
671 .iter()
672 .position(|t| t.id == self.theme)
673 {
674 let next = if forward {
675 (idx + 1) % self.available_themes.len()
676 } else if idx == 0 {
677 self.available_themes.len() - 1
678 } else {
679 idx - 1
680 };
681 self.theme = self.available_themes[next].id.clone();
682 self.modified = true;
683 let _ = crate::theme::load_theme_by_name(&self.theme);
685 }
686 }
687 SettingsField::UseGitmoji => {
688 self.use_gitmoji = !self.use_gitmoji;
689 self.modified = true;
690 }
691 SettingsField::InstructionPreset => {
692 if let Some(idx) = self
693 .available_presets
694 .iter()
695 .position(|p| p == &self.instruction_preset)
696 {
697 let next = if forward {
698 (idx + 1) % self.available_presets.len()
699 } else if idx == 0 {
700 self.available_presets.len() - 1
701 } else {
702 idx - 1
703 };
704 self.instruction_preset = self.available_presets[next].clone();
705 self.modified = true;
706 }
707 }
708 _ => {}
709 }
710 }
711
712 pub fn start_editing(&mut self) {
714 let field = self.current_field();
715 match field {
716 SettingsField::Model => {
717 self.input_buffer = self.model.clone();
718 self.editing = true;
719 }
720 SettingsField::ApiKey => {
721 self.input_buffer.clear(); self.editing = true;
723 }
724 SettingsField::CustomInstructions => {
725 self.input_buffer = self.custom_instructions.clone();
726 self.editing = true;
727 }
728 _ => {
729 self.cycle_current_field();
731 }
732 }
733 }
734
735 pub fn cancel_editing(&mut self) {
737 self.editing = false;
738 self.input_buffer.clear();
739 }
740
741 pub fn confirm_editing(&mut self) {
743 if !self.editing {
744 return;
745 }
746
747 let field = self.current_field();
748 match field {
749 SettingsField::Model => {
750 if !self.input_buffer.is_empty() {
751 self.model = self.input_buffer.clone();
752 self.modified = true;
753 }
754 }
755 SettingsField::ApiKey => {
756 if !self.input_buffer.is_empty() {
757 let key = self.input_buffer.clone();
759 self.api_key_display = Self::mask_api_key(&key);
760 self.api_key_actual = Some(key);
761 self.modified = true;
762 }
763 }
764 SettingsField::CustomInstructions => {
765 self.custom_instructions = self.input_buffer.clone();
767 self.modified = true;
768 }
769 _ => {}
770 }
771
772 self.editing = false;
773 self.input_buffer.clear();
774 }
775}
776
777#[derive(Debug, Clone, Copy)]
779pub enum RefSelectorTarget {
780 ReviewFrom,
782 ReviewTo,
784 PrFrom,
786 PrTo,
788 ChangelogFrom,
790 ChangelogTo,
792 ReleaseNotesFrom,
794 ReleaseNotesTo,
796}
797
798#[derive(Debug, Clone, Default)]
804pub enum IrisStatus {
805 #[default]
806 Idle,
807 Thinking {
808 task: String,
810 fallback: String,
812 spinner_frame: usize,
814 dynamic_messages: StatusMessageBatch,
816 },
817 Complete {
819 message: String,
821 },
822 Error(String),
823}
824
825impl IrisStatus {
826 pub fn spinner_char(&self) -> Option<char> {
828 match self {
829 IrisStatus::Thinking { spinner_frame, .. } => {
830 let frames = super::theme::SPINNER_BRAILLE;
831 Some(frames[*spinner_frame % frames.len()])
832 }
833 _ => None,
834 }
835 }
836
837 pub fn message(&self) -> Option<&str> {
839 match self {
840 IrisStatus::Thinking { task, .. } => Some(task),
841 IrisStatus::Complete { message, .. } => Some(message),
842 IrisStatus::Error(msg) => Some(msg),
843 IrisStatus::Idle => None,
844 }
845 }
846
847 pub fn is_complete(&self) -> bool {
849 matches!(self, IrisStatus::Complete { .. })
850 }
851
852 pub fn tick(&mut self) {
854 if let IrisStatus::Thinking { spinner_frame, .. } = self {
855 *spinner_frame = (*spinner_frame + 1) % super::theme::SPINNER_BRAILLE.len();
856 }
857 }
858
859 pub fn add_dynamic_message(&mut self, message: crate::agents::StatusMessage) {
861 if let IrisStatus::Thinking {
862 task,
863 dynamic_messages,
864 ..
865 } = self
866 {
867 task.clone_from(&message.message);
869 dynamic_messages.clear();
870 dynamic_messages.add(message);
871 }
872 }
873}
874
875#[derive(Debug, Clone, Default)]
881pub struct CommitEntry {
882 pub short_hash: String,
884 pub message: String,
886 pub author: String,
888 pub relative_time: String,
890}
891
892#[derive(Debug, Clone, Default)]
894pub struct CompanionSessionDisplay {
895 pub files_touched: usize,
897 pub commits_made: usize,
899 pub duration: String,
901 pub last_touched_file: Option<PathBuf>,
903 pub welcome_message: Option<String>,
905 pub welcome_shown_at: Option<std::time::Instant>,
907 pub watcher_active: bool,
909
910 pub head_commit: Option<CommitEntry>,
913 pub recent_commits: Vec<CommitEntry>,
915 pub ahead: usize,
917 pub behind: usize,
919 pub branch: String,
921 pub staged_count: usize,
923 pub unstaged_count: usize,
925}
926
927pub struct StudioState {
933 pub repo: Option<Arc<GitRepo>>,
935
936 pub git_status: GitStatus,
938
939 pub git_status_loading: bool,
941
942 pub config: Config,
944
945 pub active_mode: Mode,
947
948 pub focused_panel: PanelId,
950
951 pub modes: ModeStates,
953
954 pub modal: Option<Modal>,
956
957 pub chat_state: ChatState,
959
960 pub notifications: VecDeque<Notification>,
962
963 pub iris_status: IrisStatus,
965
966 pub companion: Option<CompanionService>,
968
969 pub companion_display: CompanionSessionDisplay,
971
972 pub dirty: bool,
974
975 pub last_render: std::time::Instant,
977}
978
979impl StudioState {
980 pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
983 let mut modes = ModeStates::default();
985 if let Some(temp_instr) = &config.temp_instructions {
986 modes.commit.custom_instructions.clone_from(temp_instr);
987 }
988 if let Some(temp_preset) = &config.temp_preset {
989 modes.commit.preset.clone_from(temp_preset);
990 }
991
992 Self {
993 repo,
994 git_status: GitStatus::default(),
995 git_status_loading: false,
996 config,
997 active_mode: Mode::Explore,
998 focused_panel: PanelId::Left,
999 modes,
1000 modal: None,
1001 chat_state: ChatState::new(),
1002 notifications: VecDeque::new(),
1003 iris_status: IrisStatus::Idle,
1004 companion: None,
1005 companion_display: CompanionSessionDisplay::default(),
1006 dirty: true,
1007 last_render: std::time::Instant::now(),
1008 }
1009 }
1010
1011 pub fn suggest_initial_mode(&self) -> Mode {
1013 let status = &self.git_status;
1014
1015 if status.has_staged() {
1017 return Mode::Commit;
1018 }
1019
1020 Mode::Explore
1027 }
1028
1029 pub fn switch_mode(&mut self, new_mode: Mode) {
1031 if !new_mode.is_available() {
1032 self.notify(Notification::warning(format!(
1033 "{} mode is not yet implemented",
1034 new_mode.display_name()
1035 )));
1036 return;
1037 }
1038
1039 let old_mode = self.active_mode;
1040
1041 match (old_mode, new_mode) {
1043 (Mode::Explore, Mode::Commit) => {
1044 }
1046 (Mode::Commit, Mode::Explore) => {
1047 }
1049 _ => {}
1050 }
1051
1052 self.active_mode = new_mode;
1053
1054 self.focused_panel = match new_mode {
1056 Mode::Commit => PanelId::Center,
1058 Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => PanelId::Center,
1060 Mode::Explore => PanelId::Left,
1062 };
1063 self.dirty = true;
1064 }
1065
1066 pub fn notify(&mut self, notification: Notification) {
1068 self.notifications.push_back(notification);
1069 while self.notifications.len() > 5 {
1071 self.notifications.pop_front();
1072 }
1073 self.dirty = true;
1074 }
1075
1076 pub fn current_notification(&self) -> Option<&Notification> {
1078 self.notifications.iter().rev().find(|n| !n.is_expired())
1079 }
1080
1081 pub fn cleanup_notifications(&mut self) {
1083 let had_notifications = !self.notifications.is_empty();
1084 self.notifications.retain(|n| !n.is_expired());
1085 if had_notifications && self.notifications.is_empty() {
1086 self.dirty = true;
1087 }
1088 }
1089
1090 pub fn mark_dirty(&mut self) {
1092 self.dirty = true;
1093 }
1094
1095 pub fn check_dirty(&mut self) -> bool {
1097 let was_dirty = self.dirty;
1098 self.dirty = false;
1099 was_dirty
1100 }
1101
1102 pub fn focus_next_panel(&mut self) {
1104 self.focused_panel = self.focused_panel.next();
1105 self.dirty = true;
1106 }
1107
1108 pub fn focus_prev_panel(&mut self) {
1110 self.focused_panel = self.focused_panel.prev();
1111 self.dirty = true;
1112 }
1113
1114 pub fn show_help(&mut self) {
1116 self.modal = Some(Modal::Help);
1117 self.dirty = true;
1118 }
1119
1120 pub fn show_chat(&mut self) {
1122 if self.chat_state.messages.is_empty() {
1124 let context = self.build_chat_context();
1125 self.chat_state = ChatState::with_context("git workflow", context.as_deref());
1126 }
1127
1128 self.modal = Some(Modal::Chat);
1130 self.dirty = true;
1131 }
1132
1133 fn build_chat_context(&self) -> Option<String> {
1135 let mut sections = Vec::new();
1136
1137 if let Some(msg) = self
1139 .modes
1140 .commit
1141 .messages
1142 .get(self.modes.commit.current_index)
1143 {
1144 let formatted = format_commit_message(msg);
1145 if !formatted.trim().is_empty() {
1146 sections.push(format!("Commit Message:\n{}", formatted));
1147 }
1148 }
1149
1150 if !self.modes.review.review_content.is_empty() {
1152 let preview = truncate_preview(&self.modes.review.review_content, 300);
1153 sections.push(format!("Code Review:\n{}", preview));
1154 }
1155
1156 if !self.modes.pr.pr_content.is_empty() {
1158 let preview = truncate_preview(&self.modes.pr.pr_content, 300);
1159 sections.push(format!("PR Description:\n{}", preview));
1160 }
1161
1162 if !self.modes.changelog.changelog_content.is_empty() {
1164 let preview = truncate_preview(&self.modes.changelog.changelog_content, 300);
1165 sections.push(format!("Changelog:\n{}", preview));
1166 }
1167
1168 if !self.modes.release_notes.release_notes_content.is_empty() {
1170 let preview = truncate_preview(&self.modes.release_notes.release_notes_content, 300);
1171 sections.push(format!("Release Notes:\n{}", preview));
1172 }
1173
1174 if sections.is_empty() {
1175 None
1176 } else {
1177 Some(sections.join("\n\n"))
1178 }
1179 }
1180
1181 pub fn close_modal(&mut self) {
1183 if self.modal.is_some() {
1184 self.modal = None;
1185 self.dirty = true;
1186 }
1187 }
1188
1189 pub fn set_iris_thinking(&mut self, task: impl Into<String>) {
1191 let msg = task.into();
1192 self.iris_status = IrisStatus::Thinking {
1193 task: msg.clone(),
1194 fallback: msg,
1195 spinner_frame: 0,
1196 dynamic_messages: StatusMessageBatch::new(),
1197 };
1198 self.dirty = true;
1199 }
1200
1201 pub fn add_status_message(&mut self, message: crate::agents::StatusMessage) {
1203 self.iris_status.add_dynamic_message(message);
1204 self.dirty = true;
1205 }
1206
1207 pub fn set_iris_idle(&mut self) {
1209 self.iris_status = IrisStatus::Idle;
1210 self.dirty = true;
1211 }
1212
1213 pub fn set_iris_complete(&mut self, message: impl Into<String>) {
1215 let msg = message.into();
1216 let capitalized = {
1218 let mut chars = msg.chars();
1219 match chars.next() {
1220 None => String::new(),
1221 Some(first) => first.to_uppercase().chain(chars).collect(),
1222 }
1223 };
1224 self.iris_status = IrisStatus::Complete {
1225 message: capitalized,
1226 };
1227 self.dirty = true;
1228 }
1229
1230 pub fn set_iris_error(&mut self, error: impl Into<String>) {
1232 self.iris_status = IrisStatus::Error(error.into());
1233 self.dirty = true;
1234 }
1235
1236 pub fn tick(&mut self) {
1238 self.iris_status.tick();
1239 self.cleanup_notifications();
1240
1241 if matches!(self.iris_status, IrisStatus::Thinking { .. }) {
1243 self.dirty = true;
1244 }
1245
1246 if matches!(self.modal, Some(Modal::Chat)) && self.chat_state.is_responding {
1248 self.dirty = true;
1249 }
1250 }
1251
1252 pub fn get_branch_refs(&self) -> Vec<String> {
1254 let Some(git_repo) = &self.repo else {
1255 return vec!["main".to_string(), "master".to_string()];
1256 };
1257
1258 let Ok(repo) = git_repo.open_repo() else {
1259 return vec!["main".to_string(), "master".to_string()];
1260 };
1261
1262 let mut refs = Vec::new();
1263
1264 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1266 for branch in branches.flatten() {
1267 if let Ok(Some(name)) = branch.0.name() {
1268 refs.push(name.to_string());
1269 }
1270 }
1271 }
1272
1273 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1275 for branch in branches.flatten() {
1276 if let Ok(Some(name)) = branch.0.name() {
1277 if !name.ends_with("/HEAD") {
1279 refs.push(name.to_string());
1280 }
1281 }
1282 }
1283 }
1284
1285 refs.sort_by(|a, b| {
1287 let priority = |s: &str| -> i32 {
1288 match s {
1289 "main" => 0,
1290 "master" => 1,
1291 s if s.starts_with("origin/main") => 2,
1292 s if s.starts_with("origin/master") => 3,
1293 s if s.starts_with("origin/") => 5,
1294 _ => 4,
1295 }
1296 };
1297 priority(a).cmp(&priority(b)).then(a.cmp(b))
1298 });
1299
1300 if refs.is_empty() {
1301 refs.push("main".to_string());
1302 }
1303
1304 refs
1305 }
1306
1307 pub fn get_commit_presets(&self) -> Vec<PresetInfo> {
1309 use crate::instruction_presets::{PresetType, get_instruction_preset_library};
1310
1311 let library = get_instruction_preset_library();
1312 let mut presets: Vec<PresetInfo> = library
1313 .list_presets_by_type(Some(PresetType::Commit))
1314 .into_iter()
1315 .chain(library.list_presets_by_type(Some(PresetType::Both)))
1316 .map(|(key, preset)| PresetInfo {
1317 key: key.clone(),
1318 name: preset.name.clone(),
1319 description: preset.description.clone(),
1320 emoji: preset.emoji.clone(),
1321 })
1322 .collect();
1323
1324 presets.sort_by(|a, b| {
1326 if a.key == "default" {
1327 std::cmp::Ordering::Less
1328 } else if b.key == "default" {
1329 std::cmp::Ordering::Greater
1330 } else {
1331 a.name.cmp(&b.name)
1332 }
1333 });
1334
1335 presets
1336 }
1337
1338 pub fn get_emoji_list(&self) -> Vec<EmojiInfo> {
1340 use crate::gitmoji::get_gitmoji_list;
1341
1342 let mut emojis = vec![
1343 EmojiInfo {
1344 emoji: "∅".to_string(),
1345 key: "none".to_string(),
1346 description: "No emoji".to_string(),
1347 },
1348 EmojiInfo {
1349 emoji: "✨".to_string(),
1350 key: "auto".to_string(),
1351 description: "Let AI choose".to_string(),
1352 },
1353 ];
1354
1355 for line in get_gitmoji_list().lines() {
1357 let parts: Vec<&str> = line.splitn(3, " - ").collect();
1359 if parts.len() >= 3 {
1360 let emoji = parts[0].trim().to_string();
1361 let key = parts[1].trim_matches(':').to_string();
1362 let description = parts[2].to_string();
1363 emojis.push(EmojiInfo {
1364 emoji,
1365 key,
1366 description,
1367 });
1368 }
1369 }
1370
1371 emojis
1372 }
1373
1374 pub fn update_companion_display(&mut self) {
1376 if let Some(ref companion) = self.companion {
1378 let session = companion.session().read();
1379
1380 let duration = session.duration();
1382 let duration_str = if duration.num_hours() > 0 {
1383 format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
1384 } else if duration.num_minutes() > 0 {
1385 format!("{}m", duration.num_minutes())
1386 } else {
1387 "just started".to_string()
1388 };
1389
1390 let last_touched = session.recent_files().first().map(|f| f.path.clone());
1392
1393 self.companion_display.files_touched = session.files_count();
1394 self.companion_display.commits_made = session.commits_made.len();
1395 self.companion_display.duration = duration_str;
1396 self.companion_display.last_touched_file = last_touched;
1397 self.companion_display.watcher_active = companion.has_watcher();
1398 }
1399
1400 self.companion_display.branch = self.git_status.branch.clone();
1402 self.companion_display.staged_count = self.git_status.staged_count;
1403 self.companion_display.unstaged_count =
1404 self.git_status.modified_count + self.git_status.untracked_count;
1405 self.companion_display.ahead = self.git_status.commits_ahead;
1406 self.companion_display.behind = self.git_status.commits_behind;
1407
1408 if let Some(ref repo) = self.repo
1410 && let Ok(commits) = repo.get_recent_commits(6)
1411 {
1412 let mut entries: Vec<CommitEntry> = commits
1413 .into_iter()
1414 .map(|c| {
1415 let relative_time = Self::format_relative_time(&c.timestamp);
1416 CommitEntry {
1417 short_hash: c.hash[..7.min(c.hash.len())].to_string(),
1418 message: c.message.lines().next().unwrap_or("").to_string(),
1419 author: c
1420 .author
1421 .split('<')
1422 .next()
1423 .unwrap_or(&c.author)
1424 .trim()
1425 .to_string(),
1426 relative_time,
1427 }
1428 })
1429 .collect();
1430
1431 self.companion_display.head_commit = entries.first().cloned();
1433
1434 if !entries.is_empty() {
1436 entries.remove(0);
1437 }
1438 self.companion_display.recent_commits = entries.into_iter().take(5).collect();
1439 }
1440 }
1441
1442 fn format_relative_time(timestamp: &str) -> String {
1444 use chrono::{DateTime, Utc};
1445
1446 if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
1448 let now = Utc::now();
1449 let then: DateTime<Utc> = dt.into();
1450 let duration = now.signed_duration_since(then);
1451
1452 if duration.num_days() > 365 {
1453 format!("{}y ago", duration.num_days() / 365)
1454 } else if duration.num_days() > 30 {
1455 format!("{}mo ago", duration.num_days() / 30)
1456 } else if duration.num_days() > 0 {
1457 format!("{}d ago", duration.num_days())
1458 } else if duration.num_hours() > 0 {
1459 format!("{}h ago", duration.num_hours())
1460 } else if duration.num_minutes() > 0 {
1461 format!("{}m ago", duration.num_minutes())
1462 } else {
1463 "just now".to_string()
1464 }
1465 } else {
1466 timestamp.split('T').next().unwrap_or(timestamp).to_string()
1468 }
1469 }
1470
1471 pub fn clear_companion_welcome(&mut self) {
1473 self.companion_display.welcome_message = None;
1474 }
1475
1476 pub fn companion_touch_file(&mut self, path: PathBuf) {
1478 if let Some(ref companion) = self.companion {
1479 companion.touch_file(path);
1480 }
1481 }
1482
1483 pub fn companion_record_commit(&mut self, hash: String) {
1485 if let Some(ref companion) = self.companion {
1486 companion.record_commit(hash);
1487 }
1488 self.update_companion_display();
1490 }
1491}