Merge 29d10d55a3 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-05-14 11:44:15 -07:00
committed by GitHub
6 changed files with 406 additions and 13 deletions

View File

@@ -13,6 +13,8 @@ use tui_textarea::Input;
use tui_textarea::Key;
use tui_textarea::TextArea;
use super::command_popup::CommandPopup;
/// Minimum number of visible text rows inside the textarea.
const MIN_TEXTAREA_ROWS: usize = 1;
/// Rows consumed by the border.
@@ -24,9 +26,10 @@ pub enum InputResult {
None,
}
pub(crate) struct ChatComposer<'a> {
textarea: TextArea<'a>,
}
pub(crate) struct ChatComposer<'a> {
textarea: TextArea<'a>,
command_popup: Option<CommandPopup>,
}
impl ChatComposer<'_> {
pub fn new(has_input_focus: bool) -> Self {
@@ -34,7 +37,10 @@ impl ChatComposer<'_> {
textarea.set_placeholder_text("send a message");
textarea.set_cursor_line_style(ratatui::style::Style::default());
let mut this = Self { textarea };
let mut this = Self {
textarea,
command_popup: None,
};
this.update_border(has_input_focus);
this
}
@@ -43,9 +49,116 @@ impl ChatComposer<'_> {
self.update_border(has_focus);
}
/// Handle key event when no overlay is present.
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self) {
// Inspect only the first line to decide whether to show the popup. In
// the common case (no leading slash) we avoid copying the entire
// textarea contents.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
if first_line.starts_with('/') {
// Create popup lazily when the user starts a slash command.
let popup = self
.command_popup
.get_or_insert_with(CommandPopup::new);
// Forward *only* the first line since `CommandPopup` only needs
// the command token.
popup.on_composer_text_change(first_line.to_string());
} else {
// Remove popup when '/' is no longer the first character.
self.command_popup = None;
}
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match self.command_popup {
Some(_) => self.handle_key_event_with_popup(key_event),
None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_command_popup();
result
}
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let Some(popup) = self.command_popup.as_mut() else {
tracing::error!("handle_key_event_with_popup called without an active popup");
return (InputResult::None, false);
};
match key_event.into() {
Input { key: Key::Up, .. } => {
popup.move_up();
(InputResult::None, true)
}
Input { key: Key::Down, .. } => {
popup.move_down();
(InputResult::None, true)
}
Input { key: Key::Tab, .. } => {
if let Some(cmd) = popup.selected_command() {
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
if !starts_with_cmd {
self.textarea.select_all();
self.textarea.cut();
let _ = self
.textarea
.insert_str(format!("/{} ", cmd.command()));
}
// hide popup
self.command_popup = None;
}
(InputResult::None, true)
}
Input {
key: Key::Enter,
shift: false,
alt: false,
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() {
// TODO: actually run command instead of submitting it to the model.
let _command_text = format!("/{}", cmd.command());
self.textarea.select_all();
self.textarea.cut();
// Hide popup since command has been handled.
self.command_popup = None;
return (InputResult::None, true);
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
}
input => self.handle_input_basic(input),
}
}
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let input: Input = key_event.into();
match input {
Input {
key: Key::Enter,
shift: false,
@@ -69,16 +182,25 @@ impl ChatComposer<'_> {
self.textarea.insert_newline();
(InputResult::None, true)
}
input => {
self.textarea.input(input);
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
self.textarea.input(input);
(InputResult::None, true)
}
pub fn calculate_required_height(&self, _area: &Rect) -> u16 {
let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
rows as u16 + BORDER_LINES
let mut total = rows as u16 + BORDER_LINES;
if let Some(popup) = &self.command_popup {
total += popup.calculate_required_height(_area);
}
total
}
fn update_border(&mut self, has_focus: bool) {
@@ -108,10 +230,37 @@ impl ChatComposer<'_> {
.border_style(bs.border_style),
);
}
pub(crate) fn is_command_popup_visible(&self) -> bool {
self.command_popup.is_some()
}
}
impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
self.textarea.render(area, buf);
if let Some(popup) = &self.command_popup {
let popup_height = popup.calculate_required_height(&area);
// Split the provided rect so that the popup is rendered at the
// *top* and the textarea occupies the remaining space below.
let popup_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: popup_height.min(area.height),
};
let textarea_rect = Rect {
x: area.x,
y: area.y + popup_rect.height,
width: area.width,
height: area.height.saturating_sub(popup_rect.height),
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
} else {
self.textarea.render(area, buf);
}
}
}

View File

@@ -0,0 +1,185 @@
use std::collections::HashMap;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
const MAX_POPUP_ROWS: usize = 5;
use ratatui::style::Modifier;
pub(crate) struct CommandPopup {
command_filter: String,
all_commands: HashMap<String, SlashCommand>,
selected_idx: Option<usize>,
}
impl CommandPopup {
pub(crate) fn new() -> Self {
Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
selected_idx: None,
}
}
/// Update the filter string based on the current composer text. The text
/// passed in is expected to start with a leading '/'. Everything after the
/// *first* '/" on the *first* line becomes the active filter that is used
/// to narrow down the list of available commands.
pub(crate) fn on_composer_text_change(&mut self, text: String) {
let first_line = text.lines().next().unwrap_or("");
if let Some(stripped) = first_line.strip_prefix('/') {
// Extract the *first* token (sequence of non-whitespace
// characters) after the slash so that `/clear something` still
// shows the help for `/clear`.
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
// Update the filter keeping the original case (commands are all
// lower-case for now but this may change in the future).
self.command_filter = cmd_token.to_string();
} else {
// The composer no longer starts with '/'. Reset the filter so the
// popup shows the *full* command list if it is still displayed
// for some reason.
self.command_filter.clear();
}
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
self.selected_idx = match matches_len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
};
}
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
let matches = self.filtered_commands();
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
// Account for the border added by the Block that wraps the table.
// 2 = one line at the top, one at the bottom.
row_count + 2
}
/// Return the list of commands that match the current filter. Matching is
/// performed using a *prefix* comparison on the command name.
fn filtered_commands(&self) -> Vec<&SlashCommand> {
let mut cmds: Vec<&SlashCommand> = self
.all_commands
.values()
.filter(|cmd| {
if self.command_filter.is_empty() {
true
} else {
cmd.command()
.starts_with(&self.command_filter.to_ascii_lowercase())
}
})
.collect();
// Sort the commands alphabetically so the order is stable and
// predictable.
cmds.sort_by(|a, b| a.command().cmp(b.command()));
cmds
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
if len == usize::MAX {
return;
}
}
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
} else if !self.filtered_commands().is_empty() {
self.selected_idx = Some(0);
}
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_commands().len();
if matches_len == 0 {
self.selected_idx = None;
return;
}
match self.selected_idx {
Some(idx) if idx + 1 < matches_len => {
self.selected_idx = Some(idx + 1);
}
None => {
self.selected_idx = Some(0);
}
_ => {}
}
}
/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
let matches = self.filtered_commands();
self.selected_idx
.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, BorderType, Cell, Row, Table, Widget};
let style = Style::default().bg(Color::Blue).fg(Color::White);
let matches = self.filtered_commands();
let mut rows: Vec<Row> = Vec::new();
let visible_matches: Vec<&SlashCommand> = matches.into_iter().take(MAX_POPUP_ROWS).collect();
if visible_matches.is_empty() {
rows.push(Row::new(vec![
Cell::from("").style(style),
Cell::from("No matching commands").style(style.add_modifier(Modifier::ITALIC)),
]));
} else {
for (idx, cmd) in visible_matches.iter().enumerate() {
let highlight = Style::default().bg(Color::White).fg(Color::Blue);
let cmd_style = if Some(idx) == self.selected_idx {
highlight
} else {
style
};
rows.push(Row::new(vec![
Cell::from(cmd.command().to_string()).style(cmd_style),
Cell::from(cmd.description().to_string()).style(style),
]));
}
}
use ratatui::layout::Constraint;
let table = Table::new(rows, [Constraint::Length(15), Constraint::Min(10)])
.style(style)
.column_spacing(1)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(style),
);
table.render(area, buf);
}
}

View File

@@ -15,6 +15,7 @@ use crate::user_approval_widget::ApprovalRequest;
mod approval_modal_view;
mod bottom_pane_view;
mod chat_composer;
mod command_popup;
mod status_indicator_view;
pub(crate) use chat_composer::ChatComposer;
@@ -168,6 +169,11 @@ impl BottomPane<'_> {
pub(crate) fn request_redraw(&self) -> Result<(), SendError<AppEvent>> {
self.app_event_tx.send(AppEvent::Redraw)
}
/// Returns true when the slash-command popup inside the composer is visible.
pub(crate) fn is_command_popup_visible(&self) -> bool {
self.active_view.is_none() && self.composer.is_command_popup_visible()
}
}
impl WidgetRef for &BottomPane<'_> {

View File

@@ -124,8 +124,12 @@ impl ChatWidget<'_> {
&mut self,
key_event: KeyEvent,
) -> std::result::Result<(), SendError<AppEvent>> {
// Special-case <tab>: does not get dispatched to child components.
if matches!(key_event.code, crossterm::event::KeyCode::Tab) {
// Special-case <Tab>: normally toggles focus between history and bottom panes.
// However, when the slash-command popup is visible we forward the key
// to the bottom pane so it can handle auto-completion.
if matches!(key_event.code, crossterm::event::KeyCode::Tab)
&& !self.bottom_pane.is_command_popup_visible()
{
self.input_focus = match self.input_focus {
InputFocus::HistoryPane => InputFocus::BottomPane,
InputFocus::BottomPane => InputFocus::HistoryPane,

View File

@@ -26,6 +26,7 @@ mod history_cell;
mod log_layer;
mod markdown;
mod scroll_event_helper;
mod slash_command;
mod status_indicator_widget;
mod tui;
mod user_approval_widget;

View File

@@ -0,0 +1,48 @@
use std::collections::HashMap;
/// Command that can be invoked via the composer by starting the message with a
/// slash followed by the command name.
#[derive(Debug, Clone)]
pub struct SlashCommand {
/// Command name without the leading slash.
command: &'static str,
/// Command description suitable for display in the UI.
description: &'static str,
}
impl SlashCommand {
/// Return the command string without the leading slash.
pub fn command(&self) -> &str {
self.command
}
/// Return the human-readable description for the command.
pub fn description(&self) -> &str {
self.description
}
}
pub fn built_in_slash_commands() -> HashMap<String, SlashCommand> {
vec![
SlashCommand {
command: "help",
description: "Show this help message.",
},
SlashCommand {
command: "clear",
description: "Clear the chat history.",
},
SlashCommand {
command: "reset",
description: "Reset the chat history.",
},
SlashCommand {
command: "exit",
description: "Exit the application.",
},
]
.into_iter()
.map(|cmd| (cmd.command.to_owned(), cmd))
.collect::<HashMap<String, SlashCommand>>()
}