2
0

add ais (rust fuzzy selector) and atproto plugin

This commit is contained in:
ai
2026-04-03 08:51:57 +00:00
parent 67c20e9dab
commit 3a6f31e861
4 changed files with 333 additions and 0 deletions

249
rust/ais/src/main.rs Normal file
View File

@@ -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<String> = 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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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;
}
_ => {}
}
}
}