use crate::input;
use crate::scroll_buffer::ScrollBuffer;
use crate::tui_types::{MenuContent, PromptMode};
use crossterm::event::{Event, KeyCode, KeyModifiers};
use koda_core::engine::{ApprovalDecision, EngineCommand};
use koda_core::trust::{self, TrustMode};
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use tokio::sync::mpsc;
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_crossterm_event_inline(
ev: Event,
session_cancel: &koda_core::session::SessionCancel,
cmd_tx: &mpsc::Sender<EngineCommand>,
scroll_buffer: &mut ScrollBuffer,
hist_h: usize,
menu: &mut MenuContent,
prompt_mode: &mut PromptMode,
pending_approval_id: &mut Option<String>,
textarea: &mut crate::composer::textarea::TextArea,
shared_mode: &koda_core::trust::SharedTrustMode,
completer: &mut crate::completer::InputCompleter,
history: &mut Vec<String>,
history_idx: &mut Option<usize>,
paste_blocks: &mut Vec<input::PasteBlock>,
db: &koda_core::db::Database,
bg_agents: &koda_core::child_agent::ChildAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
child_activity: &mut crate::child_activity::ChildActivityTracker,
) {
use crossterm::event::MouseEventKind;
match ev {
Event::Resize(_, _) => {
let (w, _) = crossterm::terminal::size()
.map(|(c, r)| (c as usize, r as usize))
.unwrap_or((80, 24));
scroll_buffer.clamp_offset(w, hist_h);
}
Event::Mouse(mouse) => {
let (w, _) = crossterm::terminal::size()
.map(|(c, r)| (c as usize, r as usize))
.unwrap_or((80, 24));
match mouse.kind {
MouseEventKind::ScrollUp => scroll_buffer.scroll_up(3, w, hist_h),
MouseEventKind::ScrollDown => scroll_buffer.scroll_down(3),
_ => {}
}
}
Event::Paste(text) => {
let char_count = text.chars().count();
if char_count < input::PASTE_BLOCK_THRESHOLD {
textarea.insert_str(&text);
} else {
paste_blocks.push(input::PasteBlock {
content: text,
char_count,
});
}
}
Event::Key(key) => {
handle_inference_key_inline(
key,
session_cancel,
cmd_tx,
scroll_buffer,
menu,
prompt_mode,
pending_approval_id,
textarea,
shared_mode,
completer,
history,
history_idx,
db,
bg_agents,
bg_processes,
child_activity,
)
.await;
}
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_inference_key_inline(
key: crossterm::event::KeyEvent,
session_cancel: &koda_core::session::SessionCancel,
cmd_tx: &mpsc::Sender<EngineCommand>,
scroll_buffer: &mut ScrollBuffer,
menu: &mut MenuContent,
prompt_mode: &mut PromptMode,
pending_approval_id: &mut Option<String>,
textarea: &mut crate::composer::textarea::TextArea,
shared_mode: &koda_core::trust::SharedTrustMode,
completer: &mut crate::completer::InputCompleter,
history: &mut Vec<String>,
history_idx: &mut Option<usize>,
db: &koda_core::db::Database,
bg_agents: &koda_core::child_agent::ChildAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
child_activity: &mut crate::child_activity::ChildActivityTracker,
) {
if textarea.should_handle_vim_insert_escape(key) {
textarea.input(key);
return;
}
let stops_turn = key.code == KeyCode::Esc
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL));
if stops_turn {
close_dialog(menu, prompt_mode, pending_approval_id, textarea);
}
if let MenuContent::Approval { id, .. } = menu {
let approval_id = id.clone();
let decision = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => Some(ApprovalDecision::Approve),
KeyCode::Char('n') | KeyCode::Char('N') => Some(ApprovalDecision::Reject),
KeyCode::Char('a') | KeyCode::Char('A') => {
if let Err(msg) = trust::set_trust_checked(
shared_mode,
TrustMode::Auto,
koda_core::sandbox::is_available(),
) {
tracing::warn!(
"Approve+Always pressed but Auto refused: {msg} \u{2014} approving one-shot only"
);
}
Some(ApprovalDecision::Approve)
}
KeyCode::Char('f') | KeyCode::Char('F') => {
*prompt_mode = PromptMode::WizardInput {
label: "Feedback".into(),
mask: false,
};
*menu = MenuContent::WizardTrail(vec![(
"Action".into(),
"Rejected with feedback".into(),
)]);
*pending_approval_id = Some(approval_id.clone());
textarea.set_text_clearing_elements("");
None
}
_ => None,
};
if let Some(d) = decision {
*menu = MenuContent::None;
let _ = cmd_tx
.send(EngineCommand::ApprovalResponse {
id: approval_id,
decision: d,
})
.await;
}
return;
}
if let MenuContent::LoopCap { id } = menu {
let id = id.clone();
let action = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
Some(koda_core::loop_guard::LoopContinuation::Continue200)
}
KeyCode::Char('n') | KeyCode::Char('N') => {
Some(koda_core::loop_guard::LoopContinuation::Stop)
}
_ => None,
};
if let Some(a) = action {
*menu = MenuContent::None;
let _ = cmd_tx
.send(EngineCommand::LoopDecision { id, action: a })
.await;
}
return;
}
if matches!(menu, MenuContent::AskUser { .. })
&& matches!(prompt_mode, PromptMode::WizardInput { .. })
{
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
textarea.insert_str("\n");
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let answer = textarea.text().to_string();
textarea.set_text_clearing_elements("");
*prompt_mode = PromptMode::Chat;
if let MenuContent::AskUser { id, .. } = std::mem::replace(menu, MenuContent::None)
{
let _ = cmd_tx
.send(EngineCommand::AskUserResponse { id, answer })
.await;
}
}
_ => {
textarea.input(key);
}
}
return;
}
if matches!(prompt_mode, PromptMode::WizardInput { .. }) && pending_approval_id.is_some() {
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
textarea.insert_str("\n");
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let feedback = textarea.text().to_string();
textarea.set_text_clearing_elements("");
*prompt_mode = PromptMode::Chat;
*menu = MenuContent::None;
if let Some(aid) = pending_approval_id.take() {
let decision = if feedback.trim().is_empty() {
ApprovalDecision::Reject
} else {
ApprovalDecision::RejectWithFeedback { feedback }
};
let _ = cmd_tx
.send(EngineCommand::ApprovalResponse { id: aid, decision })
.await;
}
}
_ => {
textarea.input(key);
}
}
return;
}
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
textarea.insert_str("\n");
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let text = textarea.text().to_string();
if !text.trim().is_empty() {
if is_slash_command_attempt(&text) {
textarea.set_text_clearing_elements("");
crate::tui_output::warn_msg(
scroll_buffer,
"Slash commands are disabled during inference. \
Press Esc to interrupt, then run the command at idle."
.into(),
);
return;
}
textarea.set_text_clearing_elements("");
history.push(text.clone());
let _ = db.history_push(&text).await;
*history_idx = None;
let preview = truncate_preview(&text, 80);
scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled("\u{1f4e5} Next: ", Style::default().fg(Color::Green)),
Span::styled(preview, Style::default().fg(Color::DarkGray)),
]));
let _ = cmd_tx.send(EngineCommand::QueueNext { text }).await;
}
}
(KeyCode::Esc, _) => {
session_cancel.interrupt();
crate::tui_bg_tasks::cancel_all_bg_work(
scroll_buffer,
bg_agents,
bg_processes,
child_activity,
);
}
(KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => {
session_cancel.interrupt();
crate::tui_bg_tasks::cancel_all_bg_work(
scroll_buffer,
bg_agents,
bg_processes,
child_activity,
);
}
(KeyCode::BackTab, _) => {
if let Err(msg) =
trust::cycle_trust_checked(shared_mode, koda_core::sandbox::is_available())
{
scroll_buffer.push(Line::styled(
format!("\u{26a0} {msg}"),
Style::default().fg(Color::Yellow),
));
}
}
(KeyCode::Tab, KeyModifiers::NONE) => {
let current = textarea.text().to_string();
if let Some(completed) = completer.complete(¤t) {
textarea.set_text_clearing_elements("");
textarea.insert_str(&completed);
}
}
_ => {
completer.reset();
textarea.input(key);
}
}
}
pub(super) fn close_dialog(
menu: &mut MenuContent,
prompt_mode: &mut PromptMode,
pending_approval_id: &mut Option<String>,
textarea: &mut crate::composer::textarea::TextArea,
) {
let typing = matches!(prompt_mode, PromptMode::WizardInput { .. });
if typing {
textarea.set_text_clearing_elements("");
*prompt_mode = PromptMode::Chat;
}
if typing
|| matches!(
menu,
MenuContent::Approval { .. } | MenuContent::LoopCap { .. }
)
{
*menu = MenuContent::None;
}
*pending_approval_id = None;
}
fn is_slash_command_attempt(text: &str) -> bool {
text.trim_start().starts_with('/')
}
fn truncate_preview(s: &str, max_chars: usize) -> String {
let flat: String = s.chars().map(|c| if c == '\n' { '↵' } else { c }).collect();
if flat.len() <= max_chars {
flat
} else {
let mut out: String = flat.chars().take(max_chars.saturating_sub(1)).collect();
out.push('…');
out
}
}
#[cfg(test)]
mod slash_guard_tests {
use super::is_slash_command_attempt;
#[test]
fn bare_slash_command_is_detected() {
assert!(is_slash_command_attempt("/compact"));
assert!(is_slash_command_attempt("/clear"));
assert!(is_slash_command_attempt("/model"));
}
#[test]
fn slash_command_with_args_is_detected() {
assert!(is_slash_command_attempt("/model claude-haiku"));
assert!(is_slash_command_attempt("/model gpt-4o"));
}
#[test]
fn leading_whitespace_does_not_bypass_guard() {
assert!(is_slash_command_attempt(" /compact"));
assert!(is_slash_command_attempt(" /clear"));
assert!(is_slash_command_attempt("\t/model gpt-4o"));
}
#[test]
fn plain_text_is_allowed_through() {
assert!(!is_slash_command_attempt("please use rust 2024 edition"));
assert!(!is_slash_command_attempt("actually, skip the test for now"));
assert!(!is_slash_command_attempt(""));
assert!(!is_slash_command_attempt(" "));
}
#[test]
fn mid_text_slash_is_not_a_command() {
assert!(!is_slash_command_attempt("check /etc/hosts for the entry"));
assert!(!is_slash_command_attempt("see https://example.com/path"));
assert!(!is_slash_command_attempt("the ratio is 1/2"));
}
}