#![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 crate::app::App;
const COMPOSER_MULTI_CLICK: Duration = Duration::from_millis(400);
pub struct Command {
pub name: &'static str,
pub desc: &'static str,
pub aliases: &'static [&'static str],
}
pub enum Match {
Builtin(&'static Command),
}
impl Match {
pub const fn name(&self) -> &str {
match self {
Self::Builtin(c) => c.name,
}
}
pub const fn desc(&self) -> &str {
match self {
Self::Builtin(c) => c.desc,
}
}
}
pub const COMMANDS: &[Command] = &[
Command {
name: "new",
desc: "start new chat",
aliases: &["chat", "clear"],
},
Command {
name: "compact",
desc: "summarize old messages",
aliases: &["compaction", "summarize"],
},
Command {
name: "session",
desc: "switch sessions",
aliases: &["sessions", "history", "resume", "continue", "switch"],
},
Command {
name: "space",
desc: "switch spaces",
aliases: &["spaces", "project", "workspace"],
},
Command {
name: "model",
desc: "pick a model",
aliases: &["models", "llm"],
},
Command {
name: "login",
desc: "pick a backend to log into",
aliases: &[
"key",
"apikey",
"token",
"auth",
"codex",
"subscription",
"oauth",
"chatgpt",
"opencode",
],
},
Command {
name: "swarm",
desc: "multi-persona roundtable roster",
aliases: &["swarms", "personas", "panel"],
},
Command {
name: "config",
desc: "settings & stats",
aliases: &["settings", "stats", "nerd", "params"],
},
Command {
name: "skills",
desc: "manage skills",
aliases: &["addskill"],
},
Command {
name: "files",
desc: "browse space files / images / scripts",
aliases: &[
"file", "attach", "upload", "docs", "image", "images", "img", "pictures", "script",
"scripts",
],
},
Command {
name: "apps",
desc: "view space apps",
aliases: &["app", "webapps"],
},
Command {
name: "research",
desc: "deep multi-agent research (blank = scope topic from this chat)",
aliases: &["deep-research"],
},
Command {
name: "export",
desc: "write session's report + sources to a file",
aliases: &["save-report"],
},
Command {
name: "watch",
desc: "standing research, re-runs every 24h",
aliases: &["watches"],
},
Command {
name: "usage",
desc: "token/cache/cost analytics by backend and model",
aliases: &["analytics", "costs", "billing"],
},
Command {
name: "web",
desc: "toggle web answer mode (search-first, cited)",
aliases: &["websearch"],
},
Command {
name: "incognito",
desc: "toggle incognito (no persistence, no apps)",
aliases: &["private", "anon"],
},
Command {
name: "copy",
desc: "copy last reply",
aliases: &["yank", "clip"],
},
Command {
name: "quit",
desc: "exit the app",
aliases: &["q", "exit"],
},
];
pub fn fuzzy_score(hay: &str, needle: &str) -> Option<i32> {
let hay = hay.to_lowercase();
let needle = needle.to_lowercase();
let mut chars = hay.chars();
let mut score = 0i32;
let mut prev_matched = false;
let mut pos = 0i32;
for nc in needle.chars() {
loop {
let hc = chars.next()?;
if hc == nc {
score += 1;
if prev_matched {
score += 2;
}
if pos == 0 {
score += 3;
}
prev_matched = true;
pos += 1;
break;
}
prev_matched = false;
pos += 1;
}
}
Some(score)
}
fn command_score(c: &Command, needle: &str) -> Option<i32> {
if needle.is_empty() {
return Some(0);
}
let mut best: Option<i32> = None;
let mut upd = |s: &str, bonus: i32| {
if let Some(sc) = fuzzy_score(s, needle) {
let v = sc + bonus;
best = Some(best.map_or(v, |b| b.max(v)));
}
};
upd(c.name, 100);
for a in c.aliases {
upd(a, 50);
}
upd(c.desc, 0);
best
}
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 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 App {
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(crate) 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.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.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 crate::app::{AppsMode, Popup, 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(crate::app::FilesTab::Files);
self.start_files_add();
self.files_edit = path.to_string_lossy().to_string();
self.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 == crate::app::FilesMode::Add => {
self.files_edit.push_str(text);
}
Popup::Files
if self.files_tab == crate::app::FilesTab::Scripts
&& self.scripts_mode == crate::app::ScriptsMode::Create =>
{
self.scripts_edit.push_str(text);
}
Popup::Files if self.files_mode == crate::app::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 crate::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 == crate::app::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) {
self.input.insert_str(&md);
self.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)> = COMMANDS
.iter()
.filter_map(|c| 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, &crate::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 crate::input::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<crate::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;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
use crate::space::Space;
fn test_app() -> App {
let db = Db::open_in_memory().unwrap();
let space = Space {
root: std::env::temp_dir().join(format!("nexus-input-test-{}", uuid::Uuid::new_v4())),
};
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 = crate::app::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 = crate::app::Popup::Files;
a.files_mode = crate::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 = crate::app::Popup::Files;
a.files_mode = crate::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 = crate::app::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 = crate::app::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!(a.popup == crate::app::Popup::Files);
assert!(a.files_mode == crate::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!(a.popup == crate::app::Popup::Files);
let mut a = test_app();
a.paste("/not/a/real/path and some prose");
assert!(a.popup == crate::app::Popup::None);
assert_eq!(a.input_text(), "/not/a/real/path and some prose");
}
}