add "@ command"

This commit is contained in:
pap
2025-07-27 22:24:12 +01:00
parent d6d1df4b1f
commit a56b327428
8 changed files with 205 additions and 45 deletions

View File

@@ -397,6 +397,21 @@ impl App<'_> {
}
}
},
AppEvent::DispatchAtCommand(at_command) => match at_command {
crate::at_command::AtCommand::Image => {
match crate::clipboard_paste::paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::info!("at_command_image imported path={:?} width={} height={} format={}", path, info.width, info.height, info.encoded_format_label);
self.app_event_tx.send(AppEvent::AttachImage { path, width: info.width, height: info.height, format_label: info.encoded_format_label });
}
Err(err) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_background_event(format!("image import failed: {err}"));
}
}
}
}
},
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}

View File

@@ -4,6 +4,7 @@ use crossterm::event::KeyEvent;
use ratatui::text::Line;
use crate::slash_command::SlashCommand;
use crate::at_command::AtCommand;
#[allow(clippy::large_enum_variant)]
pub(crate) enum AppEvent {
@@ -37,6 +38,8 @@ pub(crate) enum AppEvent {
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally.
DispatchCommand(SlashCommand),
/// Dispatch an @ command.
DispatchAtCommand(AtCommand),
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there

View File

@@ -0,0 +1,22 @@
use strum::IntoEnumIterator;
use strum_macros::{AsRefStr, EnumIter, EnumString, IntoStaticStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr)]
#[strum(serialize_all = "kebab-case")]
pub enum AtCommand {
// Order is presentation order in @ popup.
Image, // import image from clipboard
}
impl AtCommand {
pub fn description(self) -> &'static str {
match self {
AtCommand::Image => "Import an image from the system clipboard (can be used with ctrl+v).",
}
}
pub fn command(self) -> &'static str { self.into() }
}
pub fn built_in_at_commands() -> Vec<(&'static str, AtCommand)> {
AtCommand::iter().map(|c| (c.command(), c)).collect()
}

View File

@@ -17,6 +17,8 @@ use tui_textarea::TextArea;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use crate::slash_command::SlashCommand; // for typing
use crate::at_command::AtCommand; // for typing
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -49,8 +51,9 @@ pub(crate) struct ChatComposer<'a> {
/// Popup state at most one can be visible at any time.
enum ActivePopup {
None,
Command(CommandPopup),
Slash(CommandPopup<SlashCommand>),
File(FileSearchPopup),
At(CommandPopup<AtCommand>),
}
impl ChatComposer<'_> {
@@ -140,8 +143,13 @@ impl ChatComposer<'_> {
} else {
self.textarea.insert_str(&pasted);
}
self.sync_command_popup();
self.sync_file_search_popup();
self.sync_slash_command_popup();
if !matches!(self.active_popup, ActivePopup::Slash(_)) {
self.sync_at_command_popup();
if !matches!(self.active_popup, ActivePopup::At(_)) {
self.sync_file_search_popup();
}
}
true
}
@@ -181,17 +189,24 @@ impl ChatComposer<'_> {
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::Slash(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::At(_) => self.handle_key_event_with_at_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.sync_slash_command_popup();
if matches!(self.active_popup, ActivePopup::Slash(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
// Try @-command popup next if slash popup not active.
self.sync_at_command_popup();
if matches!(self.active_popup, ActivePopup::At(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
}
}
result
@@ -199,7 +214,7 @@ impl ChatComposer<'_> {
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let ActivePopup::Command(popup) = &mut self.active_popup else {
let ActivePopup::Slash(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -301,6 +316,38 @@ impl ChatComposer<'_> {
}
}
/// Handle key event when the @-command popup is visible.
fn handle_key_event_with_at_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let ActivePopup::At(popup) = &mut self.active_popup else { unreachable!() };
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()));
}
}
(InputResult::None, true)
}
Input { key: Key::Enter, shift: false, alt: false, ctrl: false } => {
if let Some(cmd) = popup.selected_command() {
self.app_event_tx.send(AppEvent::DispatchAtCommand(*cmd));
self.textarea.select_all();
self.textarea.cut();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
self.handle_key_event_without_popup(key_event)
}
input => self.handle_input_basic(input),
}
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -632,7 +679,7 @@ impl ChatComposer<'_> {
/// 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) {
fn sync_slash_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.
@@ -645,7 +692,7 @@ impl ChatComposer<'_> {
let input_starts_with_slash = first_line.starts_with('/');
match &mut self.active_popup {
ActivePopup::Command(popup) => {
ActivePopup::Slash(popup) => {
if input_starts_with_slash {
popup.on_composer_text_change(first_line.to_string());
} else {
@@ -654,9 +701,9 @@ impl ChatComposer<'_> {
}
_ => {
if input_starts_with_slash {
let mut command_popup = CommandPopup::new();
let mut command_popup = CommandPopup::slash();
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
self.active_popup = ActivePopup::Slash(command_popup);
}
}
}
@@ -698,6 +745,23 @@ impl ChatComposer<'_> {
self.dismissed_file_popup_token = None;
}
// NEW: Synchronize @-command popup.
fn sync_at_command_popup(&mut self) {
// Do not show if slash popup active.
if matches!(self.active_popup, ActivePopup::Slash(_)) { return; }
let first_line = self.textarea.lines().first().map(|s| s.as_str()).unwrap_or("");
let input_starts_with_at = first_line.starts_with('@');
match &mut self.active_popup {
ActivePopup::At(popup) => {
if input_starts_with_at { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; }
}
_ => {
if input_starts_with_at { let mut popup: CommandPopup<AtCommand> = CommandPopup::at(); popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::At(popup); }
}
}
}
fn update_border(&mut self, has_focus: bool) {
struct BlockState {
right_title: Line<'static>,
@@ -737,7 +801,7 @@ impl ChatComposer<'_> {
impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match &self.active_popup {
ActivePopup::Command(popup) => {
ActivePopup::Slash(popup) => {
let popup_height = popup.calculate_required_height(&area);
// Split the provided rect so that the popup is rendered at the
@@ -782,6 +846,13 @@ impl WidgetRef for &ChatComposer<'_> {
ActivePopup::None => {
self.textarea.render(area, buf);
}
ActivePopup::At(popup) => {
let popup_height = popup.calculate_required_height(&area);
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);
}
}
}
}
@@ -1315,4 +1386,23 @@ mod tests {
assert!(composer.textarea.lines().join("\n").contains(&placeholder2) == false);
assert!(composer.attached_images.is_empty());
}
#[test]
fn at_image_command_triggers_dispatch() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::mpsc::Receiver;
use crate::app_event::AppEvent;
use crate::at_command::AtCommand;
let (tx, rx):(std::sync::mpsc::Sender<AppEvent>, Receiver<AppEvent>) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
// Type '@' to open popup
composer.handle_key_event(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE));
// Press Enter (should dispatch Image since only option)
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Expect a DispatchCommand(Image)
let ev = rx.try_recv().expect("expected an event");
match ev { AppEvent::DispatchAtCommand(AtCommand::Image) => {}, other => panic!("unexpected event: {:?}", other) }
}
}

View File

@@ -14,6 +14,21 @@ use ratatui::widgets::WidgetRef;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::at_command::{AtCommand, built_in_at_commands}; // NEW
pub trait CommandInfo: Copy {
fn command(&self) -> &'static str;
fn description(&self) -> &'static str;
}
impl CommandInfo for SlashCommand {
fn command(&self) -> &'static str { SlashCommand::command(*self) }
fn description(&self) -> &'static str { SlashCommand::description(*self) }
}
impl CommandInfo for AtCommand {
fn command(&self) -> &'static str { AtCommand::command(*self) }
fn description(&self) -> &'static str { AtCommand::description(*self) }
}
const MAX_POPUP_ROWS: usize = 5;
/// Ideally this is enough to show the longest command name.
@@ -21,44 +36,30 @@ const FIRST_COLUMN_WIDTH: u16 = 20;
use ratatui::style::Modifier;
pub(crate) struct CommandPopup {
pub(crate) struct CommandPopup<C: CommandInfo> { // generic
prefix: char,
command_filter: String,
all_commands: Vec<(&'static str, SlashCommand)>,
all_commands: Vec<(&'static str, C)>,
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,
}
impl<C: CommandInfo> CommandPopup<C> {
pub(crate) fn new(prefix: char, all_commands: Vec<(&'static str, C)>) -> Self {
Self { prefix, command_filter: String::new(), all_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.
/// passed in is expected to start with this popup's prefix (e.g. '/' or '@').
/// Everything after the prefix up to the first ASCII whitespace becomes
/// the active filter token.
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`.
if first_line.starts_with(self.prefix) {
let stripped = &first_line[self.prefix.len_utf8()..];
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();
}
} else { self.command_filter.clear(); }
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
@@ -81,7 +82,7 @@ impl CommandPopup {
/// 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> {
fn filtered_commands(&self) -> Vec<&C> {
self.all_commands
.iter()
.filter_map(|(_name, cmd)| {
@@ -95,7 +96,7 @@ impl CommandPopup {
None
}
})
.collect::<Vec<&SlashCommand>>()
.collect::<Vec<&C>>()
}
/// Move the selection cursor one step up.
@@ -135,18 +136,26 @@ impl CommandPopup {
}
/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
pub(crate) fn selected_command(&self) -> Option<&C> {
let matches = self.filtered_commands();
self.selected_idx.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
impl CommandPopup<SlashCommand> {
pub(crate) fn slash() -> Self { CommandPopup::new('/', built_in_slash_commands()) }
}
impl CommandPopup<AtCommand> {
pub(crate) fn at() -> Self { CommandPopup::new('@', built_in_at_commands()) }
}
impl<C: CommandInfo> WidgetRef for CommandPopup<C> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let matches = self.filtered_commands();
let mut rows: Vec<Row> = Vec::new();
let visible_matches: Vec<&SlashCommand> =
let visible_matches: Vec<&C> =
matches.into_iter().take(MAX_POPUP_ROWS).collect();
if visible_matches.is_empty() {
@@ -168,7 +177,7 @@ impl WidgetRef for CommandPopup {
};
rows.push(Row::new(vec![
Cell::from(format!("/{}", cmd.command())).style(cmd_style),
Cell::from(format!("{}{}", self.prefix, cmd.command())).style(cmd_style),
Cell::from(cmd.description().to_string()).style(desc_style),
]));
}

View File

@@ -22,6 +22,7 @@ mod status_indicator_view;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
pub(crate) use command_popup::CommandInfo;
use approval_modal_view::ApprovalModalView;
use status_indicator_view::StatusIndicatorView;

View File

@@ -0,0 +1,18 @@
use crate::slash_command::SlashCommand;
use crate::at_command::AtCommand;
use crate::bottom_pane::CommandInfo;
#[derive(Clone, Copy, Debug)]
pub enum Command {
Slash(SlashCommand),
At(AtCommand),
}
impl CommandInfo for Command {
fn command(&self) -> &'static str {
match self { Command::Slash(s) => s.command(), Command::At(a) => a.command() }
}
fn description(&self) -> &'static str {
match self { Command::Slash(s) => s.description(), Command::At(a) => a.description() }
}
}

View File

@@ -45,6 +45,8 @@ mod text_formatting;
mod tui;
mod user_approval_widget;
mod clipboard_paste;
mod at_command;
mod command;
pub use cli::Cli;