Files
pikl/crates/pikl/src/main.rs

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(())
}