#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
use std::time::{Duration, Instant};
use anyhow::Result;
use ratatui::style::{Color, Style};
use tui_textarea::{CursorMove, TextArea};
use nexus_core::app::{AppCommand, Match, Popup};
use crate::app_view::AppView;
const COMPOSER_MULTI_CLICK: Duration = Duration::from_millis(400);
pub fn new_textarea() -> TextArea<'static> {
let mut ta = TextArea::default();
ta.set_cursor_line_style(Style::default());
ta.set_selection_style(Style::default().bg(Color::Blue).fg(Color::White));
ta.set_wrap_mode(tui_textarea::WrapMode::WordOrGlyph);
ta.set_max_rows(20);
ta
}
pub(crate) fn copy_to_clipboard(clipboard: &mut Option<arboard::Clipboard>, text: &str) -> String {
if text.is_empty() {
return String::new();
}
let n = text.chars().count();
if clipboard
.as_mut()
.is_some_and(|cb| cb.set_text(text.to_string()).is_ok())
{
format!("copied {n} chars")
} else {
"clipboard unavailable".to_string()
}
}
fn pasted_file_path(text: &str) -> Option<std::path::PathBuf> {
let t = text.trim().trim_matches('"').trim_matches('\'');
let t = t.strip_prefix("file://").unwrap_or(t);
if t.contains('\n') || !t.starts_with('/') {
return None;
}
let path = std::path::PathBuf::from(t);
path.is_file().then_some(path)
}
impl AppView {
pub fn input_text(&self) -> String {
self.input.lines().join("\n")
}
pub fn set_input(&mut self, text: &str) {
let lines: Vec<String> = text.split('\n').map(str::to_string).collect();
let row = lines.len().saturating_sub(1);
let col = lines.last().map_or(0, |l| l.chars().count());
self.input.set_lines(lines, (row, col));
}
pub fn clear_input(&mut self) {
self.input = new_textarea();
}
pub fn copy_selection(&mut self) {
self.input.copy();
let text = self.input.yank_text();
self.input.cancel_selection();
let msg = copy_to_clipboard(&mut self.clipboard, &text);
if !msg.is_empty() {
self.push_status(msg);
}
}
pub fn copy_selection_live(&mut self) {
let Some(((r1, c1), (r2, c2))) = self.input.selection_range() else {
return;
};
let lines = self.input.lines();
let text = if r1 == r2 {
lines[r1].chars().skip(c1).take(c2 - c1).collect::<String>()
} else {
let mut out = lines[r1].chars().skip(c1).collect::<String>();
for line in &lines[r1 + 1..r2] {
out.push('\n');
out.push_str(line);
}
out.push('\n');
out.push_str(&lines[r2].chars().take(c2).collect::<String>());
out
};
let msg = copy_to_clipboard(&mut self.clipboard, &text);
if !msg.is_empty() {
self.push_status(msg);
}
}
pub fn cut_selection(&mut self) {
if self.input.cut()
&& let Some(cb) = self.clipboard.as_mut()
{
let _ = cb.set_text(self.input.yank_text());
}
}
pub fn paste(&mut self, text: &str) {
use nexus_core::app::{AppsMode, FilesMode, SessionMode, SkillsMode, SpaceMode};
if text.is_empty() {
return;
}
match self.popup {
Popup::None => {
if let Some(path) = pasted_file_path(text) {
self.open_files_popup(nexus_core::app::FilesTab::Files);
self.start_files_add();
self.files_edit = path.to_string_lossy().to_string();
self.push_status(
"import this file? Enter to confirm · Esc to cancel".to_string(),
);
return;
}
self.input.insert_str(text);
}
Popup::Key => self.key_input.push_str(text),
Popup::Session if self.session_mode == SessionMode::Rename => {
self.session_edit.push_str(text);
}
Popup::Space if matches!(self.space_mode, SpaceMode::Create | SpaceMode::Rename) => {
self.space_edit.push_str(text);
}
Popup::Skills if self.skills_mode == SkillsMode::Install => {
self.skills_edit.push_str(text);
}
Popup::Apps if self.apps_mode == AppsMode::EditFile => self.apps_edit.push_str(text),
Popup::Files if self.files_mode == FilesMode::Add => {
self.files_edit.push_str(text);
}
Popup::Files
if self.files_tab == nexus_core::app::FilesTab::Scripts
&& self.scripts_mode == nexus_core::app::ScriptsMode::Create =>
{
self.scripts_edit.push_str(text);
}
Popup::Files if self.files_mode == FilesMode::Pick => {
for c in text.chars().filter(|c| !c.is_control()) {
self.picker_filter_push(c);
}
}
Popup::Settings => {
if let Some(i) = self.text_index() {
use nexus_core::app::SettingsField;
let numeric = !matches!(
self.settings_field(),
Some(
SettingsField::SearxngUrl
| SettingsField::LangsearchKey
| SettingsField::EmbeddingModel
| SettingsField::BlockedDomains
)
);
let filtered: String = if numeric {
text.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect()
} else {
text.chars().filter(|c| !c.is_control()).collect()
};
self.settings_inputs[i].push_str(&filtered);
}
}
_ => {}
}
}
pub fn paste_from_clipboard(&mut self) {
if self.popup == Popup::None
&& let Some(img) = self.clipboard.as_mut().and_then(|cb| cb.get_image().ok())
{
if let Some(md) = self.save_clipboard_image(img.width, img.height, &img.bytes) {
self.input.insert_str(&md);
self.push_status("image attached as markdown".to_string());
}
return;
}
let Some(cb) = self.clipboard.as_mut() else {
return;
};
let Ok(text) = cb.get_text() else { return };
self.paste(&text);
}
pub fn composer_click_down(&mut self, screen_pos: (u16, u16)) -> u8 {
let count = match self.composer_click {
Some((t, p)) if t.elapsed() <= COMPOSER_MULTI_CLICK && p == screen_pos => {
self.composer_click_count + 1
}
_ => 1,
};
self.composer_click = Some((Instant::now(), screen_pos));
self.composer_click_count = count;
count
}
pub fn select_composer_word(&mut self) {
self.input.cancel_selection();
self.input.move_cursor(CursorMove::WordBack);
self.composer_word_anchor = Some(self.input.cursor());
self.input.start_selection();
self.input.move_cursor(CursorMove::WordEnd);
self.input.move_cursor(CursorMove::Forward);
}
pub fn select_composer_line(&mut self) {
self.input.cancel_selection();
self.input.move_cursor(CursorMove::Head);
self.composer_word_anchor = Some(self.input.cursor());
self.input.start_selection();
self.input.move_cursor(CursorMove::End);
}
pub fn extend_composer_word_selection(&mut self) {
let Some(anchor) = self.composer_word_anchor else {
return;
};
let cur = self.input.cursor();
self.input.cancel_selection();
self.jump_cursor(anchor);
if cur >= anchor {
self.input.start_selection();
self.jump_cursor(cur);
self.input.move_cursor(CursorMove::WordEnd);
self.input.move_cursor(CursorMove::Forward); } else {
self.input.move_cursor(CursorMove::WordEnd);
self.input.move_cursor(CursorMove::Forward); self.input.start_selection();
self.jump_cursor(cur);
self.input.move_cursor(CursorMove::WordBack);
}
}
pub fn extend_composer_line_selection(&mut self) {
let Some(anchor) = self.composer_word_anchor else {
return;
};
let cur = self.input.cursor();
self.input.cancel_selection();
if cur >= anchor {
self.jump_cursor((anchor.0, 0));
self.input.start_selection();
self.jump_cursor(cur);
self.input.move_cursor(CursorMove::End);
} else {
self.jump_cursor(anchor);
self.input.move_cursor(CursorMove::End);
self.input.start_selection();
self.jump_cursor((cur.0, 0));
}
}
fn jump_cursor(&mut self, (row, col): (usize, usize)) {
self.input
.move_cursor(CursorMove::Jump(row as u16, col as u16));
}
pub fn command_matches(&self) -> Vec<Match> {
let text = self.input_text();
let Some(rest) = text.strip_prefix('/') else {
return Vec::new();
};
if rest.contains(char::is_whitespace) {
return Vec::new();
}
let mut scored: Vec<(i32, Match)> = nexus_core::app::COMMANDS
.iter()
.filter_map(|c| nexus_core::app::command_score(c, rest).map(|s| (s, Match::Builtin(c))))
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name().cmp(b.1.name())));
scored.into_iter().map(|(_, m)| m).collect()
}
pub fn command_selected(&self) -> usize {
let n = self.command_matches().len();
if n == 0 {
0
} else {
self.cmd_selected.min(n - 1)
}
}
pub fn move_command_selection(&mut self, delta: i32) {
let n = self.command_matches().len() as i32;
if n == 0 {
return;
}
self.cmd_selected = (self.command_selected() as i32 + delta).rem_euclid(n) as usize;
}
pub fn accept_command(&mut self, run: bool) -> Result<()> {
let matches = self.command_matches();
let Some(idx) = matches
.get(self.command_selected())
.map(|_| self.command_selected())
else {
return Ok(());
};
let name = matches[idx].name().to_string();
self.cmd_selected = 0;
if run {
self.clear_input();
self.run_command(&name)?;
} else {
self.set_input(&format!("/{name} "));
}
Ok(())
}
fn at_query(&self) -> Option<(String, usize)> {
let text = self.input_text();
let cursor = self.input.cursor();
let pos = cursor.1;
let before = text.get(..pos)?;
let at = before.rfind('@')?;
let rest = &text[at + 1..pos];
if rest.contains(char::is_whitespace) || rest.contains('/') {
return None;
}
Some((rest.to_string(), at))
}
pub fn refresh_at_matches(&mut self) {
let Some((query, at_offset)) = self.at_query() else {
self.at_state = None;
return;
};
if query.is_empty() {
let all = self.files_cache.clone();
if all.is_empty() {
self.at_state = None;
return;
}
self.at_state = Some((all, 0, at_offset));
return;
}
let lower = query.to_lowercase();
let mut scored: Vec<(i32, &nexus_core::db::FileRow)> = self
.files_cache
.iter()
.filter_map(|f| {
let name_lower = f.name.to_lowercase();
let score = if name_lower.starts_with(&lower) {
100 - (name_lower.len() as i32)
} else if let Some(idx) = name_lower.find(&lower) {
50 - idx as i32
} else if nexus_core::app::fuzzy_score(&query, &f.name).is_some() {
10
} else {
return None;
};
Some((score, f))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.name.cmp(&b.1.name)));
let matches: Vec<nexus_core::db::FileRow> =
scored.into_iter().map(|(_, f)| f.clone()).collect();
if matches.is_empty() {
self.at_state = None;
return;
}
let selected = self
.at_state
.as_ref()
.map_or(0, |s| s.1.min(matches.len().saturating_sub(1)));
self.at_state = Some((matches, selected, at_offset));
}
pub fn accept_at_match(&mut self) {
let Some((ref matches, selected, at_offset)) = self.at_state.clone() else {
return;
};
let Some(f) = matches.get(selected) else {
return;
};
let text = self.input_text();
let cursor = self.input.cursor();
let pos = cursor.1;
let suffix = if pos < text.len() { &text[pos..] } else { "" };
self.set_input(&format!("{}{} {suffix}", &text[..at_offset], f.name));
self.at_state = None;
}
pub const fn move_at_selection(&mut self, delta: i32) {
let Some((ref matches, ref mut selected, _)) = self.at_state else {
return;
};
let n = matches.len() as i32;
if n == 0 {
return;
}
*selected = (*selected as i32 + delta).rem_euclid(n) as usize;
}
pub fn submit(&mut self) -> Result<()> {
let text = self.input_text();
self.clear_input();
self.sel.clear(); let text = text.trim();
if text.is_empty() {
return Ok(());
}
if let Some(cmd) = text.strip_prefix('/') {
self.run_command(cmd)?;
} else {
self.core.send_message(text.to_string())?;
}
Ok(())
}
pub fn run_command(&mut self, cmd: &str) -> Result<()> {
match self.core.parse_command(cmd) {
Ok(cmd) => self.execute(cmd),
Err(message) => {
self.push_status(message);
Ok(())
}
}
}
pub fn execute(&mut self, cmd: AppCommand) -> Result<()> {
match cmd {
AppCommand::Quit => self.should_quit = true,
AppCommand::OpenSessionPicker => self.open_session_picker()?,
AppCommand::OpenSpacePicker => {
self.open_space_picker()?;
}
AppCommand::OpenModelPicker => self.open_model_picker(),
AppCommand::OpenLogin => self.open_login_popup(),
AppCommand::OpenSwarm => self.open_swarm_popup(),
AppCommand::OpenSettings => self.open_settings(),
AppCommand::SetTheme { mode } => self.set_background_mode(&mode)?,
AppCommand::OpenCopyMenu => self.open_copy_menu(),
AppCommand::OpenSkills => self.open_skills_popup(),
AppCommand::OpenFiles { tab } => {
if self.core.incognito {
self.push_status("not available in incognito mode");
} else {
self.open_files_popup(tab);
}
}
AppCommand::OpenApps => {
if self.core.incognito {
self.push_status("apps not available in incognito mode");
} else {
self.open_apps_popup();
}
}
AppCommand::OpenUsage => self.open_usage_popup(),
AppCommand::Watch { topic } => {
if !self.core.is_research_session() {
self.push_status(
"watch is only available in research sessions — use /research first",
);
} else if let Some(t) = topic {
self.core.create_watch(&t);
} else {
self.open_watch_picker()?;
}
}
other => self.core.execute(other)?,
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use nexus_core::app::App;
use nexus_core::db::Db;
use nexus_core::space::Space;
fn test_app() -> AppView {
let db = Db::open_in_memory().unwrap();
let space = Space {
root: std::env::temp_dir().join(format!("nexus-input-test-{}", uuid::Uuid::new_v4())),
};
AppView::new(App::new(db, Some("k"), space))
}
#[test]
fn double_click_selects_word_and_remembers_anchor() {
let mut a = test_app();
a.set_input("foo bar baz");
a.input.move_cursor(CursorMove::Jump(0, 5)); a.select_composer_word();
assert!(a.input.is_selecting());
a.input.copy();
assert_eq!(a.input.yank_text(), "bar");
}
#[test]
fn triple_click_selects_whole_line() {
let mut a = test_app();
a.set_input("foo bar baz");
a.input.move_cursor(CursorMove::Jump(0, 5));
a.select_composer_line();
a.input.copy();
assert_eq!(a.input.yank_text(), "foo bar baz");
}
#[test]
fn drag_after_double_click_extends_word_by_word() {
let mut a = test_app();
a.set_input("foo bar baz qux");
a.input.move_cursor(CursorMove::Jump(0, 5)); a.select_composer_word();
a.input.move_cursor(CursorMove::Jump(0, 9)); a.extend_composer_word_selection();
a.input.copy();
assert_eq!(a.input.yank_text(), "bar baz");
}
#[test]
fn drag_before_double_click_anchor_extends_backward_word_by_word() {
let mut a = test_app();
a.set_input("foo bar baz qux");
a.input.move_cursor(CursorMove::Jump(0, 9)); a.select_composer_word();
a.input.move_cursor(CursorMove::Jump(0, 5)); a.extend_composer_word_selection();
a.input.copy();
assert_eq!(a.input.yank_text(), "bar baz");
}
#[test]
fn composer_click_down_counts_rapid_same_pos_clicks() {
let mut a = test_app();
assert_eq!(a.composer_click_down((5, 0)), 1);
assert_eq!(a.composer_click_down((5, 0)), 2);
assert_eq!(a.composer_click_down((5, 0)), 3);
assert_eq!(a.composer_click_down((6, 0)), 1);
}
#[test]
fn paste_inserts_into_composer_when_no_popup_open() {
let mut a = test_app();
a.set_input("hello ");
a.paste("world");
assert_eq!(a.input_text(), "hello world");
}
#[test]
fn paste_goes_into_key_popup_field() {
let mut a = test_app();
a.popup = Popup::Key;
a.paste("sk-or-abc123");
assert_eq!(a.key_input, "sk-or-abc123");
}
#[test]
fn paste_goes_into_files_add_field() {
let mut a = test_app();
a.popup = Popup::Files;
a.files_mode = nexus_core::app::FilesMode::Add;
a.paste("/tmp/report.pdf");
assert_eq!(a.files_edit, "/tmp/report.pdf");
}
#[test]
fn paste_in_picker_mode_feeds_the_filter() {
let mut a = test_app();
a.popup = Popup::Files;
a.files_mode = nexus_core::app::FilesMode::Pick;
a.paste("doc");
assert_eq!(a.picker_filter, "doc");
}
#[test]
fn paste_into_numeric_settings_field_filters_non_numeric_chars() {
let mut a = test_app();
a.popup = Popup::Settings;
a.settings_selected = 6; a.paste("0.7abc");
assert_eq!(a.settings_inputs[0], "0.7");
}
#[test]
fn paste_into_url_settings_field_keeps_full_text() {
let mut a = test_app();
a.popup = Popup::Settings;
a.settings_selected = 15; a.paste("http://localhost:8080");
assert_eq!(a.settings_inputs[4], "http://localhost:8080");
}
#[test]
fn pasting_a_file_path_offers_import() {
let mut a = test_app();
let src = std::env::temp_dir().join(format!("nexus-paste-{}.txt", uuid::Uuid::new_v4()));
std::fs::write(&src, "x").unwrap();
a.paste(&src.to_string_lossy());
assert_eq!(a.popup, Popup::Files);
assert!(a.files_mode == nexus_core::app::FilesMode::Add);
assert_eq!(a.files_edit, src.to_string_lossy());
assert!(a.input_text().is_empty());
let mut a = test_app();
a.paste(&format!("file://{}", src.to_string_lossy()));
assert_eq!(a.popup, Popup::Files);
let mut a = test_app();
a.paste("/not/a/real/path and some prose");
assert_eq!(a.popup, Popup::None);
assert_eq!(a.input_text(), "/not/a/real/path and some prose");
}
#[test]
fn submit_routes_slash_commands_through_run_command() {
let mut a = test_app();
a.set_input("/unknowncmd");
a.submit().unwrap();
assert!(a.last_status().contains("unknown command"));
assert!(a.session.is_none());
assert!(a.input_text().is_empty());
}
#[tokio::test]
async fn submit_clears_composer_and_sends() {
let mut a = test_app();
a.current_model = Some("a/one".into());
a.set_input("hello");
a.submit().unwrap();
assert!(a.session.is_some());
assert!(a.input_text().is_empty());
assert_eq!(a.core.messages.len(), 1);
}
#[tokio::test]
async fn run_command_executes_through_the_seam() {
let mut a = test_app();
a.run_command("model").unwrap();
assert_eq!(a.popup, Popup::Model);
a.run_command("theme opaque").unwrap();
assert_eq!(a.background_mode, crate::theme::BackgroundMode::Opaque);
assert!(
a.db.load_settings()
.unwrap()
.iter()
.any(|(key, value)| key == "ui_background" && value == "opaque")
);
a.run_command("quit").unwrap();
assert!(a.should_quit);
a.run_command("bogus").unwrap();
assert!(a.last_status().contains("unknown command"));
}
#[test]
fn accept_command_fill_vs_run() {
let mut a = test_app();
a.set_input("/hist");
a.accept_command(false).unwrap();
assert_eq!(a.input_text(), "/session ");
let mut b = test_app();
b.current_model = Some("a/one".into());
let space_id = b.active_space.id.clone();
b.session = Some(
b.db.create_session("old chat", "a/one", &space_id, "chat")
.unwrap(),
);
b.set_input("/clear");
b.accept_command(true).unwrap();
assert!(b.input_text().is_empty());
assert!(b.session.is_none()); }
#[test]
fn command_autocomplete_fuzzy_matches_names_aliases_and_desc() {
let mut a = test_app();
a.skills.clear();
a.set_input("/");
assert_eq!(a.command_matches().len(), nexus_core::app::COMMANDS.len());
a.set_input("/new foo");
assert!(a.command_matches().is_empty());
a.set_input("/history");
assert_eq!(a.command_matches()[0].name(), "session");
a.set_input("/stats");
assert_eq!(a.command_matches()[0].name(), "config");
a.set_input("/zzzz");
assert!(a.command_matches().is_empty());
}
}