use std::sync::Arc;
use crossterm::event::{KeyCode, KeyModifiers, MouseEventKind, MouseButton, Event};
use synaps_cli::skills::registry::CommandRegistry;
use super::app::{App, ChatMessage};
pub(super) enum InputAction {
None,
Submit(String),
SlashCommand(String, String),
StreamingInput(String),
Quit,
Abort,
SettingsApply(&'static str, String),
ModelsApply(String),
ModelsExpandProvider(String),
PluginsOutcome(super::plugins::InputOutcome),
HelpFindOutcome,
OpenPluginsMarketplace,
PingModels,
PluginEditorOpen { plugin_id: String, category: String, field: String },
PluginEditorKey { plugin_id: String, category: String, field: String, key: crossterm::event::KeyEvent },
}
pub(super) fn handle_event(
event: Event,
app: &mut App,
runtime: &synaps_cli::Runtime,
streaming: bool,
registry: &Arc<CommandRegistry>,
keybinds: &synaps_cli::skills::keybinds::KeybindRegistry,
) -> InputAction {
if let Some(state) = app.help_find.as_mut() {
if let Event::Key(key) = event {
let outcome = super::help_find::handle_event(state, key);
return match outcome {
super::help_find::HelpFindAction::Close => {
app.help_find = None;
InputAction::None
}
super::help_find::HelpFindAction::None => InputAction::HelpFindOutcome,
};
}
return InputAction::None;
}
if let Some(state) = app.models.as_mut() {
if let Event::Key(key) = event {
match super::models::handle_event(state, key, runtime.model()) {
super::models::InputOutcome::Close => {
app.models = None;
return InputAction::None;
}
super::models::InputOutcome::Apply(model) => {
app.models = None;
return InputAction::ModelsApply(model);
}
super::models::InputOutcome::None => return InputAction::None,
super::models::InputOutcome::ExpandProvider(provider) => return InputAction::ModelsExpandProvider(provider),
}
}
return InputAction::None;
}
if let Some(state) = app.plugins.as_mut() {
if let Event::Key(key) = event {
let outcome = super::plugins::handle_event(state, key);
return match outcome {
super::plugins::InputOutcome::Close => {
app.plugins = None;
InputAction::None
}
super::plugins::InputOutcome::None => InputAction::None,
other => InputAction::PluginsOutcome(other),
};
}
return InputAction::None;
}
if let Some(state) = app.settings.as_mut() {
if let Some(super::settings::ActiveEditor::PluginCustom { plugin_id, category, field, .. }) = &state.edit_mode {
if let Event::Key(key) = event {
if key.code == KeyCode::Esc {
state.edit_mode = None;
return InputAction::None;
}
return InputAction::PluginEditorKey {
plugin_id: plugin_id.clone(),
category: category.clone(),
field: field.clone(),
key,
};
}
return InputAction::None;
}
if let Event::Paste(text) = event {
match &mut state.edit_mode {
Some(super::settings::ActiveEditor::ApiKey { buffer, .. }) => {
buffer.push_str(&text);
}
Some(super::settings::ActiveEditor::Text { buffer, .. }) => {
buffer.push_str(&text);
}
Some(super::settings::ActiveEditor::CustomModel { buffer, .. }) => {
buffer.push_str(&text);
}
_ => {}
}
return InputAction::None;
}
if let Event::Key(key) = event {
let snap = super::settings::RuntimeSnapshot::from_runtime_with_health(runtime, registry, app.model_health.clone());
match super::settings::handle_event(state, key, &snap) {
super::settings::InputOutcome::Close => { app.settings = None; }
super::settings::InputOutcome::None => {}
super::settings::InputOutcome::Apply { key, value } => {
return InputAction::SettingsApply(key, value);
}
super::settings::InputOutcome::PluginApply { plugin_id, key, value } => {
let row_key = format!("plugin.{}.{}", plugin_id, key);
match synaps_cli::extensions::config_store::write_plugin_config(
&plugin_id, &key, &value,
) {
Ok(()) => {
state.edit_mode = None;
state.row_error = Some((row_key, "saved".to_string()));
}
Err(e) => {
state.row_error = Some((row_key, e.to_string()));
}
}
}
super::settings::InputOutcome::PluginCustomOpen { plugin_id, category, key } => {
return InputAction::PluginEditorOpen {
plugin_id,
category,
field: key,
};
}
super::settings::InputOutcome::SetProviderKey { provider_id, value } => {
let cfg_key = format!("provider.{}", provider_id);
match synaps_cli::config::write_config_value(&cfg_key, &value) {
Ok(()) => {
state.edit_mode = None;
state.row_error = Some((cfg_key, "saved".to_string()));
}
Err(e) => {
state.row_error = Some((cfg_key, e.to_string()));
}
}
}
super::settings::InputOutcome::TogglePlugin { name, enabled } => {
let mut config = synaps_cli::config::load_config();
match super::plugins::actions::toggle_plugin_config(
&name, enabled, &mut config, registry,
) {
Ok(()) => {
state.row_error = None;
}
Err(e) => {
state.row_error = Some(("disabled_plugins".to_string(), e));
}
}
}
super::settings::InputOutcome::PreviewTheme { name } => {
if let Some(theme) = super::theme::load_theme_by_name(&name) {
super::theme::set_theme(theme);
}
}
super::settings::InputOutcome::RevertTheme => {
let theme = super::theme::load_theme_from_config();
super::theme::set_theme(theme);
}
super::settings::InputOutcome::OpenPluginsMarketplace => {
return InputAction::OpenPluginsMarketplace;
}
super::settings::InputOutcome::PingModels => {
return InputAction::PingModels;
}
}
}
return InputAction::None;
}
match event {
Event::Key(key) => handle_key(key.code, key.modifiers, app, streaming, registry, keybinds),
Event::Mouse(mouse) => {
handle_mouse(mouse, app)
}
Event::Paste(text) => {
if let Some(deadline) = app.suppress_paste_until {
if std::time::Instant::now() < deadline {
app.suppress_paste_until = None;
return InputAction::None;
}
app.suppress_paste_until = None;
}
const MAX_PASTE_CHARS: usize = 100_000;
if !streaming || !app.input.is_empty() {
let text = if text.chars().count() > MAX_PASTE_CHARS {
let truncated: String = text.chars().take(MAX_PASTE_CHARS).collect();
app.push_msg(ChatMessage::System(
format!("Paste truncated to {} chars (was {})", MAX_PASTE_CHARS, text.chars().count())
));
truncated
} else {
text
};
if app.input_before_paste.is_none() {
app.input_before_paste = Some(app.input.clone());
}
let byte_pos = app.cursor_byte_pos();
app.input.insert_str(byte_pos, &text);
app.cursor_pos += text.chars().count();
app.pasted_char_count += text.chars().count();
}
InputAction::None
}
_ => InputAction::None,
}
}
fn handle_mouse(mouse: crossterm::event::MouseEvent, app: &mut App) -> InputAction {
match mouse.kind {
MouseEventKind::ScrollUp => {
app.clear_selection();
app.scroll_back = app.scroll_back.saturating_add(3);
app.scroll_pinned = false;
}
MouseEventKind::ScrollDown => {
app.clear_selection();
app.scroll_back = app.scroll_back.saturating_sub(3);
if app.scroll_back == 0 {
app.scroll_pinned = true;
}
}
MouseEventKind::Down(MouseButton::Left) => {
if is_in_msg_area(app, mouse.column, mouse.row) {
app.selection_anchor = Some((mouse.column, mouse.row));
app.selection_end = None;
} else {
app.clear_selection();
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if app.selection_anchor.is_some() {
app.selection_end = Some((mouse.column, mouse.row));
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(anchor) = app.selection_anchor {
let end = (mouse.column, mouse.row);
if anchor == end {
app.clear_selection();
} else {
app.selection_end = Some(end);
}
}
}
MouseEventKind::Down(MouseButton::Right) => {
if app.has_selection() {
if let Some(text) = app.selected_text() {
copy_to_clipboard(&text);
app.push_msg(ChatMessage::System(format!("Copied {} chars", text.chars().count())));
}
app.suppress_paste_until = Some(std::time::Instant::now() + std::time::Duration::from_millis(150));
app.clear_selection();
} else {
if let Some(text) = paste_from_clipboard() {
if !text.is_empty() {
if app.input_before_paste.is_none() {
app.input_before_paste = Some(app.input.clone());
}
let byte_pos = app.cursor_byte_pos();
app.input.insert_str(byte_pos, &text);
app.cursor_pos += text.chars().count();
app.pasted_char_count += text.chars().count();
}
}
app.suppress_paste_until = Some(std::time::Instant::now() + std::time::Duration::from_millis(150));
}
}
_ => {}
}
InputAction::None
}
fn is_in_msg_area(app: &App, col: u16, row: u16) -> bool {
if let Some(rect) = app.msg_area_rect {
col >= rect.x && col < rect.x + rect.width
&& row >= rect.y && row < rect.y + rect.height
} else {
false
}
}
fn copy_to_clipboard(text: &str) {
use std::sync::{OnceLock, mpsc};
static TX: OnceLock<mpsc::Sender<String>> = OnceLock::new();
let sender = TX.get_or_init(|| {
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
let Ok(mut clipboard) = arboard::Clipboard::new() else { return };
while let Ok(text) = rx.recv() {
let _ = clipboard.set_text(&text);
}
});
tx
});
let _ = sender.send(text.to_string());
}
fn paste_from_clipboard() -> Option<String> {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(text) = clipboard.get_text() {
if !text.is_empty() {
return Some(text);
}
}
}
None
}
fn handle_key(
code: KeyCode,
modifiers: KeyModifiers,
app: &mut App,
streaming: bool,
registry: &Arc<CommandRegistry>,
keybinds: &synaps_cli::skills::keybinds::KeybindRegistry,
) -> InputAction {
app.clear_selection();
if !matches!(code, KeyCode::Tab) {
app.tab_cycle = None;
}
if !streaming {
let traceable = matches!(code, KeyCode::F(_))
|| modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT);
if traceable {
tracing::info!(
?code,
?modifiers,
"key event received in chatui input"
);
}
if let Some(bind) = keybinds.match_key(code, modifiers) {
use synaps_cli::skills::keybinds::KeybindAction;
return match &bind.action {
KeybindAction::SlashCommand(cmd) => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
let resolved = super::commands::resolve_prefix(parts[0], &super::commands::all_commands_with_skills(registry));
InputAction::SlashCommand(resolved, parts.get(1).unwrap_or(&"").to_string())
}
KeybindAction::LoadSkill(skill) => {
InputAction::SlashCommand("load".to_string(), skill.clone())
}
KeybindAction::InjectPrompt(text) => {
InputAction::Submit(text.clone())
}
KeybindAction::Disabled => InputAction::None,
KeybindAction::RunScript { .. } => {
InputAction::None
}
};
}
}
match (code, modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
return InputAction::Quit;
}
(KeyCode::Esc, _) if streaming => {
return InputAction::Abort;
}
(KeyCode::Enter, KeyModifiers::SHIFT) if !streaming => {
let byte_pos = app.cursor_byte_pos();
app.input.insert(byte_pos, '\n');
app.cursor_pos += 1;
}
(KeyCode::Enter, _) if !streaming && !app.input.is_empty() => {
return process_submit(app, registry);
}
(KeyCode::Enter, _) if streaming && !app.input.is_empty() => {
return process_streaming_submit(app);
}
(KeyCode::Tab, _) if app.input.starts_with('/') && app.input.len() > 1 => {
if open_help_find_for_ambiguous_slash(app, registry) {
return InputAction::HelpFindOutcome;
}
handle_tab_complete(app, registry);
return InputAction::None;
}
(KeyCode::Char('a'), KeyModifiers::CONTROL) => {
app.cursor_pos = 0;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
app.cursor_pos = app.input.chars().count();
}
(KeyCode::Char('w'), KeyModifiers::CONTROL) | (KeyCode::Backspace, KeyModifiers::ALT) => {
delete_word_backward(app);
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
app.input.clear();
app.cursor_pos = 0;
}
(KeyCode::Home, _) => {
app.cursor_pos = 0;
}
(KeyCode::End, _) => {
app.cursor_pos = app.input.chars().count();
}
(KeyCode::Left, KeyModifiers::ALT) => {
jump_word_left(app);
}
(KeyCode::Right, KeyModifiers::ALT) => {
jump_word_right(app);
}
(KeyCode::Char('o'), KeyModifiers::CONTROL) => {
app.show_full_output = !app.show_full_output;
app.invalidate();
}
(KeyCode::Char(c), _) => {
let byte_pos = app.cursor_byte_pos();
app.input.insert(byte_pos, c);
app.cursor_pos += 1;
}
(KeyCode::Backspace, _) if app.cursor_pos > 0 => {
app.cursor_pos -= 1;
let byte_pos = app.cursor_byte_pos();
app.input.remove(byte_pos);
}
(KeyCode::Left, _) if app.cursor_pos > 0 => {
app.cursor_pos -= 1;
}
(KeyCode::Right, _) if app.cursor_pos < app.input_char_count() => {
app.cursor_pos += 1;
}
(KeyCode::Up, KeyModifiers::SHIFT) => {
app.scroll_back = app.scroll_back.saturating_add(1);
app.scroll_pinned = false;
}
(KeyCode::Down, KeyModifiers::SHIFT) => {
app.scroll_back = app.scroll_back.saturating_sub(1);
if app.scroll_back == 0 {
app.scroll_pinned = true;
}
}
(KeyCode::Up, _) => {
app.history_up();
}
(KeyCode::Down, _) => {
app.history_down();
}
_ => {}
}
InputAction::None
}
fn process_submit(app: &mut App, registry: &Arc<CommandRegistry>) -> InputAction {
if app.messages.is_empty() {
app.logo_dismiss_t = Some(0.001);
}
let input = app.input.clone();
app.input_history.push(input.clone());
app.history_index = None;
app.input_stash.clear();
app.input.clear();
app.cursor_pos = 0;
app.scroll_back = 0;
app.scroll_pinned = true;
if input.starts_with('/') && input.len() > 1 {
let parts: Vec<&str> = input[1..].splitn(2, ' ').collect();
let raw_cmd = parts[0];
let arg = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string();
let commands = super::commands::all_commands_with_skills(registry);
let cmd = super::commands::resolve_prefix(raw_cmd, &commands);
InputAction::SlashCommand(cmd, arg)
} else {
InputAction::Submit(input)
}
}
fn process_streaming_submit(app: &mut App) -> InputAction {
let input = app.input.clone();
app.input_history.push(input.clone());
app.history_index = None;
app.input_stash.clear();
app.input.clear();
app.cursor_pos = 0;
app.input_before_paste = None;
app.pasted_char_count = 0;
InputAction::StreamingInput(input)
}
fn open_help_find_for_ambiguous_slash(app: &mut App, registry: &Arc<CommandRegistry>) -> bool {
let Some(query) = synaps_cli::help::prefilter_query_for_slash_command(&app.input) else {
return false;
};
let help_registry = synaps_cli::help::HelpRegistry::new(
synaps_cli::help::builtin_entries(),
registry.plugin_help_entries(),
);
if help_registry.command_prefix_match_count(&query) < 2 {
return false;
}
app.help_find = Some(synaps_cli::help::HelpFindState::new(
help_registry.entries().to_vec(),
&query,
));
true
}
fn handle_tab_complete(app: &mut App, registry: &Arc<CommandRegistry>) {
let commands = super::commands::all_commands_with_skills(registry);
if let Some((ref prefix, idx, ref matching_cmds)) = app.tab_cycle.clone() {
if matching_cmds.is_empty() {
app.tab_cycle = None;
return;
}
let next = (idx + 1) % matching_cmds.len();
app.input = format!("/{}", matching_cmds[next]);
app.cursor_pos = app.input.chars().count();
app.tab_cycle = Some((prefix.clone(), next, matching_cmds.clone()));
return;
}
let partial = app.input[1..].to_string();
let matches: Vec<String> = commands.iter()
.filter(|c| c.starts_with(partial.as_str()))
.cloned()
.collect();
if matches.len() == 1 {
app.input = format!("/{}", matches[0]);
app.cursor_pos = app.input.chars().count();
return;
}
if !matches.is_empty() {
let first = &matches[0];
let common_len = (0..first.len())
.take_while(|&i| matches.iter().all(|m| m.as_bytes().get(i) == first.as_bytes().get(i)))
.count();
if common_len > partial.len() {
app.input = format!("/{}", &first[..common_len]);
app.cursor_pos = app.input.chars().count();
} else {
app.input = format!("/{}", matches[0]);
app.cursor_pos = app.input.chars().count();
app.tab_cycle = Some((partial, 0, matches));
}
return;
}
if let Some(fuzzy) = super::commands::fuzzy_match(&partial, &commands) {
app.input = format!("/{}", fuzzy);
app.cursor_pos = app.input.chars().count();
}
}
fn delete_word_backward(app: &mut App) {
let chars: Vec<char> = app.input.chars().collect();
let mut pos = app.cursor_pos;
while pos > 0 && chars[pos - 1] == ' ' { pos -= 1; }
while pos > 0 && chars[pos - 1] != ' ' { pos -= 1; }
let byte_start = app.input.char_indices().nth(pos).map(|(i, _)| i).unwrap_or(app.input.len());
let byte_end = app.cursor_byte_pos();
app.input.drain(byte_start..byte_end);
app.cursor_pos = pos;
}
fn jump_word_left(app: &mut App) {
let chars: Vec<char> = app.input.chars().collect();
let mut pos = app.cursor_pos;
while pos > 0 && chars[pos - 1] == ' ' { pos -= 1; }
while pos > 0 && chars[pos - 1] != ' ' { pos -= 1; }
app.cursor_pos = pos;
}
fn jump_word_right(app: &mut App) {
let chars: Vec<char> = app.input.chars().collect();
let len = chars.len();
let mut pos = app.cursor_pos;
while pos < len && chars[pos] != ' ' { pos += 1; }
while pos < len && chars[pos] == ' ' { pos += 1; }
app.cursor_pos = pos;
}