99from typing import Any , TypedDict
1010
1111import questionary
12- import questionary .prompts .text
1312from prompt_toolkit .key_binding import KeyBindings
1413from prompt_toolkit .key_binding .key_processor import KeyPressEvent
1514from prompt_toolkit .keys import Keys
15+ from prompt_toolkit .styles import Style
1616
1717from commitizen import factory , git , out
1818from commitizen .config import BaseConfig
@@ -45,8 +45,11 @@ class CommitArgs(TypedDict, total=False):
4545 retry : bool
4646
4747
48- def _handle_questionary_prompt (question : CzQuestion , cz_style : Any ) -> dict [str , Any ]:
49- """Handle questionary prompt with error handling."""
48+ def _handle_questionary_prompt (question : CzQuestion , cz_style : Style ) -> dict [str , Any ]:
49+ """Handle questionary prompt with multiline and error handling."""
50+ if question ["type" ] == "input" and question .get ("multiline" , False ):
51+ return _handle_multiline_question (question , cz_style )
52+
5053 try :
5154 answer = questionary .prompt ([question ], style = cz_style )
5255 if not answer :
@@ -55,13 +58,76 @@ def _handle_questionary_prompt(question: CzQuestion, cz_style: Any) -> dict[str,
5558 except ValueError as err :
5659 root_err = err .__context__
5760 if isinstance (root_err , CzException ):
58- raise CustomError (root_err . __str__ ( ))
61+ raise CustomError (str ( root_err ))
5962 raise err
6063
6164
62- def _handle_multiline_fallback (multiline_question : InputQuestion , cz_style : Any ) -> dict [str , Any ]:
63- """Handle fallback to standard behavior if custom multiline approach fails."""
64- return _handle_questionary_prompt (multiline_question , cz_style )
65+ def _handle_multiline_question (
66+ question : InputQuestion , cz_style : Style
67+ ) -> dict [str , Any ]:
68+ """Handle multiline input questions."""
69+ is_optional = (
70+ question .get ("default" ) == ""
71+ or "skip" in question .get ("message" , "" ).lower ()
72+ or "[enter] to skip" in question .get ("message" , "" ).lower ()
73+ )
74+
75+ guidance = (
76+ "💡 Press Enter on empty line to skip, Alt+Enter to finish"
77+ if is_optional
78+ else "💡 Press Alt+Enter to finish"
79+ )
80+ out .info (guidance )
81+
82+ def _handle_key_press (event : KeyPressEvent , is_finish_key : bool ) -> None :
83+ buffer = event .current_buffer
84+ is_empty = not buffer .text .strip ()
85+
86+ if is_empty :
87+ if is_optional and not is_finish_key :
88+ event .app .exit (result = "" )
89+ elif not is_optional :
90+ out .error (
91+ "⚠ This field is required. Please enter some content or press Ctrl+C to abort."
92+ )
93+ out .line ("> " , end = "" , flush = True )
94+ else :
95+ event .app .exit (result = buffer .text )
96+ else :
97+ if is_finish_key :
98+ event .app .exit (result = buffer .text )
99+ else :
100+ buffer .newline ()
101+
102+ bindings = KeyBindings ()
103+
104+ @bindings .add (Keys .Enter )
105+ def _ (event : KeyPressEvent ) -> None :
106+ _handle_key_press (event , is_finish_key = False )
107+
108+ @bindings .add (Keys .Escape , Keys .Enter )
109+ def _ (event : KeyPressEvent ) -> None :
110+ _handle_key_press (event , is_finish_key = True )
111+
112+ result = questionary .text (
113+ message = question ["message" ],
114+ multiline = True ,
115+ style = cz_style ,
116+ key_bindings = bindings ,
117+ ).unsafe_ask ()
118+
119+ if result is None :
120+ result = question .get ("default" , "" )
121+
122+ if "filter" in question :
123+ try :
124+ result = question ["filter" ](result )
125+ except Exception as e :
126+ out .error (f"⚠ { str (e )} " )
127+ out .line ("> " , end = "" , flush = True )
128+ return _handle_multiline_question (question , cz_style )
129+
130+ return {question ["name" ]: result }
65131
66132
67133class Commit :
@@ -92,116 +158,12 @@ def _prompt_commit_questions(self) -> str:
92158 questions = cz .questions ()
93159 answers = {}
94160
95- # Handle questions one by one to support custom continuation
96161 for question in questions :
97162 if question ["type" ] == "list" :
98163 question ["use_shortcuts" ] = self .config .settings ["use_shortcuts" ]
99- answer = _handle_questionary_prompt (question , cz .style )
100- answers .update (answer )
101- elif question ["type" ] == "input" and question .get ("multiline" , False ):
102- is_optional = (
103- question .get ("default" ) == ""
104- or "skip" in question .get ("message" , "" ).lower ()
105- )
106164
107- if is_optional :
108- out .info (
109- "💡 Multiline input:\n Press Enter on empty line to skip, Enter after text for new lines, Alt+Enter to finish"
110- )
111- else :
112- out .info (
113- "💡 Multiline input:\n Press Enter for new lines and Alt+Enter to finish"
114- )
115-
116- # Create custom multiline input with Enter-on-empty behavior for optional fields
117-
118- multiline_question = question .copy ()
119- multiline_question ["multiline" ] = True
120-
121- if is_optional :
122- # Create custom key bindings for optional fields
123- bindings = KeyBindings ()
124-
125- @bindings .add (Keys .Enter )
126- def _ (event : KeyPressEvent ) -> None :
127- buffer = event .current_buffer
128- # If buffer is completely empty, submit
129- if not buffer .text .strip ():
130- event .app .exit (result = buffer .text )
131- else :
132- # If there's text, add new line
133- buffer .newline ()
134-
135- # Use the text prompt directly with custom bindings
136- try :
137- result = questionary .prompts .text .text (
138- message = question ["message" ],
139- multiline = True ,
140- style = cz .style ,
141- key_bindings = bindings ,
142- ).ask ()
143-
144- field_name = question ["name" ]
145- if result is None :
146- result = question .get ("default" , "" )
147-
148- # Apply filter if present
149- if "filter" in question :
150- result = question ["filter" ](result )
151-
152- answer = {field_name : result }
153- answers .update (answer )
154-
155- except Exception :
156- # Fallback to standard behavior if custom approach fails
157- answer = _handle_multiline_fallback (multiline_question , cz .style )
158- answers .update (answer )
159- else :
160- # Required fields - don't allow newline on empty first line and show error
161- bindings = KeyBindings ()
162-
163- @bindings .add (Keys .Enter )
164- def _ (event : KeyPressEvent ) -> None :
165- buffer = event .current_buffer
166- # If buffer is completely empty (no content at all), show error and don't allow newline
167- if not buffer .text .strip ():
168- # Show error message with prompt
169- out .error (
170- "\n ⚠ This field is required. Please enter some content or press Ctrl+C to abort."
171- )
172- print ("> " , end = "" , flush = True )
173- # Don't do anything - require content first
174- pass
175- else :
176- # If there's text, add new line
177- buffer .newline ()
178-
179- try :
180- result = questionary .prompts .text .text (
181- message = question ["message" ],
182- multiline = True ,
183- style = cz .style ,
184- key_bindings = bindings ,
185- ).ask ()
186-
187- field_name = question ["name" ]
188- if result is None :
189- result = ""
190-
191- # Apply filter if present
192- if "filter" in question :
193- result = question ["filter" ](result )
194-
195- answer = {field_name : result }
196- answers .update (answer )
197-
198- except Exception :
199- # Fallback to standard behavior if custom approach fails
200- answer = _handle_multiline_fallback (multiline_question , cz .style )
201- answers .update (answer )
202- else :
203- answer = _handle_questionary_prompt (question , cz .style )
204- answers .update (answer )
165+ answer = _handle_questionary_prompt (question , cz .style )
166+ answers .update (answer )
205167
206168 message = cz .message (answers )
207169 message_len = len (message .partition ("\n " )[0 ].strip ())
0 commit comments