commiti 1.3.2 → 1.3.3

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: 82b26f3eb5a9606aa58e723e85a764395b485d7d84175878560173262c266615
4
- data.tar.gz: 4e92dadbaf1f3fc84f15b4650b632a054aa0ddf924349590d652361964213db5
3
+ metadata.gz: 7853991e7fa65bd7c35642c50b00aead27bc7b3a4c11ffec7bb38be03a9e96ca
4
+ data.tar.gz: a42e9fbfec33a80cf95befe00c14f3e2bd23e7d334606589e21d2e1b6ab31a6f
5
5
  SHA512:
6
- metadata.gz: b0fe4d3e034e6c05809c8ec000b1b44eecc8de21c3d166e5f8735f96f8d718d3bcfc2472a421f540e1509c203ed91c430d99122ae128bd4cfe9824d83de93efa
7
- data.tar.gz: d79d70ab801e09ab81c8d6d2e9339b01d799ff874225ec49684b21e9e55baf0b85352ebf8914e2710a3ed4a2d46496f567ca9ee7cce2106047da8129e90c5ec0
6
+ metadata.gz: 82ed71618c42e1fbb745bfc04974a754b43396f232d26923c214a34738860d0a7566c90c22caa8e60dee383d953dcce0937e908a3bd81c1d1519e4cb84b3790a
7
+ data.tar.gz: b45f9669306182469b502bc98a2a1754c9ad3e9320b8801ac2a72bd3f412d0d01f348e8ac75fd5a82b002f84356b9e0ebb99df9c941ac6cc4607b8f53912c2ce
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Commiti
2
2
 
3
+ [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen)](coverage/index.html) [![RSpec](https://img.shields.io/badge/rspec-passing-brightgreen)](#)
4
+
3
5
  AI-powered commit message and pull request description generator for Git repositories, using Google AI models.
4
6
 
5
7
  ## What It Does
@@ -100,6 +102,8 @@ Never commit `.env` to git.
100
102
  By default, Commiti creates a single commit from staged changes.
101
103
  Use `--auto-split` to let Commiti group connected file changes into multiple atomic commits.
102
104
 
105
+ When auto-split detects multiple groups, Commiti can optionally open a quick group editor so you can move files between groups before committing.
106
+
103
107
  1. Shows `git status --short`.
104
108
  2. Asks for confirmation before staging (`git add -A`).
105
109
  3. Ensures there are staged changes.
data/bin/commiti CHANGED
@@ -47,6 +47,12 @@ OptionParser.new do |opts|
47
47
  end.parse!
48
48
 
49
49
  begin
50
+ model_label = ENV.fetch('COMMITI_MODEL', Commiti::GoogleClient::DEFAULT_MODEL)
51
+ flow_label = options[:type] == :pr ? 'PR flow' : 'Commit flow'
52
+ base_label = options[:type] == :pr ? "Base: #{options[:base_branch] || 'main'}" : nil
53
+ meta = [flow_label, "Model: #{model_label}", base_label].compact.join(' • ')
54
+ puts Commiti::TerminalUI.banner(title: 'Commiti', subtitle: 'AI commit & PR generator', meta: meta)
55
+
50
56
  flow = if options[:type] == :pr
51
57
  Commiti::Flows::PrFlow.new(options: options)
52
58
  else
data/lib/commiti.rb CHANGED
@@ -20,6 +20,7 @@ require_relative 'services/message_presenter'
20
20
  require_relative 'services/git/commit/commit_staging'
21
21
  require_relative 'services/git/commit/commit_execution'
22
22
  require_relative 'services/git/commit/change_grouping'
23
+ require_relative 'services/git/commit/group_editor'
23
24
  require_relative 'flows/base_flow'
24
25
  require_relative 'flows/commit_flow'
25
26
  require_relative 'flows/pr_flow'
@@ -61,7 +61,12 @@ module Commiti
61
61
  end
62
62
 
63
63
  def run_grouped_context(context:, client:, model:)
64
- groups = context[:change_groups]
64
+ groups = Commiti::GroupEditor.edit(context[:change_groups])
65
+ if groups.length <= 1
66
+ single_context = groups.first ? build_context(diff: group_diff(groups.first), client:, model:) : context
67
+ return run_single_group_context(context: single_context, client:, model:)
68
+ end
69
+
65
70
  run_stage('Unstaging current index for grouped commit execution') { Commiti::GitWriter.unstage_all! }
66
71
 
67
72
  puts "\n#{Commiti::TerminalUI.status(:info, "Auto-split detected #{groups.length} connected change groups.")}"
@@ -75,8 +80,7 @@ module Commiti
75
80
  run_stage("Staging files for group #{index + 1}/#{total}") { Commiti::GitWriter.stage_files!(group[:files]) }
76
81
  return :continue unless run_stage('Checking staged changes') { Commiti::GitWriter.staged_changes? }
77
82
 
78
- puts "\n#{Commiti::TerminalUI.header("Group #{index + 1}/#{total} files")}:"
79
- group[:files].each { |path| puts "- #{path}" }
83
+ puts "\n#{Commiti::TerminalUI.panel("Group #{index + 1}/#{total} files", Commiti::TerminalUI.bullets(group[:files]))}\n"
80
84
 
81
85
  group_context = build_context(diff: group_diff(group), client:, model:)
82
86
  Commiti::MessagePresenter.print_summarization_notice(group_context[:summarized_result])
data/lib/flows/pr_flow.rb CHANGED
@@ -27,7 +27,7 @@ module Commiti
27
27
  prompt_text = 'Create PR and open it in browser now?'
28
28
 
29
29
  unless Commiti::InteractivePrompt.ask_yes_no(prompt_text, default: :no)
30
- puts "\nPR creation skipped.\n\n"
30
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'PR creation skipped.')}\n\n"
31
31
  return
32
32
  end
33
33
 
@@ -66,7 +66,7 @@ module Commiti
66
66
  end
67
67
 
68
68
  run_stage('Opening browser') { Commiti::PrOpener.open_in_browser(pr_url) }
69
- puts "\nOpened PR page:\n#{pr_url}\n\n"
69
+ puts "\n#{Commiti::TerminalUI.panel('Opened PR page', pr_url)}\n\n"
70
70
  end
71
71
  end
72
72
  end
@@ -18,6 +18,12 @@ module Commiti
18
18
  end
19
19
  end
20
20
 
21
+ def self.related?(left, right)
22
+ return false if left.to_s.strip.empty? || right.to_s.strip.empty?
23
+
24
+ connected?(left, right)
25
+ end
26
+
21
27
  def self.connected_components(paths)
22
28
  visited = {}
23
29
  ordered_components = []
@@ -39,7 +39,7 @@ module Commiti
39
39
  return commit_message(working_message, run_stage: run_stage) if errors.empty?
40
40
 
41
41
  puts "\n#{Commiti::TerminalUI.status(:warn, 'Current message needs fixes before commit:')}"
42
- errors.each { |error| puts "- #{error}" }
42
+ errors.each { |error| puts Commiti::TerminalUI.bullet(error) }
43
43
 
44
44
  unless Commiti::InteractivePrompt.ask_yes_no('Open editor to fix now?', default: :yes)
45
45
  print_skip_message
@@ -88,7 +88,7 @@ module Commiti
88
88
  return edited if errors.empty?
89
89
 
90
90
  puts "\n#{Commiti::TerminalUI.status(:warn, 'Edited message needs fixes:')}"
91
- errors.each { |error| puts "- #{error}" }
91
+ errors.each { |error| puts Commiti::TerminalUI.bullet(error) }
92
92
  return edited unless Commiti::InteractivePrompt.ask_yes_no('Re-open editor now?', default: :yes)
93
93
 
94
94
  working = edited
@@ -11,7 +11,7 @@ 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 "\n#{Commiti::TerminalUI.header('Current git status')}\n\n#{status}"
14
+ puts "\n#{Commiti::TerminalUI.panel('Current git status', status)}\n"
15
15
  return unless Commiti::InteractivePrompt.ask_yes_no('Run git add -A now?', default: :no)
16
16
 
17
17
  run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module GroupEditor
5
+ HELP_TEXT = <<~TEXT.freeze
6
+ Select files by number, then choose where to move them.
7
+
8
+ Examples:
9
+ 1,3,5 (select files 1, 3, 5)
10
+ 2-4 (select files 2 through 4)
11
+
12
+ Targets:
13
+ 1..N move to an existing group
14
+ n new group
15
+ a auto-reassign (best matching group or new)
16
+ c cancel
17
+ TEXT
18
+
19
+ def self.edit(groups)
20
+ return groups if groups.empty?
21
+ return groups unless Commiti::InteractivePrompt.ask_yes_no('Edit auto-split groups before committing?', default: :no)
22
+
23
+ working = deep_copy(groups)
24
+ chunk_map = build_chunk_map(working)
25
+ all_paths = working.flat_map { |group| group[:files] }.uniq
26
+ path_order = all_paths.each_with_index.to_h
27
+
28
+ puts "\n#{Commiti::TerminalUI.panel('Group editor', HELP_TEXT)}\n"
29
+
30
+ loop do
31
+ index_map, indexed_groups = index_groups(working)
32
+ print_groups(indexed_groups, total: working.length)
33
+
34
+ input = Commiti::InteractivePrompt.ask_text('Move which files? (numbers, Enter to finish)')
35
+ break if input.nil? || input.empty?
36
+
37
+ selected_indices = parse_indices(input, max_index: index_map.length)
38
+ if selected_indices.empty?
39
+ puts Commiti::TerminalUI.status(:warn, 'No valid file numbers selected.')
40
+ next
41
+ end
42
+
43
+ selected_paths = selected_indices.map { |index| index_map[index] }.compact
44
+ target = Commiti::InteractivePrompt.ask_text(
45
+ "Move to group [1-#{working.length}], n=new, a=auto, c=cancel"
46
+ ).to_s.strip.downcase
47
+
48
+ next if target.empty? || target == 'c'
49
+
50
+ case target
51
+ when 'a'
52
+ auto_reassign(working, selected_paths, chunk_map)
53
+ when 'n'
54
+ move_to_new_group(working, selected_paths, chunk_map)
55
+ else
56
+ group_index = integer_or_nil(target)
57
+ unless group_index && working[group_index - 1]
58
+ puts Commiti::TerminalUI.status(:warn, "Group #{target} does not exist.")
59
+ next
60
+ end
61
+
62
+ move_to_group(working, group_index, selected_paths)
63
+ end
64
+
65
+ normalize_groups(working, chunk_map, path_order)
66
+ end
67
+
68
+ normalize_groups(working, chunk_map, path_order)
69
+ end
70
+
71
+ def self.print_groups(indexed_groups, total:)
72
+ panels = indexed_groups.map do |group|
73
+ title = "Group #{group[:index]}/#{total}"
74
+ body = if group[:entries].empty?
75
+ Commiti::TerminalUI.muted('No files')
76
+ else
77
+ group[:entries].map { |entry| "#{entry[:index]}. #{entry[:path]}" }.join("\n")
78
+ end
79
+ Commiti::TerminalUI.panel(title, body)
80
+ end
81
+ puts "\n#{panels.join("\n\n")}\n"
82
+ end
83
+ private_class_method :print_groups
84
+
85
+ def self.index_groups(groups)
86
+ index_map = {}
87
+ indexed_groups = []
88
+ counter = 1
89
+
90
+ groups.each_with_index do |group, index|
91
+ entries = group[:files].map do |path|
92
+ entry = { index: counter, path: path }
93
+ index_map[counter] = path
94
+ counter += 1
95
+ entry
96
+ end
97
+ indexed_groups << { index: index + 1, entries: entries }
98
+ end
99
+
100
+ [index_map, indexed_groups]
101
+ end
102
+ private_class_method :index_groups
103
+
104
+ def self.parse_indices(text, max_index:)
105
+ raw = text.to_s.strip
106
+ return [] if raw.empty?
107
+
108
+ indices = []
109
+ invalid_tokens = []
110
+
111
+ raw.split(/[,\s]+/).each do |token|
112
+ next if token.empty?
113
+
114
+ if token.match?(/\A\d+-\d+\z/)
115
+ start_value, end_value = token.split('-').map(&:to_i)
116
+ range = start_value <= end_value ? (start_value..end_value) : (end_value..start_value)
117
+ indices.concat(range.to_a)
118
+ elsif token.match?(/\A\d+\z/)
119
+ indices << token.to_i
120
+ else
121
+ invalid_tokens << token
122
+ end
123
+ end
124
+
125
+ invalid_tokens.each do |token|
126
+ puts Commiti::TerminalUI.status(:warn, "Invalid token: #{token}")
127
+ end
128
+
129
+ indices = indices.uniq
130
+ valid = indices.select { |value| value.between?(1, max_index) }
131
+ (indices - valid).each do |value|
132
+ puts Commiti::TerminalUI.status(:warn, "File number #{value} is out of range.")
133
+ end
134
+ valid
135
+ end
136
+ private_class_method :parse_indices
137
+
138
+ def self.move_to_group(groups, group_index, paths)
139
+ target = groups[group_index - 1]
140
+ paths.each do |path|
141
+ remove_path_from_groups(groups, path)
142
+ target[:files] << path unless target[:files].include?(path)
143
+ end
144
+ end
145
+ private_class_method :move_to_group
146
+
147
+ def self.move_to_new_group(groups, paths, chunk_map)
148
+ paths.each { |path| remove_path_from_groups(groups, path) }
149
+ new_groups = build_groups_for_paths(paths, chunk_map)
150
+ groups.concat(new_groups)
151
+ end
152
+ private_class_method :move_to_new_group
153
+
154
+ def self.auto_reassign(groups, paths, chunk_map)
155
+ origin_groups = find_origin_groups(groups, paths)
156
+ paths.each { |path| remove_path_from_groups(groups, path) }
157
+
158
+ remaining = []
159
+ paths.each do |path|
160
+ excluded = origin_groups[path] ? [origin_groups[path]] : []
161
+ target = best_matching_group(path, groups, excluded_groups: excluded)
162
+ if target
163
+ target[:files] << path unless target[:files].include?(path)
164
+ else
165
+ remaining << path
166
+ end
167
+ end
168
+
169
+ groups.concat(build_groups_for_paths(remaining, chunk_map)) if remaining.any?
170
+ end
171
+ private_class_method :auto_reassign
172
+
173
+ def self.find_origin_groups(groups, paths)
174
+ origin = {}
175
+ groups.each do |group|
176
+ group[:files].each do |path|
177
+ origin[path] = group if paths.include?(path)
178
+ end
179
+ end
180
+ origin
181
+ end
182
+ private_class_method :find_origin_groups
183
+
184
+ def self.remove_path_from_groups(groups, path)
185
+ groups.each { |group| group[:files].delete(path) }
186
+ end
187
+ private_class_method :remove_path_from_groups
188
+
189
+ def self.best_matching_group(path, groups, excluded_groups:)
190
+ candidates = groups - excluded_groups
191
+ best = nil
192
+ best_score = 0
193
+
194
+ candidates.each do |group|
195
+ score = group[:files].count { |existing| Commiti::ChangeGrouping.related?(path, existing) }
196
+ next if score <= best_score
197
+
198
+ best_score = score
199
+ best = group
200
+ end
201
+
202
+ best_score.positive? ? best : nil
203
+ end
204
+ private_class_method :best_matching_group
205
+
206
+ def self.build_groups_for_paths(paths, chunk_map)
207
+ line_chunks = paths.map { |path| chunk_map[path] }.compact
208
+ return [] if line_chunks.empty?
209
+
210
+ Commiti::ChangeGrouping.group(line_chunks).map do |group|
211
+ {
212
+ id: group[:id],
213
+ files: group[:files],
214
+ chunks: group[:chunks]
215
+ }
216
+ end
217
+ end
218
+ private_class_method :build_groups_for_paths
219
+
220
+ def self.build_chunk_map(groups)
221
+ groups.flat_map { |group| group[:chunks] }
222
+ .each_with_object({}) { |chunk, acc| acc[chunk[:path]] = chunk }
223
+ end
224
+ private_class_method :build_chunk_map
225
+
226
+ def self.normalize_groups(groups, chunk_map, path_order)
227
+ groups.reject! { |group| group[:files].empty? }
228
+ groups.each do |group|
229
+ group[:files] = group[:files].uniq.sort_by { |path| path_order[path] || path_order.length }
230
+ group[:chunks] = group[:files].filter_map { |path| chunk_map[path] }
231
+ end
232
+ groups.each_with_index { |group, index| group[:id] = index + 1 }
233
+ end
234
+ private_class_method :normalize_groups
235
+
236
+ def self.integer_or_nil(value)
237
+ return nil unless value.to_s.match?(/\A\d+\z/)
238
+
239
+ value.to_i
240
+ end
241
+ private_class_method :integer_or_nil
242
+
243
+ def self.deep_copy(groups)
244
+ groups.map do |group|
245
+ {
246
+ id: group[:id],
247
+ files: group[:files].dup,
248
+ chunks: group[:chunks].map(&:dup)
249
+ }
250
+ end
251
+ end
252
+ private_class_method :deep_copy
253
+ end
254
+ end
@@ -12,7 +12,7 @@ module Commiti
12
12
 
13
13
  def self.ask_yes_no(question, default: :no)
14
14
  suffix = default == :yes ? '[Y/n]' : '[y/N]'
15
- input = read_input("#{question} #{suffix} ")
15
+ input = read_input("#{Commiti::TerminalUI.prompt(question)} #{Commiti::TerminalUI.muted(suffix)} ")
16
16
  return default == :yes ? :yes : nil if input.nil?
17
17
 
18
18
  value = input.strip.downcase
@@ -24,7 +24,7 @@ module Commiti
24
24
  end
25
25
 
26
26
  def self.ask_commit_action
27
- input = read_input('Commit with this message? [y/e/N] ')
27
+ input = read_input("#{Commiti::TerminalUI.prompt('Commit with this message?')} #{Commiti::TerminalUI.muted('[y/e/N]')} ")
28
28
  return :no if input.nil?
29
29
 
30
30
  value = input.strip.downcase
@@ -38,17 +38,27 @@ module Commiti
38
38
  return 0 if count <= 1
39
39
 
40
40
  loop do
41
- input = read_input("Select candidate [1-#{count}] (default: #{default}): ")
41
+ input = read_input(
42
+ "#{Commiti::TerminalUI.prompt('Select candidate')} " \
43
+ "#{Commiti::TerminalUI.muted("[1-#{count}] (default: #{default})")}: "
44
+ )
42
45
  return default - 1 if input.nil?
43
46
 
44
47
  value = input.strip
45
48
  return default - 1 if value.empty?
46
49
  return value.to_i - 1 if value.match?(/\A\d+\z/) && value.to_i.between?(1, count)
47
50
 
48
- puts "Please type a number between 1 and #{count}."
51
+ puts Commiti::TerminalUI.status(:warn, "Please type a number between 1 and #{count}.")
49
52
  end
50
53
  end
51
54
 
55
+ def self.ask_text(question)
56
+ input = read_input("#{Commiti::TerminalUI.prompt(question)} ")
57
+ return nil if input.nil?
58
+
59
+ input.to_s.strip
60
+ end
61
+
52
62
  def self.edit_message(initial_message)
53
63
  # Keep the temp file closed while the external editor runs.
54
64
  # On Windows, open handles can prevent editors like Notepad from
@@ -16,7 +16,8 @@ module Commiti
16
16
  index = 0
17
17
  until done
18
18
  frame = Commiti::TerminalUI.color(FRAMES[index % FRAMES.length], :cyan)
19
- print "\r#{frame} #{message}"
19
+ line = "#{frame} #{message}"
20
+ print "\r#{Commiti::TerminalUI.pad_right(line, Commiti::TerminalUI.width)}"
20
21
  $stdout.flush
21
22
  index += 1
22
23
  sleep INTERVAL_SECONDS
@@ -31,7 +32,8 @@ module Commiti
31
32
  done = true
32
33
  spinner_thread.join
33
34
 
34
- print "\r#{final_status_line(error, message)}\n"
35
+ final_line = final_status_line(error, message)
36
+ print "\r#{Commiti::TerminalUI.pad_right(final_line, Commiti::TerminalUI.width)}\n"
35
37
  $stdout.flush
36
38
  end
37
39
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
4
+
3
5
  module Commiti
4
6
  module TerminalUI
5
7
  COLORS = {
@@ -8,17 +10,39 @@ module Commiti
8
10
  yellow: 33,
9
11
  blue: 34,
10
12
  cyan: 36,
13
+ magenta: 35,
11
14
  gray: 90,
12
15
  bold: 1
13
16
  }.freeze
14
17
 
15
- SYMBOLS = {
16
- success: '',
17
- fail: '',
18
+ UNICODE_ICONS = {
19
+ success: '',
20
+ fail: '',
18
21
  info: 'ℹ',
19
22
  warn: '⚠'
20
23
  }.freeze
21
24
 
25
+ ASCII_ICONS = {
26
+ success: '+',
27
+ fail: 'x',
28
+ info: 'i',
29
+ warn: '!'
30
+ }.freeze
31
+
32
+ UNICODE_MARKERS = {
33
+ prompt: '›',
34
+ header: '▸',
35
+ bullet: '•',
36
+ rule: '─'
37
+ }.freeze
38
+
39
+ ASCII_MARKERS = {
40
+ prompt: '>',
41
+ header: '>',
42
+ bullet: '-',
43
+ rule: '-'
44
+ }.freeze
45
+
22
46
  def self.supports_ansi?
23
47
  return false unless $stdout.tty?
24
48
  return false if ENV.key?('NO_COLOR')
@@ -27,6 +51,23 @@ module Commiti
27
51
  term != 'dumb'
28
52
  end
29
53
 
54
+ def self.supports_unicode?
55
+ encoding = $stdout.external_encoding || Encoding.default_external
56
+ encoding.name.upcase.include?('UTF-8')
57
+ rescue StandardError
58
+ false
59
+ end
60
+
61
+ def self.width
62
+ cols = IO.console&.winsize&.last
63
+ cols = ENV.fetch('COLUMNS', nil) if cols.nil? || cols <= 0
64
+ cols = cols.to_i
65
+ cols = 80 if cols <= 0
66
+ [cols, 120].min
67
+ rescue StandardError
68
+ 80
69
+ end
70
+
30
71
  def self.color(text, *styles)
31
72
  return text unless supports_ansi?
32
73
 
@@ -37,22 +78,77 @@ module Commiti
37
78
  end
38
79
 
39
80
  def self.status(kind, text)
40
- symbol = SYMBOLS.fetch(kind, '*')
81
+ symbol = icon_for(kind)
41
82
  color_style = case kind
42
83
  when :success then :green
43
84
  when :fail then :red
44
85
  when :warn then :yellow
45
86
  else :blue
46
87
  end
47
- "#{color(symbol, color_style)} #{text}"
88
+ "#{color(symbol, color_style, :bold)} #{text}"
48
89
  end
49
90
 
50
- def self.separator(length = 60)
51
- color('─' * length, :gray)
91
+ def self.separator(length = nil)
92
+ char = marker(:rule)
93
+ color(char * (length || width), :gray)
52
94
  end
53
95
 
54
96
  def self.header(text)
55
- color(text, :bold, :cyan)
97
+ "#{color(marker(:header), :cyan, :bold)} #{color(text, :bold, :cyan)}"
98
+ end
99
+
100
+ def self.prompt(text)
101
+ "#{color(marker(:prompt), :cyan, :bold)} #{text}"
102
+ end
103
+
104
+ def self.muted(text)
105
+ color(text, :gray)
106
+ end
107
+
108
+ def self.panel(title, body)
109
+ [
110
+ separator,
111
+ header(title),
112
+ separator,
113
+ body.to_s.rstrip,
114
+ separator
115
+ ].join("\n")
116
+ end
117
+
118
+ def self.bullet(text)
119
+ "#{marker(:bullet)} #{text}"
120
+ end
121
+
122
+ def self.bullets(items)
123
+ items.map { |item| bullet(item) }.join("\n")
124
+ end
125
+
126
+ def self.pad_right(text, length)
127
+ padding = [length - visible_length(text), 0].max
128
+ "#{text}#{' ' * padding}"
129
+ end
130
+
131
+ def self.visible_length(text)
132
+ strip_ansi(text).length
133
+ end
134
+
135
+ def self.strip_ansi(text)
136
+ text.to_s.gsub(/\e\[[0-9;]*m/, '')
137
+ end
138
+
139
+ def self.banner(title:, subtitle: nil, meta: nil)
140
+ body_lines = [subtitle, meta].compact.join("\n")
141
+ panel(title, body_lines)
142
+ end
143
+
144
+ def self.icon_for(kind)
145
+ (supports_unicode? ? UNICODE_ICONS : ASCII_ICONS).fetch(kind, '*')
146
+ end
147
+ private_class_method :icon_for
148
+
149
+ def self.marker(kind)
150
+ (supports_unicode? ? UNICODE_MARKERS : ASCII_MARKERS).fetch(kind, '*')
56
151
  end
152
+ private_class_method :marker
57
153
  end
58
154
  end
@@ -18,7 +18,7 @@ module Commiti
18
18
 
19
19
  def generate_candidates(client:, prompt:, diff_metadata:, count:, model:)
20
20
  (1..count).map do |index|
21
- puts "\nGenerating candidate #{index}/#{count}..."
21
+ puts "\n#{Commiti::TerminalUI.status(:info, "Generating candidate #{index}/#{count}...")}"
22
22
  generate_with_quality_check(client: client, prompt: prompt, diff_metadata: diff_metadata, model: model)
23
23
  end
24
24
  end
@@ -35,8 +35,8 @@ module Commiti
35
35
  return normalize_commit_message(message, diff_metadata: diff_metadata) if reason.nil? && flow_type == :commit
36
36
  return message if reason.nil?
37
37
 
38
- puts "\nGenerated output looked weak: #{reason}"
39
- puts "Retrying once with stronger constraints...\n"
38
+ puts "\n#{Commiti::TerminalUI.status(:warn, "Generated output looked weak: #{reason}")}"
39
+ puts "#{Commiti::TerminalUI.status(:info, 'Retrying once with stronger constraints...')}\n"
40
40
 
41
41
  retried_message = clean_output(generate_from_client(
42
42
  client: client,
@@ -35,18 +35,13 @@ module Commiti
35
35
  end
36
36
  end
37
37
 
38
- def self.print_message(message)
39
- puts "\n#{Commiti::TerminalUI.separator}"
40
- puts Commiti::TerminalUI.header('Generated output')
41
- puts Commiti::TerminalUI.separator
42
- puts message
43
- puts "#{Commiti::TerminalUI.separator}\n"
38
+ def self.print_message(message, title: 'Generated output')
39
+ puts "\n#{Commiti::TerminalUI.panel(title, message)}\n"
44
40
  end
45
41
 
46
42
  def self.print_candidates(candidates)
47
43
  candidates.each_with_index do |candidate, index|
48
- puts "\n#{Commiti::TerminalUI.header("Candidate #{index + 1}")}"
49
- print_message(candidate)
44
+ print_message(candidate, title: "Candidate #{index + 1}")
50
45
  end
51
46
  end
52
47
  private_class_method :print_candidates
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.3.2
4
+ version: 1.3.3
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-22 00:00:00.000000000 Z
11
+ date: 2026-05-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dotenv
@@ -61,6 +61,7 @@ files:
61
61
  - lib/services/git/commit/change_grouping.rb
62
62
  - lib/services/git/commit/commit_execution.rb
63
63
  - lib/services/git/commit/commit_staging.rb
64
+ - lib/services/git/commit/group_editor.rb
64
65
  - lib/services/git/diff_parser.rb
65
66
  - lib/services/git/git_reader.rb
66
67
  - lib/services/git/git_writer.rb