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 #[must_use]
45 pub fn display_name(&self) -> &'static str {
46 match self {
47 Mode::Explore => "Explore",
48 Mode::Commit => "Commit",
49 Mode::Review => "Review",
50 Mode::PR => "PR",
51 Mode::Changelog => "Changelog",
52 Mode::ReleaseNotes => "Release",
53 }
54 }
55
56 #[must_use]
58 pub fn shortcut(&self) -> char {
59 match self {
60 Mode::Explore => 'E',
61 Mode::Commit => 'C',
62 Mode::Review => 'R',
63 Mode::PR => 'P',
64 Mode::Changelog => 'L',
65 Mode::ReleaseNotes => 'N',
66 }
67 }
68
69 #[must_use]
71 pub fn is_available(&self) -> bool {
72 matches!(
73 self,
74 Mode::Explore
75 | Mode::Commit
76 | Mode::Review
77 | Mode::PR
78 | Mode::Changelog
79 | Mode::ReleaseNotes
80 )
81 }
82
83 #[must_use]
85 pub fn all() -> &'static [Mode] {
86 &[
87 Mode::Explore,
88 Mode::Commit,
89 Mode::Review,
90 Mode::PR,
91 Mode::Changelog,
92 Mode::ReleaseNotes,
93 ]
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum PanelId {
104 Left,
106 Center,
108 Right,
110}
111
112impl PanelId {
113 pub fn next(&self) -> Self {
115 match self {
116 PanelId::Left => PanelId::Center,
117 PanelId::Center => PanelId::Right,
118 PanelId::Right => PanelId::Left,
119 }
120 }
121
122 pub fn prev(&self) -> Self {
124 match self {
125 PanelId::Left => PanelId::Right,
126 PanelId::Center => PanelId::Left,
127 PanelId::Right => PanelId::Center,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Default)]
138pub struct GitStatus {
139 pub branch: String,
141 pub staged_count: usize,
143 pub modified_count: usize,
145 pub untracked_count: usize,
147 pub commits_ahead: usize,
149 pub commits_behind: usize,
151 pub staged_files: Vec<PathBuf>,
153 pub modified_files: Vec<PathBuf>,
155 pub untracked_files: Vec<PathBuf>,
157}
158
159impl GitStatus {
160 #[must_use]
162 pub fn is_primary_branch(&self, primary_branch: Option<&str>) -> bool {
163 same_branch_ref(Some(self.branch.as_str()), primary_branch)
164 }
165
166 #[must_use]
168 pub fn has_changes(&self) -> bool {
169 self.staged_count > 0 || self.modified_count > 0 || self.untracked_count > 0
170 }
171
172 #[must_use]
174 pub fn has_staged(&self) -> bool {
175 self.staged_count > 0
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum NotificationLevel {
186 Info,
187 Success,
188 Warning,
189 Error,
190}
191
192#[derive(Debug, Clone)]
194pub struct Notification {
195 pub message: String,
196 pub level: NotificationLevel,
197 pub timestamp: std::time::Instant,
198}
199
200impl Notification {
201 #[must_use]
202 pub fn info(message: impl Into<String>) -> Self {
203 Self {
204 message: message.into(),
205 level: NotificationLevel::Info,
206 timestamp: std::time::Instant::now(),
207 }
208 }
209
210 pub fn success(message: impl Into<String>) -> Self {
211 Self {
212 message: message.into(),
213 level: NotificationLevel::Success,
214 timestamp: std::time::Instant::now(),
215 }
216 }
217
218 pub fn warning(message: impl Into<String>) -> Self {
219 Self {
220 message: message.into(),
221 level: NotificationLevel::Warning,
222 timestamp: std::time::Instant::now(),
223 }
224 }
225
226 pub fn error(message: impl Into<String>) -> Self {
227 Self {
228 message: message.into(),
229 level: NotificationLevel::Error,
230 timestamp: std::time::Instant::now(),
231 }
232 }
233
234 pub fn is_expired(&self) -> bool {
236 self.timestamp.elapsed() > std::time::Duration::from_secs(5)
237 }
238}
239
240#[derive(Debug, Clone)]
246pub struct PresetInfo {
247 pub key: String,
249 pub name: String,
251 pub description: String,
253 pub emoji: String,
255}
256
257#[derive(Debug, Clone)]
259pub struct EmojiInfo {
260 pub emoji: String,
262 pub key: String,
264 pub description: String,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Default)]
270pub enum EmojiMode {
271 None,
273 #[default]
275 Auto,
276 Custom(String),
278}
279
280pub enum Modal {
282 Help,
284 Search {
286 query: String,
287 results: Vec<String>,
288 selected: usize,
289 },
290 Confirm { message: String, action: String },
292 Instructions { input: String },
294 Chat,
296 RefSelector {
298 input: String,
300 refs: Vec<String>,
302 selected: usize,
304 target: RefSelectorTarget,
306 },
307 PresetSelector {
309 input: String,
311 presets: Vec<PresetInfo>,
313 selected: usize,
315 scroll: usize,
317 },
318 EmojiSelector {
320 input: String,
322 emojis: Vec<EmojiInfo>,
324 selected: usize,
326 scroll: usize,
328 },
329 Settings(Box<SettingsState>),
331 ThemeSelector {
333 input: String,
335 themes: Vec<ThemeOptionInfo>,
337 selected: usize,
339 scroll: usize,
341 },
342 CommitCount {
344 input: String,
346 target: CommitCountTarget,
348 },
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum CommitCountTarget {
354 Pr,
356 Review,
358 Changelog,
360 ReleaseNotes,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub enum SettingsSection {
371 Provider,
372 Appearance,
373 Behavior,
374}
375
376impl SettingsSection {
377 pub fn display_name(&self) -> &'static str {
379 match self {
380 SettingsSection::Provider => "Provider",
381 SettingsSection::Appearance => "Appearance",
382 SettingsSection::Behavior => "Behavior",
383 }
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum SettingsField {
390 Provider,
391 Model,
392 ApiKey,
393 Theme,
394 UseGitmoji,
395 InstructionPreset,
396 CustomInstructions,
397}
398
399impl SettingsField {
400 pub fn all() -> &'static [SettingsField] {
402 &[
403 SettingsField::Provider,
404 SettingsField::Model,
405 SettingsField::ApiKey,
406 SettingsField::Theme,
407 SettingsField::UseGitmoji,
408 SettingsField::InstructionPreset,
409 SettingsField::CustomInstructions,
410 ]
411 }
412
413 pub fn display_name(&self) -> &'static str {
415 match self {
416 SettingsField::Provider => "Provider",
417 SettingsField::Model => "Model",
418 SettingsField::ApiKey => "API Key",
419 SettingsField::Theme => "Theme",
420 SettingsField::UseGitmoji => "Gitmoji",
421 SettingsField::InstructionPreset => "Preset",
422 SettingsField::CustomInstructions => "Instructions",
423 }
424 }
425
426 pub fn section(&self) -> SettingsSection {
428 match self {
429 SettingsField::Provider | SettingsField::Model | SettingsField::ApiKey => {
430 SettingsSection::Provider
431 }
432 SettingsField::Theme => SettingsSection::Appearance,
433 SettingsField::UseGitmoji
434 | SettingsField::InstructionPreset
435 | SettingsField::CustomInstructions => SettingsSection::Behavior,
436 }
437 }
438}
439
440#[derive(Debug, Clone)]
442pub struct ThemeOptionInfo {
443 pub id: String,
445 pub display_name: String,
447 pub variant: String,
449 pub author: String,
451 pub description: String,
453}
454
455#[derive(Debug, Clone)]
457pub struct SettingsState {
458 pub selected_field: usize,
460 pub editing: bool,
462 pub input_buffer: String,
464 pub provider: String,
466 pub model: String,
468 pub api_key_display: String,
470 pub api_key_actual: Option<String>,
472 pub theme: String,
474 pub use_gitmoji: bool,
476 pub instruction_preset: String,
478 pub custom_instructions: String,
480 pub available_providers: Vec<String>,
482 pub available_themes: Vec<ThemeOptionInfo>,
484 pub available_presets: Vec<String>,
486 pub modified: bool,
488 pub error: Option<String>,
490}
491
492impl SettingsState {
493 pub fn from_config(config: &Config) -> Self {
495 use crate::instruction_presets::get_instruction_preset_library;
496 use crate::providers::Provider;
497 use crate::theme;
498
499 let provider = config.default_provider.clone();
500 let provider_config = config.get_provider_config(&provider);
501
502 let model = provider_config.map(|p| p.model.clone()).unwrap_or_default();
503
504 let api_key_display = provider_config
505 .map(|p| Self::mask_api_key(&p.api_key))
506 .unwrap_or_default();
507
508 let available_providers: Vec<String> =
509 Provider::ALL.iter().map(|p| p.name().to_string()).collect();
510
511 let mut available_themes: Vec<ThemeOptionInfo> = theme::list_available_themes()
513 .into_iter()
514 .map(|info| ThemeOptionInfo {
515 id: info.name,
516 display_name: info.display_name,
517 variant: match info.variant {
518 theme::ThemeVariant::Dark => "dark".to_string(),
519 theme::ThemeVariant::Light => "light".to_string(),
520 },
521 author: info.author,
522 description: info.description,
523 })
524 .collect();
525 available_themes.sort_by(|a, b| {
526 match (a.variant.as_str(), b.variant.as_str()) {
528 ("dark", "light") => std::cmp::Ordering::Less,
529 ("light", "dark") => std::cmp::Ordering::Greater,
530 _ => a.display_name.cmp(&b.display_name),
531 }
532 });
533
534 let current_theme = theme::current();
536 let theme_id = available_themes
537 .iter()
538 .find(|t| t.display_name == current_theme.meta.name)
539 .map_or_else(|| "silkcircuit-neon".to_string(), |t| t.id.clone());
540
541 let preset_library = get_instruction_preset_library();
542 let available_presets: Vec<String> = preset_library
543 .list_presets()
544 .iter()
545 .map(|(key, _)| (*key).clone())
546 .collect();
547
548 Self {
549 selected_field: 0,
550 editing: false,
551 input_buffer: String::new(),
552 provider,
553 model,
554 api_key_display,
555 api_key_actual: None, theme: theme_id,
557 use_gitmoji: config.use_gitmoji,
558 instruction_preset: config.instruction_preset.clone(),
559 custom_instructions: config
560 .temp_instructions
561 .clone()
562 .unwrap_or_else(|| config.instructions.clone()),
563 available_providers,
564 available_themes,
565 available_presets,
566 modified: false,
567 error: None,
568 }
569 }
570
571 fn mask_api_key(key: &str) -> String {
573 if key.is_empty() {
574 "(not set)".to_string()
575 } else {
576 let len = key.len();
577 if len <= 8 {
578 "*".repeat(len)
579 } else {
580 format!("{}...{}", &key[..4], &key[len - 4..])
581 }
582 }
583 }
584
585 pub fn current_field(&self) -> SettingsField {
587 SettingsField::all()[self.selected_field]
588 }
589
590 pub fn select_prev(&mut self) {
592 if self.selected_field > 0 {
593 self.selected_field -= 1;
594 }
595 }
596
597 pub fn select_next(&mut self) {
599 let max = SettingsField::all().len() - 1;
600 if self.selected_field < max {
601 self.selected_field += 1;
602 }
603 }
604
605 pub fn get_field_value(&self, field: SettingsField) -> String {
607 match field {
608 SettingsField::Provider => self.provider.clone(),
609 SettingsField::Model => self.model.clone(),
610 SettingsField::ApiKey => self.api_key_display.clone(),
611 SettingsField::Theme => self
612 .available_themes
613 .iter()
614 .find(|t| t.id == self.theme)
615 .map_or_else(|| self.theme.clone(), |t| t.display_name.clone()),
616 SettingsField::UseGitmoji => {
617 if self.use_gitmoji {
618 "yes".to_string()
619 } else {
620 "no".to_string()
621 }
622 }
623 SettingsField::InstructionPreset => self.instruction_preset.clone(),
624 SettingsField::CustomInstructions => {
625 if self.custom_instructions.is_empty() {
626 "(none)".to_string()
627 } else {
628 let preview = self.custom_instructions.lines().next().unwrap_or("");
630 if preview.len() > 30 || self.custom_instructions.lines().count() > 1 {
631 format!("{}...", &preview.chars().take(30).collect::<String>())
632 } else {
633 preview.to_string()
634 }
635 }
636 }
637 }
638 }
639
640 pub fn current_theme_info(&self) -> Option<&ThemeOptionInfo> {
642 self.available_themes.iter().find(|t| t.id == self.theme)
643 }
644
645 pub fn cycle_current_field(&mut self) {
647 self.cycle_field_direction(true);
648 }
649
650 pub fn cycle_current_field_back(&mut self) {
652 self.cycle_field_direction(false);
653 }
654
655 fn cycle_field_direction(&mut self, forward: bool) {
657 let field = self.current_field();
658 match field {
659 SettingsField::Provider => {
660 if let Some(idx) = self
661 .available_providers
662 .iter()
663 .position(|p| p == &self.provider)
664 {
665 let next = if forward {
666 (idx + 1) % self.available_providers.len()
667 } else if idx == 0 {
668 self.available_providers.len() - 1
669 } else {
670 idx - 1
671 };
672 self.provider = self.available_providers[next].clone();
673 self.modified = true;
674 }
675 }
676 SettingsField::Theme => {
677 if let Some(idx) = self
678 .available_themes
679 .iter()
680 .position(|t| t.id == self.theme)
681 {
682 let next = if forward {
683 (idx + 1) % self.available_themes.len()
684 } else if idx == 0 {
685 self.available_themes.len() - 1
686 } else {
687 idx - 1
688 };
689 self.theme = self.available_themes[next].id.clone();
690 self.modified = true;
691 let _ = crate::theme::load_theme_by_name(&self.theme);
693 }
694 }
695 SettingsField::UseGitmoji => {
696 self.use_gitmoji = !self.use_gitmoji;
697 self.modified = true;
698 }
699 SettingsField::InstructionPreset => {
700 if let Some(idx) = self
701 .available_presets
702 .iter()
703 .position(|p| p == &self.instruction_preset)
704 {
705 let next = if forward {
706 (idx + 1) % self.available_presets.len()
707 } else if idx == 0 {
708 self.available_presets.len() - 1
709 } else {
710 idx - 1
711 };
712 self.instruction_preset = self.available_presets[next].clone();
713 self.modified = true;
714 }
715 }
716 _ => {}
717 }
718 }
719
720 pub fn start_editing(&mut self) {
722 let field = self.current_field();
723 match field {
724 SettingsField::Model => {
725 self.input_buffer = self.model.clone();
726 self.editing = true;
727 }
728 SettingsField::ApiKey => {
729 self.input_buffer.clear(); self.editing = true;
731 }
732 SettingsField::CustomInstructions => {
733 self.input_buffer = self.custom_instructions.clone();
734 self.editing = true;
735 }
736 _ => {
737 self.cycle_current_field();
739 }
740 }
741 }
742
743 pub fn cancel_editing(&mut self) {
745 self.editing = false;
746 self.input_buffer.clear();
747 }
748
749 pub fn confirm_editing(&mut self) {
751 if !self.editing {
752 return;
753 }
754
755 let field = self.current_field();
756 match field {
757 SettingsField::Model => {
758 if !self.input_buffer.is_empty() {
759 self.model = self.input_buffer.clone();
760 self.modified = true;
761 }
762 }
763 SettingsField::ApiKey => {
764 if !self.input_buffer.is_empty() {
765 let key = self.input_buffer.clone();
767 self.api_key_display = Self::mask_api_key(&key);
768 self.api_key_actual = Some(key);
769 self.modified = true;
770 }
771 }
772 SettingsField::CustomInstructions => {
773 self.custom_instructions = self.input_buffer.clone();
775 self.modified = true;
776 }
777 _ => {}
778 }
779
780 self.editing = false;
781 self.input_buffer.clear();
782 }
783}
784
785#[derive(Debug, Clone, Copy)]
787pub enum RefSelectorTarget {
788 ReviewFrom,
790 ReviewTo,
792 PrFrom,
794 PrTo,
796 ChangelogFrom,
798 ChangelogTo,
800 ReleaseNotesFrom,
802 ReleaseNotesTo,
804}
805
806#[derive(Debug, Clone, Default)]
812pub enum IrisStatus {
813 #[default]
814 Idle,
815 Thinking {
816 task: String,
818 fallback: String,
820 spinner_frame: usize,
822 dynamic_messages: StatusMessageBatch,
824 },
825 Complete {
827 message: String,
829 },
830 Error(String),
831}
832
833impl IrisStatus {
834 pub fn spinner_char(&self) -> Option<char> {
836 match self {
837 IrisStatus::Thinking { spinner_frame, .. } => {
838 let frames = super::theme::SPINNER_BRAILLE;
839 Some(frames[*spinner_frame % frames.len()])
840 }
841 _ => None,
842 }
843 }
844
845 pub fn message(&self) -> Option<&str> {
847 match self {
848 IrisStatus::Thinking { task, .. } => Some(task),
849 IrisStatus::Complete { message, .. } => Some(message),
850 IrisStatus::Error(msg) => Some(msg),
851 IrisStatus::Idle => None,
852 }
853 }
854
855 pub fn is_complete(&self) -> bool {
857 matches!(self, IrisStatus::Complete { .. })
858 }
859
860 pub fn tick(&mut self) {
862 if let IrisStatus::Thinking { spinner_frame, .. } = self {
863 *spinner_frame = (*spinner_frame + 1) % super::theme::SPINNER_BRAILLE.len();
864 }
865 }
866
867 pub fn add_dynamic_message(&mut self, message: crate::agents::StatusMessage) {
869 if let IrisStatus::Thinking {
870 task,
871 dynamic_messages,
872 ..
873 } = self
874 {
875 task.clone_from(&message.message);
877 dynamic_messages.clear();
878 dynamic_messages.add(message);
879 }
880 }
881}
882
883#[derive(Debug, Clone, Default)]
889pub struct CommitEntry {
890 pub short_hash: String,
892 pub message: String,
894 pub author: String,
896 pub relative_time: String,
898}
899
900#[derive(Debug, Clone, Default)]
902pub struct CompanionSessionDisplay {
903 pub files_touched: usize,
905 pub commits_made: usize,
907 pub duration: String,
909 pub last_touched_file: Option<PathBuf>,
911 pub welcome_message: Option<String>,
913 pub welcome_shown_at: Option<std::time::Instant>,
915 pub watcher_active: bool,
917
918 pub head_commit: Option<CommitEntry>,
921 pub recent_commits: Vec<CommitEntry>,
923 pub ahead: usize,
925 pub behind: usize,
927 pub branch: String,
929 pub staged_count: usize,
931 pub unstaged_count: usize,
933}
934
935pub struct StudioState {
941 pub repo: Option<Arc<GitRepo>>,
943
944 pub git_status: GitStatus,
946
947 pub git_status_loading: bool,
949
950 pub config: Config,
952
953 pub active_mode: Mode,
955
956 pub focused_panel: PanelId,
958
959 pub modes: ModeStates,
961
962 pub modal: Option<Modal>,
964
965 pub chat_state: ChatState,
967
968 pub notifications: VecDeque<Notification>,
970
971 pub iris_status: IrisStatus,
973
974 pub companion: Option<CompanionService>,
976
977 pub companion_display: CompanionSessionDisplay,
979
980 pub dirty: bool,
982
983 pub last_render: std::time::Instant,
985}
986
987impl StudioState {
988 #[must_use]
991 pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
992 let mut modes = ModeStates::default();
994 let default_base_ref = repo
995 .as_ref()
996 .and_then(|repo| repo.get_default_base_ref().ok());
997 let current_branch = repo
998 .as_ref()
999 .and_then(|repo| repo.get_current_branch().ok());
1000
1001 if let Some(temp_instr) = &config.temp_instructions {
1002 modes.commit.custom_instructions.clone_from(temp_instr);
1003 }
1004 if let Some(temp_preset) = &config.temp_preset {
1005 modes.commit.preset.clone_from(temp_preset);
1006 }
1007 if let Some(default_base_ref) = &default_base_ref {
1008 modes.pr.base_branch.clone_from(default_base_ref);
1009 if !same_branch_ref(current_branch.as_deref(), Some(default_base_ref.as_str())) {
1010 modes.review.from_ref.clone_from(default_base_ref);
1011 }
1012 }
1013
1014 Self {
1015 repo,
1016 git_status: GitStatus::default(),
1017 git_status_loading: false,
1018 config,
1019 active_mode: Mode::Explore,
1020 focused_panel: PanelId::Left,
1021 modes,
1022 modal: None,
1023 chat_state: ChatState::new(),
1024 notifications: VecDeque::new(),
1025 iris_status: IrisStatus::Idle,
1026 companion: None,
1027 companion_display: CompanionSessionDisplay::default(),
1028 dirty: true,
1029 last_render: std::time::Instant::now(),
1030 }
1031 }
1032
1033 pub fn suggest_initial_mode(&self) -> Mode {
1035 let status = &self.git_status;
1036 let default_base_ref = self
1037 .repo
1038 .as_ref()
1039 .and_then(|repo| repo.get_default_base_ref().ok());
1040
1041 if status.has_staged() {
1043 return Mode::Commit;
1044 }
1045
1046 if status.commits_ahead > 0 && !status.is_primary_branch(default_base_ref.as_deref()) {
1048 return Mode::PR;
1049 }
1050
1051 Mode::Explore
1053 }
1054
1055 pub fn switch_mode(&mut self, new_mode: Mode) {
1057 if !new_mode.is_available() {
1058 self.notify(Notification::warning(format!(
1059 "{} mode is not yet implemented",
1060 new_mode.display_name()
1061 )));
1062 return;
1063 }
1064
1065 let old_mode = self.active_mode;
1066
1067 match (old_mode, new_mode) {
1069 (Mode::Explore, Mode::Commit) => {
1070 }
1072 (Mode::Commit, Mode::Explore) => {
1073 }
1075 _ => {}
1076 }
1077
1078 self.active_mode = new_mode;
1079
1080 self.focused_panel = match new_mode {
1082 Mode::Commit => PanelId::Center,
1084 Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => PanelId::Center,
1086 Mode::Explore => PanelId::Left,
1088 };
1089 self.dirty = true;
1090 }
1091
1092 pub fn notify(&mut self, notification: Notification) {
1094 self.notifications.push_back(notification);
1095 while self.notifications.len() > 5 {
1097 self.notifications.pop_front();
1098 }
1099 self.dirty = true;
1100 }
1101
1102 pub fn current_notification(&self) -> Option<&Notification> {
1104 self.notifications.iter().rev().find(|n| !n.is_expired())
1105 }
1106
1107 pub fn cleanup_notifications(&mut self) {
1109 let had_notifications = !self.notifications.is_empty();
1110 self.notifications.retain(|n| !n.is_expired());
1111 if had_notifications && self.notifications.is_empty() {
1112 self.dirty = true;
1113 }
1114 }
1115
1116 pub fn mark_dirty(&mut self) {
1118 self.dirty = true;
1119 }
1120
1121 pub fn check_dirty(&mut self) -> bool {
1123 let was_dirty = self.dirty;
1124 self.dirty = false;
1125 was_dirty
1126 }
1127
1128 pub fn focus_next_panel(&mut self) {
1130 self.focused_panel = self.focused_panel.next();
1131 self.dirty = true;
1132 }
1133
1134 pub fn focus_prev_panel(&mut self) {
1136 self.focused_panel = self.focused_panel.prev();
1137 self.dirty = true;
1138 }
1139
1140 pub fn show_help(&mut self) {
1142 self.modal = Some(Modal::Help);
1143 self.dirty = true;
1144 }
1145
1146 pub fn show_chat(&mut self) {
1148 if self.chat_state.messages.is_empty() {
1150 let context = self.build_chat_context();
1151 self.chat_state = ChatState::with_context("git workflow", context.as_deref());
1152 }
1153
1154 self.modal = Some(Modal::Chat);
1156 self.dirty = true;
1157 }
1158
1159 fn build_chat_context(&self) -> Option<String> {
1161 let mut sections = Vec::new();
1162
1163 if let Some(msg) = self
1165 .modes
1166 .commit
1167 .messages
1168 .get(self.modes.commit.current_index)
1169 {
1170 let formatted = format_commit_message(msg);
1171 if !formatted.trim().is_empty() {
1172 sections.push(format!("Commit Message:\n{}", formatted));
1173 }
1174 }
1175
1176 if !self.modes.review.review_content.is_empty() {
1178 let preview = truncate_preview(&self.modes.review.review_content, 300);
1179 sections.push(format!("Code Review:\n{}", preview));
1180 }
1181
1182 if !self.modes.pr.pr_content.is_empty() {
1184 let preview = truncate_preview(&self.modes.pr.pr_content, 300);
1185 sections.push(format!("PR Description:\n{}", preview));
1186 }
1187
1188 if !self.modes.changelog.changelog_content.is_empty() {
1190 let preview = truncate_preview(&self.modes.changelog.changelog_content, 300);
1191 sections.push(format!("Changelog:\n{}", preview));
1192 }
1193
1194 if !self.modes.release_notes.release_notes_content.is_empty() {
1196 let preview = truncate_preview(&self.modes.release_notes.release_notes_content, 300);
1197 sections.push(format!("Release Notes:\n{}", preview));
1198 }
1199
1200 if sections.is_empty() {
1201 None
1202 } else {
1203 Some(sections.join("\n\n"))
1204 }
1205 }
1206
1207 pub fn close_modal(&mut self) {
1209 if self.modal.is_some() {
1210 self.modal = None;
1211 self.dirty = true;
1212 }
1213 }
1214
1215 pub fn set_iris_thinking(&mut self, task: impl Into<String>) {
1217 let msg = task.into();
1218 self.iris_status = IrisStatus::Thinking {
1219 task: msg.clone(),
1220 fallback: msg,
1221 spinner_frame: 0,
1222 dynamic_messages: StatusMessageBatch::new(),
1223 };
1224 self.dirty = true;
1225 }
1226
1227 pub fn add_status_message(&mut self, message: crate::agents::StatusMessage) {
1229 self.iris_status.add_dynamic_message(message);
1230 self.dirty = true;
1231 }
1232
1233 pub fn set_iris_idle(&mut self) {
1235 self.iris_status = IrisStatus::Idle;
1236 self.dirty = true;
1237 }
1238
1239 pub fn set_iris_complete(&mut self, message: impl Into<String>) {
1241 let msg = message.into();
1242 let capitalized = {
1244 let mut chars = msg.chars();
1245 match chars.next() {
1246 None => String::new(),
1247 Some(first) => first.to_uppercase().chain(chars).collect(),
1248 }
1249 };
1250 self.iris_status = IrisStatus::Complete {
1251 message: capitalized,
1252 };
1253 self.dirty = true;
1254 }
1255
1256 pub fn set_iris_error(&mut self, error: impl Into<String>) {
1258 self.iris_status = IrisStatus::Error(error.into());
1259 self.dirty = true;
1260 }
1261
1262 pub fn tick(&mut self) {
1264 self.iris_status.tick();
1265 self.cleanup_notifications();
1266
1267 if matches!(self.iris_status, IrisStatus::Thinking { .. }) {
1269 self.dirty = true;
1270 }
1271
1272 if matches!(self.modal, Some(Modal::Chat)) && self.chat_state.is_responding {
1274 self.dirty = true;
1275 }
1276 }
1277
1278 pub fn get_branch_refs(&self) -> Vec<String> {
1280 let fallback_refs = || {
1281 vec![
1282 "main".to_string(),
1283 "master".to_string(),
1284 "trunk".to_string(),
1285 "develop".to_string(),
1286 ]
1287 };
1288
1289 let Some(git_repo) = &self.repo else {
1290 return fallback_refs();
1291 };
1292
1293 let Ok(repo) = git_repo.open_repo() else {
1294 return fallback_refs();
1295 };
1296 let default_base_ref = git_repo.get_default_base_ref().ok();
1297
1298 let mut refs = Vec::new();
1299
1300 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1302 for branch in branches.flatten() {
1303 if let Ok(Some(name)) = branch.0.name() {
1304 refs.push(name.to_string());
1305 }
1306 }
1307 }
1308
1309 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1311 for branch in branches.flatten() {
1312 if let Ok(Some(name)) = branch.0.name() {
1313 if !name.ends_with("/HEAD") {
1315 refs.push(name.to_string());
1316 }
1317 }
1318 }
1319 }
1320
1321 refs.sort_by(|a, b| {
1323 branch_ref_priority(a, default_base_ref.as_deref())
1324 .cmp(&branch_ref_priority(b, default_base_ref.as_deref()))
1325 .then(a.cmp(b))
1326 });
1327 refs.dedup();
1328
1329 if refs.is_empty() {
1330 if let Some(default_base_ref) = default_base_ref {
1331 refs.push(default_base_ref);
1332 } else {
1333 refs = fallback_refs();
1334 }
1335 }
1336
1337 refs
1338 }
1339
1340 pub fn get_commit_presets(&self) -> Vec<PresetInfo> {
1342 use crate::instruction_presets::{PresetType, get_instruction_preset_library};
1343
1344 let library = get_instruction_preset_library();
1345 let mut presets: Vec<PresetInfo> = library
1346 .list_presets_by_type(Some(PresetType::Commit))
1347 .into_iter()
1348 .chain(library.list_presets_by_type(Some(PresetType::Both)))
1349 .map(|(key, preset)| PresetInfo {
1350 key: key.clone(),
1351 name: preset.name.clone(),
1352 description: preset.description.clone(),
1353 emoji: preset.emoji.clone(),
1354 })
1355 .collect();
1356
1357 presets.sort_by(|a, b| {
1359 if a.key == "default" {
1360 std::cmp::Ordering::Less
1361 } else if b.key == "default" {
1362 std::cmp::Ordering::Greater
1363 } else {
1364 a.name.cmp(&b.name)
1365 }
1366 });
1367
1368 presets
1369 }
1370
1371 pub fn get_emoji_list(&self) -> Vec<EmojiInfo> {
1373 use crate::gitmoji::get_gitmoji_list;
1374
1375 let mut emojis = vec![
1376 EmojiInfo {
1377 emoji: "∅".to_string(),
1378 key: "none".to_string(),
1379 description: "No emoji".to_string(),
1380 },
1381 EmojiInfo {
1382 emoji: "✨".to_string(),
1383 key: "auto".to_string(),
1384 description: "Let AI choose".to_string(),
1385 },
1386 ];
1387
1388 for line in get_gitmoji_list().lines() {
1390 let parts: Vec<&str> = line.splitn(3, " - ").collect();
1392 if parts.len() >= 3 {
1393 let emoji = parts[0].trim().to_string();
1394 let key = parts[1].trim_matches(':').to_string();
1395 let description = parts[2].to_string();
1396 emojis.push(EmojiInfo {
1397 emoji,
1398 key,
1399 description,
1400 });
1401 }
1402 }
1403
1404 emojis
1405 }
1406
1407 pub fn update_companion_display(&mut self) {
1409 if let Some(ref companion) = self.companion {
1411 let session = companion.session().read();
1412
1413 let duration = session.duration();
1415 let duration_str = if duration.num_hours() > 0 {
1416 format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
1417 } else if duration.num_minutes() > 0 {
1418 format!("{}m", duration.num_minutes())
1419 } else {
1420 "just started".to_string()
1421 };
1422
1423 let last_touched = session.recent_files().first().map(|f| f.path.clone());
1425
1426 self.companion_display.files_touched = session.files_count();
1427 self.companion_display.commits_made = session.commits_made.len();
1428 self.companion_display.duration = duration_str;
1429 self.companion_display.last_touched_file = last_touched;
1430 self.companion_display.watcher_active = companion.has_watcher();
1431 self.companion_display.branch = session.branch.clone();
1432 } else {
1433 self.companion_display.branch = self.git_status.branch.clone();
1434 }
1435
1436 self.companion_display.staged_count = self.git_status.staged_count;
1438 self.companion_display.unstaged_count =
1439 self.git_status.modified_count + self.git_status.untracked_count;
1440 self.companion_display.ahead = self.git_status.commits_ahead;
1441 self.companion_display.behind = self.git_status.commits_behind;
1442
1443 if let Some(ref repo) = self.repo
1445 && let Ok(commits) = repo.get_recent_commits(6)
1446 {
1447 let mut entries: Vec<CommitEntry> = commits
1448 .into_iter()
1449 .map(|c| {
1450 let relative_time = Self::format_relative_time(&c.timestamp);
1451 CommitEntry {
1452 short_hash: c.hash[..7.min(c.hash.len())].to_string(),
1453 message: c.message.lines().next().unwrap_or("").to_string(),
1454 author: c
1455 .author
1456 .split('<')
1457 .next()
1458 .unwrap_or(&c.author)
1459 .trim()
1460 .to_string(),
1461 relative_time,
1462 }
1463 })
1464 .collect();
1465
1466 self.companion_display.head_commit = entries.first().cloned();
1468
1469 if !entries.is_empty() {
1471 entries.remove(0);
1472 }
1473 self.companion_display.recent_commits = entries.into_iter().take(5).collect();
1474 }
1475 }
1476
1477 fn format_relative_time(timestamp: &str) -> String {
1479 use chrono::{DateTime, Utc};
1480
1481 if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
1483 let now = Utc::now();
1484 let then: DateTime<Utc> = dt.into();
1485 let duration = now.signed_duration_since(then);
1486
1487 if duration.num_days() > 365 {
1488 format!("{}y ago", duration.num_days() / 365)
1489 } else if duration.num_days() > 30 {
1490 format!("{}mo ago", duration.num_days() / 30)
1491 } else if duration.num_days() > 0 {
1492 format!("{}d ago", duration.num_days())
1493 } else if duration.num_hours() > 0 {
1494 format!("{}h ago", duration.num_hours())
1495 } else if duration.num_minutes() > 0 {
1496 format!("{}m ago", duration.num_minutes())
1497 } else {
1498 "just now".to_string()
1499 }
1500 } else {
1501 timestamp.split('T').next().unwrap_or(timestamp).to_string()
1503 }
1504 }
1505
1506 pub fn clear_companion_welcome(&mut self) {
1508 self.companion_display.welcome_message = None;
1509 }
1510
1511 pub fn companion_touch_file(&mut self, path: PathBuf) {
1513 if let Some(ref companion) = self.companion {
1514 companion.touch_file(path);
1515 }
1516 }
1517
1518 pub fn companion_record_commit(&mut self, hash: String) {
1520 if let Some(ref companion) = self.companion {
1521 companion.record_commit(hash);
1522 }
1523 self.update_companion_display();
1525 }
1526}
1527
1528fn same_branch_ref(left: Option<&str>, right: Option<&str>) -> bool {
1529 match (left, right) {
1530 (Some(left), Some(right)) => normalize_branch_ref(left) == normalize_branch_ref(right),
1531 _ => false,
1532 }
1533}
1534
1535fn normalize_branch_ref(value: &str) -> &str {
1536 value.strip_prefix("origin/").unwrap_or(value)
1537}
1538
1539fn branch_ref_priority(candidate: &str, default_base_ref: Option<&str>) -> i32 {
1540 if same_branch_ref(Some(candidate), default_base_ref) {
1541 if default_base_ref == Some(candidate) {
1542 return 0;
1543 }
1544 return 1;
1545 }
1546
1547 if !candidate.contains('/') {
1548 return 2;
1549 }
1550
1551 if candidate.starts_with("origin/") {
1552 return 3;
1553 }
1554
1555 4
1556}