commitgpt 0.3.9 → 0.3.11

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: d812f02e181a923e72486f7ffc5ac1d2e7614bd5eee7194bca7e3a764df7afda
4
- data.tar.gz: 7195e0edac02853ef4d1dcc7179664beffd023dacdb36c1133c5a01463a0175c
3
+ metadata.gz: b8a2b39ee34a81a322ba56a693b002e1a8b29bc6adc432b4f41992c71ff3bd4a
4
+ data.tar.gz: cd4289fee106ee2feb1e89c71b29889b6fe0232c542452588fd0564b4b49593f
5
5
  SHA512:
6
- metadata.gz: 61f5d8a81bc519c7341dde8808656d729c8c3c6c826aa4ecb27779a5b59b3866f8f4fc671424139aa1fa801ce0d1bee14cac903363babafb082f5e3eb354904d
7
- data.tar.gz: a32a40428daef8ecfe051e39fc0f4cf4040755190c01f0e92eb747aed232b8cc4acabe9ba88b6a9d27b0b7edf666d2da96a07f7d0e75e80ca47adc33b1eb9227
6
+ metadata.gz: 77b3f3fe14333b001537c52482b3424f2b5c679434b7ea52bb06e001e64cd5cc0f0c61782ead4d8416bf01473a7099398ba2d4eea84c268f84a14e6a62e5448b
7
+ data.tar.gz: 073de1a61a918be54e7ca2f2bb750b094aa1f0e1d56e195ad6239a8e4eb0431570ff4dfaedd22255e843d8c994df8c248d74f7cb1d306c6644e145e064c306ca
data/README.md CHANGED
@@ -17,9 +17,12 @@ The easiest way to install and keep CommitGPT updated.
17
17
 
18
18
  ```bash
19
19
  brew tap ZPVIP/commitgpt https://github.com/ZPVIP/commitgpt
20
+ brew trust zpvip/commitgpt
20
21
  brew install commitgpt
21
22
  ```
22
23
 
24
+ > **Note**: Recent Homebrew versions refuse to load formulae from third-party taps until you trust them, so `brew trust` is required before installing.
25
+
23
26
  **Upgrade:**
24
27
 
25
28
  ```bash
@@ -194,8 +197,41 @@ docs: update API documentation
194
197
  📝 update API documentation
195
198
  ```
196
199
 
200
+ ### Choose Commit Message Detail Level
201
+ Select how much the commit message says:
202
+ ```bash
203
+ $ aicm -d
204
+ # or
205
+ $ aicm --detail
206
+ ```
207
+
208
+ - **Concise** - Subject line only (default)
209
+ - **Detailed** - Subject line, an explanatory paragraph, and a bullet list of notable changes
210
+
211
+ This is independent of the format above: the format decides how the subject line is written, the detail level decides how much follows it. Your selection is saved as `commit_detail` in `~/.config/commitgpt/config.yml`.
212
+
213
+ #### Detail Examples
214
+
215
+ **Concise:**
216
+ ```
217
+ feat(dfb): store and expand DFB event and speed logs
218
+ ```
219
+
220
+ **Detailed:**
221
+ ```
222
+ feat(dfb): store and expand DFB event and speed logs
223
+
224
+ Persist DFB device logs ingested from the proactive `evt` (event log) and
225
+ `lge` (speed stats) topics, one record per message.
226
+
227
+ - Add `dfb_event_logs` and `dfb_speed_stats` tables and models, each with an
228
+ idempotent unique index so re-delivery and overlapping backfills are safe.
229
+ - Associate both log tables on `Product`.
230
+ - Update `README_DFB.md` to document the cloud tables.
231
+ ```
232
+
197
233
  ### Check Configuration
198
- View your current configuration (Provider, Model, Format, Base URL, Diff Len):
234
+ View your current configuration (Provider, Model, Format, Detail, Base URL, Diff Len):
199
235
  ```bash
200
236
  $ aicm help
201
237
  ```
@@ -261,13 +297,17 @@ Get an API key at [https://developer.amd.com.cn/radeon/tokenfactory](https://dev
261
297
 
262
298
  > **Note**: Registration requires **both an email address and a Chinese (+86) mobile phone number**. Without a Chinese phone number you cannot complete sign-up, so this provider is only usable if you have one.
263
299
 
300
+ > **Note**: The gateway in front of this endpoint always answers `GET /models` with 403, so models cannot be discovered automatically. CommitGPT ships the list below and offers it directly during setup — no detection request is made for this provider.
301
+
264
302
  ```
265
- DeepSeek-V4-Flash
266
- Qwen3.6-35B-A3B
267
- MiniCPM5-1B
268
- MiniCPM-V46
303
+ DeepSeek-V4-Flash # Max Context Length: 1,000,000 tokens
304
+ Qwen3.6-35B-A3B # Max Context Length: 262,144 tokens
305
+ MiniCPM-V46 # Max Context Length: 262,144 tokens
306
+ MiniCPM5-1B # Max Context Length: 131,072 tokens
269
307
  ```
270
308
 
309
+ Setup reports the selected model's context window and suggests a matching `diff_len`, so you don't have to work the number out yourself.
310
+
271
311
  ## How It Works
272
312
  This CLI tool runs a `git diff` command to grab all staged changes, sends this to OpenAI's GPT API (or compatible endpoint), and returns an AI-generated commit message. The tool uses the `/v1/chat/completions` endpoint with optimized prompts/system instructions for generating conventional commit messages.
273
313
 
data/lib/commitgpt/cli.rb CHANGED
@@ -14,6 +14,7 @@ module CommitGpt
14
14
  method_option :verbose, aliases: '-v', type: :boolean, desc: 'Show git diff being sent to AI'
15
15
  method_option :provider, aliases: '-p', type: :boolean, desc: 'Switch active provider'
16
16
  method_option :format, aliases: '-f', type: :boolean, desc: 'Choose commit message format'
17
+ method_option :detail, aliases: '-d', type: :boolean, desc: 'Choose commit message detail level'
17
18
  def generate
18
19
  if options[:provider]
19
20
  CommitGpt::SetupWizard.new.switch_provider
@@ -21,6 +22,8 @@ module CommitGpt
21
22
  CommitGpt::SetupWizard.new.change_model
22
23
  elsif options[:format]
23
24
  CommitGpt::SetupWizard.new.choose_format
25
+ elsif options[:detail]
26
+ CommitGpt::SetupWizard.new.choose_detail
24
27
  else
25
28
  CommitGpt::CommitAi.new.aicm(verbose: options[:verbose])
26
29
  end
@@ -42,6 +45,7 @@ module CommitGpt
42
45
  shell.say ' -m, --models # Interactive model selection'
43
46
  shell.say ' -p, --provider # Switch active provider'
44
47
  shell.say ' -f, --format # Choose commit message format'
48
+ shell.say ' -d, --detail # Choose commit message detail level'
45
49
  shell.say ' -v, --verbose # Show git diff being sent to AI'
46
50
  shell.say ''
47
51
 
@@ -61,6 +65,7 @@ module CommitGpt
61
65
  shell.say " Provider: #{config['name'].green}"
62
66
  shell.say " Model: #{config['model'].cyan}"
63
67
  shell.say " Format: #{format.capitalize.yellow}"
68
+ shell.say " Detail: #{CommitGpt::ConfigManager.get_commit_detail.capitalize.yellow}"
64
69
  shell.say " Base URL: #{config['base_url']}"
65
70
  shell.say " Diff Len: #{config['diff_len']}"
66
71
  shell.say " Timeout: #{CommitGpt::ConfigManager.get_model_fetch_timeout}s"
@@ -133,6 +133,51 @@ module CommitGpt
133
133
  end
134
134
 
135
135
  @commit_format = ConfigManager.get_commit_format
136
+ @commit_detail = ConfigManager.get_commit_detail
137
+ end
138
+
139
+ # True when the message should carry a body, not just a subject line
140
+ def detailed?
141
+ @commit_detail == 'detailed'
142
+ end
143
+
144
+ # Streaming cutoff and completion budget, both of which have to grow to fit a body
145
+ def max_content_chars
146
+ detailed? ? 2000 : 300
147
+ end
148
+
149
+ def reasoning_disabled_max_tokens
150
+ detailed? ? 1000 : 300
151
+ end
152
+
153
+ # Header printed before the streamed message starts arriving
154
+ def content_prefix
155
+ detailed? ? "✦ Commit message:\n" : '✦ Commit message: git commit -am "'
156
+ end
157
+
158
+ # Closing quote that matches content_prefix; a body has no quote to close
159
+ def content_suffix
160
+ detailed? ? '' : '"'
161
+ end
162
+
163
+ # Editor git will actually use, resolved through its own precedence chain
164
+ def git_editor
165
+ editor = `git var GIT_EDITOR 2>/dev/null`.strip
166
+ editor.empty? ? 'your editor' : editor
167
+ end
168
+
169
+ # Trim the model output down to what git should receive
170
+ def finalize_message(full_content)
171
+ unless detailed?
172
+ # Take only the first non-empty line to avoid repetition or multi-line garbage
173
+ first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
174
+ return first_line&.gsub(/\A["']|["']\z/, '') || ''
175
+ end
176
+
177
+ body = full_content.strip.gsub(/\A["']|["']\z/, '').strip
178
+ # Keep the subject/body/bullets structure but drop trailing spaces and
179
+ # collapse the extra blank lines models like to emit between sections
180
+ body.split("\n").map(&:rstrip).join("\n").gsub(/\n{3,}/, "\n\n")
136
181
  end
137
182
 
138
183
  def aicm(verbose: false)
@@ -150,9 +195,10 @@ module CommitGpt
150
195
 
151
196
  case action
152
197
  when :commit
153
- commit_command = "git commit -m \"#{ai_commit_message}\""
154
- puts "\n→ Executing: #{commit_command}".yellow
155
- system(commit_command)
198
+ # Pass argv directly: a body with quotes, backticks or newlines must
199
+ # not be re-interpreted by the shell
200
+ puts "\n→ Executing: git commit -m <message>".yellow
201
+ system('git', 'commit', '-m', ai_commit_message)
156
202
  puts "\n\n"
157
203
  puts `git log -1`
158
204
  break
@@ -160,15 +206,15 @@ module CommitGpt
160
206
  puts "\n"
161
207
  next
162
208
  when :edit
163
- prompt = TTY::Prompt.new
164
- new_message = prompt.ask('Enter your commit message:')
165
- if new_message && !new_message.strip.empty?
166
- commit_command = "git commit -m \"#{new_message}\""
167
- system(commit_command)
168
- puts "\n"
169
- puts `git log -1`
209
+ # Let git open the message in the user's editor: it honours the
210
+ # GIT_EDITOR/core.editor/VISUAL/EDITOR chain, handles multi-line
211
+ # bodies, and aborts by itself if the message is left empty
212
+ puts "\n→ Opening #{git_editor} with the generated message...".yellow
213
+ if system('git', 'commit', '-e', '-m', ai_commit_message)
214
+ puts "\n"
215
+ puts `git log -1`
170
216
  else
171
- puts '✖ Commit aborted (empty message).'.red
217
+ puts '✖ Commit aborted.'.red
172
218
  end
173
219
  break
174
220
  when :exit
@@ -219,7 +265,7 @@ module CommitGpt
219
265
  prompt.select('Action:') do |menu|
220
266
  menu.choice 'Commit', :commit
221
267
  menu.choice 'Regenerate', :regenerate
222
- menu.choice 'Edit', :edit
268
+ menu.choice 'Edit in editor', :edit
223
269
  menu.choice 'Exit without commit', :exit
224
270
  end
225
271
  rescue TTY::Reader::InputInterrupt, Interrupt
@@ -279,7 +325,11 @@ module CommitGpt
279
325
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
280
326
  def generate_commit(diff = '', chunk_label: nil)
281
327
  # Build format-specific prompt
282
- base_prompt = 'Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body.'
328
+ base_prompt = if detailed?
329
+ 'Generate a git commit message in present tense that precisely describes the key changes in the following code diff. Focus on what was changed and why, not just file names.'
330
+ else
331
+ 'Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body.'
332
+ end
283
333
 
284
334
  format_instruction = case @commit_format
285
335
  when 'conventional'
@@ -290,17 +340,38 @@ module CommitGpt
290
340
  ''
291
341
  end
292
342
 
293
- format_spec = "The output response must be in format:\n#{COMMIT_FORMATS[@commit_format]}"
343
+ format_spec = if detailed?
344
+ "The subject line must be in format:\n#{COMMIT_FORMATS[@commit_format]}"
345
+ else
346
+ "The output response must be in format:\n#{COMMIT_FORMATS[@commit_format]}"
347
+ end
348
+
349
+ rules = if detailed?
350
+ [
351
+ '- The first line is the subject: a maximum of 100 characters, no trailing period.',
352
+ '- Then one blank line, then a body paragraph of 1-3 sentences explaining what changed and why.',
353
+ '- Then one blank line, then a bullet list where each bullet starts with "- " and describes one notable change.',
354
+ '- Wrap body and bullet text at 72 characters.',
355
+ '- Be specific: include concrete details (file, table, method, package names) rather than generic statements.',
356
+ '- Do not use markdown headings, bold, or code fences. Plain text only.',
357
+ '- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
358
+ '- Return ONLY the commit message, nothing else. No explanations, no introductions, no surrounding quotes.'
359
+ ]
360
+ else
361
+ [
362
+ '- Commit message must be a maximum of 100 characters.',
363
+ '- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
364
+ '- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text.',
365
+ '- Be specific: include concrete details (package names, versions, functionality) rather than generic statements.',
366
+ '- Return ONLY the commit message, nothing else.'
367
+ ]
368
+ end
294
369
 
295
370
  system_content = [
296
371
  base_prompt,
297
372
  'Message language: English.',
298
373
  'Rules:',
299
- '- Commit message must be a maximum of 100 characters.',
300
- '- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
301
- '- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text.',
302
- '- Be specific: include concrete details (package names, versions, functionality) rather than generic statements.',
303
- '- Return ONLY the commit message, nothing else.',
374
+ *rules,
304
375
  format_instruction,
305
376
  format_spec
306
377
  ].reject(&:empty?).join("\n")
@@ -331,7 +402,7 @@ module CommitGpt
331
402
 
332
403
  if can_disable_reasoning
333
404
  payload[:disable_reasoning] = true
334
- payload[:max_tokens] = 300
405
+ payload[:max_tokens] = reasoning_disabled_max_tokens
335
406
  else
336
407
  payload[:max_tokens] = configured_max_tokens
337
408
  end
@@ -430,13 +501,13 @@ module CommitGpt
430
501
  if chunk_label
431
502
  print "◆ #{chunk_label}: ".magenta
432
503
  else
433
- print '✦ Commit message: git commit -am "'.green
504
+ print content_prefix.green
434
505
  end
435
506
  printed_content_prefix = true
436
507
  end
437
508
 
438
509
  # Prevent infinite loops/repetitive garbage
439
- if full_content.length + content_chunk.length > 300
510
+ if full_content.length + content_chunk.length > max_content_chars
440
511
  stop_stream = true
441
512
  break
442
513
  end
@@ -460,7 +531,7 @@ module CommitGpt
460
531
  end
461
532
 
462
533
  # Close the quote
463
- puts(chunk_label ? '' : '"'.green) if printed_content_prefix
534
+ puts(chunk_label ? '' : content_suffix.green) if printed_content_prefix
464
535
 
465
536
  # Post-processing Logic (Retry if empty content)
466
537
  if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
@@ -511,9 +582,7 @@ module CommitGpt
511
582
  # Reset retrying flag
512
583
  @is_retrying = false
513
584
 
514
- # Take only the first non-empty line to avoid repetition or multi-line garbage
515
- first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
516
- first_line&.gsub(/\A["']|["']\z/, '') || ''
585
+ finalize_message(full_content)
517
586
  end
518
587
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
519
588
 
@@ -530,14 +599,31 @@ module CommitGpt
530
599
  ''
531
600
  end
532
601
 
602
+ rules = if detailed?
603
+ [
604
+ '- The first line is the subject: a maximum of 100 characters, no trailing period.',
605
+ '- Then one blank line, then a body paragraph of 1-3 sentences explaining what changed and why.',
606
+ '- Then one blank line, then a bullet list where each bullet starts with "- " and describes one notable change.',
607
+ '- Wrap body and bullet text at 72 characters.',
608
+ '- Present tense.',
609
+ '- Be specific: include concrete details rather than generic statements.',
610
+ '- Do not use markdown headings, bold, or code fences. Plain text only.',
611
+ '- Return ONLY the commit message, nothing else. No quotes, no explanations.'
612
+ ]
613
+ else
614
+ [
615
+ '- Maximum 100 characters.',
616
+ '- Present tense.',
617
+ '- Be specific: include concrete details rather than generic statements.',
618
+ '- Return ONLY the commit message, nothing else. No quotes, no explanations.'
619
+ ]
620
+ end
621
+
533
622
  system_content = [
534
623
  'You are given multiple commit messages generated from different segments of a single large git diff.',
535
- 'Synthesize them into ONE concise, unified git commit message that captures the overall change.',
624
+ "Synthesize them into ONE unified git commit message that captures the overall change.#{' Keep it concise.' unless detailed?}",
536
625
  'Rules:',
537
- '- Maximum 100 characters.',
538
- '- Present tense.',
539
- '- Be specific: include concrete details rather than generic statements.',
540
- '- Return ONLY the commit message, nothing else. No quotes, no explanations.',
626
+ *rules,
541
627
  format_instruction
542
628
  ].reject(&:empty?).join("\n")
543
629
 
@@ -558,7 +644,7 @@ module CommitGpt
558
644
 
559
645
  if can_disable_reasoning
560
646
  payload[:disable_reasoning] = true
561
- payload[:max_tokens] = 300
647
+ payload[:max_tokens] = reasoning_disabled_max_tokens
562
648
  else
563
649
  payload[:max_tokens] = provider_config['max_tokens'] || 2000
564
650
  end
@@ -606,11 +692,11 @@ module CommitGpt
606
692
  content_chunk = delta['content']
607
693
  if content_chunk && !content_chunk.empty?
608
694
  unless printed_content_prefix
609
- print "\n✦ Commit message: git commit -am \"".green
695
+ print "\n#{content_prefix}".green
610
696
  printed_content_prefix = true
611
697
  end
612
698
 
613
- if full_content.length + content_chunk.length > 300
699
+ if full_content.length + content_chunk.length > max_content_chars
614
700
  stop_stream = true
615
701
  break
616
702
  end
@@ -630,15 +716,14 @@ module CommitGpt
630
716
  return nil
631
717
  end
632
718
 
633
- puts '"'.green if printed_content_prefix
719
+ puts content_suffix.green if printed_content_prefix
634
720
 
635
721
  if full_content.strip.empty?
636
722
  puts '✖ No response from AI during synthesis.'.red
637
723
  return nil
638
724
  end
639
725
 
640
- first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
641
- first_line&.gsub(/\A["']|["']\z/, '') || ''
726
+ finalize_message(full_content)
642
727
  end
643
728
  end
644
729
  end
@@ -11,6 +11,11 @@ module CommitGpt
11
11
  # Seconds to wait when fetching a provider's model list
12
12
  DEFAULT_MODEL_FETCH_TIMEOUT = 30
13
13
 
14
+ # How much the commit message says: a subject line only, or a subject
15
+ # followed by an explanatory body
16
+ COMMIT_DETAILS = %w[concise detailed].freeze
17
+ DEFAULT_COMMIT_DETAIL = 'concise'
18
+
14
19
  class << self
15
20
  # Get the config directory path
16
21
  def config_dir
@@ -168,6 +173,23 @@ module CommitGpt
168
173
  save_main_config(main_config)
169
174
  end
170
175
 
176
+ # Get commit detail level ('concise' or 'detailed')
177
+ def get_commit_detail
178
+ return DEFAULT_COMMIT_DETAIL unless config_exists?
179
+
180
+ main_config = YAML.load_file(main_config_path)
181
+ detail = main_config['commit_detail']
182
+ COMMIT_DETAILS.include?(detail) ? detail : DEFAULT_COMMIT_DETAIL
183
+ end
184
+
185
+ # Set commit detail level
186
+ def set_commit_detail(detail)
187
+ ensure_config_dir
188
+ main_config = config_exists? ? YAML.load_file(main_config_path) : { 'providers' => [], 'active_provider' => '' }
189
+ main_config['commit_detail'] = detail
190
+ save_main_config(main_config)
191
+ end
192
+
171
193
  # Get the model list fetch timeout (seconds)
172
194
  def get_model_fetch_timeout
173
195
  return DEFAULT_MODEL_FETCH_TIMEOUT unless config_exists?
@@ -3,7 +3,16 @@
3
3
  module CommitGpt
4
4
  # Provider presets for common AI providers
5
5
  PROVIDER_PRESETS = [
6
- { label: 'AMD Radeon (China)', value: 'amd', base_url: 'https://developer.amd.com.cn/radeon/api/v1' },
6
+ # The Azure gateway in front of this endpoint always answers /models with 403,
7
+ # so the model list is built in and no request is made during setup.
8
+ # Each value is that model's context window in tokens.
9
+ { label: 'AMD Radeon (China)', value: 'amd', base_url: 'https://developer.amd.com.cn/radeon/api/v1',
10
+ models: {
11
+ 'DeepSeek-V4-Flash' => 1_000_000,
12
+ 'MiniCPM-V46' => 262_144,
13
+ 'MiniCPM5-1B' => 131_072,
14
+ 'Qwen3.6-35B-A3B' => 262_144
15
+ } },
7
16
  { label: 'Anthropic Claude', value: 'anthropic', base_url: 'https://api.anthropic.com/v1' },
8
17
  { label: 'Cerebras', value: 'cerebras', base_url: 'https://api.cerebras.ai/v1' },
9
18
  { label: 'DeepSeek', value: 'deepseek', base_url: 'https://api.deepseek.com' },
@@ -10,6 +10,18 @@ require_relative 'string'
10
10
  module CommitGpt
11
11
  # Interactive setup wizard for configuring AI providers
12
12
  class SetupWizard
13
+ # Diff bytes per context token. Code and CJK pack more bytes into a token
14
+ # than English prose, so this stays deliberately conservative.
15
+ DIFF_BYTES_PER_TOKEN = 2
16
+
17
+ # Ceiling for the suggested diff length. Past this a single request gets
18
+ # slow and one commit message stops describing the change usefully, so
19
+ # chunked mode is the better answer than a bigger window.
20
+ MAX_SUGGESTED_DIFF_LEN = 131_072
21
+
22
+ # Used when the model's context window is unknown
23
+ FALLBACK_DIFF_LEN = 32_768
24
+
13
25
  def initialize
14
26
  @prompt = TTY::Prompt.new
15
27
  end
@@ -45,13 +57,13 @@ module CommitGpt
45
57
  provider = config['providers'].find { |p| p['name'] == selected }
46
58
 
47
59
  # Fetch models and let user select
48
- models = fetch_models_with_timeout(provider['base_url'], provider['api_key'])
60
+ models = fetch_models_with_timeout(provider['base_url'], provider['api_key'], preset_models(selected))
49
61
  return if models.nil?
50
62
 
51
63
  model = select_model(models, provider['model'])
52
64
 
53
65
  # Prompt for diff length
54
- diff_len = prompt_diff_len(provider['diff_len'] || 32_768)
66
+ diff_len = prompt_diff_len(default_diff_len(selected, model, provider['diff_len']))
55
67
 
56
68
  # Update config
57
69
  ConfigManager.update_provider(selected, { 'model' => model, 'diff_len' => diff_len })
@@ -75,7 +87,8 @@ module CommitGpt
75
87
  end
76
88
 
77
89
  # Fetch models and let user select
78
- models = fetch_models_with_timeout(provider_config['base_url'], provider_config['api_key'])
90
+ models = fetch_models_with_timeout(provider_config['base_url'], provider_config['api_key'],
91
+ preset_models(provider_config['name']))
79
92
  return if models.nil?
80
93
 
81
94
  model = select_model(models, provider_config['model'])
@@ -103,8 +116,26 @@ module CommitGpt
103
116
  puts "\n✔ Commit format set to: #{format}".green
104
117
  end
105
118
 
119
+ # Choose how much the commit message says
120
+ def choose_detail
121
+ puts "\n→ Choose git commit message detail level:\n".green
122
+
123
+ detail = @prompt.select('Select detail level:', default: default_detail_index) do |menu|
124
+ menu.choice 'Concise - Subject line only', 'concise'
125
+ menu.choice 'Detailed - Subject line, explanatory paragraph, and bullet list of changes', 'detailed'
126
+ end
127
+
128
+ ConfigManager.set_commit_detail(detail)
129
+ puts "\n✔ Commit detail set to: #{detail}".green
130
+ end
131
+
106
132
  private
107
133
 
134
+ # Menu position of the currently configured detail level (1-based)
135
+ def default_detail_index
136
+ ConfigManager::COMMIT_DETAILS.index(ConfigManager.get_commit_detail).to_i + 1
137
+ end
138
+
108
139
  # Select provider from list
109
140
  def select_provider
110
141
  config = ConfigManager.load_config
@@ -148,14 +179,14 @@ module CommitGpt
148
179
  return if api_key.nil? # User cancelled
149
180
 
150
181
  # Fetch models with timeout
151
- models = fetch_models_with_timeout(base_url, api_key)
182
+ models = fetch_models_with_timeout(base_url, api_key, preset_models(provider_name))
152
183
  return if models.nil?
153
184
 
154
185
  # Let user select model
155
186
  model = select_model(models, existing_provider&.dig('model'))
156
187
 
157
188
  # Prompt for diff length
158
- diff_len = prompt_diff_len(existing_provider&.dig('diff_len') || 32_768)
189
+ diff_len = prompt_diff_len(default_diff_len(provider_name, model, existing_provider&.dig('diff_len')))
159
190
 
160
191
  # Save configuration
161
192
  ConfigManager.update_provider(
@@ -181,14 +212,14 @@ module CommitGpt
181
212
  q.default 'http://localhost:8080/v1'
182
213
  end
183
214
 
184
- api_key = @prompt.mask('Enter your API key (optional):') { |q| q.echo false }
215
+ api_key = @prompt.mask('Enter your API key (optional):') { |q| q.echo false }.to_s
185
216
 
186
217
  # Fetch models
187
218
  models = fetch_models_with_timeout(base_url, api_key)
188
219
  return if models.nil?
189
220
 
190
221
  model = select_model(models)
191
- diff_len = prompt_diff_len(32_768)
222
+ diff_len = prompt_diff_len(FALLBACK_DIFF_LEN)
192
223
 
193
224
  # Add to presets dynamically (just for this session)
194
225
  # Save to config
@@ -230,20 +261,19 @@ module CommitGpt
230
261
 
231
262
  # Prompt for API key
232
263
  def prompt_api_key(_provider_name, existing_key)
233
- message = if existing_key && !existing_key.empty?
264
+ has_existing = existing_key && !existing_key.empty?
265
+
266
+ message = if has_existing
234
267
  'Enter your API key (press Enter to keep existing):'
235
268
  else
236
269
  'Enter your API key:'
237
270
  end
238
271
 
239
- key = @prompt.mask(message) { |q| q.echo false }
272
+ # mask returns nil, not '', when the input is empty
273
+ key = @prompt.mask(message) { |q| q.echo false }.to_s
240
274
 
241
275
  # If user pressed Enter and there's an existing key, use it
242
- if key.empty? && existing_key && !existing_key.empty?
243
- existing_key
244
- else
245
- key
246
- end
276
+ key.empty? && has_existing ? existing_key : key
247
277
  end
248
278
 
249
279
  # Ask how long to wait when fetching the model list, and persist it
@@ -262,12 +292,56 @@ module CommitGpt
262
292
  seconds
263
293
  end
264
294
 
265
- # Fetch models from provider with timeout
266
- def fetch_models_with_timeout(base_url, api_key)
295
+ # Built-in models a preset declares, as { model => context tokens }
296
+ def preset_model_table(provider_name)
297
+ models = PROVIDER_PRESETS.find { |p| p[:value] == provider_name }&.dig(:models)
298
+ models unless models.nil? || models.empty?
299
+ end
300
+
301
+ # Built-in model list for a preset, if it declares one
302
+ def preset_models(provider_name)
303
+ preset_model_table(provider_name)&.keys
304
+ end
305
+
306
+ # Context window of a built-in model, in tokens
307
+ def model_context_tokens(provider_name, model)
308
+ preset_model_table(provider_name)&.dig(model)
309
+ end
310
+
311
+ # Default diff length for a model, derived from its context window when
312
+ # known, and reported so the number is not a mystery. Numbers are printed
313
+ # undelimited because they get typed straight into the next prompt.
314
+ def default_diff_len(provider_name, model, existing)
315
+ context_tokens = model_context_tokens(provider_name, model)
316
+ return existing || FALLBACK_DIFF_LEN if context_tokens.nil?
317
+
318
+ fits = context_tokens * DIFF_BYTES_PER_TOKEN
319
+ suggested = [fits, MAX_SUGGESTED_DIFF_LEN].min
320
+ capped = ' (capped; larger diffs use chunked mode)' if suggested < fits
321
+
322
+ puts "\n#{model} context window: #{context_tokens} tokens (~#{fits} bytes of diff)".gray
323
+ puts " Suggested max diff length: #{suggested} bytes#{capped}".gray
324
+
325
+ # Only a value the user actually chose outranks the suggestion
326
+ customized = existing && existing != FALLBACK_DIFF_LEN
327
+ customized ? existing : suggested
328
+ end
329
+
330
+ # Fetch models from provider with timeout.
331
+ # A preset that declares :models has no usable /models endpoint, so its
332
+ # built-in list is offered directly and no request is made.
333
+ def fetch_models_with_timeout(base_url, api_key, builtin_models = nil)
334
+ if builtin_models
335
+ puts 'Using the built-in model list (this provider does not expose /models).'.gray
336
+ return builtin_models
337
+ end
338
+
267
339
  timeout = ConfigManager.get_model_fetch_timeout
268
340
  puts 'Fetching available models...'.gray
269
341
 
270
342
  models = nil
343
+ failure = nil
344
+
271
345
  begin
272
346
  Timeout.timeout(timeout) do
273
347
  headers = {
@@ -279,28 +353,23 @@ module CommitGpt
279
353
  response = HTTParty.get("#{base_url}/models", headers: headers)
280
354
 
281
355
  if response.code == 200
282
- models = response['data'] || []
283
- models = models.map { |m| m['id'] }.compact.sort
356
+ models = (response['data'] || []).map { |m| m['id'] }.compact.sort
284
357
  else
285
- puts "Failed to fetch models: HTTP #{response.code}".red
286
- return nil
358
+ failure = "Failed to fetch models: HTTP #{response.code}"
287
359
  end
288
360
  end
289
361
  rescue Timeout::Error
290
- puts "Connection timeout (#{timeout}s). Please check your network, base_url, and api_key.".red
291
- puts " Run 'aicm setup' again to raise the timeout.".gray
292
- exit(0)
362
+ failure = "Connection timeout (#{timeout}s). Please check your network, base_url, and api_key."
293
363
  rescue StandardError => e
294
- puts "Error fetching models: #{e.message}".red
295
- exit(0)
364
+ failure = "Error fetching models: #{e.message}"
296
365
  end
297
366
 
298
- if models.nil? || models.empty?
299
- puts '✖ No models found. Please check your configuration.'.red
300
- exit(0)
301
- end
367
+ failure ||= 'No models found. Please check your configuration.' if models.nil? || models.empty?
368
+ return models if failure.nil?
302
369
 
303
- models
370
+ puts "✖ #{failure}".red
371
+ puts " Run 'aicm setup' again to raise the timeout.".gray if failure.start_with?('Connection timeout')
372
+ exit(0)
304
373
  end
305
374
 
306
375
  # Let user select a model
@@ -328,7 +397,7 @@ module CommitGpt
328
397
  end
329
398
 
330
399
  # Prompt for diff length
331
- def prompt_diff_len(default = 32_768)
400
+ def prompt_diff_len(default = FALLBACK_DIFF_LEN)
332
401
  answer = @prompt.ask('Set the maximum diff length (Bytes) for generating commit message:') do |q|
333
402
  q.default default.to_s
334
403
  q.convert :int
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommitGpt
4
- VERSION = '0.3.9'
4
+ VERSION = '0.3.11'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commitgpt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.9
4
+ version: 0.3.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peng Zhang