removing at_command

This commit is contained in:
pap
2025-08-01 19:33:58 +01:00
parent 62d85e069e
commit ac5705c0ed
7 changed files with 22 additions and 245 deletions

View File

@@ -44,14 +44,12 @@ where
crate::clipboard_paste::PasteImageError,
>,
{
if key_event.code == KeyCode::Char('v')
if key_event.kind == KeyEventKind::Press
&& key_event.code == KeyCode::Char('v')
&& key_event
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
tracing::debug!(
"Ctrl+V detected attempting clipboard image import (shortcut for /image)"
);
match paste_fn() {
Ok((path, info)) => {
tracing::info!(
@@ -423,38 +421,6 @@ impl App<'_> {
}));
}
},
AppEvent::DispatchAtCommand(at_command) => match at_command {
crate::at_command::AtCommand::ClipboardImage => {
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}"
));
}
}
}
}
crate::at_command::AtCommand::File => {
// Currently file search popup is handled entirely in the chat composer.
// No-op here to keep the match exhaustive.
}
},
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}

View File

@@ -3,7 +3,6 @@ use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::text::Line;
use crate::at_command::AtCommand;
use crate::slash_command::SlashCommand;
#[derive(Debug)]
@@ -35,8 +34,6 @@ pub(crate) enum AppEvent {
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally.
DispatchSlashCommand(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

@@ -1,30 +0,0 @@
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.
ClipboardImage, // import image from clipboard
File, // open file search popup
}
impl AtCommand {
pub fn description(self) -> &'static str {
match self {
AtCommand::ClipboardImage => {
"Import an image from the system clipboard (can be used with ctrl+v)."
}
AtCommand::File => "Search for a file to insert its path.",
}
}
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

@@ -19,8 +19,6 @@ use tui_textarea::TextArea;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use crate::at_command::AtCommand;
use crate::at_command::built_in_at_commands;
use crate::slash_command::SlashCommand;
use crate::app_event::AppEvent;
@@ -60,7 +58,6 @@ enum ActivePopup {
None,
Slash(CommandPopup<SlashCommand>),
File(FileSearchPopup),
At(CommandPopup<AtCommand>),
}
impl ChatComposer<'_> {
@@ -97,7 +94,7 @@ impl ChatComposer<'_> {
self.textarea.lines().len().max(1) as u16
+ match &self.active_popup {
ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::Slash(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
@@ -168,12 +165,7 @@ impl ChatComposer<'_> {
self.textarea.insert_str(&pasted);
}
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();
}
}
self.sync_file_search_popup();
true
}
@@ -221,7 +213,6 @@ impl ChatComposer<'_> {
let result = match &mut self.active_popup {
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),
};
@@ -230,13 +221,7 @@ impl ChatComposer<'_> {
if matches!(self.active_popup, ActivePopup::Slash(_)) {
self.dismissed_file_popup_token = None;
} else {
// 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();
}
self.sync_file_search_popup();
}
result
@@ -411,73 +396,6 @@ 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() {
match cmd {
AtCommand::ClipboardImage => {
// Dispatch image import request but only remove the @token itself (not entire input).
self.app_event_tx.send(AppEvent::DispatchAtCommand(*cmd));
self.remove_current_at_token();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
AtCommand::File => {
// Replace the textarea content with the token so file search logic picks it up.
// Remove only current token then insert @file at cursor.
self.remove_current_at_token();
// Insert a bare '@' to begin a fresh file query (do not pre-fill with 'file').
let _ = self.textarea.insert_str("@");
let file_popup = FileSearchPopup::new(); // starts empty; we will show placeholder until user types.
self.active_popup = ActivePopup::File(file_popup);
self.file_search_mode = true; // mark explicit session
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 `@`.
@@ -690,6 +608,13 @@ impl ChatComposer<'_> {
self.textarea.input(input);
let text_after = self.textarea.lines().join("\n");
// Start/continue an explicit file-search session when the cursor is on an @token.
if Self::current_at_token_allow_empty(&self.textarea).is_some() {
self.file_search_mode = true;
// Allow popup to show for this token.
self.dismissed_file_popup_token = None;
}
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -877,44 +802,6 @@ impl ChatComposer<'_> {
self.dismissed_file_popup_token = None;
}
fn sync_at_command_popup(&mut self) {
if matches!(
self.active_popup,
ActivePopup::Slash(_) | ActivePopup::File(_)
) {
return;
}
let (row, col) = self.textarea.cursor();
let line = match self.textarea.lines().get(row) {
Some(l) => l.as_str(),
None => return,
};
let cursor_byte = cursor_byte_offset(line, col as usize);
let show = if let Some((start, end)) = at_token_bounds(line, cursor_byte, true) {
let body = &line[start + 1..end];
built_in_at_commands()
.iter()
.any(|(name, _)| name.starts_with(&body.to_ascii_lowercase()))
} else {
false
};
if show {
let (start, end) = at_token_bounds(line, cursor_byte, true).unwrap();
let body = &line[start + 1..end];
let synthetic = format!("@{}", body);
match &mut self.active_popup {
ActivePopup::At(popup) => popup.on_composer_text_change(synthetic),
_ => {
let mut popup: CommandPopup<AtCommand> = CommandPopup::at();
popup.on_composer_text_change(synthetic);
self.active_popup = ActivePopup::At(popup);
}
}
} else if matches!(self.active_popup, ActivePopup::At(_)) {
self.active_popup = ActivePopup::None;
}
}
fn update_border(&mut self, has_focus: bool) {
let border_style = if has_focus {
Style::default().fg(Color::Cyan)
@@ -934,7 +821,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();
// Split the provided rect so that the popup is rendered at the
@@ -1010,23 +897,6 @@ impl WidgetRef for &ChatComposer<'_> {
.style(Style::default().dim())
.render_ref(bottom_line_rect, 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);
}
}
}
}
@@ -1568,7 +1438,7 @@ mod tests {
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
let path = std::path::PathBuf::from("/tmp/image1.png");
assert!(composer.attach_image(path.clone(), 32, 16, "PNG"));
composer.handle_paste(" hi".into());
@@ -1590,7 +1460,7 @@ mod tests {
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
let path = std::path::PathBuf::from("/tmp/image2.png");
assert!(composer.attach_image(path.clone(), 10, 5, "PNG"));
let (result, _) =
@@ -1607,7 +1477,7 @@ mod tests {
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
let path = std::path::PathBuf::from("/tmp/image3.png");
assert!(composer.attach_image(path.clone(), 20, 10, "PNG"));
let placeholder = composer.attached_images[0].0.clone();
@@ -1634,7 +1504,6 @@ mod tests {
#[test]
fn at_clipboard_image_command_triggers_dispatch() {
use crate::app_event::AppEvent;
use crate::at_command::AtCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -1643,7 +1512,7 @@ mod tests {
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);
let mut composer = ChatComposer::new(true, sender, false);
// Type '@' to open popup
composer.handle_key_event(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE));
// Press Enter (should dispatch Image since only option)

View File

@@ -12,7 +12,6 @@ use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use crate::at_command::{AtCommand, built_in_at_commands};
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
@@ -29,14 +28,6 @@ impl CommandInfo for SlashCommand {
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.
@@ -159,12 +150,6 @@ impl CommandPopup<SlashCommand> {
}
}
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();
@@ -182,10 +167,6 @@ impl<C: CommandInfo> WidgetRef for CommandPopup<C> {
let command_style = Style::default().fg(Color::LightBlue);
for (idx, cmd) in visible_matches.iter().enumerate() {
rows.push(Row::new(vec![
<<<<<<< HEAD
Cell::from(format!("{}{}", self.prefix, cmd.command())).style(cmd_style),
Cell::from(cmd.description().to_string()).style(desc_style),
=======
Cell::from(Line::from(vec![
if Some(idx) == self.selected_idx {
Span::styled(
@@ -195,10 +176,9 @@ impl<C: CommandInfo> WidgetRef for CommandPopup<C> {
} else {
Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
},
Span::styled(format!("/{}", cmd.command()), command_style),
Span::styled(format!("{}{}", self.prefix, cmd.command()), command_style),
])),
Cell::from(cmd.description().to_string()).style(default_style),
>>>>>>> main
]));
}
}

View File

@@ -195,11 +195,9 @@ impl ChatWidget<'_> {
self.bottom_pane
.attach_image(path.clone(), width, height, format_label);
// Surface a quick background event so user sees confirmation.
self.conversation_history.add_background_event(format!(
"[image copied] {}x{} {}",
width, height, format_label
));
self.emit_last_history_entry();
self.add_to_history(HistoryCell::new_background_event(format!(
"[image copied] {width}x{height} {format_label}"
)));
self.request_redraw();
}
@@ -535,8 +533,7 @@ impl ChatWidget<'_> {
}
pub(crate) fn add_background_event(&mut self, msg: String) {
self.conversation_history.add_background_event(msg);
self.emit_last_history_entry();
self.add_to_history(HistoryCell::new_background_event(msg));
self.request_redraw();
}

View File

@@ -21,13 +21,11 @@ use tracing_subscriber::prelude::*;
mod app;
mod app_event;
mod app_event_sender;
mod at_command;
mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod clipboard_paste;
mod conversation_history_widget;
mod custom_terminal;
mod exec_command;
mod file_search;