commiti 1.2.3 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55dfe17b32ed45b600920943c16b99f39b0acff0c9c66e516db63e6fa7a0debf
4
- data.tar.gz: 9f70caaeef73f63211e93bd28ae2ae69236c0e777153c48a765066a0825e4e49
3
+ metadata.gz: 026566db469248936e47f40faa446650926b7bb893089246a6c23a45dfaa432a
4
+ data.tar.gz: 251995106b1883d61cb12c00f911a4e3158d6d47e9b9d1b434d2026234dd3c2f
5
5
  SHA512:
6
- metadata.gz: a627866611dcc0974b30a005814b38226833412a1789a57f4f3559c010849313731d361dd0d0e192722577e0f1353d8fa55c2bf9487c4d83bbe596e7d5a04e16
7
- data.tar.gz: 0b8e9679be21a6f74ea419ffb4968f883fb564990eeab845c63f525190d459a151cebc17d2d852a3eba9f8293093107bd146e76cf37cae8087af88bd06f53e5e
6
+ metadata.gz: c0d857c40f016686ee37b6b7f2d1109c5d7f79a7037c0ed41d0e49e1d3556ff57ce0db9f1da3f0f26e4956840e5a7ab0376a39799f359673068542e89a11480c
7
+ data.tar.gz: 677c08e9d0d5ae62723f7bb3fdea97c3c3799dd5a3775b133ca5520af32a102d924863c5f733794e005b3ff82c79a22d30a7aaee578985118ec0af31cd13c8ce
data/README.md CHANGED
@@ -51,6 +51,7 @@ GOOGLE_API_KEY=your_google_ai_key
51
51
  # COMMITI_CANDIDATES=1
52
52
  # COMMITI_BASE_BRANCH=main
53
53
  # COMMITI_NO_COPY=false
54
+ # COMMITI_AUTO_SPLIT=false
54
55
  ```
55
56
 
56
57
  `GEMINI_API_KEY` is also accepted as an alias for `GOOGLE_API_KEY`.
@@ -67,10 +68,14 @@ Never commit `.env` to git.
67
68
  - `--base BRANCH` base branch for PR diff (default: `main`)
68
69
  - `--no-copy` print output only, skip clipboard copy
69
70
  - `--candidates N` generate `N` output candidates (`1`-`5`, default: `1`)
71
+ - `--auto-split` auto-group staged changes into multiple connected commits (commit flow only)
70
72
  - `-h`, `--help` show help
71
73
 
72
74
  ## Commit Flow (`--type commit`)
73
75
 
76
+ By default, Commiti creates a single commit from staged changes.
77
+ Use `--auto-split` to let Commiti group connected file changes into multiple atomic commits.
78
+
74
79
  1. Shows `git status --short`.
75
80
  2. Asks for confirmation before staging (`git add -A`).
76
81
  3. Ensures there are staged changes.
@@ -109,7 +114,8 @@ Commit edit mode uses:
109
114
  - `## Changes Made`
110
115
  - `## Testing Notes`
111
116
  3. Builds provider compare/MR URL with prefilled title/body using query params.
112
- - GitHub/GitBucket: compare URL
117
+ - GitHub: compare URL with `quick_pull=1` (opens the PR form directly)
118
+ - GitBucket: compare URL with `expand=1`
113
119
  - GitLab: new merge request URL
114
120
  - If the URL would exceed safe browser/provider limits, Commiti drops description prefill automatically and keeps the shortest usable URL.
115
121
  4. Asks before opening browser.
data/bin/commiti CHANGED
@@ -6,7 +6,8 @@ require 'dotenv/load'
6
6
  require 'commiti'
7
7
 
8
8
  options = {
9
- type: :commit
9
+ type: :commit,
10
+ auto_split: false
10
11
  }
11
12
 
12
13
  OptionParser.new do |opts|
@@ -35,6 +36,10 @@ OptionParser.new do |opts|
35
36
  options[:candidates] = n
36
37
  end
37
38
 
39
+ opts.on('--auto-split', 'Auto-group staged changes into multiple connected commits (commit flow only)') do
40
+ options[:auto_split] = true
41
+ end
42
+
38
43
  opts.on('-h', '--help', 'Show this help') do
39
44
  puts opts
40
45
  exit
data/lib/commiti.rb CHANGED
@@ -10,12 +10,14 @@ require_relative 'services/helpers/prompt_builder'
10
10
  require_relative 'services/helpers/interactive_prompt'
11
11
  require_relative 'services/git/pr/pr_opener'
12
12
  require_relative 'services/helpers/clipboard'
13
+ require_relative 'services/helpers/terminal_ui'
13
14
  require_relative 'services/helpers/spinner'
14
15
  require_relative 'services/flow_context_builder'
15
16
  require_relative 'services/message_generator'
16
17
  require_relative 'services/message_presenter'
17
18
  require_relative 'services/git/commit/commit_staging'
18
19
  require_relative 'services/git/commit/commit_execution'
20
+ require_relative 'services/git/commit/change_grouping'
19
21
  require_relative 'flows/base_flow'
20
22
  require_relative 'flows/commit_flow'
21
23
  require_relative 'flows/pr_flow'
@@ -3,6 +3,12 @@
3
3
  module Commiti
4
4
  module Flows
5
5
  class CommitFlow < BaseFlow
6
+ def run
7
+ return super unless options[:auto_split]
8
+
9
+ run_auto_split
10
+ end
11
+
6
12
  private
7
13
 
8
14
  def flow_type
@@ -24,6 +30,91 @@ module Commiti
24
30
  print_message: method(:print_message)
25
31
  )
26
32
  end
33
+
34
+ def run_auto_split
35
+ prepare!
36
+ diff = collect_diff
37
+ client = Commiti::GoogleClient.new(config: options)
38
+ selected_model = options[:model]
39
+ context = build_context(diff:, client:, model: selected_model)
40
+
41
+ return run_single_group_context(context:, client:, model: selected_model) if single_group?(context)
42
+
43
+ run_grouped_context(context:, client:, model: selected_model)
44
+ rescue StandardError
45
+ run_stage('Restaging uncommitted changes after failure') { Commiti::GitWriter.stage_all! }
46
+ raise
47
+ end
48
+
49
+ def single_group?(context)
50
+ context[:change_groups].length <= 1
51
+ end
52
+
53
+ def run_single_group_context(context:, client:, model:)
54
+ message = 'Auto-split found a single connected change group. Falling back to single commit flow.'
55
+ puts "\n#{Commiti::TerminalUI.status(:info, message)}"
56
+ Commiti::MessagePresenter.print_summarization_notice(context[:summarized_result])
57
+
58
+ message = generate_message_for_context(context:, client:, model:)
59
+ maybe_copy_to_clipboard(message)
60
+ finalize(message)
61
+ end
62
+
63
+ def run_grouped_context(context:, client:, model:)
64
+ groups = context[:change_groups]
65
+ run_stage('Unstaging current index for grouped commit execution') { Commiti::GitWriter.unstage_all! }
66
+
67
+ puts "\n#{Commiti::TerminalUI.status(:info, "Auto-split detected #{groups.length} connected change groups.")}"
68
+
69
+ groups.each_with_index do |group, index|
70
+ break if process_group(group:, index:, total: groups.length, client:, model:) == :stop
71
+ end
72
+ end
73
+
74
+ def process_group(group:, index:, total:, client:, model:)
75
+ run_stage("Staging files for group #{index + 1}/#{total}") { Commiti::GitWriter.stage_files!(group[:files]) }
76
+ return :continue unless run_stage('Checking staged changes') { Commiti::GitWriter.staged_changes? }
77
+
78
+ puts "\n#{Commiti::TerminalUI.header("Group #{index + 1}/#{total} files")}:"
79
+ group[:files].each { |path| puts "- #{path}" }
80
+
81
+ group_context = build_context(diff: group_diff(group), client:, model:)
82
+ Commiti::MessagePresenter.print_summarization_notice(group_context[:summarized_result])
83
+
84
+ message = generate_message_for_context(context: group_context, client:, model:)
85
+ maybe_copy_to_clipboard(message)
86
+ return :continue if finalize(message) == :committed
87
+
88
+ stop_message = "Stopping auto-split flow at group #{index + 1} because commit was skipped."
89
+ puts Commiti::TerminalUI.status(:warn, stop_message)
90
+ run_stage('Restaging remaining uncommitted changes') { Commiti::GitWriter.stage_all! }
91
+ :stop
92
+ end
93
+
94
+ def build_context(diff:, client:, model:)
95
+ Commiti::FlowContextBuilder.build(
96
+ flow_type: flow_type,
97
+ diff: diff,
98
+ client: client,
99
+ run_stage: method(:run_stage),
100
+ model: model
101
+ )
102
+ end
103
+
104
+ def generate_message_for_context(context:, client:, model:)
105
+ candidates = generate_candidates(
106
+ client: client,
107
+ prompt: context[:prompt],
108
+ diff_metadata: context[:diff_metadata],
109
+ model: model
110
+ )
111
+
112
+ select_message(candidates)
113
+ end
114
+
115
+ def group_diff(group)
116
+ group[:chunks].map { |chunk| chunk[:lines].join }.join
117
+ end
27
118
  end
28
119
  end
29
120
  end
@@ -5,6 +5,7 @@ module Commiti
5
5
  def self.build(flow_type:, diff:, client:, run_stage:, model:)
6
6
  line_chunks = Commiti::DiffParser.split_by_file_lines(diff)
7
7
  diff_metadata = Commiti::DiffParser.metadata_from_line_chunks(line_chunks)
8
+ change_groups = Commiti::ChangeGrouping.group(line_chunks)
8
9
 
9
10
  summarized_result = run_stage.call('Preparing diff for AI model') do
10
11
  Commiti::DiffSummarizer.summarize_if_needed(
@@ -25,6 +26,7 @@ module Commiti
25
26
 
26
27
  {
27
28
  diff_metadata: diff_metadata,
29
+ change_groups: change_groups,
28
30
  summarized_result: summarized_result,
29
31
  prompt: prompt
30
32
  }
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module ChangeGrouping
5
+ NOISE_DIRECTORIES = %w[lib spec test app src docs].freeze
6
+
7
+ def self.group(line_chunks)
8
+ paths = line_chunks.map { |chunk| chunk[:path].to_s.strip }.reject(&:empty?).uniq
9
+ return [] if paths.empty?
10
+
11
+ components = connected_components(paths)
12
+ components.map.with_index(1) do |component, index|
13
+ {
14
+ id: index,
15
+ files: component,
16
+ chunks: line_chunks.select { |chunk| component.include?(chunk[:path]) }
17
+ }
18
+ end
19
+ end
20
+
21
+ def self.connected_components(paths)
22
+ visited = {}
23
+ ordered_components = []
24
+
25
+ paths.each do |root|
26
+ next if visited[root]
27
+
28
+ stack = [root]
29
+ visited[root] = true
30
+ component = []
31
+
32
+ until stack.empty?
33
+ current = stack.pop
34
+ component << current
35
+
36
+ paths.each do |candidate|
37
+ next if visited[candidate]
38
+ next unless connected?(current, candidate)
39
+
40
+ visited[candidate] = true
41
+ stack << candidate
42
+ end
43
+ end
44
+
45
+ ordered_components << component.sort_by { |path| paths.index(path) }
46
+ end
47
+
48
+ ordered_components
49
+ end
50
+ private_class_method :connected_components
51
+
52
+ def self.connected?(left, right)
53
+ return true if primary_namespace(left) == primary_namespace(right)
54
+ return true if logical_stem(left) == logical_stem(right)
55
+
56
+ left_segments = normalized_segments(left)
57
+ right_segments = normalized_segments(right)
58
+ shared_depth = [left_segments.length, right_segments.length].min
59
+
60
+ (0...shared_depth).count { |index| left_segments[index] == right_segments[index] } >= 2
61
+ end
62
+ private_class_method :connected?
63
+
64
+ def self.primary_namespace(path)
65
+ segments = normalized_segments(path)
66
+ return nil if segments.empty?
67
+
68
+ segments.first(2).join('/')
69
+ end
70
+ private_class_method :primary_namespace
71
+
72
+ def self.logical_stem(path)
73
+ normalized = path.to_s
74
+ normalized = normalized.sub(%r{\A(?:lib|spec|test|app|src)/}, '') while normalized.match?(%r{\A(?:lib|spec|test|app|src)/})
75
+ normalized = normalized.sub(/_spec\.[^.]+\z/, '')
76
+ normalized.sub(/\.[^.]+\z/, '')
77
+ end
78
+ private_class_method :logical_stem
79
+
80
+ def self.normalized_segments(path)
81
+ path.to_s.split('/').reject { |segment| segment.empty? || NOISE_DIRECTORIES.include?(segment) }
82
+ end
83
+ private_class_method :normalized_segments
84
+ end
85
+ end
@@ -12,14 +12,14 @@ module Commiti
12
12
  when :yes
13
13
  errors = Commiti::InteractivePrompt.commit_message_errors(working_message)
14
14
  unless errors.empty?
15
- puts "\nCurrent message needs fixes before commit:"
15
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Current message needs fixes before commit:')}"
16
16
  errors.each { |error| puts "- #{error}" }
17
17
 
18
18
  if Commiti::InteractivePrompt.ask_yes_no('Open editor to fix now?', default: :yes)
19
19
  edited = edit_message_until_valid(working_message)
20
20
  if edited.nil?
21
- puts "\nEditor did not exit successfully. Commit skipped.\n\n"
22
- return
21
+ puts "\n#{Commiti::TerminalUI.status(:fail, 'Editor did not exit successfully. Commit skipped.')}\n\n"
22
+ return :skipped
23
23
  end
24
24
 
25
25
  working_message = edited
@@ -27,26 +27,26 @@ module Commiti
27
27
  next
28
28
  end
29
29
 
30
- puts "\nCommit skipped.\n\n"
31
- return
30
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Commit skipped.')}\n\n"
31
+ return :skipped
32
32
  end
33
33
 
34
34
  output = run_stage.call('Writing commit') { Commiti::GitWriter.commit_with_message_file(working_message) }
35
35
  puts output unless output.to_s.strip.empty?
36
- puts "\nCommit created.\n\n"
37
- return
36
+ puts "\n#{Commiti::TerminalUI.status(:success, 'Commit created.')}\n\n"
37
+ return :committed
38
38
  when :edit
39
39
  edited = edit_message_until_valid(working_message)
40
40
  if edited.nil?
41
- puts "\nEditor did not exit successfully.\n\n"
41
+ puts "\n#{Commiti::TerminalUI.status(:fail, 'Editor did not exit successfully.')}\n\n"
42
42
  next
43
43
  end
44
44
 
45
45
  working_message = edited
46
46
  print_message.call(working_message)
47
47
  else
48
- puts "\nCommit skipped.\n\n"
49
- return
48
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Commit skipped.')}\n\n"
49
+ return :skipped
50
50
  end
51
51
  end
52
52
  end
@@ -59,7 +59,7 @@ module Commiti
59
59
  return nil if edited.nil?
60
60
 
61
61
  if edited == working.to_s.strip
62
- puts "\nNo changes detected in editor."
62
+ puts "\n#{Commiti::TerminalUI.status(:info, 'No changes detected in editor.')}"
63
63
  return edited unless Commiti::InteractivePrompt.ask_yes_no('Re-open editor now?', default: :yes)
64
64
 
65
65
  next
@@ -68,7 +68,7 @@ module Commiti
68
68
  errors = Commiti::InteractivePrompt.commit_message_errors(edited)
69
69
  return edited if errors.empty?
70
70
 
71
- puts "\nEdited message needs fixes:"
71
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Edited message needs fixes:')}"
72
72
  errors.each { |error| puts "- #{error}" }
73
73
  return edited unless Commiti::InteractivePrompt.ask_yes_no('Re-open editor now?', default: :yes)
74
74
 
@@ -11,11 +11,11 @@ module Commiti
11
11
  status = run_stage.call('Reading git status') { Commiti::GitWriter.status_short }
12
12
  raise 'No changes found in working tree.' if status.strip.empty?
13
13
 
14
- puts "\nCurrent git status:\n\n#{status}"
14
+ puts "\n#{Commiti::TerminalUI.header('Current git status')}\n\n#{status}"
15
15
  return unless Commiti::InteractivePrompt.ask_yes_no('Run git add -A now?', default: :no)
16
16
 
17
- run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all }
18
- puts "\nStaged changes with git add -A.\n"
17
+ run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }
18
+ puts "\n#{Commiti::TerminalUI.status(:success, 'Staged changes with git add -A.')}\n"
19
19
  end
20
20
  private_class_method :maybe_stage_changes
21
21
 
@@ -25,8 +25,8 @@ module Commiti
25
25
 
26
26
  if Commiti::InteractivePrompt.ask_yes_no('No staged changes found. Stage all changes now with git add -A?',
27
27
  default: :yes)
28
- run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all }
29
- puts "\nStaged changes with git add -A.\n"
28
+ run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }
29
+ puts "\n#{Commiti::TerminalUI.status(:success, 'Staged changes with git add -A.')}\n"
30
30
  end
31
31
 
32
32
  staged = run_stage.call('Checking staged changes') { Commiti::GitWriter.staged_changes? }
@@ -19,11 +19,28 @@ module Commiti
19
19
  !out.strip.empty?
20
20
  end
21
21
 
22
- def self.stage_all
22
+ def self.stage_all!
23
23
  out, err, status = Open3.capture3('git', 'add', '-A')
24
24
  raise "git add failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
25
25
 
26
- true
26
+ out
27
+ end
28
+
29
+ def self.unstage_all!
30
+ out, err, status = Open3.capture3('git', 'reset')
31
+ raise "git reset failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
32
+
33
+ out
34
+ end
35
+
36
+ def self.stage_files!(paths)
37
+ normalized = Array(paths).map(&:to_s).map(&:strip).reject(&:empty?).uniq
38
+ return '' if normalized.empty?
39
+
40
+ out, err, status = Open3.capture3('git', 'add', '--', *normalized)
41
+ raise "git add failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
42
+
43
+ out
27
44
  end
28
45
 
29
46
  def self.commit_with_message_file(message)
@@ -71,7 +71,9 @@ module Commiti
71
71
  private_class_method :prefilled_url
72
72
 
73
73
  def self.github_like_compare_url(remote:, base_branch:, head_branch:, title:, body:, include_title: true, include_body: true)
74
- query_params = { 'expand' => '1' }
74
+ query_params = {
75
+ github_compare_prefill_param(remote[:provider]) => '1'
76
+ }
75
77
  normalized_title = normalize_title(title)
76
78
  query_params['title'] = normalized_title if include_title && !normalized_title.empty?
77
79
  query_params['body'] = body.to_s if include_body && !body.to_s.empty?
@@ -83,6 +85,11 @@ module Commiti
83
85
  "#{base}/#{path}/compare/#{encode_branch_for_path(base_branch)}...#{encode_branch_for_path(head_branch)}?#{query}"
84
86
  end
85
87
 
88
+ def self.github_compare_prefill_param(provider)
89
+ provider == :github ? 'quick_pull' : 'expand'
90
+ end
91
+ private_class_method :github_compare_prefill_param
92
+
86
93
  def self.gitlab_mr_url(remote:, base_branch:, head_branch:, title:, body:, include_title: true, include_description: true)
87
94
  query_params = {
88
95
  'merge_request[source_branch]' => head_branch,
@@ -8,6 +8,7 @@ module Commiti
8
8
  candidates: 1,
9
9
  base_branch: 'main',
10
10
  no_copy: false,
11
+ auto_split: false,
11
12
  temperature: Commiti::GoogleClient::DEFAULT_TEMPERATURE,
12
13
  timeout_seconds: Commiti::GoogleClient::DEFAULT_TIMEOUT_SECONDS,
13
14
  open_timeout_seconds: Commiti::GoogleClient::DEFAULT_OPEN_TIMEOUT_SECONDS
@@ -22,6 +23,7 @@ module Commiti
22
23
  candidates: integer_or_default(env.fetch('COMMITI_CANDIDATES', nil), DEFAULT_CONFIG[:candidates]),
23
24
  base_branch: present_or_default(env.fetch('COMMITI_BASE_BRANCH', nil), DEFAULT_CONFIG[:base_branch]),
24
25
  no_copy: boolean_or_default(env.fetch('COMMITI_NO_COPY', nil), DEFAULT_CONFIG[:no_copy]),
26
+ auto_split: boolean_or_default(env.fetch('COMMITI_AUTO_SPLIT', nil), DEFAULT_CONFIG[:auto_split]),
25
27
  temperature: float_or_default(env.fetch('COMMITI_MODEL_TEMPERATURE', nil), DEFAULT_CONFIG[:temperature]),
26
28
  timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_TIMEOUT_SECONDS', nil), DEFAULT_CONFIG[:timeout_seconds]),
27
29
  open_timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_OPEN_TIMEOUT_SECONDS', nil),
@@ -5,13 +5,8 @@ module Commiti
5
5
  FRAMES = ['|', '/', '-', '\\'].freeze
6
6
  INTERVAL_SECONDS = 0.1
7
7
 
8
- def self.run(message)
9
- unless $stdout.tty?
10
- puts "#{message}..."
11
- result = yield
12
- puts "[done] #{message}"
13
- return result
14
- end
8
+ def self.run(message, &block)
9
+ return run_without_spinner(message, &block) unless $stdout.tty?
15
10
 
16
11
  done = false
17
12
  error = nil
@@ -20,7 +15,7 @@ module Commiti
20
15
  spinner_thread = Thread.new do
21
16
  index = 0
22
17
  until done
23
- frame = FRAMES[index % FRAMES.length]
18
+ frame = Commiti::TerminalUI.color(FRAMES[index % FRAMES.length], :cyan)
24
19
  print "\r#{frame} #{message}"
25
20
  $stdout.flush
26
21
  index += 1
@@ -29,15 +24,14 @@ module Commiti
29
24
  end
30
25
 
31
26
  begin
32
- result = yield
27
+ result = block.call
33
28
  rescue StandardError => e
34
29
  error = e
35
30
  ensure
36
31
  done = true
37
32
  spinner_thread.join
38
33
 
39
- status = error.nil? ? '[done]' : '[fail]'
40
- print "\r#{status} #{message}\n"
34
+ print "\r#{final_status_line(error, message)}\n"
41
35
  $stdout.flush
42
36
  end
43
37
 
@@ -45,5 +39,19 @@ module Commiti
45
39
 
46
40
  result
47
41
  end
42
+
43
+ def self.run_without_spinner(message, &block)
44
+ puts Commiti::TerminalUI.status(:info, "#{message}...")
45
+ result = block.call
46
+ puts Commiti::TerminalUI.status(:success, message)
47
+ result
48
+ end
49
+ private_class_method :run_without_spinner
50
+
51
+ def self.final_status_line(error, message)
52
+ kind = error.nil? ? :success : :fail
53
+ Commiti::TerminalUI.status(kind, message)
54
+ end
55
+ private_class_method :final_status_line
48
56
  end
49
57
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module TerminalUI
5
+ COLORS = {
6
+ green: 32,
7
+ red: 31,
8
+ yellow: 33,
9
+ blue: 34,
10
+ cyan: 36,
11
+ gray: 90,
12
+ bold: 1
13
+ }.freeze
14
+
15
+ SYMBOLS = {
16
+ success: '✅',
17
+ fail: '❌',
18
+ info: 'ℹ',
19
+ warn: '⚠'
20
+ }.freeze
21
+
22
+ def self.supports_ansi?
23
+ return false unless $stdout.tty?
24
+ return false if ENV.key?('NO_COLOR')
25
+
26
+ term = ENV.fetch('TERM', '').downcase
27
+ term != 'dumb'
28
+ end
29
+
30
+ def self.color(text, *styles)
31
+ return text unless supports_ansi?
32
+
33
+ codes = styles.filter_map { |style| COLORS[style] }
34
+ return text if codes.empty?
35
+
36
+ "\e[#{codes.join(';')}m#{text}\e[0m"
37
+ end
38
+
39
+ def self.status(kind, text)
40
+ symbol = SYMBOLS.fetch(kind, '*')
41
+ color_style = case kind
42
+ when :success then :green
43
+ when :fail then :red
44
+ when :warn then :yellow
45
+ else :blue
46
+ end
47
+ "#{color(symbol, color_style)} #{text}"
48
+ end
49
+
50
+ def self.separator(length = 60)
51
+ color('─' * length, :gray)
52
+ end
53
+
54
+ def self.header(text)
55
+ color(text, :bold, :cyan)
56
+ end
57
+ end
58
+ end
@@ -4,9 +4,9 @@ module Commiti
4
4
  module MessagePresenter
5
5
  def self.print_summarization_notice(summarized_result)
6
6
  if summarized_result[:fallback_reason]
7
- puts "\n#{summarized_result[:fallback_reason]}\n"
7
+ puts "\n#{Commiti::TerminalUI.status(:warn, summarized_result[:fallback_reason])}\n"
8
8
  elsif summarized_result[:summarized]
9
- puts "\nDiff is large - summarizing first to preserve system prompt focus...\n"
9
+ puts "\n#{Commiti::TerminalUI.status(:info, 'Diff is large summarizing first to preserve prompt focus.')}\n"
10
10
  end
11
11
  end
12
12
 
@@ -19,7 +19,7 @@ module Commiti
19
19
  print_candidates(candidates)
20
20
  selected_index = Commiti::InteractivePrompt.ask_candidate_selection(candidates.length)
21
21
  selected_message = candidates[selected_index]
22
- puts "\nUsing candidate #{selected_index + 1}."
22
+ puts "\n#{Commiti::TerminalUI.status(:info, "Using candidate #{selected_index + 1}.")}"
23
23
  print_message(selected_message)
24
24
  selected_message
25
25
  end
@@ -29,21 +29,23 @@ module Commiti
29
29
 
30
30
  copied = run_stage.call('Copying output to clipboard') { Commiti::Clipboard.copy(message) }
31
31
  if copied
32
- puts "Copied to clipboard!\n\n"
32
+ puts "#{Commiti::TerminalUI.status(:success, 'Copied output to clipboard!')}\n\n"
33
33
  else
34
- puts "Clipboard not available. Install xclip: sudo apt install xclip\n\n"
34
+ puts "#{Commiti::TerminalUI.status(:warn, 'Clipboard unavailable. Install xclip: sudo apt install xclip')}\n\n"
35
35
  end
36
36
  end
37
37
 
38
38
  def self.print_message(message)
39
- puts "\n#{'─' * 60}"
39
+ puts "\n#{Commiti::TerminalUI.separator}"
40
+ puts Commiti::TerminalUI.header('Generated output')
41
+ puts Commiti::TerminalUI.separator
40
42
  puts message
41
- puts "#{'─' * 60}\n"
43
+ puts "#{Commiti::TerminalUI.separator}\n"
42
44
  end
43
45
 
44
46
  def self.print_candidates(candidates)
45
47
  candidates.each_with_index do |candidate, index|
46
- puts "\nCandidate #{index + 1}:"
48
+ puts "\n#{Commiti::TerminalUI.header("Candidate #{index + 1}")}"
47
49
  print_message(candidate)
48
50
  end
49
51
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Setoju
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-18 00:00:00.000000000 Z
11
+ date: 2026-05-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dotenv
@@ -58,6 +58,7 @@ files:
58
58
  - lib/services/diff_summarization/diff_summarizer.rb
59
59
  - lib/services/diff_summarization/fallback_builder.rb
60
60
  - lib/services/flow_context_builder.rb
61
+ - lib/services/git/commit/change_grouping.rb
61
62
  - lib/services/git/commit/commit_execution.rb
62
63
  - lib/services/git/commit/commit_staging.rb
63
64
  - lib/services/git/diff_parser.rb
@@ -70,6 +71,7 @@ files:
70
71
  - lib/services/helpers/interactive_prompt.rb
71
72
  - lib/services/helpers/prompt_builder.rb
72
73
  - lib/services/helpers/spinner.rb
74
+ - lib/services/helpers/terminal_ui.rb
73
75
  - lib/services/message_generator.rb
74
76
  - lib/services/message_presenter.rb
75
77
  homepage: https://github.com/setoju/commiti