1+ import re
12from collections .abc import Callable
23from pathlib import Path
34
1314from textual .widgets import Input , OptionList , Static , TextArea
1415from textual .widgets ._option_list import Option
1516
16- from commit_editor .git import get_signed_off_by
17+ from commit_editor .git import get_issue_pattern , get_signed_off_by
1718from commit_editor .spelling import WORD_PATTERN , SpellCheckCache
1819
1920TITLE_MAX_LENGTH = 50
2021BODY_MAX_LENGTH = 72
2122_SUGGESTION_PREFIX = "Suggestions for"
23+ _ISSUE_ID_ERROR = "Missing/Invalid issue ID in title!"
2224
2325AI_COAUTHOR_MODELS = [
2426 ("Claude Opus 4.6" , "noreply@anthropic.com" ),
@@ -325,6 +327,51 @@ def get_cursor_position(self) -> tuple[int, int]:
325327 return row + 1 , col + 1
326328
327329
330+ class ValidationBar (Static ):
331+ """Bar for showing validation error messages. Hidden when empty."""
332+
333+ DEFAULT_CSS = """
334+ ValidationBar {
335+ height: auto;
336+ background: $surface;
337+ color: $error;
338+ padding: 0 1;
339+ display: none;
340+ }
341+ ValidationBar.has-errors {
342+ display: block;
343+ }
344+ """
345+
346+ def __init__ (self , * args , ** kwargs ):
347+ super ().__init__ (* args , ** kwargs )
348+ self ._errors : list [tuple [str , str ]] = []
349+
350+ def set_error (self , key : str , message : str ) -> None :
351+ """Add or update a validation error by key."""
352+ for i , (k , _ ) in enumerate (self ._errors ):
353+ if k == key :
354+ self ._errors [i ] = (key , message )
355+ self ._refresh_display ()
356+ return
357+ self ._errors .append ((key , message ))
358+ self ._refresh_display ()
359+
360+ def clear_error (self , key : str ) -> None :
361+ """Remove a validation error by key."""
362+ self ._errors = [(k , m ) for k , m in self ._errors if k != key ]
363+ self ._refresh_display ()
364+
365+ def _refresh_display (self ) -> None :
366+ """Update the display based on current errors."""
367+ if self ._errors :
368+ self .update ("\n " .join (msg for _ , msg in self ._errors ))
369+ self .add_class ("has-errors" )
370+ else :
371+ self .update ("" )
372+ self .remove_class ("has-errors" )
373+
374+
328375class StatusBar (Static ):
329376 """Status bar showing cursor position and title length."""
330377
@@ -433,9 +480,17 @@ def __init__(self, filename: Path):
433480 self ._original_content = ""
434481 self ._prompt_mode : str | None = None # Track active prompt type
435482 self ._spell_timer = None
483+ self ._issue_pattern : re .Pattern [str ] | None = None
484+ pattern = get_issue_pattern ()
485+ if pattern :
486+ try :
487+ self ._issue_pattern = re .compile (pattern + r":" )
488+ except re .error :
489+ pass
436490
437491 def compose (self ) -> ComposeResult :
438492 yield CommitTextArea (id = "editor" , show_line_numbers = True , highlight_cursor_line = True )
493+ yield ValidationBar (id = "validation" )
439494 yield StatusBar (id = "status" )
440495 yield MessageBar (id = "message" )
441496
@@ -450,6 +505,7 @@ def on_mount(self) -> None:
450505 editor .focus ()
451506
452507 self ._update_status_bar ()
508+ self ._validate_issue_id ()
453509
454510 def check_action (self , action : str , parameters : tuple ) -> bool | None :
455511 """Disable editor actions when in prompt mode."""
@@ -514,6 +570,7 @@ def on_editor_changed(self, event: CommitTextArea.Changed) -> None:
514570 self .query_one ("#message" , MessageBar ).clear ()
515571
516572 self ._update_status_bar ()
573+ self ._validate_issue_id ()
517574
518575 @on (CommitTextArea .SelectionChanged )
519576 def on_selection_changed (self , event : CommitTextArea .SelectionChanged ) -> None :
@@ -531,6 +588,18 @@ def _update_status_bar(self) -> None:
531588
532589 status .update_status (line , col , title_length , self .dirty )
533590
591+ def _validate_issue_id (self ) -> None :
592+ """Validate issue ID in the title and update the validation bar."""
593+ if self ._issue_pattern is None :
594+ return
595+ editor = self .query_one ("#editor" , CommitTextArea )
596+ title = editor .text .split ("\n " )[0 ] if editor .text else ""
597+ validation_bar = self .query_one ("#validation" , ValidationBar )
598+ if self ._issue_pattern .match (title ):
599+ validation_bar .clear_error ("issue_id" )
600+ else :
601+ validation_bar .set_error ("issue_id" , _ISSUE_ID_ERROR )
602+
534603 def _show_message (self , message : str , error : bool = False ) -> None :
535604 """Show a message in the message bar."""
536605 message_bar = self .query_one ("#message" , MessageBar )
0 commit comments