mod chat;
mod modes;
pub use chat::{ChatMessage, ChatRole, ChatState, truncate_preview};
pub use modes::{ChangelogCommit, FileLogEntry, ModeStates, PrCommit};
use crate::agents::StatusMessageBatch;
use crate::companion::CompanionService;
use crate::config::Config;
use crate::git::GitRepo;
use crate::types::format_commit_message;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Mode {
#[default]
Explore,
Commit,
Review,
PR,
Changelog,
ReleaseNotes,
}
impl Mode {
#[must_use]
pub fn display_name(&self) -> &'static str {
match self {
Mode::Explore => "Explore",
Mode::Commit => "Commit",
Mode::Review => "Review",
Mode::PR => "PR",
Mode::Changelog => "Changelog",
Mode::ReleaseNotes => "Release",
}
}
#[must_use]
pub fn shortcut(&self) -> char {
match self {
Mode::Explore => 'E',
Mode::Commit => 'C',
Mode::Review => 'R',
Mode::PR => 'P',
Mode::Changelog => 'L',
Mode::ReleaseNotes => 'N',
}
}
#[must_use]
pub fn is_available(&self) -> bool {
matches!(
self,
Mode::Explore
| Mode::Commit
| Mode::Review
| Mode::PR
| Mode::Changelog
| Mode::ReleaseNotes
)
}
#[must_use]
pub fn all() -> &'static [Mode] {
&[
Mode::Explore,
Mode::Commit,
Mode::Review,
Mode::PR,
Mode::Changelog,
Mode::ReleaseNotes,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PanelId {
Left,
Center,
Right,
}
impl PanelId {
pub fn next(&self) -> Self {
match self {
PanelId::Left => PanelId::Center,
PanelId::Center => PanelId::Right,
PanelId::Right => PanelId::Left,
}
}
pub fn prev(&self) -> Self {
match self {
PanelId::Left => PanelId::Right,
PanelId::Center => PanelId::Left,
PanelId::Right => PanelId::Center,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GitStatus {
pub branch: String,
pub staged_count: usize,
pub modified_count: usize,
pub untracked_count: usize,
pub commits_ahead: usize,
pub commits_behind: usize,
pub staged_files: Vec<PathBuf>,
pub modified_files: Vec<PathBuf>,
pub untracked_files: Vec<PathBuf>,
}
impl GitStatus {
#[must_use]
pub fn is_primary_branch(&self, primary_branch: Option<&str>) -> bool {
same_branch_ref(Some(self.branch.as_str()), primary_branch)
}
#[must_use]
pub fn has_changes(&self) -> bool {
self.staged_count > 0 || self.modified_count > 0 || self.untracked_count > 0
}
#[must_use]
pub fn has_staged(&self) -> bool {
self.staged_count > 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationLevel {
Info,
Success,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct Notification {
pub message: String,
pub level: NotificationLevel,
pub timestamp: std::time::Instant,
}
impl Notification {
#[must_use]
pub fn info(message: impl Into<String>) -> Self {
Self {
message: message.into(),
level: NotificationLevel::Info,
timestamp: std::time::Instant::now(),
}
}
pub fn success(message: impl Into<String>) -> Self {
Self {
message: message.into(),
level: NotificationLevel::Success,
timestamp: std::time::Instant::now(),
}
}
pub fn warning(message: impl Into<String>) -> Self {
Self {
message: message.into(),
level: NotificationLevel::Warning,
timestamp: std::time::Instant::now(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
message: message.into(),
level: NotificationLevel::Error,
timestamp: std::time::Instant::now(),
}
}
pub fn is_expired(&self) -> bool {
self.timestamp.elapsed() > std::time::Duration::from_secs(5)
}
}
#[derive(Debug, Clone)]
pub struct PresetInfo {
pub key: String,
pub name: String,
pub description: String,
pub emoji: String,
}
#[derive(Debug, Clone)]
pub struct EmojiInfo {
pub emoji: String,
pub key: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum EmojiMode {
None,
#[default]
Auto,
Custom(String),
}
pub enum Modal {
Help,
Search {
query: String,
results: Vec<String>,
selected: usize,
},
Confirm { message: String, action: String },
Instructions { input: String },
Chat,
RefSelector {
input: String,
refs: Vec<String>,
selected: usize,
target: RefSelectorTarget,
},
PresetSelector {
input: String,
presets: Vec<PresetInfo>,
selected: usize,
scroll: usize,
},
EmojiSelector {
input: String,
emojis: Vec<EmojiInfo>,
selected: usize,
scroll: usize,
},
Settings(Box<SettingsState>),
ThemeSelector {
input: String,
themes: Vec<ThemeOptionInfo>,
selected: usize,
scroll: usize,
},
CommitCount {
input: String,
target: CommitCountTarget,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitCountTarget {
Pr,
Review,
Changelog,
ReleaseNotes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsSection {
Provider,
Appearance,
Behavior,
}
impl SettingsSection {
pub fn display_name(&self) -> &'static str {
match self {
SettingsSection::Provider => "Provider",
SettingsSection::Appearance => "Appearance",
SettingsSection::Behavior => "Behavior",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
Provider,
Model,
ApiKey,
Theme,
UseGitmoji,
InstructionPreset,
CustomInstructions,
}
impl SettingsField {
pub fn all() -> &'static [SettingsField] {
&[
SettingsField::Provider,
SettingsField::Model,
SettingsField::ApiKey,
SettingsField::Theme,
SettingsField::UseGitmoji,
SettingsField::InstructionPreset,
SettingsField::CustomInstructions,
]
}
pub fn display_name(&self) -> &'static str {
match self {
SettingsField::Provider => "Provider",
SettingsField::Model => "Model",
SettingsField::ApiKey => "API Key",
SettingsField::Theme => "Theme",
SettingsField::UseGitmoji => "Gitmoji",
SettingsField::InstructionPreset => "Preset",
SettingsField::CustomInstructions => "Instructions",
}
}
pub fn section(&self) -> SettingsSection {
match self {
SettingsField::Provider | SettingsField::Model | SettingsField::ApiKey => {
SettingsSection::Provider
}
SettingsField::Theme => SettingsSection::Appearance,
SettingsField::UseGitmoji
| SettingsField::InstructionPreset
| SettingsField::CustomInstructions => SettingsSection::Behavior,
}
}
}
#[derive(Debug, Clone)]
pub struct ThemeOptionInfo {
pub id: String,
pub display_name: String,
pub variant: String,
pub author: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct SettingsState {
pub selected_field: usize,
pub editing: bool,
pub input_buffer: String,
pub provider: String,
pub model: String,
pub api_key_display: String,
pub api_key_actual: Option<String>,
pub theme: String,
pub use_gitmoji: bool,
pub instruction_preset: String,
pub custom_instructions: String,
pub available_providers: Vec<String>,
pub available_themes: Vec<ThemeOptionInfo>,
pub available_presets: Vec<String>,
pub modified: bool,
pub error: Option<String>,
}
impl SettingsState {
pub fn from_config(config: &Config) -> Self {
use crate::instruction_presets::get_instruction_preset_library;
use crate::providers::Provider;
use crate::theme;
let provider = config.default_provider.clone();
let provider_config = config.get_provider_config(&provider);
let model = provider_config.map(|p| p.model.clone()).unwrap_or_default();
let api_key_display = provider_config
.map(|p| Self::mask_api_key(&p.api_key))
.unwrap_or_default();
let available_providers: Vec<String> =
Provider::ALL.iter().map(|p| p.name().to_string()).collect();
let mut available_themes: Vec<ThemeOptionInfo> = theme::list_available_themes()
.into_iter()
.map(|info| ThemeOptionInfo {
id: info.name,
display_name: info.display_name,
variant: match info.variant {
theme::ThemeVariant::Dark => "dark".to_string(),
theme::ThemeVariant::Light => "light".to_string(),
},
author: info.author,
description: info.description,
})
.collect();
available_themes.sort_by(|a, b| {
match (a.variant.as_str(), b.variant.as_str()) {
("dark", "light") => std::cmp::Ordering::Less,
("light", "dark") => std::cmp::Ordering::Greater,
_ => a.display_name.cmp(&b.display_name),
}
});
let current_theme = theme::current();
let theme_id = available_themes
.iter()
.find(|t| t.display_name == current_theme.meta.name)
.map_or_else(|| "silkcircuit-neon".to_string(), |t| t.id.clone());
let preset_library = get_instruction_preset_library();
let available_presets: Vec<String> = preset_library
.list_presets()
.iter()
.map(|(key, _)| (*key).clone())
.collect();
Self {
selected_field: 0,
editing: false,
input_buffer: String::new(),
provider,
model,
api_key_display,
api_key_actual: None, theme: theme_id,
use_gitmoji: config.use_gitmoji,
instruction_preset: config.instruction_preset.clone(),
custom_instructions: config
.temp_instructions
.clone()
.unwrap_or_else(|| config.instructions.clone()),
available_providers,
available_themes,
available_presets,
modified: false,
error: None,
}
}
fn mask_api_key(key: &str) -> String {
if key.is_empty() {
"(not set)".to_string()
} else {
let len = key.len();
if len <= 8 {
"*".repeat(len)
} else {
format!("{}...{}", &key[..4], &key[len - 4..])
}
}
}
pub fn current_field(&self) -> SettingsField {
SettingsField::all()[self.selected_field]
}
pub fn select_prev(&mut self) {
if self.selected_field > 0 {
self.selected_field -= 1;
}
}
pub fn select_next(&mut self) {
let max = SettingsField::all().len() - 1;
if self.selected_field < max {
self.selected_field += 1;
}
}
pub fn get_field_value(&self, field: SettingsField) -> String {
match field {
SettingsField::Provider => self.provider.clone(),
SettingsField::Model => self.model.clone(),
SettingsField::ApiKey => self.api_key_display.clone(),
SettingsField::Theme => self
.available_themes
.iter()
.find(|t| t.id == self.theme)
.map_or_else(|| self.theme.clone(), |t| t.display_name.clone()),
SettingsField::UseGitmoji => {
if self.use_gitmoji {
"yes".to_string()
} else {
"no".to_string()
}
}
SettingsField::InstructionPreset => self.instruction_preset.clone(),
SettingsField::CustomInstructions => {
if self.custom_instructions.is_empty() {
"(none)".to_string()
} else {
let preview = self.custom_instructions.lines().next().unwrap_or("");
if preview.len() > 30 || self.custom_instructions.lines().count() > 1 {
format!("{}...", &preview.chars().take(30).collect::<String>())
} else {
preview.to_string()
}
}
}
}
}
pub fn current_theme_info(&self) -> Option<&ThemeOptionInfo> {
self.available_themes.iter().find(|t| t.id == self.theme)
}
pub fn cycle_current_field(&mut self) {
self.cycle_field_direction(true);
}
pub fn cycle_current_field_back(&mut self) {
self.cycle_field_direction(false);
}
fn cycle_field_direction(&mut self, forward: bool) {
let field = self.current_field();
match field {
SettingsField::Provider => {
if let Some(idx) = self
.available_providers
.iter()
.position(|p| p == &self.provider)
{
let next = if forward {
(idx + 1) % self.available_providers.len()
} else if idx == 0 {
self.available_providers.len() - 1
} else {
idx - 1
};
self.provider = self.available_providers[next].clone();
self.modified = true;
}
}
SettingsField::Theme => {
if let Some(idx) = self
.available_themes
.iter()
.position(|t| t.id == self.theme)
{
let next = if forward {
(idx + 1) % self.available_themes.len()
} else if idx == 0 {
self.available_themes.len() - 1
} else {
idx - 1
};
self.theme = self.available_themes[next].id.clone();
self.modified = true;
let _ = crate::theme::load_theme_by_name(&self.theme);
}
}
SettingsField::UseGitmoji => {
self.use_gitmoji = !self.use_gitmoji;
self.modified = true;
}
SettingsField::InstructionPreset => {
if let Some(idx) = self
.available_presets
.iter()
.position(|p| p == &self.instruction_preset)
{
let next = if forward {
(idx + 1) % self.available_presets.len()
} else if idx == 0 {
self.available_presets.len() - 1
} else {
idx - 1
};
self.instruction_preset = self.available_presets[next].clone();
self.modified = true;
}
}
_ => {}
}
}
pub fn start_editing(&mut self) {
let field = self.current_field();
match field {
SettingsField::Model => {
self.input_buffer = self.model.clone();
self.editing = true;
}
SettingsField::ApiKey => {
self.input_buffer.clear(); self.editing = true;
}
SettingsField::CustomInstructions => {
self.input_buffer = self.custom_instructions.clone();
self.editing = true;
}
_ => {
self.cycle_current_field();
}
}
}
pub fn cancel_editing(&mut self) {
self.editing = false;
self.input_buffer.clear();
}
pub fn confirm_editing(&mut self) {
if !self.editing {
return;
}
let field = self.current_field();
match field {
SettingsField::Model => {
if !self.input_buffer.is_empty() {
self.model = self.input_buffer.clone();
self.modified = true;
}
}
SettingsField::ApiKey => {
if !self.input_buffer.is_empty() {
let key = self.input_buffer.clone();
self.api_key_display = Self::mask_api_key(&key);
self.api_key_actual = Some(key);
self.modified = true;
}
}
SettingsField::CustomInstructions => {
self.custom_instructions = self.input_buffer.clone();
self.modified = true;
}
_ => {}
}
self.editing = false;
self.input_buffer.clear();
}
}
#[derive(Debug, Clone, Copy)]
pub enum RefSelectorTarget {
ReviewFrom,
ReviewTo,
PrFrom,
PrTo,
ChangelogFrom,
ChangelogTo,
ReleaseNotesFrom,
ReleaseNotesTo,
}
#[derive(Debug, Clone, Default)]
pub enum IrisStatus {
#[default]
Idle,
Thinking {
task: String,
fallback: String,
spinner_frame: usize,
dynamic_messages: StatusMessageBatch,
},
Complete {
message: String,
},
Error(String),
}
impl IrisStatus {
pub fn spinner_char(&self) -> Option<char> {
match self {
IrisStatus::Thinking { spinner_frame, .. } => {
let frames = super::theme::SPINNER_BRAILLE;
Some(frames[*spinner_frame % frames.len()])
}
_ => None,
}
}
pub fn message(&self) -> Option<&str> {
match self {
IrisStatus::Thinking { task, .. } => Some(task),
IrisStatus::Complete { message, .. } => Some(message),
IrisStatus::Error(msg) => Some(msg),
IrisStatus::Idle => None,
}
}
pub fn is_complete(&self) -> bool {
matches!(self, IrisStatus::Complete { .. })
}
pub fn tick(&mut self) {
if let IrisStatus::Thinking { spinner_frame, .. } = self {
*spinner_frame = (*spinner_frame + 1) % super::theme::SPINNER_BRAILLE.len();
}
}
pub fn add_dynamic_message(&mut self, message: crate::agents::StatusMessage) {
if let IrisStatus::Thinking {
task,
dynamic_messages,
..
} = self
{
task.clone_from(&message.message);
dynamic_messages.clear();
dynamic_messages.add(message);
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CommitEntry {
pub short_hash: String,
pub message: String,
pub author: String,
pub relative_time: String,
}
#[derive(Debug, Clone, Default)]
pub struct CompanionSessionDisplay {
pub files_touched: usize,
pub commits_made: usize,
pub duration: String,
pub last_touched_file: Option<PathBuf>,
pub welcome_message: Option<String>,
pub welcome_shown_at: Option<std::time::Instant>,
pub watcher_active: bool,
pub head_commit: Option<CommitEntry>,
pub recent_commits: Vec<CommitEntry>,
pub ahead: usize,
pub behind: usize,
pub branch: String,
pub staged_count: usize,
pub unstaged_count: usize,
}
pub struct StudioState {
pub repo: Option<Arc<GitRepo>>,
pub git_status: GitStatus,
pub git_status_loading: bool,
pub config: Config,
pub active_mode: Mode,
pub focused_panel: PanelId,
pub modes: ModeStates,
pub modal: Option<Modal>,
pub chat_state: ChatState,
pub notifications: VecDeque<Notification>,
pub iris_status: IrisStatus,
pub companion: Option<CompanionService>,
pub companion_display: CompanionSessionDisplay,
pub dirty: bool,
pub last_render: std::time::Instant,
}
impl StudioState {
#[must_use]
pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
let mut modes = ModeStates::default();
let default_base_ref = repo
.as_ref()
.and_then(|repo| repo.get_default_base_ref().ok());
let current_branch = repo
.as_ref()
.and_then(|repo| repo.get_current_branch().ok());
if let Some(temp_instr) = &config.temp_instructions {
modes.commit.custom_instructions.clone_from(temp_instr);
}
if let Some(temp_preset) = &config.temp_preset {
modes.commit.preset.clone_from(temp_preset);
}
if let Some(default_base_ref) = &default_base_ref {
modes.pr.base_branch.clone_from(default_base_ref);
if !same_branch_ref(current_branch.as_deref(), Some(default_base_ref.as_str())) {
modes.review.from_ref.clone_from(default_base_ref);
}
}
Self {
repo,
git_status: GitStatus::default(),
git_status_loading: false,
config,
active_mode: Mode::Explore,
focused_panel: PanelId::Left,
modes,
modal: None,
chat_state: ChatState::new(),
notifications: VecDeque::new(),
iris_status: IrisStatus::Idle,
companion: None,
companion_display: CompanionSessionDisplay::default(),
dirty: true,
last_render: std::time::Instant::now(),
}
}
pub fn suggest_initial_mode(&self) -> Mode {
let status = &self.git_status;
let default_base_ref = self
.repo
.as_ref()
.and_then(|repo| repo.get_default_base_ref().ok());
if status.has_staged() {
return Mode::Commit;
}
if status.commits_ahead > 0 && !status.is_primary_branch(default_base_ref.as_deref()) {
return Mode::PR;
}
Mode::Explore
}
pub fn switch_mode(&mut self, new_mode: Mode) {
if !new_mode.is_available() {
self.notify(Notification::warning(format!(
"{} mode is not yet implemented",
new_mode.display_name()
)));
return;
}
let old_mode = self.active_mode;
match (old_mode, new_mode) {
(Mode::Explore, Mode::Commit) => {
}
(Mode::Commit, Mode::Explore) => {
}
_ => {}
}
self.active_mode = new_mode;
self.focused_panel = match new_mode {
Mode::Commit => PanelId::Center,
Mode::Review | Mode::PR | Mode::Changelog | Mode::ReleaseNotes => PanelId::Center,
Mode::Explore => PanelId::Left,
};
self.dirty = true;
}
pub fn notify(&mut self, notification: Notification) {
self.notifications.push_back(notification);
while self.notifications.len() > 5 {
self.notifications.pop_front();
}
self.dirty = true;
}
pub fn current_notification(&self) -> Option<&Notification> {
self.notifications.iter().rev().find(|n| !n.is_expired())
}
pub fn cleanup_notifications(&mut self) {
let had_notifications = !self.notifications.is_empty();
self.notifications.retain(|n| !n.is_expired());
if had_notifications && self.notifications.is_empty() {
self.dirty = true;
}
}
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
pub fn check_dirty(&mut self) -> bool {
let was_dirty = self.dirty;
self.dirty = false;
was_dirty
}
pub fn focus_next_panel(&mut self) {
self.focused_panel = self.focused_panel.next();
self.dirty = true;
}
pub fn focus_prev_panel(&mut self) {
self.focused_panel = self.focused_panel.prev();
self.dirty = true;
}
pub fn show_help(&mut self) {
self.modal = Some(Modal::Help);
self.dirty = true;
}
pub fn show_chat(&mut self) {
if self.chat_state.messages.is_empty() {
let context = self.build_chat_context();
self.chat_state = ChatState::with_context("git workflow", context.as_deref());
}
self.modal = Some(Modal::Chat);
self.dirty = true;
}
fn build_chat_context(&self) -> Option<String> {
let mut sections = Vec::new();
if let Some(msg) = self
.modes
.commit
.messages
.get(self.modes.commit.current_index)
{
let formatted = format_commit_message(msg);
if !formatted.trim().is_empty() {
sections.push(format!("Commit Message:\n{}", formatted));
}
}
if !self.modes.review.review_content.is_empty() {
let preview = truncate_preview(&self.modes.review.review_content, 300);
sections.push(format!("Code Review:\n{}", preview));
}
if !self.modes.pr.pr_content.is_empty() {
let preview = truncate_preview(&self.modes.pr.pr_content, 300);
sections.push(format!("PR Description:\n{}", preview));
}
if !self.modes.changelog.changelog_content.is_empty() {
let preview = truncate_preview(&self.modes.changelog.changelog_content, 300);
sections.push(format!("Changelog:\n{}", preview));
}
if !self.modes.release_notes.release_notes_content.is_empty() {
let preview = truncate_preview(&self.modes.release_notes.release_notes_content, 300);
sections.push(format!("Release Notes:\n{}", preview));
}
if sections.is_empty() {
None
} else {
Some(sections.join("\n\n"))
}
}
pub fn close_modal(&mut self) {
if self.modal.is_some() {
self.modal = None;
self.dirty = true;
}
}
pub fn set_iris_thinking(&mut self, task: impl Into<String>) {
let msg = task.into();
self.iris_status = IrisStatus::Thinking {
task: msg.clone(),
fallback: msg,
spinner_frame: 0,
dynamic_messages: StatusMessageBatch::new(),
};
self.dirty = true;
}
pub fn add_status_message(&mut self, message: crate::agents::StatusMessage) {
self.iris_status.add_dynamic_message(message);
self.dirty = true;
}
pub fn set_iris_idle(&mut self) {
self.iris_status = IrisStatus::Idle;
self.dirty = true;
}
pub fn set_iris_complete(&mut self, message: impl Into<String>) {
let msg = message.into();
let capitalized = {
let mut chars = msg.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
};
self.iris_status = IrisStatus::Complete {
message: capitalized,
};
self.dirty = true;
}
pub fn set_iris_error(&mut self, error: impl Into<String>) {
self.iris_status = IrisStatus::Error(error.into());
self.dirty = true;
}
pub fn tick(&mut self) {
self.iris_status.tick();
self.cleanup_notifications();
if matches!(self.iris_status, IrisStatus::Thinking { .. }) {
self.dirty = true;
}
if matches!(self.modal, Some(Modal::Chat)) && self.chat_state.is_responding {
self.dirty = true;
}
}
pub fn get_branch_refs(&self) -> Vec<String> {
let fallback_refs = || {
vec![
"main".to_string(),
"master".to_string(),
"trunk".to_string(),
"develop".to_string(),
]
};
let Some(git_repo) = &self.repo else {
return fallback_refs();
};
let Ok(repo) = git_repo.open_repo() else {
return fallback_refs();
};
let default_base_ref = git_repo.get_default_base_ref().ok();
let mut refs = Vec::new();
if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
for branch in branches.flatten() {
if let Ok(Some(name)) = branch.0.name() {
refs.push(name.to_string());
}
}
}
if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
for branch in branches.flatten() {
if let Ok(Some(name)) = branch.0.name() {
if !name.ends_with("/HEAD") {
refs.push(name.to_string());
}
}
}
}
refs.sort_by(|a, b| {
branch_ref_priority(a, default_base_ref.as_deref())
.cmp(&branch_ref_priority(b, default_base_ref.as_deref()))
.then(a.cmp(b))
});
refs.dedup();
if refs.is_empty() {
if let Some(default_base_ref) = default_base_ref {
refs.push(default_base_ref);
} else {
refs = fallback_refs();
}
}
refs
}
pub fn get_commit_presets(&self) -> Vec<PresetInfo> {
use crate::instruction_presets::{PresetType, get_instruction_preset_library};
let library = get_instruction_preset_library();
let mut presets: Vec<PresetInfo> = library
.list_presets_by_type(Some(PresetType::Commit))
.into_iter()
.chain(library.list_presets_by_type(Some(PresetType::Both)))
.map(|(key, preset)| PresetInfo {
key: key.clone(),
name: preset.name.clone(),
description: preset.description.clone(),
emoji: preset.emoji.clone(),
})
.collect();
presets.sort_by(|a, b| {
if a.key == "default" {
std::cmp::Ordering::Less
} else if b.key == "default" {
std::cmp::Ordering::Greater
} else {
a.name.cmp(&b.name)
}
});
presets
}
pub fn get_emoji_list(&self) -> Vec<EmojiInfo> {
use crate::gitmoji::get_gitmoji_list;
let mut emojis = vec![
EmojiInfo {
emoji: "∅".to_string(),
key: "none".to_string(),
description: "No emoji".to_string(),
},
EmojiInfo {
emoji: "✨".to_string(),
key: "auto".to_string(),
description: "Let AI choose".to_string(),
},
];
for line in get_gitmoji_list().lines() {
let parts: Vec<&str> = line.splitn(3, " - ").collect();
if parts.len() >= 3 {
let emoji = parts[0].trim().to_string();
let key = parts[1].trim_matches(':').to_string();
let description = parts[2].to_string();
emojis.push(EmojiInfo {
emoji,
key,
description,
});
}
}
emojis
}
pub fn update_companion_display(&mut self) {
if let Some(ref companion) = self.companion {
let session = companion.session().read();
let duration = session.duration();
let duration_str = if duration.num_hours() > 0 {
format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
} else if duration.num_minutes() > 0 {
format!("{}m", duration.num_minutes())
} else {
"just started".to_string()
};
let last_touched = session.recent_files().first().map(|f| f.path.clone());
self.companion_display.files_touched = session.files_count();
self.companion_display.commits_made = session.commits_made.len();
self.companion_display.duration = duration_str;
self.companion_display.last_touched_file = last_touched;
self.companion_display.watcher_active = companion.has_watcher();
self.companion_display.branch = session.branch.clone();
} else {
self.companion_display.branch = self.git_status.branch.clone();
}
self.companion_display.staged_count = self.git_status.staged_count;
self.companion_display.unstaged_count =
self.git_status.modified_count + self.git_status.untracked_count;
self.companion_display.ahead = self.git_status.commits_ahead;
self.companion_display.behind = self.git_status.commits_behind;
if let Some(ref repo) = self.repo
&& let Ok(commits) = repo.get_recent_commits(6)
{
let mut entries: Vec<CommitEntry> = commits
.into_iter()
.map(|c| {
let relative_time = Self::format_relative_time(&c.timestamp);
CommitEntry {
short_hash: c.hash[..7.min(c.hash.len())].to_string(),
message: c.message.lines().next().unwrap_or("").to_string(),
author: c
.author
.split('<')
.next()
.unwrap_or(&c.author)
.trim()
.to_string(),
relative_time,
}
})
.collect();
self.companion_display.head_commit = entries.first().cloned();
if !entries.is_empty() {
entries.remove(0);
}
self.companion_display.recent_commits = entries.into_iter().take(5).collect();
}
}
fn format_relative_time(timestamp: &str) -> String {
use chrono::{DateTime, Utc};
if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
let now = Utc::now();
let then: DateTime<Utc> = dt.into();
let duration = now.signed_duration_since(then);
if duration.num_days() > 365 {
format!("{}y ago", duration.num_days() / 365)
} else if duration.num_days() > 30 {
format!("{}mo ago", duration.num_days() / 30)
} else if duration.num_days() > 0 {
format!("{}d ago", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{}h ago", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{}m ago", duration.num_minutes())
} else {
"just now".to_string()
}
} else {
timestamp.split('T').next().unwrap_or(timestamp).to_string()
}
}
pub fn clear_companion_welcome(&mut self) {
self.companion_display.welcome_message = None;
}
pub fn companion_touch_file(&mut self, path: PathBuf) {
if let Some(ref companion) = self.companion {
companion.touch_file(path);
}
}
pub fn companion_record_commit(&mut self, hash: String) {
if let Some(ref companion) = self.companion {
companion.record_commit(hash);
}
self.update_companion_display();
}
}
fn same_branch_ref(left: Option<&str>, right: Option<&str>) -> bool {
match (left, right) {
(Some(left), Some(right)) => normalize_branch_ref(left) == normalize_branch_ref(right),
_ => false,
}
}
fn normalize_branch_ref(value: &str) -> &str {
value.strip_prefix("origin/").unwrap_or(value)
}
fn branch_ref_priority(candidate: &str, default_base_ref: Option<&str>) -> i32 {
if same_branch_ref(Some(candidate), default_base_ref) {
if default_base_ref == Some(candidate) {
return 0;
}
return 1;
}
if !candidate.contains('/') {
return 2;
}
if candidate.starts_with("origin/") {
return 3;
}
4
}