From 3a6f31e861e8f56e417098c7af57d3eae76ced26 Mon Sep 17 00:00:00 2001 From: ai Date: Fri, 3 Apr 2026 08:51:57 +0000 Subject: [PATCH] add ais (rust fuzzy selector) and atproto plugin --- .zsh/plugin/ais.zsh | 42 +++++++ .zsh/plugin/atproto.zsh | 31 +++++ rust/ais/Cargo.toml | 11 ++ rust/ais/src/main.rs | 249 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 .zsh/plugin/ais.zsh create mode 100644 .zsh/plugin/atproto.zsh create mode 100644 rust/ais/Cargo.toml create mode 100644 rust/ais/src/main.rs diff --git a/.zsh/plugin/ais.zsh b/.zsh/plugin/ais.zsh new file mode 100644 index 0000000..103fef2 --- /dev/null +++ b/.zsh/plugin/ais.zsh @@ -0,0 +1,42 @@ +# ais - ai selector (Rust) +# unified fuzzy finder replacing cdselect/fileselect/histselect +# requires: ais binary in PATH + +if ! command -v ais &>/dev/null; then + return +fi + +# C-j: directory selector +ais-cd() { + local dir=$(ais cd) + if [[ -n "$dir" ]]; then + cd "$dir" + fi + zle reset-prompt +} +zle -N ais-cd +bindkey '^j' ais-cd + +# C-f: file selector -> insert into buffer +ais-file() { + local file=$(ais file) + if [[ -n "$file" ]]; then + BUFFER+="$file" + CURSOR=${#BUFFER} + fi + zle reset-prompt +} +zle -N ais-file +bindkey '^f' ais-file + +# C-r: history selector -> insert into buffer +ais-hist() { + local cmd=$(ais hist) + if [[ -n "$cmd" ]]; then + BUFFER="$cmd" + CURSOR=${#BUFFER} + fi + zle reset-prompt +} +zle -N ais-hist +bindkey '^r' ais-hist diff --git a/.zsh/plugin/atproto.zsh b/.zsh/plugin/atproto.zsh new file mode 100644 index 0000000..77a715c --- /dev/null +++ b/.zsh/plugin/atproto.zsh @@ -0,0 +1,31 @@ +# atproto shell integration +# quick commands for atproto/ailog + +if ! command -v ailog &>/dev/null; then + return +fi + +# at: quick post +at() { + if [[ -z "$1" ]]; then + echo "usage: at " + return 1 + fi + ailog reply "$*" 2>/dev/null +} + +# atn: check notifications +atn() { + local count=$(ailog notify count 2>/dev/null | jq -r '.count // 0' 2>/dev/null) + if [[ "$count" -gt 0 ]]; then + echo "\e[33m${count}\e[0m notifications" + ailog notify list 2>/dev/null | jq -r '.notifications[:5][] | "\(.reason) from @\(.author.handle // "unknown")"' 2>/dev/null + else + echo "no notifications" + fi +} + +# atw: who am i +atw() { + ailog pds s 2>/dev/null | jq -r '"@\(.handle) on \(.pds)"' 2>/dev/null +} diff --git a/rust/ais/Cargo.toml b/rust/ais/Cargo.toml new file mode 100644 index 0000000..7f346d8 --- /dev/null +++ b/rust/ais/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ais" +version = "0.1.0" +edition = "2021" +description = "ai selector - unified fuzzy finder for shell" + +[dependencies] + +[[bin]] +name = "ais" +path = "src/main.rs" diff --git a/rust/ais/src/main.rs b/rust/ais/src/main.rs new file mode 100644 index 0000000..ed28a58 --- /dev/null +++ b/rust/ais/src/main.rs @@ -0,0 +1,249 @@ +// ais - ai selector +// unified fuzzy finder: directories, files, history +// usage: ais [cd|file|hist] or pipe: echo "a\nb" | ais + +use std::collections::HashSet; +use std::env; +use std::io::{Read, Write, stdout, stdin}; +use std::process::Command; + +fn main() { + let args: Vec = env::args().collect(); + let mode = args.get(1).map(|s| s.as_str()).unwrap_or("cd"); + + let items = if !is_tty_stdin() { + read_stdin_lines() + } else { + match mode { + "cd" => get_dirs(), + "file" | "f" => get_files(), + "hist" | "h" => get_history(), + _ => { + eprintln!("usage: ais [cd|file|hist]"); + return; + } + } + }; + + if items.is_empty() { + return; + } + + if let Some(selected) = fuzzy_select(&items, mode) { + print!("{}", selected); + } +} + +fn is_tty_stdin() -> bool { + extern "C" { + fn isatty(fd: i32) -> i32; + } + unsafe { isatty(0) != 0 } +} + +fn get_dirs() -> Vec { + let home = env::var("HOME").unwrap_or_default(); + let mut dirs = vec![]; + + // try zsh recent-dirs + let chpwd = format!("{}/.local/share/zsh/recent-dirs", home); + if let Ok(content) = std::fs::read_to_string(&chpwd) { + for line in content.lines() { + let path = line.trim().replace("$'", "").replace('\'', ""); + if !path.is_empty() && std::path::Path::new(&path).is_dir() { + dirs.push(path); + } + } + } + + // fallback: home subdirs + if dirs.is_empty() { + if let Ok(entries) = std::fs::read_dir(&home) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with('.') { + dirs.push(entry.path().to_string_lossy().to_string()); + } + } + } + } + dirs.sort(); + } + dirs +} + +fn get_files() -> Vec { + let output = Command::new("find") + .args([".", "-maxdepth", "3", "-not", "-path", "*/.git/*", "-type", "f"]) + .output(); + + match output { + Ok(out) => String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.strip_prefix("./").unwrap_or(l).to_string()) + .filter(|l| !l.is_empty()) + .collect(), + Err(_) => vec![], + } +} + +fn get_history() -> Vec { + let home = env::var("HOME").unwrap_or_default(); + let histfile = format!("{}/.zsh_history", home); + + match std::fs::read(&histfile) { + Ok(bytes) => { + let content = String::from_utf8_lossy(&bytes); + let mut seen = HashSet::new(); + let mut lines = vec![]; + for line in content.lines().rev() { + let cmd = if let Some(pos) = line.find(';') { + &line[pos + 1..] + } else { + line + }; + let cmd = cmd.trim().to_string(); + if !cmd.is_empty() && seen.insert(cmd.clone()) { + lines.push(cmd); + } + } + lines + } + Err(_) => vec![], + } +} + +fn read_stdin_lines() -> Vec { + let mut buf = String::new(); + let _ = stdin().read_to_string(&mut buf); + buf.lines() + .map(|l| l.to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + +fn fuzzy_match(item: &str, query: &str) -> bool { + if query.is_empty() { + return true; + } + let lower = item.to_lowercase(); + query + .split_whitespace() + .all(|w| lower.contains(&w.to_lowercase())) +} + +fn fuzzy_select(items: &[String], label: &str) -> Option { + let mut query = String::new(); + let mut cursor: usize = 0; + + // save terminal state and enter raw mode + let _ = Command::new("stty") + .args(["-echo", "raw"]) + .stdin(std::process::Stdio::inherit()) + .status(); + + let result = run_select_loop(items, label, &mut query, &mut cursor); + + // restore terminal + let _ = Command::new("stty") + .args(["echo", "-raw"]) + .stdin(std::process::Stdio::inherit()) + .status(); + print!("\x1b[2J\x1b[H"); + let _ = stdout().flush(); + + result +} + +fn run_select_loop( + items: &[String], + label: &str, + query: &mut String, + cursor: &mut usize, +) -> Option { + loop { + let filtered: Vec<&String> = items.iter().filter(|i| fuzzy_match(i, query)).collect(); + + if filtered.is_empty() { + *cursor = 0; + } else if *cursor >= filtered.len() { + *cursor = filtered.len() - 1; + } + + // draw + let mut out = stdout(); + let _ = write!(out, "\x1b[2J\x1b[H"); + let _ = write!(out, "\x1b[33m{}\x1b[0m> {}\r\n", label, query); + let _ = write!( + out, + "\x1b[90m{} items\x1b[0m\r\n", + filtered.len() + ); + + let max_show = 20; + for (i, item) in filtered.iter().enumerate().take(max_show) { + if i == *cursor { + let _ = write!(out, "\x1b[7m {}\x1b[0m\r\n", item); + } else { + let _ = write!(out, " {}\r\n", item); + } + } + if filtered.len() > max_show { + let _ = write!( + out, + " \x1b[90m+{} more\x1b[0m\r\n", + filtered.len() - max_show + ); + } + let _ = out.flush(); + + // read key from /dev/tty (works even when stdin is piped) + let mut tty = match std::fs::File::open("/dev/tty") { + Ok(f) => f, + Err(_) => return None, + }; + let mut buf = [0u8; 3]; + let n = tty.read(&mut buf).unwrap_or(0); + if n == 0 { + return None; + } + + match buf[0] { + b'\r' | b'\n' => { + return filtered.get(*cursor).map(|s| s.to_string()); + } + b'\x1b' => { + if n >= 3 && buf[1] == b'[' { + match buf[2] { + b'A' => { + if *cursor > 0 { + *cursor -= 1; + } + } + b'B' => *cursor += 1, + _ => {} + } + } else { + return None; + } + } + b'\x0e' => *cursor += 1, // C-n + b'\x10' => { // C-p + if *cursor > 0 { + *cursor -= 1; + } + } + b'\x03' => return None, // C-c + b'\x7f' | b'\x08' => { // backspace + query.pop(); + *cursor = 0; + } + c if c >= b' ' && c <= b'~' => { // printable + query.push(c as char); + *cursor = 0; + } + _ => {} + } + } +}