use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use reedline::{EditMode, Emacs, ExternalPrinter, PromptEditMode, ReedlineEvent, ReedlineRawEvent};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::Mutex;
use crate::session::chat::reedline_adapter::{LineState, PendingClipboardItem};
use crate::session::image::ImageProcessor;
use crate::session::video::VideoProcessor;
pub struct EmacsWithShortcutHelp {
emacs: Emacs,
buffer_empty: Arc<AtomicBool>,
reverse_search_active: Arc<AtomicBool>,
hint_available: Arc<AtomicBool>,
line_state: Arc<Mutex<LineState>>,
notifier: ExternalPrinter<String>,
meta_pending: bool,
}
impl EmacsWithShortcutHelp {
pub fn new(
emacs: Emacs,
buffer_empty: Arc<AtomicBool>,
reverse_search_active: Arc<AtomicBool>,
hint_available: Arc<AtomicBool>,
line_state: Arc<Mutex<LineState>>,
notifier: ExternalPrinter<String>,
) -> Self {
Self {
emacs,
buffer_empty,
reverse_search_active,
hint_available,
line_state,
notifier,
meta_pending: false,
}
}
fn attach_and_notify(&self, item: PendingClipboardItem) {
let label = match &item {
PendingClipboardItem::Image(att) => format_image_label(att),
PendingClipboardItem::Video(att) => format_video_label(att),
};
let preview = match &item {
PendingClipboardItem::Image(att) => ImageProcessor::render_inline_escape(att),
PendingClipboardItem::Video(_) => None,
};
if let Ok(mut state) = self.line_state.lock() {
state.pending_clipboard.push(item);
}
let payload = match preview {
Some(esc) => format!("\x1b[36m{}\x1b[0m\n{}", label, esc.trim_end_matches('\n')),
None => format!("\x1b[36m{}\x1b[0m", label),
};
let _ = self.notifier.print(payload);
}
}
fn try_capture_clipboard() -> Option<PendingClipboardItem> {
if let Ok(Some(image)) = ImageProcessor::load_from_clipboard() {
return Some(PendingClipboardItem::Image(image));
}
let mut clipboard = arboard::Clipboard::new().ok()?;
let text = clipboard.get_text().ok()?;
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.contains('\n') {
return None;
}
let path_str = trimmed.strip_prefix("file://").unwrap_or(trimmed);
let expanded = if let Some(rest) = path_str.strip_prefix("~/") {
dirs::home_dir().map(|h| h.join(rest))
} else {
Some(std::path::PathBuf::from(path_str))
}?;
if !expanded.is_file() || !VideoProcessor::is_supported_video(&expanded) {
return None;
}
VideoProcessor::load_from_path(&expanded)
.ok()
.map(PendingClipboardItem::Video)
}
fn format_image_label(att: &crate::session::image::ImageAttachment) -> String {
let dims = att
.dimensions
.map(|(w, h)| format!("{}×{}", w, h))
.unwrap_or_else(|| "?×?".to_string());
let size = att.size_bytes.map(format_size).unwrap_or_default();
let suffix = if size.is_empty() {
String::new()
} else {
format!(", {}", size)
};
format!("📎 Image attached ({}{}) — keep typing", dims, suffix)
}
fn format_video_label(att: &crate::session::video::VideoAttachment) -> String {
let dims = att
.dimensions
.map(|(w, h)| format!("{}×{}", w, h))
.unwrap_or_else(|| att.media_type.clone());
let size = att.size_bytes.map(format_size).unwrap_or_default();
let suffix = if size.is_empty() {
String::new()
} else {
format!(", {}", size)
};
let name = match &att.source_type {
crate::session::video::SourceType::File(p) => p
.file_name()
.and_then(|n| n.to_str())
.map(|s| format!(" {}", s))
.unwrap_or_default(),
_ => String::new(),
};
format!(
"🎬 Video attached{} ({}{}) — keep typing",
name, dims, suffix
)
}
fn format_size(bytes: u64) -> String {
let kb = bytes as f64 / 1024.0;
if kb >= 1024.0 {
format!("{:.1} MB", kb / 1024.0)
} else {
format!("{:.0} KB", kb)
}
}
impl EditMode for EmacsWithShortcutHelp {
fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent {
let event: Event = event.into();
if let Event::Paste(_) = &event {
if let Some(item) = try_capture_clipboard() {
self.attach_and_notify(item);
return ReedlineEvent::None;
}
}
if let Event::Key(KeyEvent {
code, modifiers, ..
}) = event
{
if modifiers == KeyModifiers::NONE && code == KeyCode::Esc {
self.meta_pending = true;
return ReedlineEvent::None;
}
if self.meta_pending {
self.meta_pending = false;
match code {
KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Backspace => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::BackspaceWord]);
}
KeyCode::Char('d') | KeyCode::Char('D') => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::CutWordRight]);
}
KeyCode::Char('b') | KeyCode::Char('B') => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::MoveWordLeft {
select: false,
}]);
}
KeyCode::Char('f') | KeyCode::Char('F') => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::MoveWordRight {
select: false,
}]);
}
_ => {}
}
}
if modifiers.contains(KeyModifiers::ALT) && modifiers.contains(KeyModifiers::CONTROL) {
match code {
KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Backspace => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::BackspaceWord]);
}
KeyCode::Char('d') | KeyCode::Char('D') => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::CutWordRight]);
}
KeyCode::Char('b') | KeyCode::Char('B') => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::MoveWordLeft {
select: false,
}]);
}
KeyCode::Char('f') | KeyCode::Char('F') => {
return ReedlineEvent::Edit(vec![reedline::EditCommand::MoveWordRight {
select: false,
}]);
}
_ => {}
}
}
if code == KeyCode::Char('a') && modifiers == KeyModifiers::CONTROL {
return ReedlineEvent::Edit(vec![reedline::EditCommand::MoveToLineStart {
select: false,
}]);
}
if code == KeyCode::Char('e') && modifiers == KeyModifiers::CONTROL {
if self.reverse_search_active.load(Ordering::SeqCst) {
return ReedlineEvent::Enter;
}
if self.hint_available.load(Ordering::SeqCst) {
return ReedlineEvent::HistoryHintComplete;
}
return ReedlineEvent::Edit(vec![reedline::EditCommand::MoveToLineEnd {
select: false,
}]);
}
if code == KeyCode::Char('u') && modifiers == KeyModifiers::CONTROL {
let state = self.line_state.lock().ok();
if let Some(state) = state {
let cursor = crate::utils::truncation::floor_char_boundary(
&state.buffer,
state.cursor.min(state.buffer.len()),
);
let line_start = state.buffer[..cursor]
.rfind('\n')
.map(|idx| idx + 1)
.unwrap_or(0);
if cursor == line_start && line_start > 0 {
return ReedlineEvent::Edit(vec![reedline::EditCommand::Backspace]);
}
}
return ReedlineEvent::Edit(vec![reedline::EditCommand::CutFromLineStart]);
}
if code == KeyCode::Char('?')
&& modifiers == KeyModifiers::NONE
&& self.buffer_empty.load(Ordering::SeqCst)
{
return ReedlineEvent::ExecuteHostCommand("__show_shortcuts__".to_string());
}
if code == KeyCode::Char('g') && modifiers == KeyModifiers::CONTROL {
if let Ok(mut state) = self.line_state.lock() {
state.add_without_sending = true;
}
return ReedlineEvent::Submit;
}
if code == KeyCode::Char('v') && modifiers == KeyModifiers::CONTROL {
if let Some(item) = try_capture_clipboard() {
self.attach_and_notify(item);
return ReedlineEvent::None;
}
}
if code == KeyCode::Char('c') && modifiers == KeyModifiers::CONTROL {
if self.reverse_search_active.load(Ordering::SeqCst) {
return ReedlineEvent::Esc;
}
return ReedlineEvent::CtrlC;
}
}
match ReedlineRawEvent::try_from(event) {
Ok(raw_event) => self.emacs.parse_event(raw_event),
Err(()) => ReedlineEvent::None,
}
}
fn edit_mode(&self) -> PromptEditMode {
self.emacs.edit_mode()
}
}