add ais (rust fuzzy selector) and atproto plugin
This commit is contained in:
42
.zsh/plugin/ais.zsh
Normal file
42
.zsh/plugin/ais.zsh
Normal file
@@ -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
|
||||||
31
.zsh/plugin/atproto.zsh
Normal file
31
.zsh/plugin/atproto.zsh
Normal file
@@ -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 <text>"
|
||||||
|
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
|
||||||
|
}
|
||||||
11
rust/ais/Cargo.toml
Normal file
11
rust/ais/Cargo.toml
Normal file
@@ -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"
|
||||||
249
rust/ais/src/main.rs
Normal file
249
rust/ais/src/main.rs
Normal 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;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user