use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc as std_mpsc;
use std::thread;
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tokio::sync::mpsc::UnboundedSender;
use crate::commands::{COMMAND_CATALOG, Command};
use crate::repl::SteerBuffer;
use crate::repl::tui::app::App;
use crate::repl::tui::event::{Job, UiEvent};
use crate::repl::tui::request_shutdown;
use tui_textarea::CursorMove;
fn key_modifiers(key: &KeyEvent) -> (bool, bool, bool) {
(
key.modifiers.contains(KeyModifiers::SHIFT),
key.modifiers.contains(KeyModifiers::ALT),
key.modifiers.contains(KeyModifiers::CONTROL),
)
}
pub(super) fn handle_idle_key(
app: &mut App,
key: KeyEvent,
job_tx: &std_mpsc::Sender<Job>,
interrupt: &Arc<AtomicBool>,
steer_buffer: &SteerBuffer,
) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let (shift, alt, ctrl) = key_modifiers(&key);
let bare = !shift && !alt && !ctrl;
let popup_consumed =
app.slash_popup.is_visible() && handle_slash_popup_key(app, key, job_tx, steer_buffer);
if !popup_consumed {
match key.code {
KeyCode::Char('c') if ctrl => {
if app.busy() {
if interrupt.load(Ordering::SeqCst) {
request_shutdown(app, job_tx);
} else {
interrupt.store(true, Ordering::SeqCst);
}
} else {
request_shutdown(app, job_tx);
}
}
KeyCode::Char('d') if ctrl && !app.busy() && app.textarea.is_empty() => {
request_shutdown(app, job_tx);
}
KeyCode::Char('v') if ctrl => {
handle_clipboard_paste(app);
}
KeyCode::Char('u') if ctrl => {
app.textarea.delete_line_by_head();
}
KeyCode::Char('w') if ctrl => {
app.textarea.delete_word();
}
KeyCode::Char('k') if ctrl => {
app.textarea.delete_line_by_end();
}
KeyCode::Up if alt && !ctrl => {
app.history_prev();
}
KeyCode::Down if alt && !ctrl => {
app.history_next();
}
KeyCode::Esc if app.busy() => {
interrupt.store(true, Ordering::SeqCst);
}
KeyCode::Enter if bare => {
submit_input(app, job_tx, steer_buffer);
}
KeyCode::Tab if bare => {
if !try_open_slash_popup(app) {
app.handle_textarea_input(key);
}
}
_ => {
app.handle_textarea_input(key);
}
}
}
app.sync_slash_popup();
}
fn handle_slash_popup_key(
app: &mut App,
key: KeyEvent,
job_tx: &std_mpsc::Sender<Job>,
steer_buffer: &SteerBuffer,
) -> bool {
let (shift, alt, ctrl) = key_modifiers(&key);
let bare = !shift && !alt && !ctrl;
match key.code {
KeyCode::Up if bare => {
app.slash_popup.move_up();
true
}
KeyCode::Down if bare => {
app.slash_popup.move_down();
true
}
KeyCode::Esc if !app.busy() => {
let snapshot = app.input_text();
app.slash_popup.dismiss(&snapshot);
true
}
KeyCode::Char('c') if ctrl => {
let snapshot = app.input_text();
app.slash_popup.dismiss(&snapshot);
true
}
KeyCode::Tab if bare => {
apply_selected_command(app);
true
}
KeyCode::Enter if bare => {
apply_selected_command(app);
submit_input(app, job_tx, steer_buffer);
true
}
_ => false,
}
}
fn try_open_slash_popup(app: &mut App) -> bool {
let text = app.input_text();
if !text.starts_with('/') || text.contains('\n') {
return false;
}
app.slash_popup.hide();
app.sync_slash_popup();
if app.slash_popup.is_visible() && app.slash_popup.matches().len() == 1 {
apply_selected_command(app);
}
true
}
fn apply_selected_command(app: &mut App) {
let Some(entry) = app.slash_popup.selected() else {
return;
};
app.clear_input();
app.textarea.insert_str(entry.name);
app.textarea.move_cursor(CursorMove::End);
}
fn handle_clipboard_paste(app: &mut App) {
if let Some(image) = crate::clipboard::get_clipboard_image() {
let idx = app.pasted_images.len();
match crate::clipboard::marker_for_index(idx) {
Some(marker) => {
app.pasted_images.push(image);
app.textarea.insert_str(format!("{} ", marker));
}
None => {
use colored::Colorize;
println!(
"{} Too many images in one message (limit: {}). Send what you have and paste the rest separately.",
"✗".bright_red().bold(),
crate::clipboard::MAX_PASTED_IMAGES_PER_MESSAGE
);
println!();
}
}
return;
}
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(text) = clipboard.get_text() {
if !text.is_empty() {
app.textarea.insert_str(&text);
}
}
}
}
pub(super) fn handle_picker_key(app: &mut App, key: KeyEvent, job_tx: &std_mpsc::Sender<Job>) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(picker) = app.picker.as_mut() else {
return;
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if picker.cursor > 0 {
picker.cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if picker.cursor + 1 < picker.sessions.len() {
picker.cursor += 1;
}
}
KeyCode::Enter => {
let id = picker.sessions[picker.cursor].id.clone();
app.picker = None;
let _ = job_tx.send(Job::ResumeSelected(Some(id)));
}
KeyCode::Esc => {
app.picker = None;
let _ = job_tx.send(Job::ResumeSelected(None));
}
KeyCode::Char('c') if ctrl => {
app.picker = None;
let _ = job_tx.send(Job::ResumeSelected(None));
}
_ => {}
}
}
pub(super) fn handle_effort_picker_key(
app: &mut App,
key: KeyEvent,
job_tx: &std_mpsc::Sender<Job>,
) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(picker) = app.effort_picker.as_mut() else {
return;
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
KeyCode::Enter => {
let effort = picker.selected().map(|e| e.effort);
app.effort_picker = None;
let _ = job_tx.send(Job::EffortSelected(effort));
}
KeyCode::Esc => {
app.effort_picker = None;
let _ = job_tx.send(Job::EffortSelected(None));
}
KeyCode::Char('c') if ctrl => {
app.effort_picker = None;
let _ = job_tx.send(Job::EffortSelected(None));
}
_ => {}
}
}
pub(super) fn handle_model_picker_key(
app: &mut App,
key: KeyEvent,
job_tx: &std_mpsc::Sender<Job>,
) {
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(picker) = app.model_picker.as_mut() else {
return;
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
KeyCode::Enter => {
let name = picker.selected().filter(|e| e.is_available).map(|e| e.name);
app.model_picker = None;
let _ = job_tx.send(Job::ModelSelected(name));
}
KeyCode::Esc => {
app.model_picker = None;
let _ = job_tx.send(Job::ModelSelected(None));
}
KeyCode::Char('c') if ctrl => {
app.model_picker = None;
let _ = job_tx.send(Job::ModelSelected(None));
}
_ => {}
}
}
fn submit_input(app: &mut App, job_tx: &std_mpsc::Sender<Job>, steer_buffer: &SteerBuffer) {
let raw = app.input_text();
let (cleaned, indices) = crate::clipboard::strip_paste_markers(&raw);
if cleaned.is_empty() && indices.is_empty() {
return;
}
app.clear_input();
let pool = std::mem::take(&mut app.pasted_images);
let images: Vec<crate::clipboard::PastedImage> = indices
.into_iter()
.filter_map(|idx| pool.get(idx).cloned())
.collect();
app.remember_submitted(&cleaned);
let command = if images.is_empty() {
Command::from_str(cleaned.trim())
} else {
None
};
let is_command = command.is_some();
let will_steer = app.busy() && !is_command && images.is_empty();
use colored::Colorize;
let glyph = if will_steer {
"↑"
} else if app.is_safe_mode() {
":"
} else {
">"
};
let glyph_styled = if will_steer {
glyph.bright_magenta().bold()
} else {
glyph.bright_green().bold()
};
println!("{} {}", glyph_styled, cleaned);
if will_steer {
println!(
" {}",
"queued for delivery before the next tool call".dimmed()
);
}
if !images.is_empty() {
println!(
"{} {} image(s) from clipboard",
"📋".bright_cyan(),
images.len()
);
}
println!();
if let Some(cmd) = command {
let job = Job::Command(cmd);
if app.busy() {
app.queue.push_back(job);
} else {
let _ = job_tx.send(job);
}
return;
}
let trimmed = cleaned.trim();
if images.is_empty() && trimmed.starts_with('/') {
let known = COMMAND_CATALOG
.iter()
.map(|entry| entry.name)
.collect::<Vec<_>>()
.join(" ");
println!("{} Unknown command `{}`.", "✗".bright_red().bold(), trimmed);
println!(" Try: {}", known.dimmed());
println!();
return;
}
if will_steer {
steer_buffer
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(cleaned);
return;
}
let job = Job::Message {
text: cleaned,
images,
};
if app.busy() {
app.queue.push_back(job);
} else {
let _ = job_tx.send(job);
}
}
pub(super) fn spawn_input_reader(tx: UnboundedSender<UiEvent>) -> std::io::Result<()> {
const POLL_TIMEOUT: Duration = Duration::from_millis(50);
thread::Builder::new()
.name("sofos-input".into())
.spawn(move || {
loop {
match crossterm::event::poll(POLL_TIMEOUT) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => break,
}
let event = match crossterm::event::read() {
Ok(e) => e,
Err(_) => break,
};
let ui_event = match event {
Event::Key(k) => UiEvent::Key(k),
Event::Resize(_, _) => UiEvent::Resize,
Event::Paste(s) => UiEvent::Paste(s),
_ => continue,
};
if tx.send(ui_event).is_err() {
break;
}
}
})?;
Ok(())
}