diff --git a/CHANGELOG.md b/CHANGELOG.md index e60180cdc5..f073f88207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * execute git-hooks directly if possible (on *nix) else use sh instead of bash (without reading SHELL variable) [[@Joshix](https://github.com/Joshix-1)] ([#2483](https://github.com/extrawurst/gitui/pull/2483)) ### Added +* Configurable commit helper system with RON-based configuration, supporting multiple helpers with selection UI, hotkeys, and template variables * Files and status tab support pageUp and pageDown [[@fatpandac](https://github.com/fatpandac)] ([#1951](https://github.com/extrawurst/gitui/issues/1951)) * support loading custom syntax highlighting themes from a file [[@acuteenvy](https://github.com/acuteenvy)] ([#2565](https://github.com/gitui-org/gitui/pull/2565)) * Select syntax highlighting theme out of the defaults from syntect [[@vasilismanol](https://github.com/vasilismanol)] ([#1931](https://github.com/extrawurst/gitui/issues/1931)) diff --git a/commit_helpers.ron.example b/commit_helpers.ron.example new file mode 100644 index 0000000000..d6b4406e25 --- /dev/null +++ b/commit_helpers.ron.example @@ -0,0 +1,68 @@ +// Example configuration for GitUI commit helpers +// Copy this file to your GitUI config directory as "commit_helpers.ron" +// +// Config directory locations: +// - Linux: ~/.config/gitui/ +// - macOS: ~/Library/Application Support/gitui/ +// - Windows: %APPDATA%/gitui/ +// +// Template variables available in commands: +// - {staged_diff} - Output of 'git diff --staged --no-color' +// - {staged_files} - List of staged files from 'git diff --staged --name-only' +// - {branch_name} - Current branch name +// +// Helper navigation: +// - Ctrl+G: Open helper selection (if multiple helpers configured) +// - Arrow keys: Navigate between helpers in selection mode +// - Enter: Execute selected helper +// - Hotkeys: Press configured hotkey to run helper directly +// - ESC: Cancel selection or running helper + +CommitHelpers( + helpers: [ + // Claude AI helper example (using template variables) + CommitHelper( + name: "Claude AI", + command: "echo '{staged_diff}' | claude -p 'Based on the following git diff of staged changes, generate a concise, conventional commit message. Follow this format:\n\n: \n\nWhere is one of: feat, fix, docs, style, refactor, test, chore\nThe should be lowercase and concise (50 chars or less).\n\nFor multiple types of changes, use the most significant one.\nOutput ONLY the commit message, no explanation or quotes.'", + description: Some("Generate conventional commit messages using Claude AI"), + hotkey: Some('c'), + timeout_secs: Some(30), + ), + + // OpenAI ChatGPT helper example (using template variables) + CommitHelper( + name: "ChatGPT", + command: "echo '{staged_diff}' | chatgpt 'Generate a concise conventional commit message for this diff. Format: : . Types: feat, fix, docs, style, refactor, test, chore. Max 50 chars.'", + description: Some("Generate commit messages using ChatGPT"), + hotkey: Some('g'), + timeout_secs: Some(25), + ), + + // Local AI helper example (using template variables) + CommitHelper( + name: "Local AI", + command: "echo '{staged_diff}' | ollama run codellama 'Generate a conventional commit message for this git diff. Use format: type: description. Keep under 50 characters.'", + description: Some("Generate commit messages using local Ollama model"), + hotkey: Some('l'), + timeout_secs: Some(45), + ), + + // Branch-specific helper example + CommitHelper( + name: "Branch Fix", + command: "echo 'fix({branch_name}): address issues in {staged_files}'", + description: Some("Generate branch-specific fix message"), + hotkey: Some('b'), + timeout_secs: Some(5), + ), + + // Simple template-based helper + CommitHelper( + name: "Quick Fix", + command: "echo 'fix: address code issues'", + description: Some("Quick fix commit message"), + hotkey: Some('f'), + timeout_secs: Some(5), + ), + ] +) \ No newline at end of file diff --git a/src/commit_helpers.rs b/src/commit_helpers.rs new file mode 100644 index 0000000000..9ce03fbc22 --- /dev/null +++ b/src/commit_helpers.rs @@ -0,0 +1,298 @@ +use anyhow::Result; +use ron::de::from_reader; +use serde::{Deserialize, Serialize}; +use std::{ + fs::File, + path::PathBuf, + process::{Command, Stdio}, + sync::Arc, +}; + +use crate::args::get_app_config_path; + +pub type SharedCommitHelpers = Arc; + +const COMMIT_HELPERS_FILENAME: &str = "commit_helpers.ron"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitHelper { + /// Display name for the helper + pub name: String, + /// Command to execute (will be run through shell) + pub command: String, + /// Optional description of what this helper does + pub description: Option, + /// Optional hotkey for quick access + pub hotkey: Option, + /// Optional timeout in seconds (defaults to 30) + pub timeout_secs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CommitHelpers { + pub helpers: Vec, +} + +impl CommitHelpers { + fn get_config_file() -> Result { + let app_home = get_app_config_path()?; + let config_file = app_home.join(COMMIT_HELPERS_FILENAME); + Ok(config_file) + } + + pub fn init() -> Result { + let config_file = Self::get_config_file()?; + + if config_file.exists() { + let file = File::open(&config_file).map_err(|e| { + anyhow::anyhow!("Failed to open commit_helpers.ron: {e}. Check file permissions.") + })?; + + match from_reader::<_, Self>(file) { + Ok(config) => { + log::info!( + "Loaded {} commit helpers from config", + config.helpers.len() + ); + Ok(config) + } + Err(e) => { + log::error!( + "Failed to parse commit_helpers.ron: {e}" + ); + anyhow::bail!( + "Invalid RON syntax in commit_helpers.ron: {e}. \ + Check the example file or remove the config to reset." + ) + } + } + } else { + log::info!("No commit_helpers.ron found, using empty config. \ + See commit_helpers.ron.example for configuration options."); + Ok(Self::default()) + } + } + + pub fn get_helpers(&self) -> &[CommitHelper] { + &self.helpers + } + + pub fn find_by_hotkey(&self, hotkey: char) -> Option { + self.helpers.iter().position(|h| h.hotkey == Some(hotkey)) + } + + pub fn execute_helper( + &self, + helper_index: usize, + ) -> Result { + if helper_index >= self.helpers.len() { + anyhow::bail!("Invalid helper index"); + } + + let helper = &self.helpers[helper_index]; + + // Process template variables in command + let processed_command = + Self::process_template_variables(&helper.command)?; + + // Execute command through shell to support pipes and redirects + let output = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(["/C", &processed_command]) + .stdin(Stdio::null()) + .output()? + } else { + Command::new("sh") + .args(["-c", &processed_command]) + .stdin(Stdio::null()) + .output()? + }; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Command failed: {error}"); + } + + let result = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + + if result.is_empty() { + anyhow::bail!("Command returned empty output"); + } + + Ok(result) + } + + fn process_template_variables(command: &str) -> Result { + let mut processed = command.to_string(); + + // {staged_diff} - staged git diff + if processed.contains("{staged_diff}") { + let diff_output = Command::new("git") + .args(["diff", "--staged", "--no-color"]) + .output()?; + let diff = String::from_utf8_lossy(&diff_output.stdout); + processed = processed.replace("{staged_diff}", &diff); + } + + // {staged_files} - list of staged files + if processed.contains("{staged_files}") { + let files_output = Command::new("git") + .args(["diff", "--staged", "--name-only"]) + .output()?; + let files = String::from_utf8_lossy(&files_output.stdout); + processed = + processed.replace("{staged_files}", files.trim()); + } + + // {branch_name} - current branch name + if processed.contains("{branch_name}") { + let branch_output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output()?; + let branch = + String::from_utf8_lossy(&branch_output.stdout); + processed = + processed.replace("{branch_name}", branch.trim()); + } + + Ok(processed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_default_config() { + let config = CommitHelpers::default(); + assert!(config.helpers.is_empty()); + } + + #[test] + fn test_find_by_hotkey() { + let config = CommitHelpers { + helpers: vec![ + CommitHelper { + name: "Test Helper 1".to_string(), + command: "echo test1".to_string(), + description: None, + hotkey: Some('a'), + timeout_secs: None, + }, + CommitHelper { + name: "Test Helper 2".to_string(), + command: "echo test2".to_string(), + description: None, + hotkey: Some('b'), + timeout_secs: None, + }, + ], + }; + + assert_eq!(config.find_by_hotkey('a'), Some(0)); + assert_eq!(config.find_by_hotkey('b'), Some(1)); + assert_eq!(config.find_by_hotkey('c'), None); + } + + #[test] + fn test_process_template_variables() { + // Test basic template processing (these will use actual git commands) + let result = CommitHelpers::process_template_variables( + "test {branch_name} test", + ); + assert!(result.is_ok()); + + // Test no template variables + let result = CommitHelpers::process_template_variables( + "no templates here", + ) + .unwrap(); + assert_eq!(result, "no templates here"); + } + + #[test] + fn test_execute_helper_invalid_index() { + let config = CommitHelpers::default(); + let result = config.execute_helper(0); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid helper index")); + } + + #[test] + fn test_execute_helper_success() { + let config = CommitHelpers { + helpers: vec![CommitHelper { + name: "Echo Test".to_string(), + command: "echo 'test message'".to_string(), + description: None, + hotkey: None, + timeout_secs: None, + }], + }; + + let result = config.execute_helper(0); + assert!(result.is_ok()); + assert_eq!(result.unwrap().trim(), "test message"); + } + + #[test] + fn test_execute_helper_empty_output() { + let config = CommitHelpers { + helpers: vec![CommitHelper { + name: "Empty Test".to_string(), + command: "true".to_string(), // Command that succeeds but produces no output + description: None, + hotkey: None, + timeout_secs: None, + }], + }; + + let result = config.execute_helper(0); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Command returned empty output")); + } + + #[test] + fn test_config_file_parsing() { + let temp_dir = TempDir::new().unwrap(); + let config_content = r#"CommitHelpers( + helpers: [ + CommitHelper( + name: "Test Helper", + command: "echo test", + description: Some("A test helper"), + hotkey: Some('t'), + timeout_secs: Some(15), + ) + ] +)"#; + + let config_path = temp_dir.path().join("test_helpers.ron"); + fs::write(&config_path, config_content).unwrap(); + + let file = std::fs::File::open(&config_path).unwrap(); + let config: CommitHelpers = + ron::de::from_reader(file).unwrap(); + + assert_eq!(config.helpers.len(), 1); + assert_eq!(config.helpers[0].name, "Test Helper"); + assert_eq!(config.helpers[0].command, "echo test"); + assert_eq!( + config.helpers[0].description, + Some("A test helper".to_string()) + ); + assert_eq!(config.helpers[0].hotkey, Some('t')); + assert_eq!(config.helpers[0].timeout_secs, Some(15)); + } +} diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 0f2909a2fe..164041271b 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -128,6 +128,7 @@ pub struct KeysList { pub commit_history_next: GituiKeyEvent, pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, + pub commit_helper: GituiKeyEvent, } #[rustfmt::skip] @@ -225,6 +226,7 @@ impl Default for KeysList { commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + commit_helper: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), } } } diff --git a/src/main.rs b/src/main.rs index 50d7a98b93..77232ef9a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,7 @@ mod args; mod bug_report; mod clipboard; mod cmdbar; +mod commit_helpers; mod components; mod input; mod keys; @@ -271,6 +272,8 @@ fn run_app( if matches!(event, QueueEvent::SpinnerUpdate) { spinner.update(); spinner.draw(terminal)?; + // Also update app for commit helper animations + app.update()?; continue; } diff --git a/src/popups/commit.rs b/src/popups/commit.rs index 008fc6f8a7..2114a3d124 100644 --- a/src/popups/commit.rs +++ b/src/popups/commit.rs @@ -4,13 +4,14 @@ use crate::components::{ }; use crate::{ app::Environment, + commit_helpers::{CommitHelpers, SharedCommitHelpers}, keys::{key_match, SharedKeyConfig}, options::SharedOptions, queue::{InternalEvent, NeedsUpdate, Queue}, strings, try_or_popup, ui::style::SharedTheme, }; -use anyhow::{bail, Ok, Result}; +use anyhow::{bail, Result}; use asyncgit::sync::commit::commit_message_prettify; use asyncgit::{ cached, @@ -34,6 +35,9 @@ use std::{ io::{Read, Write}, path::PathBuf, str::FromStr, + sync::mpsc::{self, Receiver}, + thread, + time::Instant, }; use super::ExternalEditorPopup; @@ -51,6 +55,21 @@ enum Mode { Reword(CommitId), } +#[derive(Clone)] +enum HelperState { + Idle, + Selection { + selected_index: usize, + }, + Running { + helper_name: String, + frame_index: usize, + start_time: std::time::Instant, + }, + Success(String), // generated message + Error(String), // error message +} + pub struct CommitPopup { repo: RepoPathRef, input: TextInputComponent, @@ -63,10 +82,19 @@ pub struct CommitPopup { commit_msg_history_idx: usize, options: SharedOptions, verify: bool, + commit_helpers: SharedCommitHelpers, + helper_state: HelperState, + helper_receiver: Option>>, } const FIRST_LINE_LIMIT: usize = 50; +// Spinner animation frames +const SPINNER_FRAMES: &[&str] = + &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_INTERVAL_MS: u64 = 80; +const HELPER_TIMEOUT_SECS: u64 = 30; + impl CommitPopup { /// pub fn new(env: &Environment) -> Self { @@ -89,12 +117,94 @@ impl CommitPopup { commit_msg_history_idx: 0, options: env.options.clone(), verify: true, + commit_helpers: std::sync::Arc::new( + CommitHelpers::init().unwrap_or_default(), + ), + helper_state: HelperState::Idle, + helper_receiver: None, } } /// - pub fn update(&mut self) { + pub fn update(&mut self) -> bool { self.git_branch_name.lookup().ok(); + self.check_helper_result(); + self.update_helper_animation() + } + + fn check_helper_result(&mut self) { + if let Some(receiver) = &self.helper_receiver { + if let Ok(result) = receiver.try_recv() { + match result { + Ok(generated_msg) => { + let current_msg = self.input.get_text(); + let new_msg = + if current_msg.is_empty() { + generated_msg + } else { + format!("{current_msg}\n\n{generated_msg}") + }; + self.input.set_text(new_msg); + self.helper_state = HelperState::Success( + "Generated successfully".to_string(), + ); + } + Err(error_msg) => { + self.helper_state = + HelperState::Error(error_msg); + } + } + self.helper_receiver = None; + } + } + } + + pub fn clear_helper_message(&mut self) { + if matches!( + self.helper_state, + HelperState::Success(_) + | HelperState::Error(_) + | HelperState::Selection { .. } + ) { + self.helper_state = HelperState::Idle; + } + } + + pub fn cancel_helper(&mut self) { + if matches!(self.helper_state, HelperState::Running { .. }) { + self.helper_state = + HelperState::Error("Cancelled by user".to_string()); + self.helper_receiver = None; + } + } + + pub fn update_helper_animation(&mut self) -> bool { + if let HelperState::Running { + frame_index, + start_time, + helper_name: _, + } = &mut self.helper_state + { + let elapsed = start_time.elapsed(); + + // Check timeout + if elapsed.as_secs() > HELPER_TIMEOUT_SECS { + self.helper_state = HelperState::Error( + "Timeout: Helper took too long".to_string(), + ); + return true; + } + + // Update frame + let new_frame = (elapsed.as_millis() + / u128::from(SPINNER_INTERVAL_MS)) + as usize % SPINNER_FRAMES.len(); + if *frame_index != new_frame { + *frame_index = new_frame; + return true; // Animation frame changed, needs redraw + } + } + false // No update needed } fn draw_branch_name(&self, f: &mut Frame) { @@ -144,6 +254,62 @@ impl CommitPopup { } } + fn draw_helper_status(&self, f: &mut Frame) { + use ratatui::style::Style; + use ratatui::widgets::{Paragraph, Wrap}; + + let (msg, style) = match &self.helper_state { + HelperState::Idle => return, + HelperState::Selection { selected_index } => { + let helpers = self.commit_helpers.get_helpers(); + helpers.get(*selected_index).map_or_else( + || (String::from("No helpers available"), self.theme.text_danger()), + |helper| { + let hotkey_hint = helper.hotkey + .map(|h| format!(" [{h}]")) + .unwrap_or_default(); + (format!("Select helper: {} ({}/{}){}. [↑↓] to navigate, [Enter] to run, [ESC] to cancel", + helper.name, selected_index + 1, helpers.len(), hotkey_hint), + Style::default().fg(ratatui::style::Color::Cyan)) + } + ) + } + HelperState::Running { + helper_name, + frame_index, + start_time, + } => { + let spinner = SPINNER_FRAMES[*frame_index]; + let elapsed = start_time.elapsed().as_secs(); + (format!("{spinner} Generating with {helper_name}... ({elapsed}s) [ESC to cancel]"), + Style::default().fg(ratatui::style::Color::Yellow)) + } + HelperState::Success(msg) => ( + format!("✅ {msg}"), + Style::default().fg(ratatui::style::Color::Green), + ), + HelperState::Error(err) => { + (format!("❌ {err}"), self.theme.text_danger()) + } + }; + + let msg_length: u16 = + msg.chars().count().try_into().unwrap_or(0); + let paragraph = Paragraph::new(msg) + .style(style) + .wrap(Wrap { trim: true }); + + let rect = { + let mut rect = self.input.get_area(); + rect.y = rect.y.saturating_add(rect.height); + rect.height = 1; + rect.width = msg_length.min(rect.width); + rect + }; + + f.render_widget(paragraph, rect); + } + const fn item_status_char( item_type: StatusItemType, ) -> &'static str { @@ -347,6 +513,83 @@ impl CommitPopup { self.verify = !self.verify; } + fn run_commit_helper(&mut self) -> Result<()> { + self.open_helper_selection() + } + + fn open_helper_selection(&mut self) -> Result<()> { + // Check if already running + if matches!(self.helper_state, HelperState::Running { .. }) { + return Ok(()); + } + + let helpers = self.commit_helpers.get_helpers(); + if helpers.is_empty() { + let config_path = if cfg!(target_os = "macos") { + "~/Library/Application Support/gitui/commit_helpers.ron" + } else if cfg!(target_os = "windows") { + "%APPDATA%/gitui/commit_helpers.ron" + } else { + "~/.config/gitui/commit_helpers.ron" + }; + self.helper_state = HelperState::Error( + format!("No commit helpers configured. Create {config_path} (see .example file)") + ); + return Ok(()); + } + + if helpers.len() == 1 { + // Only one helper, run it directly + self.execute_helper_by_index(0) + } else { + // Multiple helpers, show selection UI + self.helper_state = + HelperState::Selection { selected_index: 0 }; + Ok(()) + } + } + + fn execute_helper_by_index( + &mut self, + helper_index: usize, + ) -> Result<()> { + let helpers = self.commit_helpers.get_helpers(); + if helper_index >= helpers.len() { + self.helper_state = HelperState::Error( + "Invalid helper index".to_string(), + ); + return Ok(()); + } + + let helper = helpers[helper_index].clone(); + + // Set running state with animation + self.helper_state = HelperState::Running { + helper_name: helper.name, + frame_index: 0, + start_time: Instant::now(), + }; + + // Create channel for communication + let (tx, rx) = mpsc::channel(); + self.helper_receiver = Some(rx); + + // Clone helpers for thread + let commit_helpers = self.commit_helpers.clone(); + + // Execute helper in background thread + thread::spawn(move || { + let result = commit_helpers + .execute_helper(helper_index) + .map_err(|e| format!("Failed: {e}")); + + // Send result back (ignore if receiver is dropped) + let _ = tx.send(result); + }); + + Ok(()) + } + pub fn open(&mut self, reword: Option) -> Result<()> { //only clear text if it was not a normal commit dlg before, so to preserve old commit msg that was edited if !matches!(self.mode, Mode::Normal) { @@ -485,6 +728,7 @@ impl DrawableComponent for CommitPopup { self.input.draw(f, rect)?; self.draw_branch_name(f); self.draw_warnings(f); + self.draw_helper_status(f); } Ok(()) @@ -548,6 +792,16 @@ impl Component for CommitPopup { true, true, )); + + if !self.commit_helpers.get_helpers().is_empty() { + out.push(CommandInfo::new( + strings::commands::commit_helper( + &self.key_config, + ), + true, + true, + )); + } } visibility_blocking(self) @@ -555,6 +809,95 @@ impl Component for CommitPopup { fn event(&mut self, ev: &Event) -> Result { if self.is_visible() { + // Handle helper selection navigation + if let Event::Key(key) = ev { + // Handle helper selection state + if let HelperState::Selection { selected_index } = + &self.helper_state + { + match key.code { + crossterm::event::KeyCode::Esc => { + self.helper_state = HelperState::Idle; + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Up => { + let helpers_len = self + .commit_helpers + .get_helpers() + .len(); + if helpers_len > 0 { + let new_index = + if *selected_index == 0 { + helpers_len - 1 + } else { + *selected_index - 1 + }; + self.helper_state = + HelperState::Selection { + selected_index: new_index, + }; + } + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Down => { + let helpers_len = self + .commit_helpers + .get_helpers() + .len(); + if helpers_len > 0 { + let new_index = (*selected_index + 1) + % helpers_len; + self.helper_state = + HelperState::Selection { + selected_index: new_index, + }; + } + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Enter => { + let selected = *selected_index; + try_or_popup!( + self, + "helper execution error:", + self.execute_helper_by_index( + selected + ) + ); + return Ok(EventState::Consumed); + } + crossterm::event::KeyCode::Char(c) => { + // Check for hotkey match + if let Some(index) = + self.commit_helpers.find_by_hotkey(c) + { + try_or_popup!( + self, + "helper execution error:", + self.execute_helper_by_index( + index + ) + ); + return Ok(EventState::Consumed); + } + } + _ => {} + } + } + + // Handle ESC to cancel running helper + if key.code == crossterm::event::KeyCode::Esc + && matches!( + self.helper_state, + HelperState::Running { .. } + ) { + self.cancel_helper(); + return Ok(EventState::Consumed); + } + + // Clear success/error messages on key press + self.clear_helper_message(); + } + if let Event::Key(e) = ev { let input_consumed = if key_match(e, self.key_config.keys.commit) @@ -608,6 +951,16 @@ impl Component for CommitPopup { ) { self.signoff_commit(); true + } else if key_match( + e, + self.key_config.keys.commit_helper, + ) { + try_or_popup!( + self, + "commit helper error:", + self.run_commit_helper() + ); + true } else { false }; diff --git a/src/strings.rs b/src/strings.rs index c4cff10f70..f49eaf8ff3 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1175,6 +1175,18 @@ pub mod commands { CMD_GROUP_COMMIT_POPUP, ) } + pub fn commit_helper( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Generate [{}]", + key_config.get_hint(key_config.keys.commit_helper), + ), + "generate commit message using helper", + CMD_GROUP_COMMIT_POPUP, + ) + } pub fn edit_item(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!(