Skip to content

Commit 655511e

Browse files
committed
Add Rust CLI suggestion functionality
- Implement canned prompt suggestions similar to TypeScript CLI - Add suggestion detection and rendering in chat_composer.rs - Use Option<usize> for type-safe selection indexing - Auto-hide suggestions when user starts typing - Add test coverage
1 parent b73426c commit 655511e

File tree

4 files changed

+189
-2
lines changed

4 files changed

+189
-2
lines changed

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,33 @@ pub enum InputResult {
3030
None,
3131
}
3232

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+
3339
pub(crate) struct ChatComposer<'a> {
3440
textarea: TextArea<'a>,
3541
command_popup: Option<CommandPopup>,
3642
app_event_tx: AppEventSender,
3743
history: ChatComposerHistory,
44+
selected_suggestion: Option<usize>,
45+
has_user_typed: bool,
3846
}
3947

4048
impl ChatComposer<'_> {
4149
pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self {
4250
let mut textarea = TextArea::default();
43-
textarea.set_placeholder_text("send a message");
4451
textarea.set_cursor_line_style(ratatui::style::Style::default());
4552

4653
let mut this = Self {
4754
textarea,
4855
command_popup: None,
4956
app_event_tx,
5057
history: ChatComposerHistory::new(),
58+
selected_suggestion: None,
59+
has_user_typed: false,
5160
};
5261
this.update_border(has_input_focus);
5362
this
@@ -154,6 +163,43 @@ impl ChatComposer<'_> {
154163
/// Handle key event when no popup is visible.
155164
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
156165
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+
157203
match input {
158204
// -------------------------------------------------------------
159205
// History navigation (Up / Down) – only when the composer is not
@@ -191,6 +237,7 @@ impl ChatComposer<'_> {
191237
let text = self.textarea.lines().join("\n");
192238
self.textarea.select_all();
193239
self.textarea.cut();
240+
self.has_user_typed = false;
194241

195242
if text.is_empty() {
196243
(InputResult::None, true)
@@ -218,6 +265,9 @@ impl ChatComposer<'_> {
218265
/// Handle generic Input events that modify the textarea content.
219266
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
220267
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();
221271
(InputResult::None, true)
222272
}
223273

@@ -290,6 +340,71 @@ impl ChatComposer<'_> {
290340
pub(crate) fn is_command_popup_visible(&self) -> bool {
291341
self.command_popup.is_some()
292342
}
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+
}
293408
}
294409

295410
impl WidgetRef for &ChatComposer<'_> {

codex-rs/tui/src/bottom_pane/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,16 @@ impl BottomPane<'_> {
167167
self.active_view.is_none() && self.composer.is_command_popup_visible()
168168
}
169169

170+
/// Get suggestion state for rendering (selected_index, suggestions_array, should_show)
171+
pub(crate) fn get_suggestion_info(&self, is_conversation_new: bool) -> Option<(Option<usize>, &'static [&'static str])> {
172+
if self.active_view.is_none() && is_conversation_new && self.composer.should_show_suggestions() {
173+
let (selected, suggestions) = self.composer.get_suggestion_state();
174+
Some((selected, suggestions))
175+
} else {
176+
None
177+
}
178+
}
179+
170180
// --- History helpers ---
171181

172182
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {

codex-rs/tui/src/chatwidget.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ use ratatui::layout::Constraint;
2424
use ratatui::layout::Direction;
2525
use ratatui::layout::Layout;
2626
use ratatui::layout::Rect;
27+
use ratatui::style::{Color, Style};
28+
use ratatui::text::{Line, Span};
29+
use ratatui::widgets::Paragraph;
2730
use ratatui::widgets::Widget;
2831
use ratatui::widgets::WidgetRef;
32+
use ratatui::widgets::Wrap;
2933
use tokio::sync::mpsc::UnboundedSender;
3034
use tokio::sync::mpsc::unbounded_channel;
3135

@@ -395,18 +399,70 @@ impl ChatWidget<'_> {
395399
tracing::error!("failed to submit op: {e}");
396400
}
397401
}
402+
403+
fn render_suggestions(&self, area: Rect, buf: &mut Buffer, selected_suggestion: Option<usize>, suggestions: &[&str]) {
404+
405+
// Pre-allocate spans vector to avoid reallocations
406+
let mut spans = Vec::with_capacity(suggestions.len() * 2);
407+
408+
// Use static styles to avoid repeated allocations
409+
const TRY_STYLE: Style = Style::new().fg(Color::DarkGray);
410+
const NORMAL_STYLE: Style = Style::new().fg(Color::DarkGray);
411+
const SELECTED_STYLE: Style = Style::new().bg(Color::DarkGray).fg(Color::White);
412+
const SEPARATOR_STYLE: Style = Style::new().fg(Color::DarkGray);
413+
414+
spans.push(Span::styled("try: ", TRY_STYLE));
415+
416+
for (index, suggestion) in suggestions.iter().enumerate() {
417+
if index > 0 {
418+
spans.push(Span::styled(" | ", SEPARATOR_STYLE));
419+
}
420+
421+
let style = if Some(index) == selected_suggestion {
422+
SELECTED_STYLE
423+
} else {
424+
NORMAL_STYLE
425+
};
426+
427+
spans.push(Span::styled(*suggestion, style));
428+
}
429+
430+
let paragraph = Paragraph::new(Line::from(spans))
431+
.wrap(Wrap { trim: true });
432+
433+
paragraph.render(area, buf);
434+
}
398435
}
399436

400437
impl WidgetRef for &ChatWidget<'_> {
401438
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
402439
let bottom_height = self.bottom_pane.calculate_required_height(&area);
440+
441+
let is_conversation_new = self.conversation_history.is_conversation_new();
442+
let suggestion_info = self.bottom_pane.get_suggestion_info(is_conversation_new);
443+
444+
// Check if we need extra space for suggestions
445+
let suggestions_height = if suggestion_info.is_some() {
446+
1 // One line for suggestions
447+
} else {
448+
0
449+
};
403450

404451
let chunks = Layout::default()
405452
.direction(Direction::Vertical)
406-
.constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
453+
.constraints([
454+
Constraint::Min(0),
455+
Constraint::Length(bottom_height),
456+
Constraint::Length(suggestions_height)
457+
])
407458
.split(area);
408459

409460
self.conversation_history.render(chunks[0], buf);
410461
(&self.bottom_pane).render(chunks[1], buf);
462+
463+
// Render suggestions if present
464+
if let Some((selected_suggestion, suggestions)) = suggestion_info {
465+
self.render_suggestions(chunks[2], buf, selected_suggestion, suggestions);
466+
}
411467
}
412468
}

codex-rs/tui/src/conversation_history_widget.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ impl ConversationHistoryWidget {
5151
self.has_input_focus = has_input_focus;
5252
}
5353

54+
pub(crate) fn is_conversation_new(&self) -> bool {
55+
!self.entries.iter().any(|entry|
56+
matches!(entry.cell, HistoryCell::UserPrompt { .. })
57+
)
58+
}
59+
5460
/// Returns true if it needs a redraw.
5561
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
5662
match key_event.code {

0 commit comments

Comments
 (0)