feat: Expand hook system to handle simple exec and plugin extensibility.

Item Model Expansion - Item now caches sublabel, icon, group with accessors. Added resolve_field_path() for dotted path traversal and field_value() on Item.
Output Struct - New OutputItem with OutputAction (select/cancel) and index. Object values flatten, strings get a value field. MenuResult::Selected now carries { value, index }.
Hook Types - Replaced the old Hook trait with HookEvent (serializable, 6 variants), HookResponse (deserializable, 5 commands), HookHandler trait (sync for dyn-compatibility), and parse_hook_response() with tracing warnings.
New Actions & Menu Methods - Added ReplaceItems, RemoveItems, ProcessHookResponse, CloseMenu actions. Menu trait gained original_index(), replace_all(), remove_by_indices(), formatted_label(). Pipeline got rebuild() and rebuild_with_values(). Smart cursor preservation on replace.
Lifecycle Events - MenuRunner emits Open, Close, Hover, Select, Cancel, Filter events through the dispatcher. Cursor tracking for Hover detection.
Debounce - DebouncedDispatcher with 4 modes: None, Debounce, CancelStale, DebounceAndCancelStale. Defaults: hover=DebounceAndCancelStale(200ms), filter=Debounce(200ms).
Exec Hooks - ShellExecHandler maps --on-{open,close,hover,select,cancel,filter}-exec flags to fire-and-forget subprocesses. Event JSON piped to stdin.
Handler Hooks - ShellHandlerHook launches persistent processes per --on-{event} flag. Bidirectional JSON lines: events on stdin, responses on stdout flowing back through Action::ProcessHookResponse. CompositeHookHandler dispatches to both.
--filter-fields - --filter-fields label,sublabel,meta.tags searches multiple fields. Combined text for fuzzy, individual for exact/regex.
--format - FormatTemplate parses {field.path} placeholders. --format '{label} - {sublabel}' controls display. TUI renders formatted_text when available.
Field Filters - meta.res:3840 in query syntax matches specific fields. !meta.res:3840 for inverse. Pipeline stores item Values for field resolution. Requires dotted path (single word colons stay fuzzy).
This commit is contained in:
2026-03-14 01:33:41 -04:00
parent 7082ceada0
commit 8bf3366740
27 changed files with 2548 additions and 274 deletions

View File

@@ -85,7 +85,7 @@ file_picker() {
on_select_hook() {
printf "one\ntwo\nthree\nfour\nfive\n" \
| pikl --on-select 'echo "you picked: $(cat)"'
| pikl --on-select-exec 'echo "you picked: $(cat)" >&2'
}
mixed_input() {
@@ -97,6 +97,184 @@ another plain string
ITEMS
}
# ── Phase 3 demos ────────────────────────────────────────
structured_items() {
echo "Items have sublabel, icon, and metadata fields." >&2
echo "The output JSON includes action and index context." >&2
echo "" >&2
cat <<'ITEMS' | pikl
{"label": "Firefox", "sublabel": "Web Browser", "icon": "firefox", "meta": {"version": "125", "type": "browser"}}
{"label": "Neovim", "sublabel": "Text Editor", "icon": "nvim", "meta": {"version": "0.10", "type": "editor"}}
{"label": "Alacritty", "sublabel": "Terminal Emulator", "icon": "alacritty", "meta": {"version": "0.13", "type": "terminal"}}
{"label": "Thunar", "sublabel": "File Manager", "icon": "thunar", "meta": {"version": "4.18", "type": "filemanager"}}
{"label": "mpv", "sublabel": "Media Player", "icon": "mpv", "meta": {"version": "0.37", "type": "media"}}
ITEMS
}
format_template() {
echo "Using --format to control display." >&2
echo "Template: '{label} ({sublabel}) v{meta.version}'" >&2
echo "" >&2
cat <<'ITEMS' | pikl --format '{label} ({sublabel}) v{meta.version}'
{"label": "Firefox", "sublabel": "Web Browser", "meta": {"version": "125"}}
{"label": "Neovim", "sublabel": "Text Editor", "meta": {"version": "0.10"}}
{"label": "Alacritty", "sublabel": "Terminal Emulator", "meta": {"version": "0.13"}}
{"label": "Thunar", "sublabel": "File Manager", "meta": {"version": "4.18"}}
{"label": "mpv", "sublabel": "Media Player", "meta": {"version": "0.37"}}
ITEMS
}
filter_fields_demo() {
echo "Using --filter-fields to search sublabel and meta fields." >&2
echo "Try typing 'browser' or 'editor' to filter by sublabel." >&2
echo "" >&2
cat <<'ITEMS' | pikl --filter-fields label,sublabel --format '{label} - {sublabel}'
{"label": "Firefox", "sublabel": "Web Browser"}
{"label": "Neovim", "sublabel": "Text Editor"}
{"label": "Alacritty", "sublabel": "Terminal Emulator"}
{"label": "Thunar", "sublabel": "File Manager"}
{"label": "mpv", "sublabel": "Media Player"}
{"label": "GIMP", "sublabel": "Image Editor"}
{"label": "Inkscape", "sublabel": "Vector Graphics Editor"}
ITEMS
}
field_filter_demo() {
echo "Field filters in the query: type meta.type:browser to match a field." >&2
echo "Try: meta.type:browser or meta.type:editor or !meta.type:browser" >&2
echo "" >&2
cat <<'ITEMS' | pikl --format '{label} [{meta.type}] {meta.res}'
{"label": "Firefox", "meta": {"type": "browser", "res": "n/a"}}
{"label": "Chrome", "meta": {"type": "browser", "res": "n/a"}}
{"label": "Neovim", "meta": {"type": "editor", "res": "n/a"}}
{"label": "Helix", "meta": {"type": "editor", "res": "n/a"}}
{"label": "Alacritty", "meta": {"type": "terminal", "res": "n/a"}}
{"label": "kitty", "meta": {"type": "terminal", "res": "n/a"}}
{"label": "mpv", "meta": {"type": "media", "res": "3840x2160"}}
{"label": "vlc", "meta": {"type": "media", "res": "1920x1080"}}
ITEMS
}
exec_hooks_demo() {
echo "Exec hooks: --on-hover-exec fires a command on each cursor move." >&2
echo "Watch stderr for hover notifications as you navigate." >&2
echo "" >&2
cat <<'ITEMS' | pikl \
--on-hover-exec 'jq -r ".item.label // empty" | xargs -I{} echo " hovering: {}" >&2' \
--on-select-exec 'jq -r ".item.label // .value // empty" | xargs -I{} echo " selected: {}" >&2'
{"label": "Arch Linux", "sublabel": "rolling release"}
{"label": "NixOS", "sublabel": "declarative"}
{"label": "Void Linux", "sublabel": "runit-based"}
{"label": "Debian", "sublabel": "rock solid"}
{"label": "Alpine", "sublabel": "musl + busybox"}
ITEMS
}
handler_hook_demo() {
echo "Handler hooks: a persistent process receives events on stdin" >&2
echo "and can emit commands on stdout to modify the menu." >&2
echo "" >&2
echo "This demo logs hover events to stderr via a handler script." >&2
echo "The handler stays alive for the menu's lifetime." >&2
echo "" >&2
# Create a temporary handler script
local handler
handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh)
cat > "$handler" <<'HANDLER'
#!/bin/bash
# Simple handler: logs events to stderr, demonstrates the protocol
while IFS= read -r event; do
event_type=$(echo "$event" | jq -r '.event // "unknown"')
case "$event_type" in
hover)
label=$(echo "$event" | jq -r '.item.label // "?"')
index=$(echo "$event" | jq -r '.index // "?"')
echo " handler got hover: $label (index $index)" >&2
;;
filter)
text=$(echo "$event" | jq -r '.text // ""')
echo " handler got filter: '$text'" >&2
;;
open)
echo " handler got open event" >&2
;;
close)
echo " handler got close event" >&2
;;
*)
echo " handler got: $event_type" >&2
;;
esac
done
HANDLER
chmod +x "$handler"
cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 100
{"label": "Maple", "sublabel": "Studio founder"}
{"label": "Cedar", "sublabel": "Backend dev"}
{"label": "Birch", "sublabel": "Frontend dev"}
{"label": "Pine", "sublabel": "DevOps"}
{"label": "Spruce", "sublabel": "QA"}
ITEMS
rm -f "$handler"
}
handler_add_items_demo() {
echo "Handler hooks can modify the menu by emitting commands." >&2
echo "This demo adds items when you hover over specific entries." >&2
echo "" >&2
local handler
handler=$(mktemp /tmp/pikl-handler-XXXXXX.sh)
cat > "$handler" <<'HANDLER'
#!/bin/bash
# Handler that adds related items on hover
while IFS= read -r event; do
event_type=$(echo "$event" | jq -r '.event // "unknown"')
if [ "$event_type" = "hover" ]; then
label=$(echo "$event" | jq -r '.item.label // ""')
case "$label" in
"Languages")
echo '{"action": "add_items", "items": [{"label": " Rust"}, {"label": " Go"}, {"label": " Python"}]}'
;;
"Editors")
echo '{"action": "add_items", "items": [{"label": " Neovim"}, {"label": " Helix"}, {"label": " Emacs"}]}'
;;
esac
fi
done
HANDLER
chmod +x "$handler"
cat <<'ITEMS' | pikl --on-hover "$handler" --on-hover-debounce 300
{"label": "Languages"}
{"label": "Editors"}
{"label": "Shells"}
ITEMS
rm -f "$handler"
}
pipeline_filter_demo() {
echo "Filter pipeline demo: chain filters with |" >&2
echo "Try: 'rolling | !void (exact 'rolling', then exclude void)" >&2
echo "Try: /sys/ (regex: items containing 'sys')" >&2
echo "Try: meta.init:systemd (field filter on init system)" >&2
echo "" >&2
cat <<'ITEMS' | pikl --format '{label} ({meta.category}, {meta.init})'
{"label": "Arch Linux", "meta": {"category": "rolling", "init": "systemd"}}
{"label": "NixOS", "meta": {"category": "rolling", "init": "systemd"}}
{"label": "Void Linux", "meta": {"category": "rolling", "init": "runit"}}
{"label": "Debian", "meta": {"category": "stable", "init": "systemd"}}
{"label": "Alpine", "meta": {"category": "stable", "init": "openrc"}}
{"label": "Fedora", "meta": {"category": "semi-rolling", "init": "systemd"}}
{"label": "Gentoo", "meta": {"category": "rolling", "init": "openrc"}}
ITEMS
}
# ── Scenario menu ─────────────────────────────────────────
scenarios=(
@@ -107,22 +285,45 @@ scenarios=(
"Git branches"
"Git log (last 30)"
"File picker"
"on-select hook"
"Mixed input (plain + JSON)"
"---"
"Structured items (sublabel, meta)"
"Format template (--format)"
"Filter fields (--filter-fields)"
"Field filters (meta.type:browser)"
"Pipeline + field filters"
"---"
"Exec hooks (on-hover/select)"
"Handler hook (event logging)"
"Handler hook (add items on hover)"
"---"
"on-select-exec hook (legacy)"
)
# Map display names to functions
run_scenario() {
case "$1" in
*"Plain text"*) plain_list ;;
*"Big list"*) big_list ;;
*"JSON objects"*) json_objects ;;
*"label-key"*) custom_label_key ;;
*"Git branches"*) git_branches ;;
*"Git log"*) git_log_picker ;;
*"File picker"*) file_picker ;;
*"on-select"*) on_select_hook ;;
*"Mixed input"*) mixed_input ;;
*"Plain text"*) plain_list ;;
*"Big list"*) big_list ;;
*"JSON objects"*) json_objects ;;
*"label-key"*) custom_label_key ;;
*"Git branches"*) git_branches ;;
*"Git log"*) git_log_picker ;;
*"File picker"*) file_picker ;;
*"Mixed input"*) mixed_input ;;
*"Structured items"*) structured_items ;;
*"Format template"*) format_template ;;
*"Filter fields"*) filter_fields_demo ;;
*"Field filters"*) field_filter_demo ;;
*"Pipeline + field"*) pipeline_filter_demo ;;
*"Exec hooks"*) exec_hooks_demo ;;
*"Handler hook (event"*) handler_hook_demo ;;
*"Handler hook (add"*) handler_add_items_demo ;;
*"on-select-exec"*) on_select_hook ;;
"---")
echo "that's a separator, not a scenario" >&2
return 1
;;
*)
echo "unknown scenario" >&2
return 1
@@ -142,8 +343,8 @@ main() {
exit 1
}
# pikl outputs JSON. Strip the quotes for matching.
choice=$(echo "$choice" | tr -d '"')
# pikl outputs JSON. Strip quotes and extract value for matching.
choice=$(echo "$choice" | jq -r '.value // .' 2>/dev/null || echo "$choice" | tr -d '"')
echo "" >&2
echo "── running: $choice ──" >&2