use super::*;
impl TuiContext {
pub(crate) async fn handle_idle_event(&mut self, ev: Event) -> anyhow::Result<bool> {
match ev {
Event::Resize(_, _) => {
let (w, _) = self.term_dims();
let vh = (self.history_area_height as usize).max(1);
self.scroll_buffer.clamp_offset(w, vh);
}
Event::Mouse(mouse) => {
self.handle_mouse(mouse);
}
Event::Paste(text) => {
self.handle_idle_paste(&text);
}
Event::Key(key) => {
return self.handle_idle_key(key).await;
}
_ => {}
}
Ok(true)
}
pub(crate) fn handle_idle_paste(&mut self, text: &str) {
let char_count = text.chars().count();
if matches!(self.prompt_mode, PromptMode::WizardInput { .. })
|| char_count < input::PASTE_BLOCK_THRESHOLD
{
self.textarea.insert_str(text);
} else {
self.paste_blocks.push(input::PasteBlock {
content: text.to_string(),
char_count,
});
let label = format!("\u{1f4cb} Pasted text ({char_count} chars)");
self.scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(label, Style::default().fg(Color::Yellow)),
]));
let preview: String = text.chars().take(80).collect();
let preview = preview.replace('\n', "\u{21b5}");
let preview = if char_count > 80 {
format!("{preview}\u{2026}")
} else {
preview
};
self.scroll_buffer.push(Line::from(vec![
Span::raw(" "),
Span::styled(preview, Style::default().fg(Color::DarkGray)),
]));
}
}
pub(crate) async fn handle_idle_key(
&mut self,
key: crossterm::event::KeyEvent,
) -> anyhow::Result<bool> {
if !self.menu.is_none()
&& let Some(consumed) = self.handle_menu_key(key).await
{
return Ok(consumed);
}
match (key.code, key.modifiers) {
(KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
self.textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
return self.handle_idle_enter().await;
}
(KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.history_up();
}
(KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
let input = self.textarea.lines().join("\n");
let trimmed = input.trim_end();
if trimmed.starts_with('/')
&& !trimmed.contains(' ')
&& let Some(dd) = crate::widgets::slash_menu::from_input(
crate::completer::SLASH_COMMANDS,
trimmed,
)
{
self.menu = MenuContent::Slash(dd);
return Ok(true);
}
self.history_down();
}
(KeyCode::Esc, _) => {
self.textarea.select_all();
self.textarea.cut();
self.history_idx = None;
}
(KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.select_all();
self.textarea.cut();
self.history_idx = None;
}
(KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => {
if self.textarea.lines().join("").trim().is_empty() {
self.should_quit = true;
}
}
(KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => {
self.scroll_buffer.scroll_to_bottom();
}
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
self.open_history_search();
}
(KeyCode::PageUp, _) => {
let (w, h) = self.term_dims();
self.scroll_buffer.scroll_up(20, w, h);
}
(KeyCode::PageDown, _) => {
self.scroll_buffer.scroll_down(20);
}
(KeyCode::Home, _) => {
let (w, h) = self.term_dims();
self.scroll_buffer.scroll_to_top(w, h);
}
(KeyCode::End, _) => {
self.scroll_buffer.scroll_to_bottom();
}
(KeyCode::BackTab, _) => {
let new_mode = trust::cycle_trust(&self.shared_mode);
let _ = self
.session
.db
.set_session_mode(&self.session.id, new_mode.as_str())
.await;
}
(KeyCode::Tab, KeyModifiers::NONE) => {
let current = self.textarea.lines().join("\n");
if let Some(completed) = self.completer.complete(¤t) {
self.textarea.select_all();
self.textarea.cut();
self.textarea.insert_str(&completed);
self.completer.reset();
}
}
_ => {
self.history_idx = None;
self.completer.reset();
self.textarea.input(Event::Key(key));
self.update_reactive_menu();
}
}
Ok(true)
}
pub(crate) async fn handle_idle_enter(&mut self) -> anyhow::Result<bool> {
if matches!(self.prompt_mode, PromptMode::WizardInput { .. }) {
self.handle_wizard_submit().await;
return Ok(true);
}
let text = self.textarea.lines().join("\n");
if !text.trim().is_empty() {
self.textarea.select_all();
self.textarea.cut();
self.history.push(text.clone());
let _ = self.session.db.history_push(&text).await;
self.history_idx = None;
let mode = trust::read_trust(&self.shared_mode);
let icon = match mode {
TrustMode::Plan => "\u{1f4cb}",
TrustMode::Safe => "\u{1f512}",
TrustMode::Auto => "\u{26a1}",
};
self.scroll_buffer.push(Line::from(vec![
Span::styled(format!("{icon}> "), Style::default().fg(Color::Cyan)),
Span::raw(text.clone()),
]));
self.pending_command = Some(text);
}
Ok(true)
}
pub(crate) fn term_dims(&self) -> (usize, usize) {
let size = self.terminal.size().unwrap_or_default();
(size.width as usize, size.height as usize)
}
fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
use crate::mouse_select::{Selection, VisualPos};
use crossterm::event::{MouseButton, MouseEventKind};
let (w, _) = self.term_dims();
let hist_y = self.history_area_y;
let hist_h = self.history_area_height;
let in_history = mouse.row >= hist_y && mouse.row < hist_y + hist_h;
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_buffer.scroll_up(3, w, hist_h as usize),
MouseEventKind::ScrollDown => self.scroll_buffer.scroll_down(3),
MouseEventKind::Down(MouseButton::Left) if in_history => {
let scroll_from_top = self.scroll_buffer.paragraph_scroll(hist_h as usize, w).0;
let screen_row = mouse.row.saturating_sub(hist_y);
let buffer_row = screen_row.saturating_add(scroll_from_top);
self.mouse_selection = Some(Selection {
anchor: VisualPos {
row: buffer_row,
col: mouse.column,
},
cursor: VisualPos {
row: buffer_row,
col: mouse.column,
},
scroll_from_top,
});
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(sel) = &mut self.mouse_selection {
if mouse.row < hist_y {
self.scroll_buffer.scroll_up(1, w, hist_h as usize);
} else if mouse.row >= hist_y + hist_h {
self.scroll_buffer.scroll_down(1);
}
sel.scroll_from_top = self.scroll_buffer.paragraph_scroll(hist_h as usize, w).0;
let screen_row = mouse
.row
.max(hist_y)
.min(hist_y + hist_h.saturating_sub(1))
.saturating_sub(hist_y);
let buffer_row = screen_row.saturating_add(sel.scroll_from_top);
sel.cursor = VisualPos {
row: buffer_row,
col: mouse.column,
};
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(sel) = self.mouse_selection.take() {
if sel.anchor != sel.cursor {
let lines: Vec<Line<'_>> =
self.scroll_buffer.all_lines().cloned().collect();
let gutter_ws: Vec<u16> =
self.scroll_buffer.gutter_widths().iter().copied().collect();
let (all_rows, all_gutters) =
crate::mouse_select::build_all_visual_rows(&lines, &gutter_ws, w);
let text = crate::mouse_select::extract_selected_text(
&all_rows,
&all_gutters,
&sel,
);
if !text.is_empty() {
match crate::mouse_select::copy_to_clipboard(&text) {
Ok(msg) => {
self.scroll_buffer.push(Line::from(vec![
Span::styled(
" \u{1f4cb} ",
Style::default().fg(Color::Green),
),
Span::styled(msg, Style::default().fg(Color::Green)),
]));
}
Err(e) => {
tracing::warn!("clipboard copy failed: {e}");
}
}
}
}
}
}
_ => {}
}
}
fn history_up(&mut self) {
if let Some(idx) = history_up_index(self.history_idx, self.history.len()) {
self.history_idx = Some(idx);
self.textarea.select_all();
self.textarea.cut();
self.textarea.insert_str(&self.history[idx]);
}
}
fn history_down(&mut self) {
let next = history_down_index(self.history_idx, self.history.len());
self.history_idx = next;
self.textarea.select_all();
self.textarea.cut();
if let Some(idx) = next {
self.textarea.insert_str(&self.history[idx]);
}
}
fn update_reactive_menu(&mut self) {
let after_input = self.textarea.lines().join("\n");
let trimmed = after_input.trim_end();
if trimmed.starts_with('/') && !trimmed.contains(' ') {
if let Some(dd) =
crate::widgets::slash_menu::from_input(crate::completer::SLASH_COMMANDS, trimmed)
{
self.menu = MenuContent::Slash(dd);
} else if matches!(self.menu, MenuContent::Slash(_)) {
self.menu = MenuContent::None;
}
} else if let Some(at_pos) = crate::completer::find_last_at_token(trimmed) {
let partial = &trimmed[at_pos + 1..];
let prefix = &trimmed[..at_pos];
let matches = crate::completer::list_path_matches_public(&self.project_root, partial);
if !matches.is_empty() {
let items: Vec<crate::widgets::file_menu::FileItem> = matches
.iter()
.map(|p| crate::widgets::file_menu::FileItem {
path: p.clone(),
is_dir: p.ends_with('/'),
})
.collect();
let dd = crate::widgets::dropdown::DropdownState::new(items, "\u{1f4c2} Files");
self.menu = MenuContent::File {
dropdown: dd,
prefix: prefix.to_string(),
};
} else if matches!(self.menu, MenuContent::File { .. }) {
self.menu = MenuContent::None;
}
} else if matches!(self.menu, MenuContent::Slash(_) | MenuContent::File { .. }) {
self.menu = MenuContent::None;
}
}
}
pub(crate) fn history_up_index(current: Option<usize>, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
Some(match current {
None => len - 1,
Some(i) => i.saturating_sub(1),
})
}
pub(crate) fn history_down_index(current: Option<usize>, len: usize) -> Option<usize> {
match current {
Some(i) if i + 1 < len => Some(i + 1),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_history_up_from_none() {
assert_eq!(history_up_index(None, 5), Some(4));
}
#[test]
fn test_history_up_from_middle() {
assert_eq!(history_up_index(Some(3), 5), Some(2));
}
#[test]
fn test_history_up_at_top() {
assert_eq!(history_up_index(Some(0), 5), Some(0));
}
#[test]
fn test_history_up_empty() {
assert_eq!(history_up_index(None, 0), None);
}
#[test]
fn test_history_down_from_middle() {
assert_eq!(history_down_index(Some(2), 5), Some(3));
}
#[test]
fn test_history_down_at_bottom() {
assert_eq!(history_down_index(Some(4), 5), None);
}
#[test]
fn test_history_down_from_none() {
assert_eq!(history_down_index(None, 5), None);
}
#[tokio::test]
async fn test_history_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let db = koda_core::db::Database::init(tmp.path()).await.unwrap();
db.history_push("hello").await.unwrap();
db.history_push("world").await.unwrap();
db.history_push("/model gpt-4").await.unwrap();
let loaded = db.history_load().await.unwrap();
assert_eq!(loaded, vec!["hello", "world", "/model gpt-4"]);
}
#[tokio::test]
async fn test_history_empty_db() {
let tmp = tempfile::tempdir().unwrap();
let db = koda_core::db::Database::init(tmp.path()).await.unwrap();
let loaded = db.history_load().await.unwrap();
assert!(loaded.is_empty());
}
}