Skip to content

Interactive

Interactive commit interface for CodeMap.

logger module-attribute

logger = getLogger(__name__)

MAX_PREVIEW_LENGTH module-attribute

MAX_PREVIEW_LENGTH = 200

MAX_PREVIEW_LINES module-attribute

MAX_PREVIEW_LINES = 10

ChunkAction

Bases: Enum

Possible actions for a diff chunk.

Source code in src/codemap/git/interactive.py
29
30
31
32
33
34
35
36
37
class ChunkAction(Enum):
	"""Possible actions for a diff chunk."""

	COMMIT = auto()
	EDIT = auto()
	SKIP = auto()
	ABORT = auto()
	REGENERATE = auto()
	EXIT = auto()

COMMIT class-attribute instance-attribute

COMMIT = auto()

EDIT class-attribute instance-attribute

EDIT = auto()

SKIP class-attribute instance-attribute

SKIP = auto()

ABORT class-attribute instance-attribute

ABORT = auto()

REGENERATE class-attribute instance-attribute

REGENERATE = auto()

EXIT class-attribute instance-attribute

EXIT = auto()

ChunkResult dataclass

Result of processing a diff chunk.

Source code in src/codemap/git/interactive.py
40
41
42
43
44
45
@dataclass
class ChunkResult:
	"""Result of processing a diff chunk."""

	action: ChunkAction
	message: str | None = None

__init__

__init__(
	action: ChunkAction, message: str | None = None
) -> None

action instance-attribute

action: ChunkAction

message class-attribute instance-attribute

message: str | None = None

CommitUI

Interactive UI for the commit process.

Source code in src/codemap/git/interactive.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
class CommitUI:
	"""Interactive UI for the commit process."""

	def __init__(self) -> None:
		"""Initialize the commit UI."""
		self.console = Console()

	def display_chunk(self, chunk: DiffChunk, index: int = 0, total: int = 1) -> None:
		"""
		Display a diff chunk to the user.

		Args:
		    chunk: DiffChunk to display
		    index: The 0-based index of the current chunk
		    total: The total number of chunks

		"""
		# Build file information
		file_info = Text("Files: ", style="blue")
		file_info.append(", ".join(chunk.files))

		# Calculate changes
		added = len(
			[line for line in chunk.content.splitlines() if line.startswith("+") and not line.startswith("+++")]
		)
		removed = len(
			[line for line in chunk.content.splitlines() if line.startswith("-") and not line.startswith("---")]
		)
		changes_info = Text("\nChanges: ", style="blue")
		changes_info.append(f"{added} added, {removed} removed")

		# Prepare diff content
		panel_content = chunk.content
		if not panel_content.strip():
			panel_content = "No content diff available (e.g., new file or mode change)"

		# Truncate to maximum of MAX_PREVIEW_LINES lines
		content_lines = panel_content.splitlines()
		if len(content_lines) > MAX_PREVIEW_LINES:
			remaining_lines = len(content_lines) - MAX_PREVIEW_LINES
			panel_content = "\n".join(content_lines[:MAX_PREVIEW_LINES]) + f"\n... ({remaining_lines} more lines)"

		diff_content = Text("\n" + panel_content)

		# Determine title for the panel - use provided index and total
		panel_title = f"[bold]Commit {index + 1} of {total}[/bold]"

		# Create content for the panel conditionally
		if getattr(chunk, "description", None):
			# If there's a description, create a combined panel
			if getattr(chunk, "is_llm_generated", False):
				message_title = "[bold blue]Proposed message (AI)[/]"
				message_style = "blue"
			else:
				message_title = "[bold yellow]Proposed message (Simple)[/]"
				message_style = "yellow"

			# Create separate panels and print them
			# First, print the diff panel
			diff_panel = Panel(
				Group(file_info, changes_info, diff_content),
				title=panel_title,
				border_style="cyan",
				expand=True,
				width=self.console.width,
				padding=(1, 2),
			)
			self.console.print(diff_panel)

			# Print divider
			self.console.print(Rule(style="dim"))

			# Then print the message panel
			message_panel = Panel(
				Text(str(chunk.description), style="green"),
				title=message_title,
				border_style=message_style,
				expand=True,
				width=self.console.width,
				padding=(1, 2),
			)
			self.console.print(message_panel)
		else:
			# If no description, just print the diff panel
			panel = Panel(
				Group(file_info, changes_info, diff_content),
				title=panel_title,
				border_style="cyan",
				expand=True,
				width=self.console.width,
				padding=(1, 2),
			)
			self.console.print()
			self.console.print(panel)
			self.console.print()

	def display_group(self, group: SemanticGroup, index: int = 0, total: int = 1) -> None:
		"""
		Display a semantic group to the user.

		Args:
		        group: SemanticGroup to display
		        index: The 0-based index of the current group
		        total: The total number of groups

		"""
		# Build file information
		file_list = "\n".join([f"  - {file}" for file in group.files])
		file_info = Text(f"Files ({len(group.files)}):\n", style="blue")
		file_info.append(file_list)

		# Prepare diff preview - show first few lines of diff content
		diff_preview = group.content
		content_lines = diff_preview.splitlines()
		if len(content_lines) > MAX_PREVIEW_LINES:
			remaining_lines = len(content_lines) - MAX_PREVIEW_LINES
			diff_preview = "\n".join(content_lines[:MAX_PREVIEW_LINES]) + f"\n... ({remaining_lines} more lines)"
		diff_content = Text("\n\nDiff Preview:\n", style="blue")
		diff_content.append(diff_preview)

		# Calculate changes
		added = len(
			[line for line in group.content.splitlines() if line.startswith("+") and not line.startswith("+++")]
		)
		removed = len(
			[line for line in group.content.splitlines() if line.startswith("-") and not line.startswith("---")]
		)
		changes_info = Text("\nChanges: ", style="blue")
		changes_info.append(f"{added} added, {removed} removed")

		# Determine title for the panel
		panel_title = f"[bold]Group {index + 1} of {total}[/bold]"

		# Create diff panel
		diff_panel = Panel(
			Group(file_info, changes_info, diff_content),
			title=panel_title,
			border_style="cyan",
			expand=True,
			width=self.console.width,
			padding=(1, 2),
		)
		self.console.print(diff_panel)

		# Print divider
		self.console.print(Rule(style="dim"))

		# Create message panel if message exists
		if hasattr(group, "message") and group.message:
			# Create message panel
			message_panel = Panel(
				Text(str(group.message), style="green"),
				title="[bold blue]Generated message[/]",
				border_style="green",
				expand=True,
				width=self.console.width,
				padding=(1, 2),
			)
			self.console.print(message_panel)
		else:
			self.console.print(
				Panel(
					Text("No message generated yet", style="dim"),
					title="[bold]Message[/]",
					border_style="yellow",
					expand=True,
					width=self.console.width,
					padding=(1, 2),
				)
			)

	def display_message(self, message: str, is_llm_generated: bool = False) -> None:
		"""
		Display a commit message to the user.

		Args:
		    message: The commit message to display
		    is_llm_generated: Whether the message was generated by an LLM

		"""
		tag = "AI" if is_llm_generated else "Simple"
		message_panel = Panel(
			Text(message, style="green"),
			title=f"[bold {'blue' if is_llm_generated else 'yellow'}]Proposed message ({tag})[/]",
			border_style="blue" if is_llm_generated else "yellow",
			expand=False,
			padding=(1, 2),
		)
		self.console.print(message_panel)

	def get_user_action(self) -> ChunkAction:
		"""
		Get the user's desired action for the current chunk.

		Returns:
		    ChunkAction indicating what to do with the chunk

		"""
		# Define options with their display text and corresponding action
		options: list[tuple[str, ChunkAction]] = [
			("Commit with this message", ChunkAction.COMMIT),
			("Edit message and commit", ChunkAction.EDIT),
			("Regenerate message", ChunkAction.REGENERATE),
			("Skip this chunk", ChunkAction.SKIP),
			("Exit without committing", ChunkAction.EXIT),
		]

		# Use questionary to get the user's choice
		result = questionary.select(
			"What would you like to do?",
			choices=[option[0] for option in options],
			default=options[0][0],  # Set "Commit with this message" as default
			qmark="»",
			use_indicator=True,
			use_arrow_keys=True,
		).ask()

		# Map the result back to the ChunkAction
		for option, action in options:
			if option == result:
				return action

		# Fallback (should never happen)
		return ChunkAction.EXIT

	def get_user_action_on_lint_failure(self) -> ChunkAction:
		"""
		Get the user's desired action when linting fails.

		Returns:
		    ChunkAction indicating what to do.

		"""
		options: list[tuple[str, ChunkAction]] = [
			("Regenerate message", ChunkAction.REGENERATE),
			("Bypass linter and commit with --no-verify", ChunkAction.COMMIT),
			("Edit message manually", ChunkAction.EDIT),
			("Skip this chunk", ChunkAction.SKIP),
			("Exit without committing", ChunkAction.EXIT),
		]
		result = questionary.select(
			"Linting failed. What would you like to do?",
			choices=[option[0] for option in options],
			qmark="?»",  # Use a different qmark to indicate failure state
			use_indicator=True,
			use_arrow_keys=True,
		).ask()
		for option, action in options:
			if option == result:
				return action
		return ChunkAction.EXIT  # Fallback

	def edit_message(self, current_message: str) -> str:
		"""
		Get an edited commit message from the user.

		Args:
		    current_message: Current commit message

		Returns:
		    Edited commit message

		"""
		self.console.print("\n[bold blue]Edit commit message:[/]")
		self.console.print("[dim]Press Enter to keep current message[/]")
		return Prompt.ask("Message", default=current_message)

	def process_chunk(self, chunk: DiffChunk, index: int = 0, total: int = 1) -> ChunkResult:
		"""
		Process a single diff chunk interactively.

		Args:
		    chunk: DiffChunk to process
		    index: The 0-based index of the current chunk
		    total: The total number of chunks

		Returns:
		    ChunkResult with the user's action and any modified message

		"""
		# Display the combined diff and message panel
		self.display_chunk(chunk, index, total)

		# Now get the user's action through questionary (without displaying another message panel)
		action = self.get_user_action()

		if action == ChunkAction.EDIT:
			message = self.edit_message(chunk.description or "")
			return ChunkResult(ChunkAction.COMMIT, message)

		if action == ChunkAction.COMMIT:
			return ChunkResult(action, chunk.description)

		return ChunkResult(action)

	def confirm_abort(self) -> bool:
		"""
		Ask the user to confirm aborting the commit process.

		Returns:
		    True if the user confirms, False otherwise

		Raises:
		    typer.Exit: When the user confirms exiting

		"""
		confirmed = Confirm.ask(
			"\n[bold yellow]Are you sure you want to exit without committing?[/]",
			default=False,
		)

		if confirmed:
			self.console.print("[yellow]Exiting commit process...[/yellow]")
			# Use a zero exit code to indicate a successful (intended) exit
			# This prevents error messages from showing when exiting
			raise typer.Exit(code=0)

		return False

	def confirm_bypass_hooks(self) -> ChunkAction:
		"""
		Ask the user what to do when git hooks fail.

		Returns:
		    ChunkAction indicating what to do next

		"""
		self.console.print("\n[bold yellow]Git hooks failed.[/]")
		self.console.print("[yellow]This may be due to linting or other pre-commit checks.[/]")

		options: list[tuple[str, ChunkAction]] = [
			("Force commit and bypass hooks", ChunkAction.COMMIT),
			("Regenerate message and try again", ChunkAction.REGENERATE),
			("Edit message manually", ChunkAction.EDIT),
			("Skip this group", ChunkAction.SKIP),
			("Exit without committing", ChunkAction.EXIT),
		]

		result = questionary.select(
			"What would you like to do?",
			choices=[option[0] for option in options],
			qmark="»",
			use_indicator=True,
			use_arrow_keys=True,
		).ask()

		for option, action in options:
			if option == result:
				return action

		# Fallback (should never happen)
		return ChunkAction.EXIT

	def show_success(self, message: str) -> None:
		"""
		Show a success message.

		Args:
		    message: Message to display

		"""
		self.console.print(f"\n[bold green]✓[/] {message}")

	def show_warning(self, message: str) -> None:
		"""
		Show a warning message to the user.

		Args:
		    message: Warning message to display

		"""
		self.console.print(f"\n[bold yellow]⚠[/] {message}")

	def show_error(self, message: str) -> None:
		"""
		Show an error message to the user.

		Args:
		    message: Error message to display

		"""
		if "No changes to commit" in message:
			# This is an informational message, not an error
			self.console.print(f"[yellow]{message}[/yellow]")
		else:
			# This is a real error
			self.console.print(f"[red]Error:[/red] {message}")

	def show_skipped(self, files: list[str]) -> None:
		"""
		Show which files were skipped.

		Args:
		    files: List of skipped files

		"""
		if files:
			self.console.print("\n[yellow]Skipped changes in:[/]")
			for file in files:
				self.console.print(f"  • {file}")

	def show_message(self, message: str) -> None:
		"""
		Show a general informational message.

		Args:
		    message: Message to display

		"""
		self.console.print(f"\n{message}")

	def show_regenerating(self) -> None:
		"""Show message indicating message regeneration."""
		self.console.print("\n[yellow]Regenerating commit message...[/yellow]")

	def show_all_committed(self) -> None:
		"""Show message indicating all changes are committed."""
		self.console.print("[green]✓[/green] All changes committed!")

	def show_all_done(self) -> None:
		"""
		Show a final success message when the process completes.

		This is an alias for show_all_committed for now, but could be
		customized.

		"""
		self.show_all_committed()

	def show_lint_errors(self, errors: list[str]) -> None:
		"""Display linting errors to the user."""
		self.console.print("[bold red]Commit message failed linting:[/bold red]")
		for error in errors:
			self.console.print(f"  - {error}")

	def confirm_commit_with_lint_errors(self) -> bool:
		"""Ask the user if they want to commit despite lint errors."""
		return questionary.confirm("Commit message has lint errors. Commit anyway?", default=False).ask()

	def confirm_exit(self) -> bool:
		"""Ask the user to confirm exiting without committing."""
		return questionary.confirm("Are you sure you want to exit without committing?", default=False).ask()

	def display_failed_lint_message(self, message: str, lint_errors: list[str], is_llm_generated: bool = False) -> None:
		"""
		Display a commit message that failed linting, along with the errors.

		Args:
		    message: The commit message to display.
		    lint_errors: List of linting error messages.
		    is_llm_generated: Whether the message was generated by an LLM.

		"""
		tag = "AI" if is_llm_generated else "Simple"
		message_panel = Panel(
			Text(message, style="yellow"),  # Use yellow style for the message text
			title=f"[bold yellow]Proposed message ({tag}) - LINTING FAILED[/]",
			border_style="yellow",  # Yellow border to indicate warning/failure
			expand=False,
			padding=(1, 2),
		)
		self.console.print(message_panel)

		# Display lint errors below
		if lint_errors:
			error_text = Text("\n".join([f"- {err}" for err in lint_errors]), style="red")
			error_panel = Panel(
				error_text,
				title="[bold red]Linting Errors[/]",
				border_style="red",
				expand=False,
				padding=(1, 2),
			)
			self.console.print(error_panel)

	def display_failed_json_message(
		self, raw_content: str, json_errors: list[str], is_llm_generated: bool = True
	) -> None:
		"""
		Display a raw response that failed JSON validation, along with the errors.

		Args:
		    raw_content: The raw string content that failed JSON validation.
		    json_errors: List of JSON validation/formatting error messages.
		    is_llm_generated: Whether the message was generated by an LLM (usually True here).
		"""
		tag = "AI" if is_llm_generated else "Manual"
		message_panel = Panel(
			Text(raw_content, style="dim yellow"),  # Use dim yellow for the raw content
			title=f"[bold yellow]Invalid JSON Response ({tag}) - VALIDATION FAILED[/]",
			border_style="yellow",  # Yellow border to indicate JSON warning
			expand=False,
			padding=(1, 2),
		)
		self.console.print(message_panel)

		# Display JSON errors below
		if json_errors:
			error_text = Text("\n".join([f"- {err}" for err in json_errors]), style="red")
			error_panel = Panel(
				error_text,
				title="[bold red]JSON Validation Errors[/]",
				border_style="red",
				expand=False,
				padding=(1, 2),
			)
			self.console.print(error_panel)

	def get_group_action(self) -> ChunkAction:
		"""
		Get the user's desired action for the current semantic group.

		Returns:
		        ChunkAction indicating what to do with the group

		"""
		# Define options with their display text and corresponding action
		options: list[tuple[str, ChunkAction]] = [
			("Commit this group", ChunkAction.COMMIT),
			("Edit message and commit", ChunkAction.EDIT),
			("Regenerate message", ChunkAction.REGENERATE),
			("Skip this group", ChunkAction.SKIP),
			("Exit without committing", ChunkAction.EXIT),
		]

		# Use questionary to get the user's choice
		result = questionary.select(
			"What would you like to do with this group?",
			choices=[option[0] for option in options],
			default=options[0][0],  # Set "Commit this group" as default
			qmark="»",
			use_indicator=True,
			use_arrow_keys=True,
		).ask()

		# Map the result back to the ChunkAction
		for option, action in options:
			if option == result:
				return action

		# Fallback (should never happen)
		return ChunkAction.EXIT

__init__

__init__() -> None

Initialize the commit UI.

Source code in src/codemap/git/interactive.py
51
52
53
def __init__(self) -> None:
	"""Initialize the commit UI."""
	self.console = Console()

console instance-attribute

console = Console()

display_chunk

display_chunk(
	chunk: DiffChunk, index: int = 0, total: int = 1
) -> None

Display a diff chunk to the user.

Parameters:

Name Type Description Default
chunk DiffChunk

DiffChunk to display

required
index int

The 0-based index of the current chunk

0
total int

The total number of chunks

1
Source code in src/codemap/git/interactive.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def display_chunk(self, chunk: DiffChunk, index: int = 0, total: int = 1) -> None:
	"""
	Display a diff chunk to the user.

	Args:
	    chunk: DiffChunk to display
	    index: The 0-based index of the current chunk
	    total: The total number of chunks

	"""
	# Build file information
	file_info = Text("Files: ", style="blue")
	file_info.append(", ".join(chunk.files))

	# Calculate changes
	added = len(
		[line for line in chunk.content.splitlines() if line.startswith("+") and not line.startswith("+++")]
	)
	removed = len(
		[line for line in chunk.content.splitlines() if line.startswith("-") and not line.startswith("---")]
	)
	changes_info = Text("\nChanges: ", style="blue")
	changes_info.append(f"{added} added, {removed} removed")

	# Prepare diff content
	panel_content = chunk.content
	if not panel_content.strip():
		panel_content = "No content diff available (e.g., new file or mode change)"

	# Truncate to maximum of MAX_PREVIEW_LINES lines
	content_lines = panel_content.splitlines()
	if len(content_lines) > MAX_PREVIEW_LINES:
		remaining_lines = len(content_lines) - MAX_PREVIEW_LINES
		panel_content = "\n".join(content_lines[:MAX_PREVIEW_LINES]) + f"\n... ({remaining_lines} more lines)"

	diff_content = Text("\n" + panel_content)

	# Determine title for the panel - use provided index and total
	panel_title = f"[bold]Commit {index + 1} of {total}[/bold]"

	# Create content for the panel conditionally
	if getattr(chunk, "description", None):
		# If there's a description, create a combined panel
		if getattr(chunk, "is_llm_generated", False):
			message_title = "[bold blue]Proposed message (AI)[/]"
			message_style = "blue"
		else:
			message_title = "[bold yellow]Proposed message (Simple)[/]"
			message_style = "yellow"

		# Create separate panels and print them
		# First, print the diff panel
		diff_panel = Panel(
			Group(file_info, changes_info, diff_content),
			title=panel_title,
			border_style="cyan",
			expand=True,
			width=self.console.width,
			padding=(1, 2),
		)
		self.console.print(diff_panel)

		# Print divider
		self.console.print(Rule(style="dim"))

		# Then print the message panel
		message_panel = Panel(
			Text(str(chunk.description), style="green"),
			title=message_title,
			border_style=message_style,
			expand=True,
			width=self.console.width,
			padding=(1, 2),
		)
		self.console.print(message_panel)
	else:
		# If no description, just print the diff panel
		panel = Panel(
			Group(file_info, changes_info, diff_content),
			title=panel_title,
			border_style="cyan",
			expand=True,
			width=self.console.width,
			padding=(1, 2),
		)
		self.console.print()
		self.console.print(panel)
		self.console.print()

display_group

display_group(
	group: SemanticGroup, index: int = 0, total: int = 1
) -> None

Display a semantic group to the user.

Parameters:

Name Type Description Default
group SemanticGroup

SemanticGroup to display

required
index int

The 0-based index of the current group

0
total int

The total number of groups

1
Source code in src/codemap/git/interactive.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def display_group(self, group: SemanticGroup, index: int = 0, total: int = 1) -> None:
	"""
	Display a semantic group to the user.

	Args:
	        group: SemanticGroup to display
	        index: The 0-based index of the current group
	        total: The total number of groups

	"""
	# Build file information
	file_list = "\n".join([f"  - {file}" for file in group.files])
	file_info = Text(f"Files ({len(group.files)}):\n", style="blue")
	file_info.append(file_list)

	# Prepare diff preview - show first few lines of diff content
	diff_preview = group.content
	content_lines = diff_preview.splitlines()
	if len(content_lines) > MAX_PREVIEW_LINES:
		remaining_lines = len(content_lines) - MAX_PREVIEW_LINES
		diff_preview = "\n".join(content_lines[:MAX_PREVIEW_LINES]) + f"\n... ({remaining_lines} more lines)"
	diff_content = Text("\n\nDiff Preview:\n", style="blue")
	diff_content.append(diff_preview)

	# Calculate changes
	added = len(
		[line for line in group.content.splitlines() if line.startswith("+") and not line.startswith("+++")]
	)
	removed = len(
		[line for line in group.content.splitlines() if line.startswith("-") and not line.startswith("---")]
	)
	changes_info = Text("\nChanges: ", style="blue")
	changes_info.append(f"{added} added, {removed} removed")

	# Determine title for the panel
	panel_title = f"[bold]Group {index + 1} of {total}[/bold]"

	# Create diff panel
	diff_panel = Panel(
		Group(file_info, changes_info, diff_content),
		title=panel_title,
		border_style="cyan",
		expand=True,
		width=self.console.width,
		padding=(1, 2),
	)
	self.console.print(diff_panel)

	# Print divider
	self.console.print(Rule(style="dim"))

	# Create message panel if message exists
	if hasattr(group, "message") and group.message:
		# Create message panel
		message_panel = Panel(
			Text(str(group.message), style="green"),
			title="[bold blue]Generated message[/]",
			border_style="green",
			expand=True,
			width=self.console.width,
			padding=(1, 2),
		)
		self.console.print(message_panel)
	else:
		self.console.print(
			Panel(
				Text("No message generated yet", style="dim"),
				title="[bold]Message[/]",
				border_style="yellow",
				expand=True,
				width=self.console.width,
				padding=(1, 2),
			)
		)

display_message

display_message(
	message: str, is_llm_generated: bool = False
) -> None

Display a commit message to the user.

Parameters:

Name Type Description Default
message str

The commit message to display

required
is_llm_generated bool

Whether the message was generated by an LLM

False
Source code in src/codemap/git/interactive.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def display_message(self, message: str, is_llm_generated: bool = False) -> None:
	"""
	Display a commit message to the user.

	Args:
	    message: The commit message to display
	    is_llm_generated: Whether the message was generated by an LLM

	"""
	tag = "AI" if is_llm_generated else "Simple"
	message_panel = Panel(
		Text(message, style="green"),
		title=f"[bold {'blue' if is_llm_generated else 'yellow'}]Proposed message ({tag})[/]",
		border_style="blue" if is_llm_generated else "yellow",
		expand=False,
		padding=(1, 2),
	)
	self.console.print(message_panel)

get_user_action

get_user_action() -> ChunkAction

Get the user's desired action for the current chunk.

Returns:

Type Description
ChunkAction

ChunkAction indicating what to do with the chunk

Source code in src/codemap/git/interactive.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def get_user_action(self) -> ChunkAction:
	"""
	Get the user's desired action for the current chunk.

	Returns:
	    ChunkAction indicating what to do with the chunk

	"""
	# Define options with their display text and corresponding action
	options: list[tuple[str, ChunkAction]] = [
		("Commit with this message", ChunkAction.COMMIT),
		("Edit message and commit", ChunkAction.EDIT),
		("Regenerate message", ChunkAction.REGENERATE),
		("Skip this chunk", ChunkAction.SKIP),
		("Exit without committing", ChunkAction.EXIT),
	]

	# Use questionary to get the user's choice
	result = questionary.select(
		"What would you like to do?",
		choices=[option[0] for option in options],
		default=options[0][0],  # Set "Commit with this message" as default
		qmark="»",
		use_indicator=True,
		use_arrow_keys=True,
	).ask()

	# Map the result back to the ChunkAction
	for option, action in options:
		if option == result:
			return action

	# Fallback (should never happen)
	return ChunkAction.EXIT

get_user_action_on_lint_failure

get_user_action_on_lint_failure() -> ChunkAction

Get the user's desired action when linting fails.

Returns:

Type Description
ChunkAction

ChunkAction indicating what to do.

Source code in src/codemap/git/interactive.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def get_user_action_on_lint_failure(self) -> ChunkAction:
	"""
	Get the user's desired action when linting fails.

	Returns:
	    ChunkAction indicating what to do.

	"""
	options: list[tuple[str, ChunkAction]] = [
		("Regenerate message", ChunkAction.REGENERATE),
		("Bypass linter and commit with --no-verify", ChunkAction.COMMIT),
		("Edit message manually", ChunkAction.EDIT),
		("Skip this chunk", ChunkAction.SKIP),
		("Exit without committing", ChunkAction.EXIT),
	]
	result = questionary.select(
		"Linting failed. What would you like to do?",
		choices=[option[0] for option in options],
		qmark="?»",  # Use a different qmark to indicate failure state
		use_indicator=True,
		use_arrow_keys=True,
	).ask()
	for option, action in options:
		if option == result:
			return action
	return ChunkAction.EXIT  # Fallback

edit_message

edit_message(current_message: str) -> str

Get an edited commit message from the user.

Parameters:

Name Type Description Default
current_message str

Current commit message

required

Returns:

Type Description
str

Edited commit message

Source code in src/codemap/git/interactive.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
def edit_message(self, current_message: str) -> str:
	"""
	Get an edited commit message from the user.

	Args:
	    current_message: Current commit message

	Returns:
	    Edited commit message

	"""
	self.console.print("\n[bold blue]Edit commit message:[/]")
	self.console.print("[dim]Press Enter to keep current message[/]")
	return Prompt.ask("Message", default=current_message)

process_chunk

process_chunk(
	chunk: DiffChunk, index: int = 0, total: int = 1
) -> ChunkResult

Process a single diff chunk interactively.

Parameters:

Name Type Description Default
chunk DiffChunk

DiffChunk to process

required
index int

The 0-based index of the current chunk

0
total int

The total number of chunks

1

Returns:

Type Description
ChunkResult

ChunkResult with the user's action and any modified message

Source code in src/codemap/git/interactive.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def process_chunk(self, chunk: DiffChunk, index: int = 0, total: int = 1) -> ChunkResult:
	"""
	Process a single diff chunk interactively.

	Args:
	    chunk: DiffChunk to process
	    index: The 0-based index of the current chunk
	    total: The total number of chunks

	Returns:
	    ChunkResult with the user's action and any modified message

	"""
	# Display the combined diff and message panel
	self.display_chunk(chunk, index, total)

	# Now get the user's action through questionary (without displaying another message panel)
	action = self.get_user_action()

	if action == ChunkAction.EDIT:
		message = self.edit_message(chunk.description or "")
		return ChunkResult(ChunkAction.COMMIT, message)

	if action == ChunkAction.COMMIT:
		return ChunkResult(action, chunk.description)

	return ChunkResult(action)

confirm_abort

confirm_abort() -> bool

Ask the user to confirm aborting the commit process.

Returns:

Type Description
bool

True if the user confirms, False otherwise

Raises:

Type Description
Exit

When the user confirms exiting

Source code in src/codemap/git/interactive.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
def confirm_abort(self) -> bool:
	"""
	Ask the user to confirm aborting the commit process.

	Returns:
	    True if the user confirms, False otherwise

	Raises:
	    typer.Exit: When the user confirms exiting

	"""
	confirmed = Confirm.ask(
		"\n[bold yellow]Are you sure you want to exit without committing?[/]",
		default=False,
	)

	if confirmed:
		self.console.print("[yellow]Exiting commit process...[/yellow]")
		# Use a zero exit code to indicate a successful (intended) exit
		# This prevents error messages from showing when exiting
		raise typer.Exit(code=0)

	return False

confirm_bypass_hooks

confirm_bypass_hooks() -> ChunkAction

Ask the user what to do when git hooks fail.

Returns:

Type Description
ChunkAction

ChunkAction indicating what to do next

Source code in src/codemap/git/interactive.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def confirm_bypass_hooks(self) -> ChunkAction:
	"""
	Ask the user what to do when git hooks fail.

	Returns:
	    ChunkAction indicating what to do next

	"""
	self.console.print("\n[bold yellow]Git hooks failed.[/]")
	self.console.print("[yellow]This may be due to linting or other pre-commit checks.[/]")

	options: list[tuple[str, ChunkAction]] = [
		("Force commit and bypass hooks", ChunkAction.COMMIT),
		("Regenerate message and try again", ChunkAction.REGENERATE),
		("Edit message manually", ChunkAction.EDIT),
		("Skip this group", ChunkAction.SKIP),
		("Exit without committing", ChunkAction.EXIT),
	]

	result = questionary.select(
		"What would you like to do?",
		choices=[option[0] for option in options],
		qmark="»",
		use_indicator=True,
		use_arrow_keys=True,
	).ask()

	for option, action in options:
		if option == result:
			return action

	# Fallback (should never happen)
	return ChunkAction.EXIT

show_success

show_success(message: str) -> None

Show a success message.

Parameters:

Name Type Description Default
message str

Message to display

required
Source code in src/codemap/git/interactive.py
401
402
403
404
405
406
407
408
409
def show_success(self, message: str) -> None:
	"""
	Show a success message.

	Args:
	    message: Message to display

	"""
	self.console.print(f"\n[bold green]✓[/] {message}")

show_warning

show_warning(message: str) -> None

Show a warning message to the user.

Parameters:

Name Type Description Default
message str

Warning message to display

required
Source code in src/codemap/git/interactive.py
411
412
413
414
415
416
417
418
419
def show_warning(self, message: str) -> None:
	"""
	Show a warning message to the user.

	Args:
	    message: Warning message to display

	"""
	self.console.print(f"\n[bold yellow]⚠[/] {message}")

show_error

show_error(message: str) -> None

Show an error message to the user.

Parameters:

Name Type Description Default
message str

Error message to display

required
Source code in src/codemap/git/interactive.py
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def show_error(self, message: str) -> None:
	"""
	Show an error message to the user.

	Args:
	    message: Error message to display

	"""
	if "No changes to commit" in message:
		# This is an informational message, not an error
		self.console.print(f"[yellow]{message}[/yellow]")
	else:
		# This is a real error
		self.console.print(f"[red]Error:[/red] {message}")

show_skipped

show_skipped(files: list[str]) -> None

Show which files were skipped.

Parameters:

Name Type Description Default
files list[str]

List of skipped files

required
Source code in src/codemap/git/interactive.py
436
437
438
439
440
441
442
443
444
445
446
447
def show_skipped(self, files: list[str]) -> None:
	"""
	Show which files were skipped.

	Args:
	    files: List of skipped files

	"""
	if files:
		self.console.print("\n[yellow]Skipped changes in:[/]")
		for file in files:
			self.console.print(f"  • {file}")

show_message

show_message(message: str) -> None

Show a general informational message.

Parameters:

Name Type Description Default
message str

Message to display

required
Source code in src/codemap/git/interactive.py
449
450
451
452
453
454
455
456
457
def show_message(self, message: str) -> None:
	"""
	Show a general informational message.

	Args:
	    message: Message to display

	"""
	self.console.print(f"\n{message}")

show_regenerating

show_regenerating() -> None

Show message indicating message regeneration.

Source code in src/codemap/git/interactive.py
459
460
461
def show_regenerating(self) -> None:
	"""Show message indicating message regeneration."""
	self.console.print("\n[yellow]Regenerating commit message...[/yellow]")

show_all_committed

show_all_committed() -> None

Show message indicating all changes are committed.

Source code in src/codemap/git/interactive.py
463
464
465
def show_all_committed(self) -> None:
	"""Show message indicating all changes are committed."""
	self.console.print("[green]✓[/green] All changes committed!")

show_all_done

show_all_done() -> None

Show a final success message when the process completes.

This is an alias for show_all_committed for now, but could be customized.

Source code in src/codemap/git/interactive.py
467
468
469
470
471
472
473
474
475
def show_all_done(self) -> None:
	"""
	Show a final success message when the process completes.

	This is an alias for show_all_committed for now, but could be
	customized.

	"""
	self.show_all_committed()

show_lint_errors

show_lint_errors(errors: list[str]) -> None

Display linting errors to the user.

Source code in src/codemap/git/interactive.py
477
478
479
480
481
def show_lint_errors(self, errors: list[str]) -> None:
	"""Display linting errors to the user."""
	self.console.print("[bold red]Commit message failed linting:[/bold red]")
	for error in errors:
		self.console.print(f"  - {error}")

confirm_commit_with_lint_errors

confirm_commit_with_lint_errors() -> bool

Ask the user if they want to commit despite lint errors.

Source code in src/codemap/git/interactive.py
483
484
485
def confirm_commit_with_lint_errors(self) -> bool:
	"""Ask the user if they want to commit despite lint errors."""
	return questionary.confirm("Commit message has lint errors. Commit anyway?", default=False).ask()

confirm_exit

confirm_exit() -> bool

Ask the user to confirm exiting without committing.

Source code in src/codemap/git/interactive.py
487
488
489
def confirm_exit(self) -> bool:
	"""Ask the user to confirm exiting without committing."""
	return questionary.confirm("Are you sure you want to exit without committing?", default=False).ask()

display_failed_lint_message

display_failed_lint_message(
	message: str,
	lint_errors: list[str],
	is_llm_generated: bool = False,
) -> None

Display a commit message that failed linting, along with the errors.

Parameters:

Name Type Description Default
message str

The commit message to display.

required
lint_errors list[str]

List of linting error messages.

required
is_llm_generated bool

Whether the message was generated by an LLM.

False
Source code in src/codemap/git/interactive.py
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def display_failed_lint_message(self, message: str, lint_errors: list[str], is_llm_generated: bool = False) -> None:
	"""
	Display a commit message that failed linting, along with the errors.

	Args:
	    message: The commit message to display.
	    lint_errors: List of linting error messages.
	    is_llm_generated: Whether the message was generated by an LLM.

	"""
	tag = "AI" if is_llm_generated else "Simple"
	message_panel = Panel(
		Text(message, style="yellow"),  # Use yellow style for the message text
		title=f"[bold yellow]Proposed message ({tag}) - LINTING FAILED[/]",
		border_style="yellow",  # Yellow border to indicate warning/failure
		expand=False,
		padding=(1, 2),
	)
	self.console.print(message_panel)

	# Display lint errors below
	if lint_errors:
		error_text = Text("\n".join([f"- {err}" for err in lint_errors]), style="red")
		error_panel = Panel(
			error_text,
			title="[bold red]Linting Errors[/]",
			border_style="red",
			expand=False,
			padding=(1, 2),
		)
		self.console.print(error_panel)

display_failed_json_message

display_failed_json_message(
	raw_content: str,
	json_errors: list[str],
	is_llm_generated: bool = True,
) -> None

Display a raw response that failed JSON validation, along with the errors.

Parameters:

Name Type Description Default
raw_content str

The raw string content that failed JSON validation.

required
json_errors list[str]

List of JSON validation/formatting error messages.

required
is_llm_generated bool

Whether the message was generated by an LLM (usually True here).

True
Source code in src/codemap/git/interactive.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def display_failed_json_message(
	self, raw_content: str, json_errors: list[str], is_llm_generated: bool = True
) -> None:
	"""
	Display a raw response that failed JSON validation, along with the errors.

	Args:
	    raw_content: The raw string content that failed JSON validation.
	    json_errors: List of JSON validation/formatting error messages.
	    is_llm_generated: Whether the message was generated by an LLM (usually True here).
	"""
	tag = "AI" if is_llm_generated else "Manual"
	message_panel = Panel(
		Text(raw_content, style="dim yellow"),  # Use dim yellow for the raw content
		title=f"[bold yellow]Invalid JSON Response ({tag}) - VALIDATION FAILED[/]",
		border_style="yellow",  # Yellow border to indicate JSON warning
		expand=False,
		padding=(1, 2),
	)
	self.console.print(message_panel)

	# Display JSON errors below
	if json_errors:
		error_text = Text("\n".join([f"- {err}" for err in json_errors]), style="red")
		error_panel = Panel(
			error_text,
			title="[bold red]JSON Validation Errors[/]",
			border_style="red",
			expand=False,
			padding=(1, 2),
		)
		self.console.print(error_panel)

get_group_action

get_group_action() -> ChunkAction

Get the user's desired action for the current semantic group.

Returns:

Type Description
ChunkAction

ChunkAction indicating what to do with the group

Source code in src/codemap/git/interactive.py
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
def get_group_action(self) -> ChunkAction:
	"""
	Get the user's desired action for the current semantic group.

	Returns:
	        ChunkAction indicating what to do with the group

	"""
	# Define options with their display text and corresponding action
	options: list[tuple[str, ChunkAction]] = [
		("Commit this group", ChunkAction.COMMIT),
		("Edit message and commit", ChunkAction.EDIT),
		("Regenerate message", ChunkAction.REGENERATE),
		("Skip this group", ChunkAction.SKIP),
		("Exit without committing", ChunkAction.EXIT),
	]

	# Use questionary to get the user's choice
	result = questionary.select(
		"What would you like to do with this group?",
		choices=[option[0] for option in options],
		default=options[0][0],  # Set "Commit this group" as default
		qmark="»",
		use_indicator=True,
		use_arrow_keys=True,
	).ask()

	# Map the result back to the ChunkAction
	for option, action in options:
		if option == result:
			return action

	# Fallback (should never happen)
	return ChunkAction.EXIT