522 lines
16 KiB
Rust
522 lines
16 KiB
Rust
mod handler;
|
|
mod hook;
|
|
|
|
use std::io::{BufReader, IsTerminal, Write};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use clap::Parser;
|
|
|
|
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
|
|
use pikl_core::error::PiklError;
|
|
use pikl_core::event::{Action, MenuResult, Mode};
|
|
use pikl_core::format::FormatTemplate;
|
|
use pikl_core::hook::{HookEventKind, HookHandler};
|
|
use pikl_core::input::read_items_sync;
|
|
use pikl_core::item::Item;
|
|
use pikl_core::json_menu::JsonMenu;
|
|
use pikl_core::menu::MenuRunner;
|
|
use pikl_core::output::{OutputAction, OutputItem};
|
|
use pikl_core::script::action_fd::{self, ScriptAction, ShowAction};
|
|
use serde_json::Value;
|
|
|
|
use handler::ShellHandlerHook;
|
|
use hook::ShellExecHandler;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "pikl",
|
|
about = "Keyboard-driven streaming menu. Pipe stuff in, pick stuff out."
|
|
)]
|
|
struct Cli {
|
|
/// JSON key to use as the display label for object items
|
|
#[arg(long, default_value = "label")]
|
|
label_key: String,
|
|
|
|
// -- Exec hooks (fire-and-forget subprocess per event) --
|
|
/// Shell command to run on menu open
|
|
#[arg(long)]
|
|
on_open_exec: Option<String>,
|
|
|
|
/// Shell command to run on menu close
|
|
#[arg(long)]
|
|
on_close_exec: Option<String>,
|
|
|
|
/// Shell command to run on cursor hover (item JSON on stdin)
|
|
#[arg(long)]
|
|
on_hover_exec: Option<String>,
|
|
|
|
/// Shell command to run on selection (item JSON on stdin)
|
|
#[arg(long)]
|
|
on_select_exec: Option<String>,
|
|
|
|
/// Shell command to run on cancel
|
|
#[arg(long)]
|
|
on_cancel_exec: Option<String>,
|
|
|
|
/// Shell command to run on filter change
|
|
#[arg(long)]
|
|
on_filter_exec: Option<String>,
|
|
|
|
// -- Handler hooks (persistent bidirectional process) --
|
|
/// Handler hook command for open events
|
|
#[arg(long)]
|
|
on_open: Option<String>,
|
|
|
|
/// Handler hook command for close events
|
|
#[arg(long)]
|
|
on_close: Option<String>,
|
|
|
|
/// Handler hook command for hover events
|
|
#[arg(long)]
|
|
on_hover: Option<String>,
|
|
|
|
/// Handler hook command for select events
|
|
#[arg(long)]
|
|
on_select: Option<String>,
|
|
|
|
/// Handler hook command for cancel events
|
|
#[arg(long)]
|
|
on_cancel: Option<String>,
|
|
|
|
/// Handler hook command for filter events
|
|
#[arg(long)]
|
|
on_filter: Option<String>,
|
|
|
|
// -- Debounce flags --
|
|
/// Debounce delay in ms for hover hooks (default: 200)
|
|
#[arg(long, value_name = "MS")]
|
|
on_hover_debounce: Option<u64>,
|
|
|
|
/// Cancel in-flight hover hooks when a new hover fires
|
|
#[arg(long)]
|
|
on_hover_cancel_stale: bool,
|
|
|
|
/// Debounce delay in ms for filter hooks (default: 200)
|
|
#[arg(long, value_name = "MS")]
|
|
on_filter_debounce: Option<u64>,
|
|
|
|
/// Read action script from this file descriptor (enables headless mode)
|
|
#[arg(long, value_name = "FD")]
|
|
action_fd: Option<i32>,
|
|
|
|
/// Timeout in seconds for reading stdin (default: 30 with --action-fd, 0 otherwise)
|
|
#[arg(long, value_name = "SECONDS")]
|
|
stdin_timeout: Option<u64>,
|
|
|
|
/// Start in this input mode (insert or normal, default: insert)
|
|
#[arg(long, value_name = "MODE", default_value = "insert")]
|
|
start_mode: String,
|
|
|
|
/// Comma-separated list of fields to search during filtering
|
|
#[arg(long, value_name = "FIELDS", value_delimiter = ',')]
|
|
filter_fields: Option<Vec<String>>,
|
|
|
|
/// Format template for display text (e.g. "{label} - {sublabel}")
|
|
#[arg(long, value_name = "TEMPLATE")]
|
|
format: Option<String>,
|
|
|
|
/// Wrap output in structured JSON with action metadata
|
|
#[arg(long)]
|
|
structured: bool,
|
|
}
|
|
|
|
fn main() {
|
|
// Initialize tracing from RUST_LOG env var
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Install a panic hook that restores the terminal so a crash
|
|
// doesn't leave the user staring at a broken shell.
|
|
let default_hook = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
pikl_tui::restore_terminal();
|
|
default_hook(info);
|
|
}));
|
|
|
|
// STEP 1: If action-fd, load + validate script FIRST (fail fast on bad scripts)
|
|
let script: Option<Vec<ScriptAction>> = match cli.action_fd {
|
|
Some(fd) => {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::io::FromRawFd;
|
|
let file = unsafe { std::fs::File::from_raw_fd(fd) };
|
|
match action_fd::load_script(BufReader::new(file)) {
|
|
Ok(s) => Some(s),
|
|
Err(e) => {
|
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pikl: --action-fd is only supported on unix"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
// STEP 2: Read stdin items (terminal check only when NOT in headless mode)
|
|
if script.is_none() && std::io::stdin().is_terminal() {
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pikl: no input. pipe something in (e.g. ls | pikl)"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
|
|
let timeout = cli
|
|
.stdin_timeout
|
|
.unwrap_or(if script.is_some() { 30 } else { 0 });
|
|
|
|
let items = match read_stdin_with_timeout(timeout, &cli.label_key) {
|
|
Ok(items) => items,
|
|
Err(e) => {
|
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
};
|
|
|
|
if items.is_empty() {
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pikl: empty input. nothing to pick from"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
|
|
// STEP 3: Build menu, start runtime
|
|
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pikl: failed to start runtime: {e}"
|
|
);
|
|
std::process::exit(2);
|
|
});
|
|
|
|
// Reopen stdin from /dev/tty before entering async context.
|
|
if script.is_none()
|
|
&& let Err(e) = reopen_stdin_from_tty()
|
|
{
|
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
|
|
// Parse start mode
|
|
let start_mode = match cli.start_mode.as_str() {
|
|
"insert" => Mode::Insert,
|
|
"normal" => Mode::Normal,
|
|
other => {
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pikl: unknown mode '{other}', expected insert or normal"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
};
|
|
|
|
// STEP 4: Branch on headless vs interactive
|
|
let result = if let Some(script) = script {
|
|
rt.block_on(run_headless(items, &cli, script, start_mode))
|
|
} else {
|
|
rt.block_on(run_interactive(items, &cli, start_mode))
|
|
};
|
|
|
|
// STEP 5: Handle result
|
|
handle_result(result, &cli);
|
|
}
|
|
|
|
/// Build a JsonMenu with optional filter_fields and format template.
|
|
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
|
|
let mut menu = JsonMenu::new(items, cli.label_key.clone());
|
|
if let Some(ref fields) = cli.filter_fields {
|
|
menu.set_filter_fields(fields.clone());
|
|
}
|
|
if let Some(ref template) = cli.format {
|
|
menu.set_format_template(FormatTemplate::parse(template));
|
|
}
|
|
menu
|
|
}
|
|
|
|
/// Build the composite hook handler from CLI flags, if any hooks are specified.
|
|
fn build_hook_handler(
|
|
cli: &Cli,
|
|
action_tx: &tokio::sync::mpsc::Sender<Action>,
|
|
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
|
|
let exec_handler = ShellExecHandler::from_cli(cli);
|
|
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
|
|
|
|
let has_exec = exec_handler.has_hooks();
|
|
let has_handler = handler_hook.is_some();
|
|
|
|
if !has_exec && !has_handler {
|
|
return None;
|
|
}
|
|
|
|
let handler: Arc<dyn HookHandler> = if has_exec && has_handler {
|
|
Arc::new(CompositeHookHandler {
|
|
exec: exec_handler,
|
|
handler: handler_hook,
|
|
})
|
|
} else if has_handler {
|
|
if let Some(h) = handler_hook {
|
|
Arc::new(h)
|
|
} else {
|
|
Arc::new(exec_handler)
|
|
}
|
|
} else {
|
|
Arc::new(exec_handler)
|
|
};
|
|
|
|
let mut dispatcher = DebouncedDispatcher::new(Arc::clone(&handler), action_tx.clone());
|
|
dispatcher.apply_defaults();
|
|
|
|
// Apply custom debounce settings
|
|
if let Some(ms) = cli.on_hover_debounce {
|
|
let mode = if cli.on_hover_cancel_stale {
|
|
DebounceMode::DebounceAndCancelStale(Duration::from_millis(ms))
|
|
} else {
|
|
DebounceMode::Debounce(Duration::from_millis(ms))
|
|
};
|
|
dispatcher.set_mode(HookEventKind::Hover, mode);
|
|
} else if cli.on_hover_cancel_stale {
|
|
dispatcher.set_mode(HookEventKind::Hover, DebounceMode::CancelStale);
|
|
}
|
|
|
|
if let Some(ms) = cli.on_filter_debounce {
|
|
dispatcher.set_mode(
|
|
HookEventKind::Filter,
|
|
DebounceMode::Debounce(Duration::from_millis(ms)),
|
|
);
|
|
}
|
|
|
|
Some((handler, dispatcher))
|
|
}
|
|
|
|
/// Composite handler that delegates to exec and handler hooks
|
|
/// based on whether they have a command for the event kind.
|
|
struct CompositeHookHandler {
|
|
exec: ShellExecHandler,
|
|
handler: Option<ShellHandlerHook>,
|
|
}
|
|
|
|
impl HookHandler for CompositeHookHandler {
|
|
fn handle(
|
|
&self,
|
|
event: pikl_core::hook::HookEvent,
|
|
) -> Result<(), PiklError> {
|
|
// Both fire. Exec is fire-and-forget, handler may
|
|
// send responses through action_tx.
|
|
let _ = self.exec.handle(event.clone());
|
|
if let Some(ref h) = self.handler {
|
|
h.handle(event)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Run in headless mode: replay a script, optionally hand
|
|
/// off to a TUI if the script ends with show-ui/show-tui/show-gui.
|
|
async fn run_headless(
|
|
items: Vec<Item>,
|
|
cli: &Cli,
|
|
script: Vec<ScriptAction>,
|
|
start_mode: Mode,
|
|
) -> Result<MenuResult, PiklError> {
|
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
|
menu.set_initial_mode(start_mode);
|
|
|
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
|
menu.set_dispatcher(dispatcher);
|
|
}
|
|
|
|
let event_rx = menu.subscribe();
|
|
|
|
// Default headless viewport
|
|
let _ = action_tx.send(Action::Resize { height: 50 }).await;
|
|
|
|
let menu_handle = tokio::spawn(menu.run());
|
|
|
|
// Run script. May return a Show* action.
|
|
let show = action_fd::run_script(script, &action_tx).await?;
|
|
|
|
if let Some(show_action) = show {
|
|
// Hand off to interactive frontend
|
|
reopen_stdin_from_tty()?;
|
|
|
|
match show_action {
|
|
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
|
|
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
|
|
let result = menu_handle
|
|
.await
|
|
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??;
|
|
let _ = tui_handle.await;
|
|
Ok(result)
|
|
}
|
|
}
|
|
} else {
|
|
// No show-ui. Drop sender, let menu finish.
|
|
drop(action_tx);
|
|
menu_handle
|
|
.await
|
|
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))?
|
|
}
|
|
}
|
|
|
|
/// Run in interactive mode: start the TUI and let the user
|
|
/// pick from the menu.
|
|
async fn run_interactive(
|
|
items: Vec<Item>,
|
|
cli: &Cli,
|
|
start_mode: Mode,
|
|
) -> Result<MenuResult, PiklError> {
|
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
|
menu.set_initial_mode(start_mode);
|
|
|
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
|
menu.set_dispatcher(dispatcher);
|
|
}
|
|
|
|
let event_rx = menu.subscribe();
|
|
|
|
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
|
|
let signal_tx = action_tx.clone();
|
|
tokio::spawn(async move {
|
|
if let Ok(()) = tokio::signal::ctrl_c().await {
|
|
pikl_tui::restore_terminal();
|
|
let _ = signal_tx.send(Action::Cancel).await;
|
|
}
|
|
});
|
|
|
|
let tui_handle = tokio::spawn(async move { pikl_tui::run(action_tx, event_rx).await });
|
|
|
|
let result = menu.run().await;
|
|
|
|
let _ = tui_handle.await;
|
|
result
|
|
}
|
|
|
|
/// Process the menu result: print output to stdout and
|
|
/// exit with the appropriate code.
|
|
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
|
let mut out = std::io::stdout().lock();
|
|
match result {
|
|
Ok(MenuResult::Selected { value, index }) => {
|
|
if cli.structured {
|
|
let output = OutputItem {
|
|
value,
|
|
action: OutputAction::Select,
|
|
index,
|
|
};
|
|
let _ = write_output_json(&mut out, &output);
|
|
} else {
|
|
let _ = write_plain_value(&mut out, &value);
|
|
}
|
|
}
|
|
Ok(MenuResult::Quicklist { items }) => {
|
|
if cli.structured {
|
|
for (value, index) in items {
|
|
let output = OutputItem {
|
|
value,
|
|
action: OutputAction::Quicklist,
|
|
index,
|
|
};
|
|
let _ = write_output_json(&mut out, &output);
|
|
}
|
|
} else {
|
|
for (value, _) in &items {
|
|
let _ = write_plain_value(&mut out, value);
|
|
}
|
|
}
|
|
}
|
|
Ok(MenuResult::Cancelled) => {
|
|
if cli.structured {
|
|
let output = OutputItem {
|
|
value: Value::Null,
|
|
action: OutputAction::Cancel,
|
|
index: 0,
|
|
};
|
|
let _ = write_output_json(&mut out, &output);
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Serialize an OutputItem as JSON and write it to the given writer.
|
|
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
|
|
let json = serde_json::to_string(output).unwrap_or_default();
|
|
writeln!(writer, "{json}")
|
|
}
|
|
|
|
/// Write a plain value: strings without quotes, everything
|
|
/// else as JSON.
|
|
fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::io::Error> {
|
|
match value {
|
|
Value::String(s) => writeln!(writer, "{s}"),
|
|
_ => {
|
|
let json = serde_json::to_string(value).unwrap_or_default();
|
|
writeln!(writer, "{json}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read items from stdin. If `timeout_secs` is non-zero,
|
|
/// spawn a thread and bail if it doesn't finish in time.
|
|
/// A timeout of 0 means no timeout (blocking read).
|
|
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> {
|
|
if timeout_secs == 0 {
|
|
return read_items_sync(std::io::stdin().lock(), label_key);
|
|
}
|
|
|
|
let label_key = label_key.to_string();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send(read_items_sync(std::io::stdin().lock(), &label_key));
|
|
});
|
|
|
|
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
|
|
Ok(result) => result,
|
|
Err(_) => {
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pikl: timed out reading stdin ({timeout_secs}s)"
|
|
);
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
" = help: use --stdin-timeout to increase or set to 0 to disable"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Redirect stdin to `/dev/tty` so the TUI can read keyboard
|
|
/// input after stdin was consumed for piped items. Flushes
|
|
/// stale input so crossterm starts clean.
|
|
fn reopen_stdin_from_tty() -> Result<(), PiklError> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::io::AsRawFd;
|
|
let tty = std::fs::File::open("/dev/tty")?;
|
|
let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
|
|
if r < 0 {
|
|
return Err(PiklError::Io(std::io::Error::last_os_error()));
|
|
}
|
|
unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
|
|
}
|
|
Ok(())
|
|
}
|