77from textual import on
88from textual .app import App , ComposeResult
99from textual .binding import Binding
10+ from textual .containers import Vertical
11+ from textual .screen import ModalScreen
1012from 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
1316from commit_editor .git import get_signed_off_by
1417from commit_editor .spelling import WORD_PATTERN , SpellCheckCache
1720BODY_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
21110def 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