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 if !self.input_buffer.is_empty() => {
758 self.model = self.input_buffer.clone();
759 self.modified = true;
760 }
761 SettingsField::ApiKey if !self.input_buffer.is_empty() => {
762 let key = self.input_buffer.clone();
764 self.api_key_display = Self::mask_api_key(&key);
765 self.api_key_actual = Some(key);
766 self.modified = true;
767 }
768 SettingsField::CustomInstructions => {
769 self.custom_instructions = self.input_buffer.clone();
771 self.modified = true;
772 }
773 _ => {}
774 }
775
776 self.editing = false;
777 self.input_buffer.clear();
778 }
779}
780
781#[derive(Debug, Clone, Copy)]
783pub enum RefSelectorTarget {
784 ReviewFrom,
786 ReviewTo,
788 PrFrom,
790 PrTo,
792 ChangelogFrom,
794 ChangelogTo,
796 ReleaseNotesFrom,
798 ReleaseNotesTo,
800}
801
802#[derive(Debug, Clone, Default)]
808pub enum IrisStatus {
809 #[default]
810 Idle,
811 Thinking {
812 task: String,
814 fallback: String,
816 spinner_frame: usize,
818 dynamic_messages: StatusMessageBatch,
820 },
821 Complete {
823 message: String,
825 },
826 Error(String),
827}
828
829impl IrisStatus {
830 pub fn spinner_char(&self) -> Option<char> {
832 match self {
833 IrisStatus::Thinking { spinner_frame, .. } => {
834 let frames = super::theme::SPINNER_BRAILLE;
835 Some(frames[*spinner_frame % frames.len()])
836 }
837 _ => None,
838 }
839 }
840
841 pub fn message(&self) -> Option<&str> {
843 match self {
844 IrisStatus::Thinking { task, .. } => Some(task),
845 IrisStatus::Complete { message, .. } => Some(message),
846 IrisStatus::Error(msg) => Some(msg),
847 IrisStatus::Idle => None,
848 }
849 }
850
851 pub fn is_complete(&self) -> bool {
853 matches!(self, IrisStatus::Complete { .. })
854 }
855
856 pub fn tick(&mut self) {
858 if let IrisStatus::Thinking { spinner_frame, .. } = self {
859 *spinner_frame = (*spinner_frame + 1) % super::theme::SPINNER_BRAILLE.len();
860 }
861 }
862
863 pub fn add_dynamic_message(&mut self, message: crate::agents::StatusMessage) {
865 if let IrisStatus::Thinking {
866 task,
867 dynamic_messages,
868 ..
869 } = self
870 {
871 task.clone_from(&message.message);
873 dynamic_messages.clear();
874 dynamic_messages.add(message);
875 }
876 }
877}
878
879#[derive(Debug, Clone, Default)]
885pub struct CommitEntry {
886 pub short_hash: String,
888 pub message: String,
890 pub author: String,
892 pub relative_time: String,
894}
895
896#[derive(Debug, Clone, Default)]
898pub struct CompanionSessionDisplay {
899 pub files_touched: usize,
901 pub commits_made: usize,
903 pub duration: String,
905 pub last_touched_file: Option<PathBuf>,
907 pub welcome_message: Option<String>,
909 pub welcome_shown_at: Option<std::time::Instant>,
911 pub watcher_active: bool,
913
914 pub head_commit: Option<CommitEntry>,
917 pub recent_commits: Vec<CommitEntry>,
919 pub ahead: usize,
921 pub behind: usize,
923 pub branch: String,
925 pub staged_count: usize,
927 pub unstaged_count: usize,
929}
930
931pub struct StudioState {
937 pub repo: Option<Arc<GitRepo>>,
939
940 pub git_status: GitStatus,
942
943 pub git_status_loading: bool,
945
946 pub config: Config,
948
949 pub active_mode: Mode,
951
952 pub focused_panel: PanelId,
954
955 pub modes: ModeStates,
957
958 pub modal: Option<Modal>,
960
961 pub chat_state: ChatState,
963
964 pub notifications: VecDeque<Notification>,
966
967 pub iris_status: IrisStatus,
969
970 pub companion: Option<CompanionService>,
972
973 pub companion_display: CompanionSessionDisplay,
975
976 pub dirty: bool,
978
979 pub last_render: std::time::Instant,
981}
982
983impl StudioState {
984 #[must_use]
987 pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
988 let mut modes = ModeStates::default();
990 let default_base_ref = repo
991 .as_ref()
992 .and_then(|repo| repo.get_default_base_ref().ok());
993 let current_branch = repo
994 .as_ref()
995 .and_then(|repo| repo.get_current_branch().ok());
996
997 if let Some(temp_instr) = &config.temp_instructions {
998 modes.commit.custom_instructions.clone_from(temp_instr);
999 }
1000 if let Some(temp_preset) = &config.temp_preset {
1001 modes.commit.preset.clone_from(temp_preset);
1002 }
1003 if let Some(default_base_ref) = &default_base_ref {
1004 modes.pr.base_branch.clone_from(default_base_ref);
1005 if !same_branch_ref(current_branch.as_deref(), Some(default_base_ref.as_str())) {
1006 modes.review.from_ref.clone_from(default_base_ref);
1007 }
1008 }
1009
1010 Self {
1011 repo,
1012 git_status: GitStatus::default(),
1013 git_status_loading: false,
1014 config,
1015 active_mode: Mode::Explore,
1016 focused_panel: PanelId::Left,
1017 modes,
1018 modal: None,
1019 chat_state: ChatState::new(),
1020 notifications: VecDeque::new(),
1021 iris_status: IrisStatus::Idle,
1022 companion: None,
1023 companion_display: CompanionSessionDisplay::default(),
1024 dirty: true,
1025 last_render: std::time::Instant::now(),
1026 }
1027 }
1028
1029 pub fn suggest_initial_mode(&self) -> Mode {
1031 let status = &self.git_status;
1032 let default_base_ref = self
1033 .repo
1034 .as_ref()
1035 .and_then(|repo| repo.get_default_base_ref().ok());
1036
1037 if status.has_staged() {
1039 return Mode::Commit;
1040 }
1041
1042 if status.commits_ahead > 0 && !status.is_primary_branch(default_base_ref.as_deref()) {
1044 return Mode::PR;
1045 }
1046
1047 Mode::Explore
1049 }
1050
1051 pub fn switch_mode(&mut self, new_mode: Mode) {
1053 if !new_mode.is_available() {
1054 self.notify(Notification::warning(format!(
1055 "{} mode is not yet implemented",
1056 new_mode.display_name()
1057 )));
1058 return;
1059 }
1060
1061 let old_mode = self.active_mode;
1062
1063 match (old_mode, new_mode) {
1065 (Mode::Explore, Mode::Commit) => {
1066 }
1068 (Mode::Commit, Mode::Explore) => {
1069 }
1071 _ => {}
1072 }
1073
1074 self.active_mode = new_mode;
1075
1076 self.focused_panel = match new_mode {
1078 Mode::Commit => PanelId::Center,
1080 Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => PanelId::Center,
1082 Mode::Explore => PanelId::Left,
1084 };
1085 self.dirty = true;
1086 }
1087
1088 pub fn notify(&mut self, notification: Notification) {
1090 self.notifications.push_back(notification);
1091 while self.notifications.len() > 5 {
1093 self.notifications.pop_front();
1094 }
1095 self.dirty = true;
1096 }
1097
1098 pub fn current_notification(&self) -> Option<&Notification> {
1100 self.notifications.iter().rev().find(|n| !n.is_expired())
1101 }
1102
1103 pub fn cleanup_notifications(&mut self) {
1105 let had_notifications = !self.notifications.is_empty();
1106 self.notifications.retain(|n| !n.is_expired());
1107 if had_notifications && self.notifications.is_empty() {
1108 self.dirty = true;
1109 }
1110 }
1111
1112 pub fn mark_dirty(&mut self) {
1114 self.dirty = true;
1115 }
1116
1117 pub fn check_dirty(&mut self) -> bool {
1119 let was_dirty = self.dirty;
1120 self.dirty = false;
1121 was_dirty
1122 }
1123
1124 pub fn focus_next_panel(&mut self) {
1126 self.focused_panel = self.focused_panel.next();
1127 self.dirty = true;
1128 }
1129
1130 pub fn focus_prev_panel(&mut self) {
1132 self.focused_panel = self.focused_panel.prev();
1133 self.dirty = true;
1134 }
1135
1136 pub fn show_help(&mut self) {
1138 self.modal = Some(Modal::Help);
1139 self.dirty = true;
1140 }
1141
1142 pub fn show_chat(&mut self) {
1144 if self.chat_state.messages.is_empty() {
1146 let context = self.build_chat_context();
1147 self.chat_state = ChatState::with_context("git workflow", context.as_deref());
1148 }
1149
1150 self.modal = Some(Modal::Chat);
1152 self.dirty = true;
1153 }
1154
1155 fn build_chat_context(&self) -> Option<String> {
1157 let mut sections = Vec::new();
1158
1159 if let Some(msg) = self
1161 .modes
1162 .commit
1163 .messages
1164 .get(self.modes.commit.current_index)
1165 {
1166 let formatted = format_commit_message(msg);
1167 if !formatted.trim().is_empty() {
1168 sections.push(format!("Commit Message:\n{}", formatted));
1169 }
1170 }
1171
1172 if !self.modes.review.review_content.is_empty() {
1174 let preview = truncate_preview(&self.modes.review.review_content, 300);
1175 sections.push(format!("Code Review:\n{}", preview));
1176 }
1177
1178 if !self.modes.pr.pr_content.is_empty() {
1180 let preview = truncate_preview(&self.modes.pr.pr_content, 300);
1181 sections.push(format!("PR Description:\n{}", preview));
1182 }
1183
1184 if !self.modes.changelog.changelog_content.is_empty() {
1186 let preview = truncate_preview(&self.modes.changelog.changelog_content, 300);
1187 sections.push(format!("Changelog:\n{}", preview));
1188 }
1189
1190 if !self.modes.release_notes.release_notes_content.is_empty() {
1192 let preview = truncate_preview(&self.modes.release_notes.release_notes_content, 300);
1193 sections.push(format!("Release Notes:\n{}", preview));
1194 }
1195
1196 if sections.is_empty() {
1197 None
1198 } else {
1199 Some(sections.join("\n\n"))
1200 }
1201 }
1202
1203 pub fn close_modal(&mut self) {
1205 if self.modal.is_some() {
1206 self.modal = None;
1207 self.dirty = true;
1208 }
1209 }
1210
1211 pub fn set_iris_thinking(&mut self, task: impl Into<String>) {
1213 let msg = task.into();
1214 self.iris_status = IrisStatus::Thinking {
1215 task: msg.clone(),
1216 fallback: msg,
1217 spinner_frame: 0,
1218 dynamic_messages: StatusMessageBatch::new(),
1219 };
1220 self.dirty = true;
1221 }
1222
1223 pub fn add_status_message(&mut self, message: crate::agents::StatusMessage) {
1225 self.iris_status.add_dynamic_message(message);
1226 self.dirty = true;
1227 }
1228
1229 pub fn set_iris_idle(&mut self) {
1231 self.iris_status = IrisStatus::Idle;
1232 self.dirty = true;
1233 }
1234
1235 pub fn set_iris_complete(&mut self, message: impl Into<String>) {
1237 let msg = message.into();
1238 let capitalized = {
1240 let mut chars = msg.chars();
1241 match chars.next() {
1242 None => String::new(),
1243 Some(first) => first.to_uppercase().chain(chars).collect(),
1244 }
1245 };
1246 self.iris_status = IrisStatus::Complete {
1247 message: capitalized,
1248 };
1249 self.dirty = true;
1250 }
1251
1252 pub fn set_iris_error(&mut self, error: impl Into<String>) {
1254 self.iris_status = IrisStatus::Error(error.into());
1255 self.dirty = true;
1256 }
1257
1258 pub fn tick(&mut self) {
1260 self.iris_status.tick();
1261 self.cleanup_notifications();
1262
1263 if matches!(self.iris_status, IrisStatus::Thinking { .. }) {
1265 self.dirty = true;
1266 }
1267
1268 if matches!(self.modal, Some(Modal::Chat)) && self.chat_state.is_responding {
1270 self.dirty = true;
1271 }
1272 }
1273
1274 pub fn get_branch_refs(&self) -> Vec<String> {
1276 let fallback_refs = || {
1277 vec![
1278 "main".to_string(),
1279 "master".to_string(),
1280 "trunk".to_string(),
1281 "develop".to_string(),
1282 ]
1283 };
1284
1285 let Some(git_repo) = &self.repo else {
1286 return fallback_refs();
1287 };
1288
1289 let Ok(repo) = git_repo.open_repo() else {
1290 return fallback_refs();
1291 };
1292 let default_base_ref = git_repo.get_default_base_ref().ok();
1293
1294 let mut refs = Vec::new();
1295
1296 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1298 for branch in branches.flatten() {
1299 if let Ok(Some(name)) = branch.0.name() {
1300 refs.push(name.to_string());
1301 }
1302 }
1303 }
1304
1305 if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1307 for branch in branches.flatten() {
1308 if let Ok(Some(name)) = branch.0.name() {
1309 if !name.ends_with("/HEAD") {
1311 refs.push(name.to_string());
1312 }
1313 }
1314 }
1315 }
1316
1317 refs.sort_by(|a, b| {
1319 branch_ref_priority(a, default_base_ref.as_deref())
1320 .cmp(&branch_ref_priority(b, default_base_ref.as_deref()))
1321 .then(a.cmp(b))
1322 });
1323 refs.dedup();
1324
1325 if refs.is_empty() {
1326 if let Some(default_base_ref) = default_base_ref {
1327 refs.push(default_base_ref);
1328 } else {
1329 refs = fallback_refs();
1330 }
1331 }
1332
1333 refs
1334 }
1335
1336 pub fn get_commit_presets(&self) -> Vec<PresetInfo> {
1338 use crate::instruction_presets::{PresetType, get_instruction_preset_library};
1339
1340 let library = get_instruction_preset_library();
1341 let mut presets: Vec<PresetInfo> = library
1342 .list_presets_by_type(Some(PresetType::Commit))
1343 .into_iter()
1344 .chain(library.list_presets_by_type(Some(PresetType::Both)))
1345 .map(|(key, preset)| PresetInfo {
1346 key: key.clone(),
1347 name: preset.name.clone(),
1348 description: preset.description.clone(),
1349 emoji: preset.emoji.clone(),
1350 })
1351 .collect();
1352
1353 presets.sort_by(|a, b| {
1355 if a.key == "default" {
1356 std::cmp::Ordering::Less
1357 } else if b.key == "default" {
1358 std::cmp::Ordering::Greater
1359 } else {
1360 a.name.cmp(&b.name)
1361 }
1362 });
1363
1364 presets
1365 }
1366
1367 pub fn get_emoji_list(&self) -> Vec<EmojiInfo> {
1369 use crate::gitmoji::get_gitmoji_list;
1370
1371 let mut emojis = vec![
1372 EmojiInfo {
1373 emoji: "∅".to_string(),
1374 key: "none".to_string(),
1375 description: "No emoji".to_string(),
1376 },
1377 EmojiInfo {
1378 emoji: "✨".to_string(),
1379 key: "auto".to_string(),
1380 description: "Let AI choose".to_string(),
1381 },
1382 ];
1383
1384 for line in get_gitmoji_list().lines() {
1386 let parts: Vec<&str> = line.splitn(3, " - ").collect();
1388 if parts.len() >= 3 {
1389 let emoji = parts[0].trim().to_string();
1390 let key = parts[1].trim_matches(':').to_string();
1391 let description = parts[2].to_string();
1392 emojis.push(EmojiInfo {
1393 emoji,
1394 key,
1395 description,
1396 });
1397 }
1398 }
1399
1400 emojis
1401 }
1402
1403 pub fn update_companion_display(&mut self) {
1405 if let Some(ref companion) = self.companion {
1407 let session = companion.session().read();
1408
1409 let duration = session.duration();
1411 let duration_str = if duration.num_hours() > 0 {
1412 format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
1413 } else if duration.num_minutes() > 0 {
1414 format!("{}m", duration.num_minutes())
1415 } else {
1416 "just started".to_string()
1417 };
1418
1419 let last_touched = session.recent_files().first().map(|f| f.path.clone());
1421
1422 self.companion_display.files_touched = session.files_count();
1423 self.companion_display.commits_made = session.commits_made.len();
1424 self.companion_display.duration = duration_str;
1425 self.companion_display.last_touched_file = last_touched;
1426 self.companion_display.watcher_active = companion.has_watcher();
1427 self.companion_display.branch = session.branch.clone();
1428 } else {
1429 self.companion_display.branch = self.git_status.branch.clone();
1430 }
1431
1432 self.companion_display.staged_count = self.git_status.staged_count;
1434 self.companion_display.unstaged_count =
1435 self.git_status.modified_count + self.git_status.untracked_count;
1436 self.companion_display.ahead = self.git_status.commits_ahead;
1437 self.companion_display.behind = self.git_status.commits_behind;
1438
1439 if let Some(ref repo) = self.repo
1441 && let Ok(commits) = repo.get_recent_commits(6)
1442 {
1443 let mut entries: Vec<CommitEntry> = commits
1444 .into_iter()
1445 .map(|c| {
1446 let relative_time = Self::format_relative_time(&c.timestamp);
1447 CommitEntry {
1448 short_hash: c.hash[..7.min(c.hash.len())].to_string(),
1449 message: c.message.lines().next().unwrap_or("").to_string(),
1450 author: c
1451 .author
1452 .split('<')
1453 .next()
1454 .unwrap_or(&c.author)
1455 .trim()
1456 .to_string(),
1457 relative_time,
1458 }
1459 })
1460 .collect();
1461
1462 self.companion_display.head_commit = entries.first().cloned();
1464
1465 if !entries.is_empty() {
1467 entries.remove(0);
1468 }
1469 self.companion_display.recent_commits = entries.into_iter().take(5).collect();
1470 }
1471 }
1472
1473 fn format_relative_time(timestamp: &str) -> String {
1475 use chrono::{DateTime, Utc};
1476
1477 if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
1479 let now = Utc::now();
1480 let then: DateTime<Utc> = dt.into();
1481 let duration = now.signed_duration_since(then);
1482
1483 if duration.num_days() > 365 {
1484 format!("{}y ago", duration.num_days() / 365)
1485 } else if duration.num_days() > 30 {
1486 format!("{}mo ago", duration.num_days() / 30)
1487 } else if duration.num_days() > 0 {
1488 format!("{}d ago", duration.num_days())
1489 } else if duration.num_hours() > 0 {
1490 format!("{}h ago", duration.num_hours())
1491 } else if duration.num_minutes() > 0 {
1492 format!("{}m ago", duration.num_minutes())
1493 } else {
1494 "just now".to_string()
1495 }
1496 } else {
1497 timestamp.split('T').next().unwrap_or(timestamp).to_string()
1499 }
1500 }
1501
1502 pub fn clear_companion_welcome(&mut self) {
1504 self.companion_display.welcome_message = None;
1505 }
1506
1507 pub fn companion_touch_file(&mut self, path: PathBuf) {
1509 if let Some(ref companion) = self.companion {
1510 companion.touch_file(path);
1511 }
1512 }
1513
1514 pub fn companion_record_commit(&mut self, hash: String) {
1516 if let Some(ref companion) = self.companion {
1517 companion.record_commit(hash);
1518 }
1519 self.update_companion_display();
1521 }
1522}
1523
1524fn same_branch_ref(left: Option<&str>, right: Option<&str>) -> bool {
1525 match (left, right) {
1526 (Some(left), Some(right)) => normalize_branch_ref(left) == normalize_branch_ref(right),
1527 _ => false,
1528 }
1529}
1530
1531fn normalize_branch_ref(value: &str) -> &str {
1532 value.strip_prefix("origin/").unwrap_or(value)
1533}
1534
1535fn branch_ref_priority(candidate: &str, default_base_ref: Option<&str>) -> i32 {
1536 if same_branch_ref(Some(candidate), default_base_ref) {
1537 if default_base_ref == Some(candidate) {
1538 return 0;
1539 }
1540 return 1;
1541 }
1542
1543 if !candidate.contains('/') {
1544 return 2;
1545 }
1546
1547 if candidate.starts_with("origin/") {
1548 return 3;
1549 }
1550
1551 4
1552}