use super::types::{App, SearchKind};
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
pub(crate) fn approval_answer(code: KeyCode) -> Option<bool> {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => Some(true),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Some(false),
_ => None,
}
}
pub(crate) fn handle_key(app: &mut App, code: KeyCode, mods: KeyModifiers) {
if app.overlays.show_help {
app.overlays.show_help = false;
return;
}
if code == KeyCode::F(1) || (code == KeyCode::Char('?') && app.input.is_empty()) {
app.overlays.show_help = true;
return;
}
let ctrl_mod = mods.contains(KeyModifiers::CONTROL);
match code {
KeyCode::Char('o') if ctrl_mod => return app.toggle_selection_mode(),
KeyCode::Char('y') if ctrl_mod => return app.copy_last_answer(),
KeyCode::Char('b') if ctrl_mod => return app.copy_transcript(),
KeyCode::Char('r') if ctrl_mod => return app.open_search(SearchKind::History),
KeyCode::Char('f') if ctrl_mod => return app.open_search(SearchKind::Transcript),
KeyCode::F(2) => return app.toggle_selection_mode(),
KeyCode::F(3) => return app.copy_last_answer(),
KeyCode::F(4) => return app.copy_transcript(),
_ => {}
}
if app.overlays.search.is_some() {
match code {
KeyCode::Esc => app.close_search(),
KeyCode::Enter => app.commit_search(),
KeyCode::Backspace => app.search_backspace(),
KeyCode::Up => app.search_move(-1),
KeyCode::Down => app.search_move(1),
KeyCode::Char(c) => app.search_char(c),
_ => {}
}
return;
}
if app.overlays.picker.is_some() {
match code {
KeyCode::Up => app.picker_move(-1),
KeyCode::Down => app.picker_move(1),
KeyCode::Enter => app.picker_confirm(),
KeyCode::Esc => app.overlays.picker = None,
KeyCode::Backspace => app.picker_backspace(),
KeyCode::Char(c) => app.picker_char(c),
_ => {}
}
return;
}
if app.request.pending_approval.is_some() {
if let Some(allow) = approval_answer(code) {
app.answer_approval(allow);
}
return;
}
if app.overlays.menu.is_some() {
match code {
KeyCode::Up => return app.menu_move(-1),
KeyCode::Down => return app.menu_move(1),
KeyCode::Tab | KeyCode::Enter => return app.accept_selected(),
KeyCode::Esc => {
app.overlays.menu = None;
return;
}
_ => {}
}
}
if code == KeyCode::Esc && app.sql_task.is_some() {
app.detach_sql_task();
return;
}
if code == KeyCode::Esc && app.request.stream.is_some() {
if let Some(stream) = &app.request.stream {
stream.cancel.cancel();
}
app.transcript
.push(super::transcript::BlockKind::System, "Cancelling…");
return;
}
let ctrl = mods.contains(KeyModifiers::CONTROL);
let alt = mods.contains(KeyModifiers::ALT);
let word = ctrl || alt;
let was_armed = app.ctrl_c_armed;
app.ctrl_c_armed = false;
match code {
KeyCode::Char('c') if ctrl => {
if app.sql_task.is_some() {
app.detach_sql_task();
} else if app.request.stream.is_some() {
if let Some(stream) = &app.request.stream {
stream.cancel.cancel();
}
app.transcript
.push(super::transcript::BlockKind::System, "Cancelling…");
} else if !app.input.is_empty() {
app.input.clear();
app.overlays.menu = None;
} else if was_armed {
app.should_quit = true;
} else {
app.ctrl_c_armed = true;
app.transcript.push(
super::transcript::BlockKind::System,
"Press Ctrl+C again to exit.",
);
}
return;
}
KeyCode::Char('d') if ctrl && app.input.is_empty() => return app.should_quit = true,
KeyCode::Char('a') if ctrl => app.input.move_home(),
KeyCode::Char('e') if ctrl => app.input.move_end(),
KeyCode::Char('k') if ctrl => app.input.kill_to_line_end(),
KeyCode::Char('u') if ctrl => app.input.kill_to_line_start(),
KeyCode::Char('w') if ctrl => app.input.delete_word_left(),
KeyCode::Char(c) if !ctrl => app.input.insert_char(c),
KeyCode::Enter if alt || mods.contains(KeyModifiers::SHIFT) => app.input.insert_newline(),
KeyCode::Enter => return app.submit(),
KeyCode::Backspace => app.input.backspace(),
KeyCode::Delete => app.input.delete(),
KeyCode::Left if word => app.input.move_word_left(),
KeyCode::Right if word => app.input.move_word_right(),
KeyCode::Left => app.input.move_left(),
KeyCode::Right => app.input.move_right(),
KeyCode::Home => app.input.move_home(),
KeyCode::End => app.input.move_end(),
KeyCode::Up => return app.history_prev(),
KeyCode::Down => return app.history_next(),
KeyCode::PageUp => return app.scroll_pages(true),
KeyCode::PageDown => return app.scroll_pages(false),
_ => return,
}
app.history.reset();
app.refresh_menu();
}
#[cfg(test)]
mod approval_modal_tests {
use super::*;
#[test]
fn enter_never_approves_a_pending_modal() {
assert_eq!(approval_answer(KeyCode::Enter), None);
}
#[test]
fn only_explicit_y_approves_n_and_esc_deny() {
assert_eq!(approval_answer(KeyCode::Char('y')), Some(true));
assert_eq!(approval_answer(KeyCode::Char('Y')), Some(true));
assert_eq!(approval_answer(KeyCode::Char('n')), Some(false));
assert_eq!(approval_answer(KeyCode::Char('N')), Some(false));
assert_eq!(approval_answer(KeyCode::Esc), Some(false));
assert_eq!(approval_answer(KeyCode::Tab), None);
}
}
#[cfg(test)]
mod esc_sql_task_tests {
use super::*;
use crate::interactive::tui::application::tests_support::idle_app_with_sql_task;
use crate::interactive::tui::sql_task::{Followup, SqlTask};
use crate::render::TerminalEvent;
#[test]
fn esc_detaches_a_running_sql_command() {
let mut app = idle_app_with_sql_task();
assert!(app.sql_task.is_some(), "precondition: a task is running");
handle_key(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.sql_task.is_none(), "Esc detached the running task");
let last = app
.transcript
.blocks()
.last()
.expect("detach posts a message");
let text = last.text.to_lowercase();
assert!(
text.contains("running"),
"honest about still running: {text}"
);
assert!(
!text.contains("cancel"),
"must not claim cancellation: {text}"
);
}
#[test]
fn esc_does_not_detach_when_no_task_is_running() {
let mut app = idle_app_with_sql_task();
app.sql_task = None;
handle_key(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.sql_task.is_none());
assert!(app.transcript.blocks().is_empty());
}
#[test]
fn in_flight_task_shape_matches_app_state() {
let (_tx, rx) = std::sync::mpsc::channel::<TerminalEvent>();
let task = SqlTask {
profile: Some("analytics".into()),
sql: "SELECT 1".into(),
followup: Followup::Sql {
connection: Some("analytics".into()),
},
};
let _: (
std::sync::mpsc::Receiver<TerminalEvent>,
SqlTask,
std::time::Instant,
) = (rx, task, std::time::Instant::now());
}
}