Skip to content

Commit 36f52be

Browse files
mprpicclaude
andcommitted
Add Co-authored-by AI attribution trailer toggle
Ctrl+b opens a dialog to select a model from a list and adds a Co-authored-by trailer just before the Signed-off-by one (if present). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Martin Prpič <martin.prpic@gmail.com>
1 parent b856aef commit 36f52be

File tree

2 files changed

+447
-41
lines changed

2 files changed

+447
-41
lines changed

src/commit_editor/app.py

Lines changed: 191 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
from textual import on
88
from textual.app import App, ComposeResult
99
from textual.binding import Binding
10+
from textual.containers import Vertical
11+
from textual.screen import ModalScreen
1012
from textual.strip import Strip
11-
from textual.widgets import Static, TextArea
13+
from textual.widgets import Input, OptionList, Static, TextArea
14+
from textual.widgets._option_list import Option
1215

1316
from commit_editor.git import get_signed_off_by
1417
from commit_editor.spelling import WORD_PATTERN, SpellCheckCache
@@ -17,6 +20,92 @@
1720
BODY_MAX_LENGTH = 72
1821
_SUGGESTION_PREFIX = "Suggestions for"
1922

23+
AI_COAUTHOR_MODELS = [
24+
("Claude Opus 4.6", "noreply@anthropic.com"),
25+
("Claude Opus 4.6 (1M context)", "noreply@anthropic.com"),
26+
("Claude Haiku 4.5", "noreply@anthropic.com"),
27+
("Claude Sonnet 4.6 (1M context)", "noreply@anthropic.com"),
28+
("Claude Sonnet 4.6", "noreply@anthropic.com"),
29+
("Gemini 3.1 Pro", "gemini-code-assist@google.com"),
30+
("Gemini 3 Flash", "gemini-code-assist@google.com"),
31+
("Gemini 2.5 Flash", "gemini-code-assist@google.com"),
32+
("Gemini 2.5 Pro", "gemini-code-assist@google.com"),
33+
]
34+
35+
36+
def _format_coauthor(name: str, email: str) -> str:
37+
return f"Co-authored-by: {name} <{email}>"
38+
39+
40+
class CoauthorSelectScreen(ModalScreen[str | None]):
41+
"""Modal screen for selecting an AI co-author model."""
42+
43+
DEFAULT_CSS = """
44+
CoauthorSelectScreen {
45+
align: center middle;
46+
}
47+
48+
CoauthorSelectScreen > #coauthor-container {
49+
width: 50;
50+
height: auto;
51+
max-height: 80%;
52+
border: solid $primary;
53+
background: $surface;
54+
padding: 1;
55+
}
56+
57+
CoauthorSelectScreen > #coauthor-container > #coauthor-list {
58+
height: auto;
59+
max-height: 20;
60+
}
61+
62+
CoauthorSelectScreen > #coauthor-container > #coauthor-input {
63+
display: none;
64+
margin-top: 1;
65+
}
66+
67+
CoauthorSelectScreen > #coauthor-container > #coauthor-input.visible {
68+
display: block;
69+
}
70+
"""
71+
72+
BINDINGS = [
73+
Binding("escape", "cancel", "Cancel", show=False),
74+
]
75+
76+
def compose(self) -> ComposeResult:
77+
with Vertical(id="coauthor-container"):
78+
option_list = OptionList(id="coauthor-list")
79+
for i, (name, email) in enumerate(AI_COAUTHOR_MODELS):
80+
option_list.add_option(Option(f" {name}", id=str(i)))
81+
option_list.add_option(None) # separator
82+
option_list.add_option(Option(" Other...", id="other"))
83+
option_list.highlighted = 0
84+
yield option_list
85+
yield Input(id="coauthor-input", placeholder="Name <email>")
86+
87+
@on(OptionList.OptionSelected, "#coauthor-list")
88+
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
89+
option_id = event.option.id
90+
if option_id is None:
91+
return
92+
if option_id == "other":
93+
input_widget = self.query_one("#coauthor-input", Input)
94+
input_widget.add_class("visible")
95+
input_widget.focus()
96+
else:
97+
name, email = AI_COAUTHOR_MODELS[int(option_id)]
98+
self.dismiss(_format_coauthor(name, email))
99+
100+
@on(Input.Submitted, "#coauthor-input")
101+
def on_input_submitted(self, event: Input.Submitted) -> None:
102+
value = event.value.strip()
103+
if value:
104+
self.dismiss(f"Co-authored-by: {value}")
105+
106+
def action_cancel(self) -> None:
107+
self.dismiss(None)
108+
20109

21110
def wrap_line(line: str, width: int = 72) -> list[str]:
22111
"""Wrap a single line at word boundaries to fit within width.
@@ -262,7 +351,7 @@ def update_status(self, line: int, col: int, title_length: int, dirty: bool) ->
262351
title_display = f"Title: {title_length}"
263352

264353
left = f"Ln {line}, Col {col} | {title_display}{dirty_marker}"
265-
hints = "^S Save ^Q Quit ^O Sign-off ^L Spellcheck"
354+
hints = "^S Save ^Q Quit ^O Sign-off ^B Co-author ^L Spellcheck"
266355
left_width = len(Text.from_markup(left).plain)
267356
# Account for padding on both sides
268357
gap = (self.size.width - 2) - left_width - len(hints)
@@ -324,6 +413,7 @@ class CommitEditorApp(App):
324413
Binding("ctrl+q", "quit_app", "Quit", show=True),
325414
Binding("ctrl+o", "toggle_signoff", "Sign-off", show=True),
326415
Binding("ctrl+l", "toggle_spellcheck", "Spellcheck", show=True),
416+
Binding("ctrl+b", "toggle_coauthor", "Co-author", show=True),
327417
]
328418

329419
DEFAULT_CSS = """
@@ -446,6 +536,45 @@ def _show_message(self, message: str, error: bool = False) -> None:
446536
message_bar = self.query_one("#message", MessageBar)
447537
message_bar.show_message(message, error=error)
448538

539+
@staticmethod
540+
def _split_content_and_comments(
541+
lines: list[str],
542+
) -> tuple[list[str], list[str]]:
543+
"""Split lines into content and git comment lines, stripping trailing blanks."""
544+
comment_start = len(lines)
545+
for i, line in enumerate(lines):
546+
if line.startswith("#"):
547+
comment_start = i
548+
break
549+
content = lines[:comment_start]
550+
comments = lines[comment_start:]
551+
while content and not content[-1].strip():
552+
content.pop()
553+
return content, comments
554+
555+
@staticmethod
556+
def _reassemble(content_lines: list[str], comment_lines: list[str]) -> str:
557+
"""Reassemble content and comment lines into a single string."""
558+
if comment_lines:
559+
return "\n".join(content_lines) + "\n\n" + "\n".join(comment_lines)
560+
return "\n".join(content_lines)
561+
562+
def _load_and_restore_cursor(self, new_text: str) -> None:
563+
"""Load text into editor, restore cursor position, and update status bar."""
564+
editor = self.query_one("#editor", CommitTextArea)
565+
cursor_pos = editor.cursor_location
566+
editor.load_text(new_text)
567+
editor.invalidate_spell_cache()
568+
569+
new_lines = new_text.split("\n")
570+
max_row = len(new_lines) - 1
571+
new_row = min(cursor_pos[0], max_row)
572+
max_col = len(new_lines[new_row]) if new_row < len(new_lines) else 0
573+
new_col = min(cursor_pos[1], max_col)
574+
editor.cursor_location = (new_row, new_col)
575+
576+
self._update_status_bar()
577+
449578
def action_save(self) -> None:
450579
"""Save the file."""
451580
editor = self.query_one("#editor", CommitTextArea)
@@ -479,28 +608,13 @@ def action_quit_app(self) -> None:
479608
def action_toggle_signoff(self) -> None:
480609
"""Toggle the Signed-off-by line."""
481610
editor = self.query_one("#editor", CommitTextArea)
482-
text = editor.text
483-
lines = text.split("\n")
484611

485612
signoff = get_signed_off_by()
486613
if not signoff:
487614
self._show_message("Git user not configured", error=True)
488615
return
489616

490-
# Find where git comments start (lines starting with #)
491-
comment_start_index = len(lines)
492-
for i, line in enumerate(lines):
493-
if line.startswith("#"):
494-
comment_start_index = i
495-
break
496-
497-
# Split into content and comments
498-
content_lines = lines[:comment_start_index]
499-
comment_lines = lines[comment_start_index:]
500-
501-
# Remove trailing empty lines from content for clean processing
502-
while content_lines and not content_lines[-1].strip():
503-
content_lines.pop()
617+
content_lines, comment_lines = self._split_content_and_comments(editor.text.split("\n"))
504618

505619
# Check if Signed-off-by already exists in content
506620
has_signoff = False
@@ -523,31 +637,14 @@ def action_toggle_signoff(self) -> None:
523637
while content_lines and not content_lines[-1].strip():
524638
content_lines.pop()
525639
else:
526-
# Add Signed-off-by with blank line if needed
640+
# Add Signed-off-by with blank line if needed (but not after
641+
# other trailers like Co-authored-by)
527642
if content_lines and content_lines[-1].strip():
528-
content_lines.append("")
643+
if not content_lines[-1].startswith("Co-authored-by:"):
644+
content_lines.append("")
529645
content_lines.append(signoff)
530646

531-
# Reassemble: content + blank line (if comments exist) + comments
532-
if comment_lines:
533-
# Ensure blank line between content and comments
534-
new_text = "\n".join(content_lines) + "\n\n" + "\n".join(comment_lines)
535-
else:
536-
new_text = "\n".join(content_lines)
537-
cursor_pos = editor.cursor_location
538-
539-
editor.load_text(new_text)
540-
editor.invalidate_spell_cache()
541-
542-
# Restore cursor position if possible
543-
new_lines = new_text.split("\n")
544-
max_row = len(new_lines) - 1
545-
new_row = min(cursor_pos[0], max_row)
546-
max_col = len(new_lines[new_row]) if new_row < len(new_lines) else 0
547-
new_col = min(cursor_pos[1], max_col)
548-
editor.cursor_location = (new_row, new_col)
549-
550-
self._update_status_bar()
647+
self._load_and_restore_cursor(self._reassemble(content_lines, comment_lines))
551648

552649
def action_toggle_spellcheck(self) -> None:
553650
"""Toggle spellcheck on/off."""
@@ -563,6 +660,60 @@ def action_toggle_spellcheck(self) -> None:
563660
# Force re-render to update underlines
564661
editor.refresh()
565662

663+
def action_toggle_coauthor(self) -> None:
664+
"""Toggle co-author: remove if present, otherwise open selection modal."""
665+
editor = self.query_one("#editor", CommitTextArea)
666+
667+
if "Co-authored-by:" in editor.text:
668+
self._remove_coauthor()
669+
else:
670+
self.push_screen(CoauthorSelectScreen(), self._on_coauthor_selected)
671+
672+
def _remove_coauthor(self) -> None:
673+
"""Remove any existing Co-authored-by line from the editor text."""
674+
editor = self.query_one("#editor", CommitTextArea)
675+
content_lines, comment_lines = self._split_content_and_comments(editor.text.split("\n"))
676+
677+
content_lines = [line for line in content_lines if not line.startswith("Co-authored-by:")]
678+
while content_lines and not content_lines[-1].strip():
679+
content_lines.pop()
680+
681+
self._load_and_restore_cursor(self._reassemble(content_lines, comment_lines))
682+
683+
def _on_coauthor_selected(self, result: str | None) -> None:
684+
"""Handle co-author selection result."""
685+
if result is None:
686+
return
687+
688+
editor = self.query_one("#editor", CommitTextArea)
689+
content_lines, comment_lines = self._split_content_and_comments(editor.text.split("\n"))
690+
691+
# Find index of first Signed-off-by line
692+
signoff_index = -1
693+
for i, line in enumerate(content_lines):
694+
if line.startswith("Signed-off-by:"):
695+
signoff_index = i
696+
break
697+
698+
if signoff_index >= 0:
699+
# Insert directly before Signed-off-by (no blank line between trailers)
700+
# Add blank separator before the trailer block if needed
701+
if signoff_index > 0 and content_lines[signoff_index - 1].strip():
702+
content_lines.insert(signoff_index, "")
703+
signoff_index += 1
704+
content_lines.insert(signoff_index, result)
705+
else:
706+
# Append to end
707+
if not content_lines:
708+
# No content yet; add blank title + separator so trailer
709+
# doesn't land on the first line.
710+
content_lines.extend(["", ""])
711+
elif content_lines[-1].strip():
712+
content_lines.append("")
713+
content_lines.append(result)
714+
715+
self._load_and_restore_cursor(self._reassemble(content_lines, comment_lines))
716+
566717
def _schedule_spell_suggestions(self) -> None:
567718
"""Debounce spell suggestion updates to avoid blocking during rapid cursor movement."""
568719
if self._spell_timer is not None:

0 commit comments

Comments
 (0)