@@ -30,24 +30,33 @@ pub enum InputResult {
30
30
None ,
31
31
}
32
32
33
+ const SUGGESTIONS : & [ & str ] = & [
34
+ "explain this codebase to me" ,
35
+ "fix any build errors" ,
36
+ "are there any bugs in my code?" ,
37
+ ] ;
38
+
33
39
pub ( crate ) struct ChatComposer < ' a > {
34
40
textarea : TextArea < ' a > ,
35
41
command_popup : Option < CommandPopup > ,
36
42
app_event_tx : AppEventSender ,
37
43
history : ChatComposerHistory ,
44
+ selected_suggestion : Option < usize > ,
45
+ has_user_typed : bool ,
38
46
}
39
47
40
48
impl ChatComposer < ' _ > {
41
49
pub fn new ( has_input_focus : bool , app_event_tx : AppEventSender ) -> Self {
42
50
let mut textarea = TextArea :: default ( ) ;
43
- textarea. set_placeholder_text ( "send a message" ) ;
44
51
textarea. set_cursor_line_style ( ratatui:: style:: Style :: default ( ) ) ;
45
52
46
53
let mut this = Self {
47
54
textarea,
48
55
command_popup : None ,
49
56
app_event_tx,
50
57
history : ChatComposerHistory :: new ( ) ,
58
+ selected_suggestion : None ,
59
+ has_user_typed : false ,
51
60
} ;
52
61
this. update_border ( has_input_focus) ;
53
62
this
@@ -154,6 +163,43 @@ impl ChatComposer<'_> {
154
163
/// Handle key event when no popup is visible.
155
164
fn handle_key_event_without_popup ( & mut self , key_event : KeyEvent ) -> ( InputResult , bool ) {
156
165
let input: Input = key_event. into ( ) ;
166
+
167
+ // Handle suggested prompts when input is empty
168
+ if !self . has_user_typed && self . is_input_empty ( ) {
169
+ match input {
170
+ Input { key : Key :: Right , shift : false , .. } => {
171
+ // Cycle forward through suggestions
172
+ self . selected_suggestion = match self . selected_suggestion {
173
+ None => Some ( 0 ) ,
174
+ Some ( i) if i >= SUGGESTIONS . len ( ) - 1 => None ,
175
+ Some ( i) => Some ( i + 1 ) ,
176
+ } ;
177
+ return ( InputResult :: None , true ) ;
178
+ }
179
+ Input { key : Key :: Left , shift : false , .. } => {
180
+ // Cycle backward through suggestions
181
+ self . selected_suggestion = match self . selected_suggestion {
182
+ None => Some ( SUGGESTIONS . len ( ) - 1 ) ,
183
+ Some ( 0 ) => None ,
184
+ Some ( i) => Some ( i - 1 ) ,
185
+ } ;
186
+ return ( InputResult :: None , true ) ;
187
+ }
188
+ Input {
189
+ key : Key :: Enter ,
190
+ shift : false ,
191
+ alt : false ,
192
+ ctrl : false ,
193
+ } if self . selected_suggestion . is_some ( ) => {
194
+ if let Some ( index) = self . selected_suggestion . take ( ) {
195
+ let suggestion = SUGGESTIONS [ index] ;
196
+ return ( InputResult :: Submitted ( suggestion. to_string ( ) ) , true ) ;
197
+ }
198
+ }
199
+ _ => { }
200
+ }
201
+ }
202
+
157
203
match input {
158
204
// -------------------------------------------------------------
159
205
// History navigation (Up / Down) – only when the composer is not
@@ -191,6 +237,7 @@ impl ChatComposer<'_> {
191
237
let text = self . textarea . lines ( ) . join ( "\n " ) ;
192
238
self . textarea . select_all ( ) ;
193
239
self . textarea . cut ( ) ;
240
+ self . has_user_typed = false ;
194
241
195
242
if text. is_empty ( ) {
196
243
( InputResult :: None , true )
@@ -218,6 +265,9 @@ impl ChatComposer<'_> {
218
265
/// Handle generic Input events that modify the textarea content.
219
266
fn handle_input_basic ( & mut self , input : Input ) -> ( InputResult , bool ) {
220
267
self . textarea . input ( input) ;
268
+ self . selected_suggestion = None ;
269
+ // Mark that user has typed, but reset if input becomes empty
270
+ self . has_user_typed = !self . is_input_empty ( ) ;
221
271
( InputResult :: None , true )
222
272
}
223
273
@@ -290,6 +340,71 @@ impl ChatComposer<'_> {
290
340
pub ( crate ) fn is_command_popup_visible ( & self ) -> bool {
291
341
self . command_popup . is_some ( )
292
342
}
343
+
344
+ pub ( crate ) fn is_input_empty ( & self ) -> bool {
345
+ self . textarea . lines ( ) . iter ( ) . all ( |line| line. trim ( ) . is_empty ( ) )
346
+ }
347
+
348
+ pub ( crate ) fn should_show_suggestions ( & self ) -> bool {
349
+ !self . has_user_typed && self . is_input_empty ( )
350
+ }
351
+
352
+ pub ( crate ) fn get_suggestion_state ( & self ) -> ( Option < usize > , & ' static [ & ' static str ] ) {
353
+ ( self . selected_suggestion , SUGGESTIONS )
354
+ }
355
+ }
356
+
357
+ #[ cfg( test) ]
358
+ mod tests {
359
+ #![ expect( clippy:: expect_used) ]
360
+ use super :: * ;
361
+ use crate :: app_event_sender:: AppEventSender ;
362
+ use std:: sync:: mpsc:: channel;
363
+
364
+ #[ test]
365
+ fn test_is_input_empty_with_empty_textarea ( ) {
366
+ let ( tx, _rx) = channel ( ) ;
367
+ let tx = AppEventSender :: new ( tx) ;
368
+ let composer = ChatComposer :: new ( false , tx) ;
369
+
370
+ assert ! ( composer. is_input_empty( ) ) ;
371
+ }
372
+
373
+ #[ test]
374
+ fn test_is_input_empty_with_whitespace_only ( ) {
375
+ let ( tx, _rx) = channel ( ) ;
376
+ let tx = AppEventSender :: new ( tx) ;
377
+ let mut composer = ChatComposer :: new ( false , tx) ;
378
+
379
+ // Add whitespace-only content
380
+ composer. textarea . insert_str ( " \t \n " ) ;
381
+
382
+ assert ! ( composer. is_input_empty( ) ) ;
383
+ }
384
+
385
+ #[ test]
386
+ fn test_is_input_empty_with_actual_content ( ) {
387
+ let ( tx, _rx) = channel ( ) ;
388
+ let tx = AppEventSender :: new ( tx) ;
389
+ let mut composer = ChatComposer :: new ( false , tx) ;
390
+
391
+ // Add actual content
392
+ composer. textarea . insert_str ( "hello world" ) ;
393
+
394
+ assert ! ( !composer. is_input_empty( ) ) ;
395
+ }
396
+
397
+ #[ test]
398
+ fn test_is_input_empty_with_mixed_content ( ) {
399
+ let ( tx, _rx) = channel ( ) ;
400
+ let tx = AppEventSender :: new ( tx) ;
401
+ let mut composer = ChatComposer :: new ( false , tx) ;
402
+
403
+ // Add mixed whitespace and content
404
+ composer. textarea . insert_str ( " \n hello \n " ) ;
405
+
406
+ assert ! ( !composer. is_input_empty( ) ) ;
407
+ }
293
408
}
294
409
295
410
impl WidgetRef for & ChatComposer < ' _ > {
0 commit comments