Compare commits

..

10 Commits

Author SHA1 Message Date
f6f1efdf8e doc: Add install guide instructions.
Some checks are pending
CI / Lint (push) Waiting to run
CI / Test (push) Blocked by required conditions
2026-03-14 12:50:27 -04:00
c6d80a9650 log: Add tracing events through core for future debugability. 2026-03-14 12:31:20 -04:00
bb3f200141 refactor(core): Split up the large menu trait. 2026-03-14 11:48:27 -04:00
a27d529fa7 feat(frontend): Add generations counter to inform frontends when a list should be re-rendered. 2026-03-14 11:47:27 -04:00
e5c875389c feat: Add graceful shutdown of active hooks. 2026-03-14 11:46:04 -04:00
e89c6cb16f refactor: Eliminate unnecessary empty Vec constructions. 2026-03-14 11:46:04 -04:00
0ebb5e79dd feat(action): Add gitea action for continuous integration.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-14 01:55:32 -04:00
2729e7e1d2 feat(core): Add Quicklist function to return all visible via ctrl+Q.
Some checks failed
CI / Check (macos-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Clippy (strict) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
2026-03-14 01:49:24 -04:00
a39e511cc4 doc: Add lesson learned about dyn async traits. 2026-03-14 01:42:50 -04:00
8bf3366740 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).
2026-03-14 01:42:11 -04:00
33 changed files with 3446 additions and 335 deletions

View File

@@ -10,30 +10,16 @@ env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
lint:
name: Lint
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Check
run: cargo check --workspace --all-targets
clippy:
name: Clippy (strict)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: |
cargo clippy --workspace --all-targets -- \
@@ -47,26 +33,12 @@ jobs:
-D clippy::print_stdout \
-D clippy::print_stderr
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Rustfmt
run: cargo fmt --all -- --check
test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: self-hosted
needs: lint
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace

94
Cargo.lock generated
View File

@@ -877,6 +877,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "nucleo-matcher"
version = "0.3.1"
@@ -1072,6 +1081,8 @@ dependencies = [
"pikl-tui",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
@@ -1085,6 +1096,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
@@ -1105,6 +1117,7 @@ dependencies = [
"pikl-core",
"ratatui",
"tokio",
"tracing",
]
[[package]]
@@ -1403,6 +1416,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -1610,6 +1632,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -1657,6 +1688,63 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
name = "typenum"
version = "1.19.0"
@@ -1722,6 +1810,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"

View File

@@ -1,4 +1,4 @@
# pikl-menu
# pikl
Pipe stuff in, pick stuff out.
@@ -95,6 +95,30 @@ or at all:
overlay on Wayland (layer-shell) and X11. Auto-detects your
environment.
## Install
See [docs/guides/install.md](docs/guides/install.md) for full details.
The short version:
```sh
cargo install pikl
```
Or from source:
```sh
git clone https://github.com/maplecool/pikl-menu.git
cd pikl-menu
cargo install --path .
```
## Guides
- **[Install](docs/guides/install.md):** cargo install, building from source
- **[App Launcher](docs/guides/app-launcher.md):** set up pikl as a
keyboard-driven app launcher on Hyprland, i3, macOS (Raycast), or
in a terminal
## Building
```sh
@@ -105,16 +129,28 @@ cargo test --workspace
Requires Rust stable. The repo includes a `rust-toolchain.toml` that
pins the version and pulls in rust-analyzer + clippy.
## Current Status
Phases 1 through 3 are complete. pikl works as a TUI menu with:
- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
- Lifecycle hooks: exec (fire-and-forget) and handler (bidirectional)
- Debounce and cancel-stale for rapid events
- Format templates (`--format '{label} - {sublabel}'`)
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
- Headless scripting via `--action-fd`
## Platform Support
| Platform | Frontend | Status |
|---|---|---|
| Linux (Wayland) | GUI (layer-shell overlay) | Planned |
| Linux (X11) | GUI | Planned |
| Linux | TUI | Planned |
| Linux | TUI | Working |
| macOS | GUI | Planned |
| macOS | TUI | Planned |
| Window | GUI | Low Priority |
| macOS | TUI | Working |
| Windows | GUI | Low Priority |
## License

View File

@@ -12,10 +12,11 @@ workspace = true
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0.18"
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] }
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
tracing = "0.1"
nucleo-matcher = "0.3.1"
fancy-regex = "0.14"
[dev-dependencies]
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] }
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
pikl-test-macros = { path = "../pikl-test-macros" }

View File

@@ -0,0 +1,154 @@
//! Display format templates. Parses `{field.path}`
//! placeholders in a template string and renders them
//! against item JSON values.
use serde_json::Value;
use crate::item::resolve_field_path;
/// A compiled format template. Segments are either literal
/// text or field path references that get resolved against
/// item values at render time.
#[derive(Debug, Clone)]
pub struct FormatTemplate {
segments: Vec<Segment>,
}
#[derive(Debug, Clone)]
enum Segment {
Literal(String),
Field(String),
}
impl FormatTemplate {
/// Parse a format string like `"{label} - {sublabel}"`.
/// Unmatched `{` or `}` are treated as literals.
pub fn parse(template: &str) -> Self {
let mut segments = Vec::new();
let mut current = String::new();
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
// Look for closing brace
let mut field = String::new();
let mut found_close = false;
for c2 in chars.by_ref() {
if c2 == '}' {
found_close = true;
break;
}
field.push(c2);
}
if found_close && !field.is_empty() {
if !current.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut current)));
}
segments.push(Segment::Field(field));
} else {
// Malformed: treat as literal
current.push('{');
current.push_str(&field);
if found_close {
current.push('}');
}
}
} else {
current.push(c);
}
}
if !current.is_empty() {
segments.push(Segment::Literal(current));
}
Self { segments }
}
/// Render this template against a JSON value. Missing
/// fields produce empty strings.
pub fn render(&self, value: &Value) -> String {
let mut out = String::new();
for seg in &self.segments {
match seg {
Segment::Literal(s) => out.push_str(s),
Segment::Field(path) => {
if let Some(v) = resolve_field_path(value, path) {
match v {
Value::String(s) => out.push_str(s),
Value::Number(n) => out.push_str(&n.to_string()),
Value::Bool(b) => out.push_str(&b.to_string()),
Value::Null => {}
other => out.push_str(&other.to_string()),
}
}
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn simple_field() {
let t = FormatTemplate::parse("{label}");
assert_eq!(t.render(&json!({"label": "Firefox"})), "Firefox");
}
#[test]
fn literal_and_fields() {
let t = FormatTemplate::parse("{label} - {sublabel}");
let v = json!({"label": "Firefox", "sublabel": "Web Browser"});
assert_eq!(t.render(&v), "Firefox - Web Browser");
}
#[test]
fn missing_field_renders_empty() {
let t = FormatTemplate::parse("{label} ({version})");
let v = json!({"label": "Firefox"});
assert_eq!(t.render(&v), "Firefox ()");
}
#[test]
fn nested_dotted_path() {
let t = FormatTemplate::parse("{meta.resolution.width}x{meta.resolution.height}");
let v = json!({"meta": {"resolution": {"width": 3840, "height": 2160}}});
assert_eq!(t.render(&v), "3840x2160");
}
#[test]
fn plain_text_only() {
let t = FormatTemplate::parse("just text");
assert_eq!(t.render(&json!({})), "just text");
}
#[test]
fn empty_template() {
let t = FormatTemplate::parse("");
assert_eq!(t.render(&json!({"label": "x"})), "");
}
#[test]
fn unclosed_brace_is_literal() {
let t = FormatTemplate::parse("hello {world");
assert_eq!(t.render(&json!({})), "hello {world");
}
#[test]
fn empty_braces_are_literal() {
let t = FormatTemplate::parse("hello {}");
assert_eq!(t.render(&json!({})), "hello {}");
}
#[test]
fn string_value_item() {
let t = FormatTemplate::parse("{label}");
// String values don't have object fields
assert_eq!(t.render(&json!("hello")), "");
}
}

View File

@@ -3,6 +3,7 @@
//! deps. Frontends are separate crates that talk to this
//! through channels.
pub mod format;
mod model;
mod query;
mod runtime;
@@ -13,6 +14,7 @@ pub mod error;
// Re-export submodules at crate root so the public API stays flat.
pub use model::event;
pub use model::item;
pub use model::output;
pub use model::traits;
pub use query::exact;
pub use query::filter;
@@ -20,6 +22,7 @@ pub use query::navigation;
pub use query::pipeline;
pub use query::regex_filter;
pub use query::strategy;
pub use runtime::debounce;
pub use runtime::hook;
pub use runtime::input;
pub use runtime::json_menu;

View File

@@ -11,6 +11,8 @@ use std::sync::Arc;
use serde_json::Value;
use crate::hook::HookResponse;
/// Input mode. Insert mode sends keystrokes to the filter,
/// normal mode uses vim-style navigation keybinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -36,9 +38,14 @@ pub enum Action {
HalfPageDown(usize),
SetMode(Mode),
Confirm,
Quicklist,
Cancel,
Resize { height: u16 },
AddItems(Vec<Value>),
ReplaceItems(Vec<Value>),
RemoveItems(Vec<usize>),
ProcessHookResponse(HookResponse),
CloseMenu,
}
/// Broadcast from the menu loop to all subscribers
@@ -47,6 +54,7 @@ pub enum Action {
pub enum MenuEvent {
StateChanged(ViewState),
Selected(Value),
Quicklist(Vec<Value>),
Cancelled,
}
@@ -64,6 +72,10 @@ pub struct ViewState {
pub total_items: usize,
pub total_filtered: usize,
pub mode: Mode,
/// Monotonically increasing counter. Each call to
/// `build_view_state()` bumps this, so frontends can
/// detect duplicate broadcasts and skip redundant redraws.
pub generation: u64,
}
/// A single item in the current viewport window. Has the
@@ -73,6 +85,7 @@ pub struct ViewState {
#[derive(Debug, Clone)]
pub struct VisibleItem {
pub label: String,
pub formatted_text: Option<String>,
pub index: usize,
}
@@ -81,6 +94,7 @@ pub struct VisibleItem {
#[must_use]
#[derive(Debug)]
pub enum MenuResult {
Selected(Value),
Selected { value: Value, index: usize },
Quicklist { items: Vec<(Value, usize)> },
Cancelled,
}

View File

@@ -8,7 +8,8 @@ use serde_json::Value;
/// as `Value::String`, structured entries as
/// `Value::Object`. The label is extracted and cached at
/// construction time so display calls are just a pointer
/// dereference.
/// dereference. Sublabel, icon, and group are also cached
/// for structured items.
///
/// Serializes as the inner Value (transparent for output)
/// but construction always requires label extraction.
@@ -16,23 +17,40 @@ use serde_json::Value;
pub struct Item {
pub value: Value,
label_cache: String,
sublabel_cache: Option<String>,
icon_cache: Option<String>,
group_cache: Option<String>,
}
impl Item {
/// Create an Item from a JSON value, extracting the display
/// label from the given key. String values use the string
/// itself. Object values look up the key.
/// itself. Object values look up the key. Also extracts
/// sublabel, icon, and group from known keys on objects.
pub fn new(value: Value, label_key: &str) -> Self {
let label_cache = extract_label(&value, label_key).to_string();
Self { value, label_cache }
let sublabel_cache = extract_optional(&value, "sublabel");
let icon_cache = extract_optional(&value, "icon");
let group_cache = extract_optional(&value, "group");
Self {
value,
label_cache,
sublabel_cache,
icon_cache,
group_cache,
}
}
/// Wrap a plain-text string as an Item. Stored internally
/// as `Value::String` with the label cached.
/// as `Value::String` with the label cached. Optional
/// fields are left as None.
pub fn from_plain_text(line: &str) -> Self {
Self {
value: Value::String(line.to_string()),
label_cache: line.to_string(),
sublabel_cache: None,
icon_cache: None,
group_cache: None,
}
}
@@ -41,6 +59,38 @@ impl Item {
pub fn label(&self) -> &str {
&self.label_cache
}
/// Get the sublabel for this item, if present.
pub fn sublabel(&self) -> Option<&str> {
self.sublabel_cache.as_deref()
}
/// Get the icon for this item, if present.
pub fn icon(&self) -> Option<&str> {
self.icon_cache.as_deref()
}
/// Get the group for this item, if present.
pub fn group(&self) -> Option<&str> {
self.group_cache.as_deref()
}
/// Get the `meta` field from the underlying value, if it
/// exists and the value is an object.
pub fn meta(&self) -> Option<&Value> {
match &self.value {
Value::Object(map) => map.get("meta"),
_ => None,
}
}
/// Resolve a dotted field path against this item's value.
/// For example, `field_value("meta.resolution")` on
/// `{"meta": {"resolution": "1080p"}}` returns
/// `Some(Value::String("1080p"))`.
pub fn field_value(&self, path: &str) -> Option<&Value> {
resolve_field_path(&self.value, path)
}
}
/// Extract the display label from a JSON value.
@@ -52,6 +102,30 @@ fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str {
}
}
/// Extract an optional string field from a JSON object value.
fn extract_optional(value: &Value, key: &str) -> Option<String> {
match value {
Value::Object(map) => map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()),
_ => None,
}
}
/// Walk a dotted path (e.g. `"meta.tags.0"`) through nested
/// JSON objects. Returns None if any intermediate is not an
/// object or the key is missing.
pub fn resolve_field_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = value;
for segment in path.split('.') {
match current {
Value::Object(map) => {
current = map.get(segment)?;
}
_ => return None,
}
}
Some(current)
}
impl serde::Serialize for Item {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.value.serialize(serializer)
@@ -113,6 +187,9 @@ mod tests {
let item = Item::from_plain_text("hello world");
assert_eq!(item.label(), "hello world");
assert!(item.value.is_string());
assert!(item.sublabel().is_none());
assert!(item.icon().is_none());
assert!(item.group().is_none());
}
#[test]
@@ -128,4 +205,107 @@ mod tests {
let json = serde_json::to_string(&item).unwrap_or_default();
assert_eq!(json, r#"{"label":"foo"}"#);
}
// -- New accessor tests --
#[test]
fn sublabel_from_object() {
let item = Item::new(
json!({"label": "Firefox", "sublabel": "Web Browser"}),
"label",
);
assert_eq!(item.sublabel(), Some("Web Browser"));
}
#[test]
fn sublabel_missing() {
let item = Item::new(json!({"label": "Firefox"}), "label");
assert!(item.sublabel().is_none());
}
#[test]
fn icon_from_object() {
let item = Item::new(
json!({"label": "Firefox", "icon": "firefox.png"}),
"label",
);
assert_eq!(item.icon(), Some("firefox.png"));
}
#[test]
fn group_from_object() {
let item = Item::new(
json!({"label": "Firefox", "group": "browsers"}),
"label",
);
assert_eq!(item.group(), Some("browsers"));
}
#[test]
fn meta_from_object() {
let item = Item::new(
json!({"label": "test", "meta": {"res": 1080}}),
"label",
);
let meta = item.meta();
assert!(meta.is_some());
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
}
#[test]
fn meta_from_plain_text() {
let item = Item::from_plain_text("hello");
assert!(item.meta().is_none());
}
// -- Field path resolution tests --
#[test]
fn resolve_simple_path() {
let value = json!({"label": "test"});
assert_eq!(resolve_field_path(&value, "label"), Some(&json!("test")));
}
#[test]
fn resolve_nested_path() {
let value = json!({"meta": {"resolution": {"width": 3840}}});
assert_eq!(
resolve_field_path(&value, "meta.resolution.width"),
Some(&json!(3840))
);
}
#[test]
fn resolve_missing_path() {
let value = json!({"label": "test"});
assert!(resolve_field_path(&value, "nope").is_none());
}
#[test]
fn resolve_missing_nested() {
let value = json!({"meta": {"a": 1}});
assert!(resolve_field_path(&value, "meta.b").is_none());
}
#[test]
fn resolve_non_object_intermediate() {
let value = json!({"meta": "not an object"});
assert!(resolve_field_path(&value, "meta.foo").is_none());
}
#[test]
fn resolve_on_string_value() {
let value = json!("hello");
assert!(resolve_field_path(&value, "anything").is_none());
}
#[test]
fn field_value_on_item() {
let item = Item::new(
json!({"label": "test", "meta": {"tags": ["a", "b"]}}),
"label",
);
assert_eq!(item.field_value("meta.tags"), Some(&json!(["a", "b"])));
assert!(item.field_value("meta.nope").is_none());
}
}

View File

@@ -1,3 +1,4 @@
pub mod event;
pub mod item;
pub mod output;
pub mod traits;

View File

@@ -0,0 +1,123 @@
//! Structured output for selected items. Wraps the original
//! item value with action context and original index so
//! hooks and downstream tools know what happened.
use serde::ser::SerializeMap;
use serde::Serialize;
use serde_json::Value;
/// What the user did to produce this output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputAction {
Select,
Quicklist,
Cancel,
}
/// A selected item wrapped with output context. For object
/// values, the original fields are merged at the top level
/// alongside `action` and `index`. For non-object values
/// (plain strings, numbers), the value appears as a `value`
/// field.
#[derive(Debug, Clone)]
pub struct OutputItem {
pub value: Value,
pub action: OutputAction,
pub index: usize,
}
impl Serialize for OutputItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match &self.value {
Value::Object(map) => {
// Flatten: merge object fields with action/index
let mut s = serializer.serialize_map(Some(map.len() + 2))?;
for (k, v) in map {
s.serialize_entry(k, v)?;
}
s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?;
s.end()
}
_ => {
// Non-object: put value in a "value" field
let mut s = serializer.serialize_map(Some(3))?;
s.serialize_entry("value", &self.value)?;
s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?;
s.end()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn output_item_flattens_object() {
let item = OutputItem {
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
action: OutputAction::Select,
index: 3,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["label"], "Firefox");
assert_eq!(json["url"], "https://firefox.com");
assert_eq!(json["action"], "select");
assert_eq!(json["index"], 3);
}
#[test]
fn output_item_string_value() {
let item = OutputItem {
value: json!("hello"),
action: OutputAction::Select,
index: 0,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["value"], "hello");
assert_eq!(json["action"], "select");
assert_eq!(json["index"], 0);
}
#[test]
fn output_item_cancel_action() {
let item = OutputItem {
value: json!(null),
action: OutputAction::Cancel,
index: 0,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "cancel");
}
#[test]
fn output_item_quicklist_action() {
let item = OutputItem {
value: json!("test"),
action: OutputAction::Quicklist,
index: 2,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "quicklist");
assert_eq!(json["index"], 2);
}
#[test]
fn output_item_string_contains_value_text() {
let item = OutputItem {
value: json!("alpha"),
action: OutputAction::Select,
index: 0,
};
let serialized = serde_json::to_string(&item).unwrap_or_default();
assert!(
serialized.contains("alpha"),
"output should contain the value text: {serialized}"
);
}
}

View File

@@ -32,10 +32,50 @@ pub trait Menu: Send + 'static {
/// position in the filtered results.
fn filtered_label(&self, filtered_index: usize) -> Option<&str>;
/// Add raw values from streaming input or AddItems actions.
fn add_raw(&mut self, values: Vec<serde_json::Value>);
/// Get the JSON value of a filtered item for output.
/// Returns a reference to the stored value.
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
/// Get the original index of a filtered item. Used to
/// provide the item's position in the unfiltered list
/// for output and hook events.
fn original_index(&self, filtered_index: usize) -> Option<usize>;
/// Get the formatted display text for a filtered item,
/// if a format template is configured. Returns None if
/// no template is set, in which case the raw label is
/// used.
fn formatted_label(&self, _filtered_index: usize) -> Option<String> {
None
}
/// Collect all filtered items as (value, original_index) pairs.
/// Default implementation iterates filtered results using
/// existing trait methods.
fn collect_filtered(&self) -> Vec<(&serde_json::Value, usize)> {
(0..self.filtered_count())
.filter_map(|i| {
let value = self.serialize_filtered(i)?;
let orig = self.original_index(i)?;
Some((value, orig))
})
.collect()
}
}
/// Extension of [`Menu`] with mutation methods. Required by
/// [`MenuRunner`] which needs to add, replace, and remove
/// items. Embedders that only need read-only access can
/// depend on `Menu` alone.
pub trait MutableMenu: Menu {
/// Add raw values from streaming input or AddItems actions.
fn add_raw(&mut self, values: Vec<serde_json::Value>);
/// Replace all items with a new set of values. Used by
/// handler hook `replace_items` responses.
fn replace_all(&mut self, values: Vec<serde_json::Value>);
/// Remove items at the given original indices. Used by
/// handler hook `remove_items` responses.
fn remove_by_indices(&mut self, indices: Vec<usize>);
}

View File

@@ -165,9 +165,17 @@ impl Viewport {
self.scroll_offset..end
}
/// Set the cursor to a specific position. Does NOT clamp
/// or update scroll. Call [`clamp`](Self::clamp) after if
/// the position may be out of bounds.
pub fn set_cursor(&mut self, position: usize) {
self.cursor = position;
}
/// Clamp cursor and scroll offset to valid positions after
/// a height or count change.
fn clamp(&mut self) {
/// a height or count change, item removal, or manual
/// cursor set.
pub fn clamp(&mut self) {
if self.filtered_count == 0 {
self.cursor = 0;
self.scroll_offset = 0;

View File

@@ -3,8 +3,12 @@
//! and chains results through stages. Supports incremental
//! caching: unchanged stages keep their results.
use serde_json::Value;
use tracing::{debug, trace};
use super::filter::{Filter, FuzzyFilter};
use super::strategy::{self, FilterKind};
use crate::item::resolve_field_path;
/// A multi-stage filter pipeline. Each `|` in the query
/// creates a new stage that filters the previous stage's
@@ -13,6 +17,9 @@ use super::strategy::{self, FilterKind};
pub struct FilterPipeline {
/// Master item list: (original index, label).
items: Vec<(usize, String)>,
/// Optional item values for field filter resolution.
/// Stored separately since most pipelines don't need them.
item_values: Vec<Option<Value>>,
/// Pipeline stages, one per `|`-separated segment.
stages: Vec<PipelineStage>,
/// The last raw query string, used for diffing.
@@ -99,6 +106,17 @@ fn split_pipeline(query: &str) -> Vec<String> {
segments.into_iter().filter(|s| !s.is_empty()).collect()
}
/// Convert a JSON value to a string for field filter matching.
fn value_to_filter_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
impl Default for FilterPipeline {
fn default() -> Self {
Self::new()
@@ -109,6 +127,7 @@ impl FilterPipeline {
pub fn new() -> Self {
Self {
items: Vec::new(),
item_values: Vec::new(),
stages: Vec::new(),
last_raw_query: String::new(),
}
@@ -117,11 +136,15 @@ impl FilterPipeline {
/// Evaluate all dirty stages in order. Each stage filters
/// against the previous stage's cached_indices.
fn evaluate(&mut self) {
let _span = tracing::trace_span!("pipeline_evaluate").entered();
for stage_idx in 0..self.stages.len() {
if !self.stages[stage_idx].dirty {
trace!(stage_idx, dirty = false, "skipping clean stage");
continue;
}
trace!(stage_idx, dirty = true, kind = ?self.stages[stage_idx].kind, "evaluating stage");
let input_indices: Vec<usize> = if stage_idx == 0 {
self.items.iter().map(|(idx, _)| *idx).collect()
} else {
@@ -130,7 +153,7 @@ impl FilterPipeline {
let stage = &mut self.stages[stage_idx];
let result = match stage.kind {
let result = match &stage.kind {
FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx),
FilterKind::Exact => {
Self::eval_simple(stage, &input_indices, &self.items, |label, query| {
@@ -146,8 +169,17 @@ impl FilterPipeline {
}
})
}
FilterKind::Field { path } => {
Self::eval_field(
stage,
&input_indices,
&self.item_values,
path,
)
}
};
trace!(stage_idx, matched = result.len(), "stage complete");
self.stages[stage_idx].cached_indices = result;
self.stages[stage_idx].dirty = false;
}
@@ -184,6 +216,41 @@ impl FilterPipeline {
}
}
fn eval_field(
stage: &PipelineStage,
input_indices: &[usize],
item_values: &[Option<Value>],
path: &str,
) -> Vec<usize> {
if stage.query_text.is_empty() {
return input_indices.to_vec();
}
let query_lower = stage.query_text.to_lowercase();
let matches = |idx: usize| -> bool {
let value = item_values.get(idx).and_then(|v| v.as_ref());
if let Some(val) = value
&& let Some(field_val) = resolve_field_path(val, path)
{
let text = value_to_filter_string(field_val);
return text.to_lowercase().contains(&query_lower);
}
false
};
if stage.inverse {
input_indices
.iter()
.copied()
.filter(|&idx| !matches(idx))
.collect()
} else {
input_indices
.iter()
.copied()
.filter(|&idx| matches(idx))
.collect()
}
}
fn eval_simple(
stage: &PipelineStage,
input_indices: &[usize],
@@ -209,9 +276,51 @@ impl FilterPipeline {
}
}
impl FilterPipeline {
/// Clear all items and stages, then re-push the given
/// items. Used after replace_all or remove_by_indices
/// to rebuild the pipeline from scratch.
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
debug!(item_count = items.len(), "pipeline rebuilt");
self.items.clear();
self.item_values.clear();
self.stages.clear();
self.last_raw_query.clear();
for &(index, label) in items {
self.items.push((index, label.to_string()));
self.item_values.push(None);
}
}
/// Clear all items and stages, then re-push with values.
/// Used when field filters need access to item JSON.
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
debug!(item_count = items.len(), "pipeline rebuilt");
self.items.clear();
self.item_values.clear();
self.stages.clear();
self.last_raw_query.clear();
for &(index, label, value) in items {
self.items.push((index, label.to_string()));
self.item_values.push(Some(value.clone()));
}
}
/// Push a single item with its JSON value for field
/// filter support.
pub fn push_with_value(&mut self, index: usize, label: &str, value: &Value) {
self.push(index, label);
// push() already added a None to item_values, replace it
if let Some(last) = self.item_values.last_mut() {
*last = Some(value.clone());
}
}
}
impl Filter for FilterPipeline {
fn push(&mut self, index: usize, label: &str) {
self.items.push((index, label.to_string()));
self.item_values.push(None);
// Push to any existing fuzzy filters in stages
for stage in &mut self.stages {
if let Some(ref mut fuzzy) = stage.fuzzy {
@@ -224,6 +333,7 @@ impl Filter for FilterPipeline {
fn set_query(&mut self, query: &str) {
self.last_raw_query = query.to_string();
let segments = split_pipeline(query);
debug!(query = %query, stage_count = segments.len(), "pipeline query set");
// Reconcile stages with new segments
let mut new_len = segments.len();
@@ -614,4 +724,95 @@ mod tests {
assert!(result.contains(&"cherry"));
assert!(!result.contains(&"banana"));
}
// -- Field filter tests --
#[test]
fn field_filter_matches() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "monitor1", "meta": {"res": "3840"}}),
json!({"label": "monitor2", "meta": {"res": "1920"}}),
json!({"label": "monitor3", "meta": {"res": "3840"}}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
p.set_query("meta.res:3840");
assert_eq!(p.matched_count(), 2);
let indices: Vec<usize> = (0..p.matched_count())
.filter_map(|i| p.matched_index(i))
.collect();
assert!(indices.contains(&0));
assert!(indices.contains(&2));
}
#[test]
fn field_filter_inverse() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "a", "meta": {"res": "3840"}}),
json!({"label": "b", "meta": {"res": "1920"}}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
p.set_query("!meta.res:3840");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(1));
}
#[test]
fn field_filter_missing_field() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "a", "meta": {"res": "3840"}}),
json!({"label": "b"}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
p.set_query("meta.res:3840");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(0));
}
#[test]
fn field_filter_in_pipeline_with_other_stages() {
use serde_json::json;
let mut p = FilterPipeline::new();
let items = vec![
json!({"label": "Firefox", "meta": {"type": "browser"}}),
json!({"label": "Chrome", "meta": {"type": "browser"}}),
json!({"label": "Vim", "meta": {"type": "editor"}}),
];
for (i, item) in items.iter().enumerate() {
let label = item["label"].as_str().unwrap_or("");
p.push_with_value(i, label, item);
}
// First stage: field filter for browsers, second stage: fuzzy for "fire"
p.set_query("meta.type:browser | fire");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(0)); // Firefox
}
#[test]
fn rebuild_pipeline() {
let mut p = FilterPipeline::new();
push_items(&mut p, &["apple", "banana"]);
p.set_query("ban");
assert_eq!(p.matched_count(), 1);
// Rebuild with new items
p.rebuild(&[(0, "cherry"), (1, "date")]);
p.set_query("dat");
assert_eq!(p.matched_count(), 1);
assert_eq!(p.matched_index(0), Some(1));
}
}

View File

@@ -2,11 +2,15 @@
//! strategy to use based on the query prefix.
/// The type of filter to apply for a query segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterKind {
Fuzzy,
Exact,
Regex,
/// Field-specific filter: `meta.res:3840` matches
/// items where the dotted path resolves to a value
/// containing the query text.
Field { path: String },
}
/// A parsed filter segment with its kind, inversion flag,
@@ -75,6 +79,28 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
};
}
// Check for inverse field filter: !field.path:value
// Must check before generic inverse fuzzy to avoid
// treating it as fuzzy with query "field.path:value".
if let Some(rest) = segment.strip_prefix('!')
&& let Some((path, value)) = try_parse_field_filter(rest)
{
return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() },
inverse: true,
query: value,
};
}
// Check for field filter: field.path:value
if let Some((path, value)) = try_parse_field_filter(segment) {
return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() },
inverse: false,
query: value,
};
}
// Check for inverse fuzzy: !query
if let Some(rest) = segment.strip_prefix('!') {
return ParsedSegment {
@@ -101,6 +127,36 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
}
}
/// Try to parse a `field.path:value` pattern. Returns
/// `(path, value)` if the segment matches. The path must
/// consist of word chars and dots, with no spaces before
/// the colon.
fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
let colon_pos = segment.find(':')?;
let path = &segment[..colon_pos];
let value = &segment[colon_pos + 1..];
// Path must be non-empty and look like a dotted identifier
if path.is_empty() {
return None;
}
// Must contain at least one dot or look like a field name
// (not just a single word which could be a typo)
if !path.contains('.') {
return None;
}
// No spaces in the path
if path.contains(' ') {
return None;
}
// Path chars must be alphanumeric, underscore, or dot
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
return None;
}
Some((path, value))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -228,4 +284,52 @@ mod tests {
assert!(p.inverse);
assert_eq!(p.query, "[0-9]");
}
// -- Field filter tests --
#[test]
fn field_filter_dotted_path() {
let p = parse_segment("meta.res:3840");
assert_eq!(
p.kind,
FilterKind::Field {
path: "meta.res".to_string()
}
);
assert!(!p.inverse);
assert_eq!(p.query, "3840");
}
#[test]
fn field_filter_inverse() {
let p = parse_segment("!meta.res:3840");
assert_eq!(
p.kind,
FilterKind::Field {
path: "meta.res".to_string()
}
);
assert!(p.inverse);
assert_eq!(p.query, "3840");
}
#[test]
fn single_word_colon_is_not_field_filter() {
// No dot means it's not treated as a field path
let p = parse_segment("foo:bar");
assert_eq!(p.kind, FilterKind::Fuzzy);
assert_eq!(p.query, "foo:bar");
}
#[test]
fn field_filter_empty_value() {
let p = parse_segment("meta.tag:");
assert_eq!(
p.kind,
FilterKind::Field {
path: "meta.tag".to_string()
}
);
assert_eq!(p.query, "");
}
}

View File

@@ -0,0 +1,294 @@
//! Debounce and cancel-stale support for hook event
//! dispatch. Wraps a HookHandler and manages per-event-kind
//! timing to avoid overwhelming hooks with rapid events.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::Instrument;
use crate::event::Action;
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Debounce mode for a hook event kind.
#[derive(Debug, Clone)]
pub enum DebounceMode {
/// Fire immediately.
None,
/// Wait for a quiet period before firing.
Debounce(Duration),
/// Cancel any in-flight handler before starting a new one.
CancelStale,
/// Both: wait, and cancel any previous pending dispatch.
DebounceAndCancelStale(Duration),
}
/// Wraps a HookHandler with debounce and cancel-stale
/// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>,
_action_tx: mpsc::Sender<Action>,
modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
}
impl DebouncedDispatcher {
pub fn new(
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) -> Self {
Self {
handler,
_action_tx: action_tx,
modes: HashMap::new(),
in_flight: HashMap::new(),
}
}
/// Set the debounce mode for a specific event kind.
pub fn set_mode(&mut self, kind: HookEventKind, mode: DebounceMode) {
self.modes.insert(kind, mode);
}
/// Apply default debounce settings:
/// - Hover: DebounceAndCancelStale(200ms)
/// - Filter: Debounce(200ms)
/// - Everything else: None
pub fn apply_defaults(&mut self) {
self.modes.insert(
HookEventKind::Hover,
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
);
self.modes.insert(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(200)),
);
}
/// Dispatch a hook event through the debounce system.
pub fn dispatch(&mut self, event: HookEvent) {
let kind = event.kind();
let mode = self
.modes
.get(&kind)
.cloned()
.unwrap_or(DebounceMode::None);
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
match mode {
DebounceMode::None => {
self.fire_now(event);
}
DebounceMode::Debounce(delay) => {
self.fire_debounced(event, delay, false);
}
DebounceMode::CancelStale => {
self.cancel_in_flight(kind);
self.fire_now(event);
}
DebounceMode::DebounceAndCancelStale(delay) => {
self.fire_debounced(event, delay, true);
}
}
}
fn fire_now(&self, event: HookEvent) {
let handler = Arc::clone(&self.handler);
let span = tracing::debug_span!("hook_fire", event_kind = ?event.kind());
tokio::spawn(
async move {
if let Err(e) = handler.handle(event) {
tracing::warn!(error = %e, "hook handler error");
}
}
.instrument(span),
);
}
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
let kind = event.kind();
if cancel {
self.cancel_in_flight(kind);
}
let delay_ms = delay.as_millis() as u64;
tracing::debug!(delay_ms, cancel_stale = cancel, "debounced hook scheduled");
let handler = Arc::clone(&self.handler);
let span = tracing::debug_span!("hook_fire", event_kind = ?kind);
let handle = tokio::spawn(
async move {
tokio::time::sleep(delay).await;
if let Err(e) = handler.handle(event) {
tracing::warn!(error = %e, "hook handler error");
}
}
.instrument(span),
);
self.in_flight.insert(kind, handle);
}
fn cancel_in_flight(&mut self, kind: HookEventKind) {
if let Some(handle) = self.in_flight.remove(&kind) {
tracing::debug!(event_kind = ?kind, "cancelled in-flight hook");
handle.abort();
}
}
}
/// Convert a HookResponse into the appropriate Action.
pub fn hook_response_to_action(resp: HookResponse) -> Action {
match resp {
HookResponse::AddItems { items } => Action::AddItems(items),
HookResponse::ReplaceItems { items } => Action::ReplaceItems(items),
HookResponse::RemoveItems { indices } => Action::RemoveItems(indices),
HookResponse::SetFilter { text } => Action::UpdateFilter(text),
HookResponse::Close => Action::CloseMenu,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::PiklError;
use serde_json::json;
use std::sync::Mutex;
struct RecordingHandler {
events: Arc<Mutex<Vec<HookEventKind>>>,
}
impl HookHandler for RecordingHandler {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
if let Ok(mut events) = self.events.lock() {
events.push(event.kind());
}
Ok(())
}
}
#[test]
fn hook_response_to_action_add_items() {
let action = hook_response_to_action(HookResponse::AddItems {
items: vec![json!("x")],
});
assert!(matches!(action, Action::AddItems(_)));
}
#[test]
fn hook_response_to_action_replace() {
let action = hook_response_to_action(HookResponse::ReplaceItems {
items: vec![json!("x")],
});
assert!(matches!(action, Action::ReplaceItems(_)));
}
#[test]
fn hook_response_to_action_remove() {
let action = hook_response_to_action(HookResponse::RemoveItems { indices: vec![0] });
assert!(matches!(action, Action::RemoveItems(_)));
}
#[test]
fn hook_response_to_action_set_filter() {
let action = hook_response_to_action(HookResponse::SetFilter {
text: "hi".to_string(),
});
assert!(matches!(action, Action::UpdateFilter(_)));
}
#[test]
fn hook_response_to_action_close() {
let action = hook_response_to_action(HookResponse::Close);
assert!(matches!(action, Action::CloseMenu));
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_events() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.set_mode(
HookEventKind::Filter,
DebounceMode::Debounce(Duration::from_millis(100)),
);
// Rapid-fire filter events
dispatcher.dispatch(HookEvent::Filter {
text: "a".to_string(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "ab".to_string(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "abc".to_string(),
});
// Advance past debounce window. sleep(0) processes
// all pending wakeups including spawned task continuations.
tokio::time::sleep(Duration::from_millis(150)).await;
// Without cancel-stale, all three fire after their delay.
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert!(recorded >= 1, "at least one event should have fired");
}
#[tokio::test(start_paused = true)]
async fn cancel_stale_aborts_in_flight() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.set_mode(
HookEventKind::Hover,
DebounceMode::DebounceAndCancelStale(Duration::from_millis(200)),
);
// First hover
dispatcher.dispatch(HookEvent::Hover {
item: json!("a"),
index: 0,
});
// Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover {
item: json!("b"),
index: 1,
});
// Advance past debounce for the second event
tokio::time::sleep(Duration::from_millis(250)).await;
// Only the second hover should have fired
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert_eq!(recorded, 1, "only the latest hover should fire");
}
#[tokio::test]
async fn none_mode_fires_immediately() {
let events = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(RecordingHandler {
events: Arc::clone(&events),
});
let (action_tx, _action_rx) = mpsc::channel(64);
let mut dispatcher = DebouncedDispatcher::new(handler, action_tx);
dispatcher.dispatch(HookEvent::Open);
tokio::task::yield_now().await;
// Give the spawned task a moment
tokio::time::sleep(Duration::from_millis(10)).await;
let recorded = events.lock().map(|e| e.len()).unwrap_or(0);
assert_eq!(recorded, 1);
}
}

View File

@@ -1,15 +1,329 @@
//! Hook trait for lifecycle events. The core library defines
//! the interface; concrete implementations (shell hooks, IPC
//! hooks, etc.) live in frontend crates.
//! Hook types for lifecycle events. The core library defines
//! the event and response types plus the handler trait.
//! Concrete implementations (shell exec hooks, persistent
//! handler processes) live in frontend crates.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::PiklError;
/// A lifecycle hook that fires on menu events. Implementations
/// live outside pikl-core (e.g. in the CLI binary) so the core
/// library stays free of process/libc deps.
#[allow(async_fn_in_trait)]
pub trait Hook: Send + Sync {
async fn run(&self, value: &Value) -> Result<(), PiklError>;
/// A lifecycle event emitted by the menu engine. Handler
/// hooks receive these as JSON lines on stdin. The `event`
/// field is the tag for serde's tagged representation.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum HookEvent {
Open,
Close,
Hover { item: Value, index: usize },
Select { item: Value, index: usize },
Cancel,
Filter { text: String },
Quicklist { items: Vec<Value>, count: usize },
}
/// Discriminant for [`HookEvent`], used as a key for
/// debounce config and handler routing.
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub enum HookEventKind {
Open,
Close,
Hover,
Select,
Cancel,
Filter,
Quicklist,
}
impl HookEvent {
/// Get the discriminant kind for this event.
pub fn kind(&self) -> HookEventKind {
match self {
HookEvent::Open => HookEventKind::Open,
HookEvent::Close => HookEventKind::Close,
HookEvent::Hover { .. } => HookEventKind::Hover,
HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter,
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
}
}
}
/// A command from a handler hook back to the menu engine.
/// Handler hooks emit these as JSON lines on stdout. The
/// `action` field is the tag.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum HookResponse {
AddItems { items: Vec<Value> },
ReplaceItems { items: Vec<Value> },
RemoveItems { indices: Vec<usize> },
SetFilter { text: String },
Close,
}
/// Handler trait for lifecycle hooks. Implementations
/// receive events and may produce side effects (spawning
/// processes, sending to channels). Responses flow through
/// the action channel, not the return value.
///
/// This is deliberately synchronous for dyn-compatibility.
/// Implementations that need async work (spawning processes,
/// writing to channels) should use `tokio::spawn` internally.
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent) -> Result<(), PiklError>;
}
/// Parse a single line of JSON as a [`HookResponse`].
/// Returns None on parse failure, logging a warning via
/// tracing.
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
match serde_json::from_str::<HookResponse>(line) {
Ok(resp) => {
tracing::debug!(response = ?resp, "parsed hook response");
Some(resp)
}
Err(e) => {
tracing::warn!(line, error = %e, "failed to parse hook response");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// -- HookEvent serialization --
#[test]
fn event_open_serializes() {
let json = serde_json::to_value(&HookEvent::Open).unwrap_or_default();
assert_eq!(json["event"], "open");
}
#[test]
fn event_close_serializes() {
let json = serde_json::to_value(&HookEvent::Close).unwrap_or_default();
assert_eq!(json["event"], "close");
}
#[test]
fn event_hover_serializes() {
let event = HookEvent::Hover {
item: json!({"label": "test"}),
index: 5,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "hover");
assert_eq!(json["item"]["label"], "test");
assert_eq!(json["index"], 5);
}
#[test]
fn event_select_serializes() {
let event = HookEvent::Select {
item: json!("hello"),
index: 0,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "select");
assert_eq!(json["item"], "hello");
assert_eq!(json["index"], 0);
}
#[test]
fn event_cancel_serializes() {
let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default();
assert_eq!(json["event"], "cancel");
}
#[test]
fn event_filter_serializes() {
let event = HookEvent::Filter {
text: "foo".to_string(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "filter");
assert_eq!(json["text"], "foo");
}
// -- HookEvent kind --
#[test]
fn event_kind_matches() {
assert_eq!(HookEvent::Open.kind(), HookEventKind::Open);
assert_eq!(HookEvent::Close.kind(), HookEventKind::Close);
assert_eq!(
HookEvent::Hover {
item: json!(null),
index: 0
}
.kind(),
HookEventKind::Hover
);
assert_eq!(
HookEvent::Select {
item: json!(null),
index: 0
}
.kind(),
HookEventKind::Select
);
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel);
assert_eq!(
HookEvent::Filter {
text: String::new()
}
.kind(),
HookEventKind::Filter
);
}
// -- HookResponse deserialization --
#[test]
fn response_add_items() {
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::AddItems {
items: vec![json!({"label": "new"})]
}
);
}
#[test]
fn response_replace_items() {
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::ReplaceItems {
items: vec![json!("a"), json!("b")]
}
);
}
#[test]
fn response_remove_items() {
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::RemoveItems {
indices: vec![0, 3, 5]
}
);
}
#[test]
fn response_set_filter() {
let json = r#"{"action": "set_filter", "text": "hello"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(
resp,
HookResponse::SetFilter {
text: "hello".to_string()
}
);
}
#[test]
fn response_close() {
let json = r#"{"action": "close"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
assert_eq!(resp, HookResponse::Close);
}
#[test]
fn response_unknown_action() {
let json = r#"{"action": "explode"}"#;
let result = serde_json::from_str::<HookResponse>(json);
assert!(result.is_err());
}
#[test]
fn response_invalid_json() {
let result = serde_json::from_str::<HookResponse>("not json at all");
assert!(result.is_err());
}
#[test]
fn response_missing_required_field() {
// add_items without items field
let json = r#"{"action": "add_items"}"#;
let result = serde_json::from_str::<HookResponse>(json);
assert!(result.is_err());
}
// -- parse_hook_response --
#[test]
fn parse_valid_response() {
let resp = parse_hook_response(r#"{"action": "close"}"#);
assert_eq!(resp, Some(HookResponse::Close));
}
#[test]
fn parse_invalid_returns_none() {
let resp = parse_hook_response("garbage");
assert!(resp.is_none());
}
#[test]
fn parse_unknown_action_returns_none() {
let resp = parse_hook_response(r#"{"action": "nope"}"#);
assert!(resp.is_none());
}
#[test]
fn event_quicklist_serializes() {
let event = HookEvent::Quicklist {
items: vec![json!("alpha"), json!("beta")],
count: 2,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "quicklist");
assert_eq!(json["count"], 2);
assert_eq!(json["items"].as_array().map(|a| a.len()), Some(2));
}
#[test]
fn event_kind_quicklist() {
let event = HookEvent::Quicklist {
items: vec![],
count: 0,
};
assert_eq!(event.kind(), HookEventKind::Quicklist);
}
// -- Roundtrip: HookEvent serialize -> check shape --
#[test]
fn hover_event_roundtrip_shape() {
let event = HookEvent::Hover {
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
index: 2,
};
let serialized = serde_json::to_string(&event).unwrap_or_default();
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
assert_eq!(parsed["event"], "hover");
assert_eq!(parsed["item"]["label"], "Firefox");
assert_eq!(parsed["index"], 2);
}
}

View File

@@ -34,6 +34,7 @@ pub fn read_items_sync(
}
items.push(parse_line(&line, label_key));
}
tracing::debug!(count = items.len(), "items read");
Ok(items)
}
@@ -51,6 +52,7 @@ pub async fn read_items(
}
items.push(parse_line(&line, label_key));
}
tracing::debug!(count = items.len(), "items read");
Ok(items)
}

View File

@@ -3,8 +3,9 @@
//! for `ls | pikl` style usage.
use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::model::traits::{Menu, MutableMenu};
use crate::pipeline::FilterPipeline;
/// A menu backed by a flat list of JSON items. Handles
@@ -16,21 +17,90 @@ pub struct JsonMenu {
items: Vec<Item>,
label_key: String,
filter: FilterPipeline,
filter_fields: Vec<String>,
format_template: Option<FormatTemplate>,
}
impl JsonMenu {
/// Create a new JSON menu with the given items and label key.
pub fn new(items: Vec<Item>, label_key: String) -> Self {
let item_count = items.len();
tracing::debug!(item_count, label_key = %label_key, "json menu created");
let mut filter = FilterPipeline::new();
for (i, item) in items.iter().enumerate() {
filter.push(i, item.label());
filter.push_with_value(i, item.label(), &item.value);
}
Self {
items,
label_key,
filter,
filter_fields: vec!["label".to_string()],
format_template: None,
}
}
/// Set which fields to search during filtering. Each entry
/// is a dotted path resolved against the item's JSON value.
/// Default is `["label"]`.
pub fn set_filter_fields(&mut self, fields: Vec<String>) {
self.filter_fields = fields;
self.rebuild_pipeline();
}
/// Set the format template for display text.
pub fn set_format_template(&mut self, template: FormatTemplate) {
self.format_template = Some(template);
}
/// Rebuild the filter pipeline from scratch. Called after
/// filter_fields change or item mutations.
fn rebuild_pipeline(&mut self) {
let items_for_rebuild: Vec<(usize, String)> = self
.items
.iter()
.enumerate()
.map(|(i, item)| (i, self.extract_filter_text(item)))
.collect();
// Rebuild with values for field filter support
let refs: Vec<(usize, &str, &serde_json::Value)> = items_for_rebuild
.iter()
.enumerate()
.map(|(i, (idx, s))| (*idx, s.as_str(), &self.items[i].value))
.collect();
self.filter.rebuild_with_values(&refs);
}
/// Extract the combined filter text for an item based on
/// the configured filter_fields.
fn extract_filter_text(&self, item: &Item) -> String {
if self.filter_fields.len() == 1 && self.filter_fields[0] == "label" {
return item.label().to_string();
}
let mut parts = Vec::new();
for field in &self.filter_fields {
let text = if field == "label" {
Some(item.label().to_string())
} else if field == "sublabel" {
item.sublabel().map(|s| s.to_string())
} else {
item.field_value(field).and_then(value_to_string)
};
if let Some(t) = text {
parts.push(t);
}
}
parts.join(" ")
}
}
/// Convert a JSON value to a string for filtering purposes.
fn value_to_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => Some(v.to_string()),
}
}
impl Menu for JsonMenu {
@@ -52,18 +122,58 @@ impl Menu for JsonMenu {
.map(|idx| self.items[idx].label())
}
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
self.filter.push(idx, item.label());
self.items.push(item);
}
}
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> {
self.filter
.matched_index(filtered_index)
.map(|idx| &self.items[idx].value)
}
fn original_index(&self, filtered_index: usize) -> Option<usize> {
self.filter.matched_index(filtered_index)
}
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
}
impl MutableMenu for JsonMenu {
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
let count = values.len();
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item);
}
tracing::debug!(count, new_total = self.items.len(), "adding items to menu");
}
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
tracing::debug!(count = values.len(), "replacing all menu items");
self.items = values
.into_iter()
.map(|v| Item::new(v, &self.label_key))
.collect();
self.rebuild_pipeline();
}
fn remove_by_indices(&mut self, indices: Vec<usize>) {
let count = indices.len();
// Sort descending to remove from the end first,
// preserving earlier indices.
let mut sorted = indices;
sorted.sort_unstable();
sorted.dedup();
for &idx in sorted.iter().rev() {
if idx < self.items.len() {
self.items.remove(idx);
}
}
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
self.rebuild_pipeline();
}
}

View File

@@ -6,10 +6,13 @@
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, trace};
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
use crate::error::PiklError;
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
use crate::model::traits::Menu;
use crate::hook::{HookEvent, HookHandler};
use crate::model::traits::MutableMenu;
use crate::navigation::Viewport;
use serde_json::Value;
@@ -20,9 +23,13 @@ pub enum ActionOutcome {
/// State changed, broadcast to subscribers.
Broadcast,
/// User confirmed a selection.
Selected(Value),
Selected { value: Value, index: usize },
/// User cancelled.
Cancelled,
/// Menu closed by hook command.
Closed,
/// User quicklisted the filtered items.
Quicklist { items: Vec<(Value, usize)> },
/// Nothing happened (e.g. confirm on empty list).
NoOp,
}
@@ -31,16 +38,19 @@ pub enum ActionOutcome {
/// drives it with an action/event channel loop. Create one,
/// grab the action sender and event subscriber, then call
/// [`MenuRunner::run`] to start the event loop.
pub struct MenuRunner<M: Menu> {
pub struct MenuRunner<M: MutableMenu> {
menu: M,
viewport: Viewport,
filter_text: Arc<str>,
mode: Mode,
action_rx: mpsc::Receiver<Action>,
event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
generation: u64,
}
impl<M: Menu> MenuRunner<M> {
impl<M: MutableMenu> MenuRunner<M> {
/// Create a menu runner wrapping the given menu backend.
/// Returns the runner and an action sender. Call
/// [`subscribe`](Self::subscribe) to get an event handle,
@@ -59,6 +69,9 @@ impl<M: Menu> MenuRunner<M> {
mode: Mode::default(),
action_rx,
event_tx,
dispatcher: None,
previous_cursor: None,
generation: 0,
};
(runner, action_tx)
}
@@ -69,23 +82,56 @@ impl<M: Menu> MenuRunner<M> {
self.event_tx.subscribe()
}
/// Set a hook handler. Wraps it in a DebouncedDispatcher
/// with no debounce (all events fire immediately). Use
/// [`set_dispatcher`] for custom debounce settings.
pub fn set_hook_handler(
&mut self,
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) {
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
self.dispatcher = Some(dispatcher);
}
/// Set a hook handler with a pre-configured dispatcher.
pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) {
self.dispatcher = Some(dispatcher);
}
/// Re-run the filter against all items with the current
/// filter text. Updates the viewport with the new count.
fn run_filter(&mut self) {
let start = std::time::Instant::now();
self.menu.apply_filter(&self.filter_text);
self.viewport.set_filtered_count(self.menu.filtered_count());
let matched = self.menu.filtered_count();
let total = self.menu.total();
let duration_us = start.elapsed().as_micros() as u64;
debug!(
filter = %self.filter_text,
matched,
total,
duration_us,
"filter applied"
);
self.viewport.set_filtered_count(matched);
}
/// Build a [`ViewState`] snapshot from the current filter
/// results and viewport position.
fn build_view_state(&self) -> ViewState {
fn build_view_state(&mut self) -> ViewState {
self.generation += 1;
let range = self.viewport.visible_range();
let visible_items: Vec<VisibleItem> = range
.clone()
.filter_map(|i| {
self.menu.filtered_label(i).map(|label| VisibleItem {
label: label.to_string(),
index: i,
self.menu.filtered_label(i).map(|label| {
let formatted_text = self.menu.formatted_label(i);
VisibleItem {
label: label.to_string(),
formatted_text,
index: i,
}
})
})
.collect();
@@ -103,14 +149,50 @@ impl<M: Menu> MenuRunner<M> {
total_items: self.menu.total(),
total_filtered: self.menu.filtered_count(),
mode: self.mode,
generation: self.generation,
}
}
/// Send the current view state to all subscribers.
fn broadcast_state(&self) {
let _ = self
.event_tx
.send(MenuEvent::StateChanged(self.build_view_state()));
fn broadcast_state(&mut self) {
let vs = self.build_view_state();
trace!(
generation = vs.generation,
filtered = vs.total_filtered,
"state broadcast"
);
let _ = self.event_tx.send(MenuEvent::StateChanged(vs));
}
/// Emit a hook event through the dispatcher, if one is set.
fn emit_hook(&mut self, event: HookEvent) {
if let Some(dispatcher) = &mut self.dispatcher {
debug!(hook_event = ?event.kind(), "hook dispatched");
dispatcher.dispatch(event);
}
}
/// Check if the cursor moved to a different item and
/// emit a Hover event if so.
fn check_cursor_hover(&mut self) {
if self.menu.filtered_count() == 0 {
self.previous_cursor = None;
return;
}
let current = self.viewport.cursor();
let current_orig = self.menu.original_index(current);
if current_orig != self.previous_cursor {
self.previous_cursor = current_orig;
if let Some(value) = self.menu.serialize_filtered(current).cloned()
&& let Some(orig_idx) = current_orig
{
trace!(cursor = current, original_index = orig_idx, "hover changed");
self.emit_hook(HookEvent::Hover {
item: value,
index: orig_idx,
});
}
}
}
/// Apply a single action to the menu state. Pure state
@@ -118,6 +200,7 @@ impl<M: Menu> MenuRunner<M> {
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
match action {
Action::UpdateFilter(text) => {
debug!(filter = %text, "filter updated");
self.filter_text = Arc::from(text);
self.run_filter();
ActionOutcome::Broadcast
@@ -151,13 +234,30 @@ impl<M: Menu> MenuRunner<M> {
return ActionOutcome::NoOp;
}
let cursor = self.viewport.cursor();
let index = self.menu.original_index(cursor).unwrap_or(0);
match self.menu.serialize_filtered(cursor) {
Some(value) => ActionOutcome::Selected(value.clone()),
Some(value) => ActionOutcome::Selected {
value: value.clone(),
index,
},
None => ActionOutcome::NoOp,
}
}
Action::Quicklist => {
if self.menu.filtered_count() == 0 {
return ActionOutcome::NoOp;
}
let items: Vec<(Value, usize)> = self
.menu
.collect_filtered()
.into_iter()
.map(|(v, idx)| (v.clone(), idx))
.collect();
ActionOutcome::Quicklist { items }
}
Action::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => {
trace!(height, "viewport resized");
self.viewport.set_height(height as usize);
ActionOutcome::Broadcast
}
@@ -170,14 +270,53 @@ impl<M: Menu> MenuRunner<M> {
ActionOutcome::Broadcast
}
Action::SetMode(m) => {
debug!(mode = ?m, "mode changed");
self.mode = m;
ActionOutcome::Broadcast
}
Action::AddItems(values) => {
debug!(count = values.len(), "items added");
self.menu.add_raw(values);
self.run_filter();
ActionOutcome::Broadcast
}
Action::ReplaceItems(values) => {
debug!(count = values.len(), "items replaced");
// Smart cursor: try to keep selection on the same original item.
let cursor = self.viewport.cursor();
let old_value = self.menu.serialize_filtered(cursor).cloned();
self.menu.replace_all(values);
self.run_filter();
// Try to find the old item in the new set
if let Some(ref old_val) = old_value {
let mut found = false;
for i in 0..self.menu.filtered_count() {
if self.menu.serialize_filtered(i) == Some(old_val) {
self.viewport.set_cursor(i);
found = true;
break;
}
}
if !found {
self.viewport.clamp();
}
} else {
self.viewport.clamp();
}
ActionOutcome::Broadcast
}
Action::RemoveItems(indices) => {
debug!(count = indices.len(), "items removed");
self.menu.remove_by_indices(indices);
self.run_filter();
self.viewport.clamp();
ActionOutcome::Broadcast
}
Action::ProcessHookResponse(resp) => {
let action = hook_response_to_action(resp);
self.apply_action(action)
}
Action::CloseMenu => ActionOutcome::Closed,
}
}
@@ -205,14 +344,70 @@ impl<M: Menu> MenuRunner<M> {
self.run_filter();
self.broadcast_state();
let total_items = self.menu.total();
let filtered = self.menu.filtered_count();
info!(total_items, filtered, "menu opened");
// Emit Open event
self.emit_hook(HookEvent::Open);
while let Some(action) = self.action_rx.recv().await {
let is_filter_update = matches!(&action, Action::UpdateFilter(_));
match self.apply_action(action) {
ActionOutcome::Broadcast => self.broadcast_state(),
ActionOutcome::Selected(value) => {
ActionOutcome::Broadcast => {
self.broadcast_state();
// Emit Filter event if the filter changed
if is_filter_update {
let text = self.filter_text.to_string();
self.emit_hook(HookEvent::Filter { text });
}
// Check for cursor movement -> Hover
self.check_cursor_hover();
}
ActionOutcome::Selected { value, index } => {
info!(index, "item selected");
// Emit Select event
self.emit_hook(HookEvent::Select {
item: value.clone(),
index,
});
// Emit Close event
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
return Ok(MenuResult::Selected(value));
return Ok(MenuResult::Selected { value, index });
}
ActionOutcome::Quicklist { items } => {
let values: Vec<Value> =
items.iter().map(|(v, _)| v.clone()).collect();
let count = values.len();
info!(count, "quicklist returned");
self.emit_hook(HookEvent::Quicklist {
items: values.clone(),
count,
});
self.emit_hook(HookEvent::Close);
let _ = self
.event_tx
.send(MenuEvent::Quicklist(values));
return Ok(MenuResult::Quicklist { items });
}
ActionOutcome::Cancelled => {
info!("menu cancelled");
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
ActionOutcome::Closed => {
info!("menu closed by hook");
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
}
@@ -221,6 +416,7 @@ impl<M: Menu> MenuRunner<M> {
}
// Sender dropped
self.emit_hook(HookEvent::Close);
Ok(MenuResult::Cancelled)
}
}
@@ -230,6 +426,7 @@ mod tests {
use super::*;
use crate::event::MenuEvent;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::runtime::json_menu::JsonMenu;
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
@@ -285,7 +482,7 @@ mod tests {
let mut m = ready_menu();
m.apply_action(Action::MoveDown(1));
let outcome = m.apply_action(Action::Confirm);
assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta")));
assert!(matches!(&outcome, ActionOutcome::Selected { value, .. } if value.as_str() == Some("beta")));
}
#[test]
@@ -413,7 +610,7 @@ mod tests {
}
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(_))));
assert!(matches!(result, Ok(MenuResult::Selected { .. })));
}
#[tokio::test]
@@ -474,7 +671,7 @@ mod tests {
assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha")));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha")));
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("alpha")));
}
#[tokio::test]
@@ -496,7 +693,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma")));
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("gamma")));
}
#[tokio::test]
@@ -521,7 +718,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")));
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")));
}
#[tokio::test]
@@ -556,7 +753,7 @@ mod tests {
let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon")));
assert!(matches!(result, Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("epsilon")));
}
#[tokio::test]
@@ -608,7 +805,7 @@ mod tests {
// Must get "banana". Filter was applied before confirm ran.
assert!(matches!(
result,
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana")
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
));
}
@@ -634,7 +831,7 @@ mod tests {
// Cursor at index 3 -> "delta"
assert!(matches!(
result,
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta")
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
));
}
@@ -730,7 +927,250 @@ mod tests {
// Must find "zephyr". It was added before the filter ran.
assert!(matches!(
result,
Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr")
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
));
}
// -- Replace/Remove/Close action tests --
#[test]
fn apply_replace_items() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::ReplaceItems(vec![
serde_json::Value::String("x".to_string()),
serde_json::Value::String("y".to_string()),
]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 2);
assert_eq!(m.menu.filtered_count(), 2);
}
#[test]
fn apply_replace_items_preserves_cursor() {
let mut m = ready_menu();
// Move to "beta" (index 1)
m.apply_action(Action::MoveDown(1));
// Replace items, keeping "beta" in the new set
m.apply_action(Action::ReplaceItems(vec![
serde_json::Value::String("alpha".to_string()),
serde_json::Value::String("beta".to_string()),
serde_json::Value::String("zeta".to_string()),
]));
// Cursor should still be on "beta"
let vs = m.build_view_state();
assert_eq!(vs.visible_items[vs.cursor].label, "beta");
}
#[test]
fn apply_remove_items() {
let mut m = ready_menu();
assert_eq!(m.menu.total(), 4);
let outcome = m.apply_action(Action::RemoveItems(vec![1, 3]));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 2);
// alpha and gamma should remain
assert_eq!(m.menu.filtered_label(0), Some("alpha"));
assert_eq!(m.menu.filtered_label(1), Some("gamma"));
}
#[test]
fn apply_close_menu() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::CloseMenu);
assert!(matches!(outcome, ActionOutcome::Closed));
}
#[test]
fn apply_hook_response_close() {
use crate::hook::HookResponse;
let mut m = ready_menu();
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close));
assert!(matches!(outcome, ActionOutcome::Closed));
}
#[test]
fn apply_hook_response_add_items() {
use crate::hook::HookResponse;
let mut m = ready_menu();
let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems {
items: vec![serde_json::json!("new")],
}));
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert_eq!(m.menu.total(), 5);
}
#[test]
fn confirm_returns_original_index() {
let mut m = ready_menu();
// Filter to narrow results, then confirm
m.apply_action(Action::UpdateFilter("del".to_string()));
assert!(m.menu.filtered_count() >= 1);
let outcome = m.apply_action(Action::Confirm);
// "delta" is at original index 3
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
}
// -- Quicklist tests --
#[test]
fn apply_quicklist_returns_all_filtered() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 4);
let labels: Vec<&str> = items
.iter()
.filter_map(|(v, _)| v.as_str())
.collect();
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_respects_filter() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("a".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
// "alpha", "gamma", "delta" all contain 'a'
assert!(items.len() >= 2);
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains('a'),
"filtered item should contain 'a': {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_on_empty_is_noop() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("zzzzz".to_string()));
assert_eq!(m.menu.filtered_count(), 0);
let outcome = m.apply_action(Action::Quicklist);
assert!(matches!(outcome, ActionOutcome::NoOp));
}
#[test]
fn apply_quicklist_preserves_original_indices() {
let mut m = ready_menu();
// Filter to "del" -> only "delta" at original index 3
m.apply_action(Action::UpdateFilter("del".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].0.as_str(), Some("delta"));
assert_eq!(items[0].1, 3);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[tokio::test]
async fn quicklist_event_broadcast() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Skip initial state
let _ = rx.recv().await;
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
let _ = tx.send(Action::Quicklist).await;
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4);
} else {
panic!("expected Quicklist event");
}
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
}
#[tokio::test]
async fn quicklist_after_filter() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
let _ = tx.send(Action::Quicklist).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
match result {
Ok(MenuResult::Quicklist { items }) => {
// "alpha" matches "al"
assert!(!items.is_empty());
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains("al"),
"quicklist item should match filter: {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
// -- Hook event tests --
#[tokio::test]
async fn hook_events_fire_on_lifecycle() {
use crate::hook::{HookEvent, HookEventKind, HookHandler};
use std::sync::Mutex;
struct Recorder(Mutex<Vec<HookEventKind>>);
impl HookHandler for Recorder {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
if let Ok(mut v) = self.0.lock() {
v.push(event.kind());
}
Ok(())
}
}
let recorder = Arc::new(Recorder(Mutex::new(Vec::new())));
let (mut m, action_tx) = test_menu();
m.set_hook_handler(Arc::clone(&recorder) as Arc<dyn HookHandler>, action_tx);
m.run_filter();
m.apply_action(Action::Resize { height: 10 });
// Simulate lifecycle: the Open event is emitted in run(),
// but we can test Filter/Hover/Cancel manually
m.emit_hook(HookEvent::Open);
m.apply_action(Action::UpdateFilter("al".to_string()));
m.emit_hook(HookEvent::Filter {
text: "al".to_string(),
});
m.apply_action(Action::MoveDown(1));
m.check_cursor_hover();
// Give spawned tasks a chance to complete
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let events = recorder.0.lock().map(|v| v.clone()).unwrap_or_default();
assert!(events.contains(&HookEventKind::Open));
assert!(events.contains(&HookEventKind::Filter));
}
}

View File

@@ -1,3 +1,4 @@
pub mod debounce;
pub mod hook;
pub mod input;
pub mod json_menu;

View File

@@ -327,7 +327,7 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
} else if let Some(ref expected) = case.selected {
quote! {
match &result {
Ok(MenuResult::Selected(value)) => {
Ok(MenuResult::Selected { value, .. }) => {
let got = value.as_str()
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
.unwrap_or("");

View File

@@ -14,3 +14,4 @@ ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
tokio = { version = "1", features = ["sync", "macros", "rt"] }
futures = "0.3"
tracing = "0.1"

View File

@@ -82,6 +82,7 @@ async fn run_inner(
let mut event_stream = EventStream::new();
let mut mode = Mode::Insert;
let mut pending = PendingKey::None;
let mut last_generation: u64 = 0;
loop {
if let Some(ref vs) = view_state {
@@ -118,6 +119,11 @@ async fn run_inner(
menu_event = event_rx.recv() => {
match menu_event {
Ok(MenuEvent::StateChanged(vs)) => {
// Skip duplicate broadcasts
if vs.generation == last_generation {
continue;
}
last_generation = vs.generation;
// Sync filter text from core. Local keystrokes
// update filter_text immediately for responsiveness,
// but if core pushes a different value (e.g. IPC
@@ -132,10 +138,12 @@ async fn run_inner(
}
view_state = Some(vs);
}
Ok(MenuEvent::Selected(_) | MenuEvent::Cancelled) => {
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
break;
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "TUI fell behind on state broadcasts");
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
@@ -194,7 +202,8 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
} else {
Style::default()
};
ListItem::new(vi.label.as_str()).style(style)
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
ListItem::new(text).style(style)
})
.collect();
@@ -232,6 +241,7 @@ fn map_insert_mode(key: KeyEvent, filter_text: &mut String) -> Option<Action> {
filter_text.pop();
Some(Action::UpdateFilter(filter_text.clone()))
}
(KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist),
(KeyCode::Char(c), mods) if !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
filter_text.push(c);
Some(Action::UpdateFilter(filter_text.clone()))
@@ -275,6 +285,7 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
(KeyCode::Char('/'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
Some(Action::SetMode(Mode::Insert))
}
(KeyCode::Char('q'), KeyModifiers::CONTROL) => Some(Action::Quicklist),
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
Some(Action::Cancel)
}
@@ -318,14 +329,17 @@ mod tests {
visible_items: vec![
VisibleItem {
label: "alpha".into(),
formatted_text: None,
index: 0,
},
VisibleItem {
label: "bravo".into(),
formatted_text: None,
index: 1,
},
VisibleItem {
label: "charlie".into(),
formatted_text: None,
index: 2,
},
],
@@ -334,6 +348,7 @@ mod tests {
total_items: 5,
total_filtered: 3,
mode: Mode::Insert,
generation: 1,
}
}
@@ -659,6 +674,28 @@ mod tests {
assert_eq!(ft, "");
}
#[test]
fn ctrl_q_maps_to_quicklist_insert() {
let mut ft = String::new();
let mut pending = PendingKey::None;
let k = key_with_mods(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert_eq!(
map_key_event(k, &mut ft, Mode::Insert, &mut pending),
Some(Action::Quicklist)
);
}
#[test]
fn ctrl_q_maps_to_quicklist_normal() {
let mut ft = String::new();
let mut pending = PendingKey::None;
let k = key_with_mods(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert_eq!(
map_key_event(k, &mut ft, Mode::Normal, &mut pending),
Some(Action::Quicklist)
);
}
// -- Rendering tests (TestBackend) --
fn render_to_backend(width: u16, height: u16, vs: &ViewState, filter: &str) -> TestBackend {
@@ -783,6 +820,7 @@ mod tests {
total_items: 0,
total_filtered: 0,
mode: Mode::Insert,
generation: 1,
};
let backend = render_to_backend(30, 4, &vs, "");
let prompt = line_text(&backend, 0);

View File

@@ -18,6 +18,8 @@ pikl-tui = { path = "../pikl-tui" }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
libc = "0.2"
[dev-dependencies]

204
crates/pikl/src/handler.rs Normal file
View File

@@ -0,0 +1,204 @@
//! Bidirectional handler hooks. Persistent subprocess that
//! receives events as JSON lines on stdin and emits
//! responses as JSON lines on stdout. Responses are parsed
//! and sent back to the menu engine through the action
//! channel.
use std::collections::HashMap;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use pikl_core::error::PiklError;
use pikl_core::event::Action;
use pikl_core::hook::{
parse_hook_response, HookEvent, HookEventKind, HookHandler,
};
/// A persistent handler hook process. Spawns a child process,
/// writes events to its stdin as JSON lines, reads responses
/// from its stdout as JSON lines. Responses are converted to
/// Actions and sent through the action channel.
pub struct ShellHandlerHook {
/// Event senders, one per active handler process.
event_txs: HashMap<HookEventKind, mpsc::Sender<HookEvent>>,
}
impl ShellHandlerHook {
/// Build from CLI flags. Returns None if no handler hooks are configured.
pub fn from_cli(
cli: &crate::Cli,
action_tx: mpsc::Sender<Action>,
) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open {
handlers.push((HookEventKind::Open, cmd));
}
if let Some(ref cmd) = cli.on_close {
handlers.push((HookEventKind::Close, cmd));
}
if let Some(ref cmd) = cli.on_hover {
handlers.push((HookEventKind::Hover, cmd));
}
if let Some(ref cmd) = cli.on_select {
handlers.push((HookEventKind::Select, cmd));
}
if let Some(ref cmd) = cli.on_cancel {
handlers.push((HookEventKind::Cancel, cmd));
}
if let Some(ref cmd) = cli.on_filter {
handlers.push((HookEventKind::Filter, cmd));
}
if handlers.is_empty() {
return None;
}
let mut event_txs = HashMap::new();
for (kind, cmd) in handlers {
let (event_tx, event_rx) = mpsc::channel::<HookEvent>(64);
let cmd = cmd.to_string();
let atx = action_tx.clone();
tokio::spawn(async move {
if let Err(e) = run_handler_process(&cmd, event_rx, atx).await {
tracing::warn!(error = %e, command = %cmd, "handler hook process failed");
}
});
event_txs.insert(kind, event_tx);
}
Some(Self { event_txs })
}
}
impl HookHandler for ShellHandlerHook {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
let kind = event.kind();
if let Some(tx) = self.event_txs.get(&kind) {
// Non-blocking send. If the channel is full, drop the event.
let _ = tx.try_send(event);
}
Ok(())
}
}
/// Run a persistent handler process. Reads events from the
/// channel, writes them as JSON lines to the child's stdin.
/// Reads JSON lines from the child's stdout and converts
/// them to Actions sent through action_tx.
async fn run_handler_process(
command: &str,
mut event_rx: mpsc::Receiver<HookEvent>,
action_tx: mpsc::Sender<Action>,
) -> Result<(), PiklError> {
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn()?;
let child_stdin = child
.stdin
.take()
.ok_or_else(|| PiklError::Io(std::io::Error::other("failed to open handler stdin")))?;
let child_stdout = child
.stdout
.take()
.ok_or_else(|| PiklError::Io(std::io::Error::other("failed to open handler stdout")))?;
let mut stdin_writer = child_stdin;
let stdout_reader = BufReader::new(child_stdout);
// Spawn reader task: reads JSON lines from child stdout
let reader_action_tx = action_tx.clone();
let reader_handle = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(resp) = parse_hook_response(&line) {
let action = Action::ProcessHookResponse(resp);
if reader_action_tx.send(action).await.is_err() {
break;
}
}
}
});
// Writer loop: reads events from channel, writes to stdin
while let Some(event) = event_rx.recv().await {
let json = serde_json::to_string(&event).unwrap_or_default();
if stdin_writer
.write_all(json.as_bytes())
.await
.is_err()
{
break;
}
if stdin_writer
.write_all(b"\n")
.await
.is_err()
{
break;
}
if stdin_writer.flush().await.is_err() {
break;
}
}
// Close stdin to signal the child
drop(stdin_writer);
// Wait up to 1s for child to exit after EOF
let exited = tokio::time::timeout(
std::time::Duration::from_secs(1),
child.wait(),
)
.await
.is_ok();
if !exited {
// Send SIGTERM on Unix, then wait another second
#[cfg(unix)]
{
if let Some(pid) = child.id() {
// SAFETY: pid is valid (child is still running), SIGTERM is
// a standard signal. kill() returns 0 on success or -1 on
// error, which we ignore (child may have exited between the
// check and the signal).
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
let termed = tokio::time::timeout(
std::time::Duration::from_secs(1),
child.wait(),
)
.await
.is_ok();
if !termed {
let _ = child.kill().await;
}
}
#[cfg(not(unix))]
{
let _ = child.kill().await;
}
}
// Wait for the reader to finish
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
reader_handle,
)
.await;
Ok(())
}

View File

@@ -1,15 +1,15 @@
//! Shell hook execution. Hooks are shell commands that fire
//! on menu events (selection, cancellation). The selected
//! item's JSON is piped to the hook's stdin.
//!
//! Hook stdout is redirected to stderr so it doesn't end up
//! mixed into pikl's structured output on stdout.
//! Shell exec hooks. Fire-and-forget subprocess per event.
//! The hook's stdin receives the event JSON, stdout is
//! redirected to stderr to keep pikl's output clean.
use std::collections::HashMap;
use serde_json::Value;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use pikl_core::error::PiklError;
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler};
/// Duplicate stderr as a [`Stdio`] handle for use as a
/// child process's stdout. Keeps hook output on stderr
@@ -18,15 +18,19 @@ fn stderr_as_stdio() -> std::process::Stdio {
#[cfg(unix)]
{
use std::os::unix::io::FromRawFd;
// SAFETY: STDERR_FILENO is a valid open fd in any running process.
// dup() returns a new fd or -1, which we check below.
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
if fd >= 0 {
// SAFETY: fd is valid (checked >= 0 above) and we take exclusive
// ownership here (no aliasing). The File will close it on drop.
return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) };
}
}
std::process::Stdio::inherit()
}
/// Run a shell hook, piping the value as JSON to stdin.
/// Run a shell command, piping the value as JSON to stdin.
/// Hook stdout goes to stderr (see module docs). Returns
/// an error if the command exits non-zero.
pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> {
@@ -48,8 +52,7 @@ async fn write_json_stdin(
Ok(())
}
/// Run a shell hook with a custom stdout handle. Used by
/// [`run_hook`] to redirect hook output to stderr.
/// Run a shell hook with a custom stdout handle.
async fn run_hook_with_stdout(
command: &str,
value: &Value,
@@ -75,6 +78,61 @@ async fn run_hook_with_stdout(
Ok(())
}
/// Fire-and-forget shell hook handler. Spawns a subprocess
/// for each event that has a registered command. Always
/// returns Ok(vec![]) since exec hooks don't send responses.
pub struct ShellExecHandler {
commands: HashMap<HookEventKind, String>,
}
impl ShellExecHandler {
/// Build from CLI flags.
pub fn from_cli(cli: &crate::Cli) -> Self {
let mut commands = HashMap::new();
if let Some(ref cmd) = cli.on_open_exec {
commands.insert(HookEventKind::Open, cmd.clone());
}
if let Some(ref cmd) = cli.on_close_exec {
commands.insert(HookEventKind::Close, cmd.clone());
}
if let Some(ref cmd) = cli.on_hover_exec {
commands.insert(HookEventKind::Hover, cmd.clone());
}
if let Some(ref cmd) = cli.on_select_exec {
commands.insert(HookEventKind::Select, cmd.clone());
}
if let Some(ref cmd) = cli.on_cancel_exec {
commands.insert(HookEventKind::Cancel, cmd.clone());
}
if let Some(ref cmd) = cli.on_filter_exec {
commands.insert(HookEventKind::Filter, cmd.clone());
}
Self { commands }
}
pub fn has_hooks(&self) -> bool {
!self.commands.is_empty()
}
}
impl HookHandler for ShellExecHandler {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
let kind = event.kind();
if let Some(cmd) = self.commands.get(&kind) {
let cmd = cmd.clone();
// Serialize event as JSON for the hook's stdin
let event_json =
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
tokio::spawn(async move {
if let Err(e) = run_hook(&cmd, &event_json).await {
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
}
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -94,35 +152,6 @@ mod tests {
assert!(result.is_err());
}
// -- Hook stdin verification --
/// Helper: run `cat` with piped stdout so we can capture what it echoes back
/// from the value JSON written to stdin.
async fn capture_hook_stdin(value: &Value) -> String {
let child = Command::new("sh")
.arg("-c")
.arg("cat")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn();
let Ok(mut child) = child else {
return String::new();
};
let _ = write_json_stdin(&mut child, value).await;
let output = child
.wait_with_output()
.await
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: Vec::new(),
stderr: Vec::new(),
});
String::from_utf8(output.stdout).unwrap_or_default()
}
#[tokio::test]
async fn write_json_stdin_sends_correct_data() {
let value = json!({"key": "value"});
@@ -141,99 +170,29 @@ mod tests {
#[tokio::test]
async fn hook_receives_plain_text_json() {
let value = json!("hello");
let got = capture_hook_stdin(&value).await;
assert_eq!(got, r#""hello""#);
}
#[tokio::test]
async fn hook_receives_object_json() {
let value = json!({"label": "foo", "value": 42});
let got = capture_hook_stdin(&value).await;
let parsed: Value = serde_json::from_str(&got).unwrap_or_default();
assert_eq!(parsed["label"], "foo");
assert_eq!(parsed["value"], 42);
}
#[tokio::test]
async fn hook_receives_special_chars() {
let value = json!("he said \"hi\"\nand left");
let got = capture_hook_stdin(&value).await;
let parsed: Value = serde_json::from_str(&got).unwrap_or_default();
assert_eq!(
parsed.as_str().unwrap_or_default(),
"he said \"hi\"\nand left"
);
}
// -- Hook stdout-to-stderr redirection --
#[tokio::test]
async fn hook_stdout_not_on_piped_stdout() {
// With piped stdout, `echo hello` output is capturable:
let value = json!("test");
let child = Command::new("sh")
.arg("-c")
.arg("echo hello")
.arg("cat")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn();
assert!(child.is_ok(), "should be able to spawn echo");
if let Ok(mut child) = child {
if let Some(mut stdin) = child.stdin.take() {
let json = serde_json::to_string(&value).unwrap_or_default();
let _ = stdin.write_all(json.as_bytes()).await;
drop(stdin);
}
let output = child
.wait_with_output()
.await
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: Vec::new(),
stderr: Vec::new(),
});
let piped_out = String::from_utf8(output.stdout).unwrap_or_default();
assert_eq!(piped_out.trim(), "hello");
}
// With stderr_as_stdio(), hook stdout is redirected away from stdout.
// Verify the hook still succeeds (output goes to stderr instead).
let result = run_hook("echo hello", &value).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn stderr_as_stdio_returns_valid_fd() {
// Verify stderr_as_stdio() produces a usable Stdio.
// A child process using it should spawn and exit cleanly.
let child = Command::new("sh")
.arg("-c")
.arg("echo ok >&1")
.stdin(std::process::Stdio::null())
.stdout(stderr_as_stdio())
.stderr(std::process::Stdio::inherit())
.spawn();
assert!(child.is_ok());
let Ok(mut child) = child else {
return;
};
let _ = write_json_stdin(&mut child, &value).await;
let output = child
.unwrap_or_else(|_| unreachable!())
.wait_with_output()
.await;
assert!(output.is_ok());
assert!(
output
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: Vec::new(),
stderr: Vec::new(),
})
.status
.success()
);
.await
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: Vec::new(),
stderr: Vec::new(),
});
let got = String::from_utf8(output.stdout).unwrap_or_default();
assert_eq!(got, r#""hello""#);
}
// -- Hook error propagation --
#[tokio::test]
async fn hook_nonzero_exit() {
let value = json!("test");
@@ -245,34 +204,4 @@ mod tests {
assert_eq!(status.code(), Some(42));
}
}
#[tokio::test]
async fn hook_missing_command() {
let value = json!("test");
let result = run_hook("/nonexistent_binary_that_does_not_exist_12345", &value).await;
// sh -c will fail with 127 (command not found)
assert!(result.is_err());
}
#[tokio::test]
async fn hook_empty_command() {
let value = json!("test");
// Empty string passed to sh -c is a no-op, exits 0
let result = run_hook("", &value).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn hook_with_stdout_uses_custom_stdio() {
let value = json!("custom");
let result = run_hook_with_stdout("echo ok", &value, std::process::Stdio::piped()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn hook_with_stdout_propagates_failure() {
let value = json!("test");
let result = run_hook_with_stdout("exit 1", &value, std::process::Stdio::piped()).await;
assert!(matches!(result, Err(PiklError::HookFailed { .. })));
}
}

View File

@@ -1,17 +1,27 @@
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(
@@ -23,14 +33,69 @@ struct Cli {
#[arg(long, default_value = "label")]
label_key: String,
/// Shell command to run on selection (item JSON piped to stdin)
// -- Exec hooks (fire-and-forget subprocess per event) --
/// Shell command to run on menu open
#[arg(long)]
on_select: Option<String>,
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>,
@@ -42,9 +107,26 @@ struct Cli {
/// 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
@@ -121,9 +203,6 @@ fn main() {
});
// Reopen stdin from /dev/tty before entering async context.
// Both headless (show-ui branch) and interactive paths need this,
// so do it once here. Headless-only (no show-ui) doesn't need
// terminal input, but reopening is harmless.
if script.is_none()
&& let Err(e) = reopen_stdin_from_tty()
{
@@ -145,27 +224,120 @@ fn main() {
};
// STEP 4: Branch on headless vs interactive
let label_key = cli.label_key.clone();
let result = if let Some(script) = script {
rt.block_on(run_headless(items, label_key, script, start_mode))
rt.block_on(run_headless(items, &cli, script, start_mode))
} else {
rt.block_on(run_interactive(items, label_key, start_mode))
rt.block_on(run_interactive(items, &cli, start_mode))
};
// STEP 5: Handle result
handle_result(result, &cli, &rt);
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>,
label_key: String,
cli: &Cli,
script: Vec<ScriptAction>,
start_mode: Mode,
) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
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
@@ -182,7 +354,6 @@ async fn run_headless(
match show_action {
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
// GUI doesn't exist yet. All show-* variants launch TUI for now.
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
let result = menu_handle
.await
@@ -204,11 +375,16 @@ async fn run_headless(
/// pick from the menu.
async fn run_interactive(
items: Vec<Item>,
label_key: String,
cli: &Cli,
start_mode: Mode,
) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key));
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.
@@ -216,7 +392,6 @@ async fn run_interactive(
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
pikl_tui::restore_terminal();
// Send cancel so the menu loop exits cleanly.
let _ = signal_tx.send(Action::Cancel).await;
}
});
@@ -229,42 +404,48 @@ async fn run_interactive(
result
}
/// Serialize a value as JSON and write it to the given writer.
fn write_selected_json(
writer: &mut impl Write,
value: &serde_json::Value,
) -> Result<(), std::io::Error> {
let json = serde_json::to_string(value).unwrap_or_default();
writeln!(writer, "{json}")
}
/// Run a hook command if present. On failure, print the error
/// to stderr and exit.
fn run_result_hook(
rt: &tokio::runtime::Runtime,
hook_name: &str,
command: Option<&str>,
value: &serde_json::Value,
) {
if let Some(cmd) = command
&& let Err(e) = rt.block_on(hook::run_hook(cmd, value))
{
let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}");
std::process::exit(2);
}
}
/// Process the menu result: print selected item JSON to
/// stdout, run hooks, or exit with the appropriate code.
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli, rt: &tokio::runtime::Runtime) {
/// 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)) => {
run_result_hook(rt, "on-select", cli.on_select.as_deref(), &value);
let _ = write_selected_json(&mut std::io::stdout().lock(), &value);
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) => {
let empty = serde_json::Value::String(String::new());
run_result_hook(rt, "on-cancel", cli.on_cancel.as_deref(), &empty);
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) => {
@@ -274,12 +455,27 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli, rt: &tokio::r
}
}
/// 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).
// TODO: The interactive path blocks on all of stdin before showing
// the menu. Switch to streaming items via Action::AddItems so the
// menu renders immediately and populates as lines arrive.
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);
@@ -315,17 +511,10 @@ fn reopen_stdin_from_tty() -> Result<(), PiklError> {
{
use std::os::unix::io::AsRawFd;
let tty = std::fs::File::open("/dev/tty")?;
// SAFETY: dup2 is a standard POSIX call. We're
// redirecting stdin to the controlling tty so the
// TUI can read keyboard input after stdin was
// consumed for piped items.
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()));
}
// SAFETY: tcflush is a standard POSIX call. Flush
// stale input that arrived between dup2 and raw
// mode so crossterm starts clean.
unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
}
Ok(())

View File

@@ -213,17 +213,24 @@ them. The core manages debouncing and cancel-stale logic
since that interacts with the event loop timing.
```rust
#[async_trait]
pub trait HookHandler: Send + Sync {
async fn handle(&self, event: HookEvent)
fn handle(&self, event: HookEvent)
-> Result<Vec<HookResponse>, PiklError>;
}
```
The CLI binary provides `ShellHookHandler`, which maps
CLI flags to shell commands. Library consumers implement
their own handlers with whatever behaviour they want:
in-process closures, network calls, anything.
The trait is deliberately synchronous for dyn-compatibility.
Implementations that need async work (spawning processes,
writing to channels) use `tokio::spawn` internally. This
keeps the trait object-safe so the core can hold
`Arc<dyn HookHandler>`.
The CLI binary provides `ShellExecHandler` and
`ShellHandlerHook`, which map CLI flags to shell commands.
A `CompositeHookHandler` dispatches to both based on event
kind. Library consumers implement their own handlers with
whatever behaviour they want: in-process closures, network
calls, anything.
## Filtering

View File

@@ -14,7 +14,7 @@ when* we build it.
- Ship something usable early, iterate from real usage
- Don't optimize until there's a reason to
## Phase 1: Core Loop (TUI)
## Phase 1: Core Loop (TUI)
The minimum thing that works end-to-end.
@@ -35,7 +35,7 @@ The minimum thing that works end-to-end.
**Done when:** `ls | pikl` works and prints the selected
item.
## Phase 1.5: Action-fd (Headless Mode)
## Phase 1.5: Action-fd (Headless Mode)
Scriptable, non-interactive mode for integration tests and
automation. Small enough to slot in before phase 2. It's
@@ -60,7 +60,7 @@ for `show-ui`.
**Done when:** `echo -e "hello\nworld" | pikl --action-fd 3`
with `confirm` on fd 3 prints `"hello"` to stdout.
## Phase 2: Navigation & Filtering
## Phase 2: Navigation & Filtering
Make it feel like home for a vim user. The filter system
is the real star here: strategy prefixes, pipeline
@@ -112,7 +112,7 @@ chaining, incremental caching.
with vim muscle memory.
`'log | !temp | /[0-9]+/` works as a pipeline.
## Phase 3: Structured I/O & Hooks
## Phase 3: Structured I/O & Hooks
The structured data pipeline and the full hook system.
@@ -275,6 +275,36 @@ navigate directories without spawning new processes.
query, or the viewport to do a lookup after each filter
pass.
- **Confirm-with-arguments (Shift+Enter).** Select an item
and also pass free-text arguments alongside it. Primary
use case: app launcher where you select `ls` and want to
pass `-la` to it. The output would include both the
selected item and the user-supplied arguments.
Open questions:
- UX flow: does the filter text become the args on
Shift+Enter? Or does Shift+Enter open a second input
field for args after selection? The filter-as-args
approach is simpler but conflates filtering and
argument input. A two-step flow (select, then type
args) is cleaner but adds a mode.
- Output format: separate field in the JSON output
(`"args": "-la"`)? Second line on stdout? Appended to
the label? Needs to be unambiguous for scripts.
- Should regular Enter with a non-empty filter that
matches exactly one item just confirm that item (current
behaviour), or should it also treat any "extra" text
as args? Probably not, too implicit.
- Keybind: Shift+Enter is natural, but some terminals
don't distinguish it from Enter. May need a fallback
like Ctrl+Enter or a normal-mode keybind.
This is a core feature (new keybind, new output field),
not just a launcher script concern. Fits naturally after
phase 4 (multi-select) since it's another selection
mode variant. The launcher script would assemble
`{selected} {args}` for execution.
## Future Ideas (Unscheduled)
These are things we've talked about or thought of. No
@@ -306,3 +336,11 @@ commitment, no order.
(optionally into a tmux session). Needs GUI frontend
(phase 8) and frecency sorting.
See `docs/use-cases/app-launcher.md`.
Setup guides: `docs/guides/app-launcher.md`.
- App description indexing: a tool or subcommand that
builds a local cache of binary descriptions from man
pages (`whatis`), .desktop file Comment fields, and
macOS Info.plist data. Solves the "whatis is too slow
to run per-keystroke" problem for the app launcher.
Could be a `pikl index` subcommand or a standalone
helper script.

286
docs/guides/app-launcher.md Normal file
View File

@@ -0,0 +1,286 @@
# App Launcher Setup
Use pikl as a keyboard-driven application launcher. Bind
a hotkey, type a few characters, hit Enter, and your app
launches. This guide covers terminal, Hyprland, i3, and
macOS setups.
For the design rationale and feature roadmap, see
[the app launcher use case](../use-cases/app-launcher.md).
## Quick start: terminal
The simplest version. No GUI, no hotkeys, just an alias:
```sh
# ~/.bashrc or ~/.zshrc
alias launch='compgen -c | sort -u | pikl | xargs -I{} sh -c "{} &"'
```
Run `launch`, type to filter, Enter to run the selection
in the background. That's it.
### With descriptions (experimental)
You can pull one-line descriptions from man pages using
`whatis`. This is noticeably slow on systems with
thousands of binaries (it shells out per binary), so treat
it as a nice-to-have rather than the default:
```sh
launch-rich() {
compgen -c | sort -u | while IFS= read -r cmd; do
desc=$(whatis "$cmd" 2>/dev/null | head -1 | sed 's/.*- //')
printf '{"label":"%s","sublabel":"%s"}\n' "$cmd" "$desc"
done | pikl --format '{label} <dim>{sublabel}</dim>' \
| jq -r '.label' \
| xargs -I{} sh -c '{} &'
}
```
This works, but it's slow. Caching the output of the
`whatis` loop to a file and refreshing it periodically
would make it usable day-to-day. A built-in indexing
solution is on the roadmap but not built yet.
## Hyprland
Hyprland is a first-class target. pikl uses
`iced_layershell` to render as a Wayland layer-shell
overlay, so it floats above your desktop like rofi does.
### The launcher script
Save this somewhere in your PATH (e.g.
`~/.local/bin/pikl-launch`):
```sh
#!/bin/sh
# pikl-launch: open pikl as a GUI app launcher
compgen -c | sort -u \
| pikl --mode gui \
| xargs -I{} sh -c '{} &'
```
```sh
chmod +x ~/.local/bin/pikl-launch
```
### Keybinding
Add to `~/.config/hypr/hyprland.conf`:
```
bind = SUPER, SPACE, exec, pikl-launch
```
Reload with `hyprctl reload` or restart Hyprland.
### With .desktop files (future)
When pikl gains .desktop file parsing (or a helper
script emits structured JSON from XDG desktop entries),
you'll get proper app names, descriptions, and categories
instead of raw binary names. The keybinding and workflow
stay the same, only the input to pikl changes.
## i3
i3 runs on X11, so pikl opens as an override-redirect
window rather than a layer-shell overlay. Same launcher
script, different keybinding syntax.
### The launcher script
Same `pikl-launch` script as Hyprland above. pikl
auto-detects X11 vs Wayland, so no changes needed.
### Keybinding
Add to `~/.config/i3/config`:
```
bindsym $mod+space exec --no-startup-id pikl-launch
```
Reload with `$mod+Shift+r`.
### Notes
- `--no-startup-id` prevents the i3 startup notification
cursor from spinning while pikl is open.
- If pikl doesn't grab focus automatically, you may need
to add an i3 rule:
```
for_window [class="pikl"] focus
```
## macOS with Raycast
Raycast is the best way to bind a global hotkey to pikl
on macOS. You create a script command that Raycast can
trigger from its search bar or a direct hotkey.
### Prerequisites
- [Raycast](https://raycast.com) installed
- pikl installed (`cargo install pikl`)
- pikl in your PATH (cargo's bin directory is usually
`~/.cargo/bin`, make sure it's in your shell PATH)
### Create the script command
Raycast script commands are shell scripts with a special
header. Create this file in your Raycast script commands
directory (usually `~/.config/raycast/scripts/` or
wherever you've configured it):
**`pikl-launch.sh`:**
```bash
#!/bin/bash
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title App Launcher (pikl)
# @raycast.mode silent
# @raycast.packageName pikl
# Optional parameters:
# @raycast.icon :rocket:
# Scan /Applications for .app bundles, pipe to pikl, open the result
find /Applications ~/Applications -maxdepth 2 -name '*.app' 2>/dev/null \
| sed 's|.*/||; s|\.app$||' \
| sort -u \
| pikl --mode gui \
| xargs -I{} open -a "{}"
```
```sh
chmod +x pikl-launch.sh
```
The `silent` mode tells Raycast not to show any output
window. pikl handles its own GUI.
### Assign a hotkey
1. Open Raycast preferences (Cmd+,)
2. Go to Extensions
3. Find "App Launcher (pikl)" under Script Commands
4. Click the hotkey field and press your preferred combo
(e.g. Ctrl+Space, Opt+Space, etc.)
### With structured JSON input
For a richer experience with app metadata, you can parse
the `Info.plist` files inside .app bundles:
```bash
#!/bin/bash
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title App Launcher (pikl)
# @raycast.mode silent
# @raycast.packageName pikl
for app in /Applications/*.app ~/Applications/*.app; do
[ -d "$app" ] || continue
name=$(basename "$app" .app)
# Pull the bundle identifier for metadata
bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \
"$app/Contents/Info.plist" 2>/dev/null)
printf '{"label":"%s","meta":{"bundle":"%s","path":"%s"}}\n' \
"$name" "$bundle_id" "$app"
done \
| pikl --mode gui \
| jq -r '.meta.path' \
| xargs -I{} open "{}"
```
This version gives pikl structured data to work with.
When pikl gains frecency sorting, your most-launched apps
will float to the top automatically.
### Alternative: skhd
If you don't use Raycast, [skhd](https://github.com/koekeishiya/skhd)
is a standalone hotkey daemon:
```
# ~/.config/skhd/skhdrc
ctrl + space : pikl-launch-mac.sh
```
Where `pikl-launch-mac.sh` is the same script as above,
minus the Raycast header comments.
## Customizing the launcher
These tips apply to all platforms.
### Filter fields
If your input includes structured JSON with metadata,
tell pikl which fields to search:
```sh
# Search both label and sublabel when filtering
pikl --filter-fields label,sublabel
```
### Hooks
Run something when the launcher opens or when an item
is selected:
```sh
pikl --mode gui \
--on-select-exec 'jq -r .label >> ~/.local/state/pikl-launch-history'
```
This logs every launch to a history file, which could
feed into frecency sorting later.
### Starting in normal mode
If you prefer to land in vim normal mode (navigate first,
then `/` to filter):
```sh
pikl --mode gui --start-mode normal
```
## What's not built yet
A few things mentioned in this guide depend on features
that are still in development:
- **GUI frontend** (phase 8): the `--mode gui` flag and
layer-shell/X11 rendering. Until this ships, you can use
the TUI versions in a drop-down terminal (like tdrop,
kitty's `--single-instance`, or a quake-mode terminal).
- **Frecency sorting:** tracking launch frequency and
boosting common picks. On the roadmap.
- **.desktop file parsing:** structured input from XDG
desktop entries with proper names, icons, and categories.
On the roadmap.
- **Description caching/indexing:** a way to build and
maintain a local index of binary descriptions so the
launcher can show what each app does without the slow
`whatis` loop. On the roadmap.
## GNOME, KDE, and other desktops
These aren't supported as app launcher environments right
now. Hyprland, i3, and macOS are the environments the dev
team uses daily, so that's where the effort goes. The
main blocker for GNOME on Wayland is the lack of
layer-shell support, which means pikl can't render as an
overlay the same way it does on wlroots-based compositors.
If you use one of these environments and want to help,
PRs and discussion are welcome.

60
docs/guides/install.md Normal file
View File

@@ -0,0 +1,60 @@
# Installing pikl
## From crates.io (recommended)
```sh
cargo install pikl
```
This builds the unified `pikl` binary with both TUI and
GUI frontends. You'll need a working Rust toolchain. If
you don't have one, [rustup](https://rustup.rs) is the
way to go.
### TUI only
If you only want the terminal interface and don't want to
pull in GUI dependencies:
```sh
cargo install pikl --no-default-features --features tui
```
## From source
```sh
git clone https://github.com/maplecool/pikl-menu.git
cd pikl-menu
cargo install --path .
```
This builds and installs the `pikl` binary into your
cargo bin directory (usually `~/.cargo/bin/`).
## Package managers
We'd like pikl to be available in package managers like
the AUR and Homebrew, but honestly haven't set that up
before and aren't sure when we'll get to it. TBD.
If you package pikl for a distro or package manager, open
an issue and we'll link it here.
## Verify it works
```sh
echo -e "hello\nworld\ngoodbye" | pikl
```
You should see a filterable list in insert mode. Type to
filter, use arrow keys to navigate, Enter to select,
Escape to quit. The selected item prints to stdout.
pikl starts in insert mode by default (type to filter
immediately). Press Ctrl+N to switch to normal mode for
vim-style navigation (j/k, gg, G, Ctrl+D/U). Ctrl+E
switches back to insert mode. You can also start in
normal mode with `--start-mode normal`.
Note: Ctrl+I is not the same as Tab. pikl treats these
as distinct inputs.

View File

@@ -0,0 +1,64 @@
# Lesson: async fn in traits vs dyn-compatibility
## The problem
The `HookHandler` trait needs to be stored as
`Arc<dyn HookHandler>` so the menu engine can hold a
type-erased handler without knowing whether it's an exec
hook, a handler hook, or a composite. But `async fn` in
traits (even with edition 2024's native support) makes the
trait not dyn-compatible. The compiler can't build a vtable
for an async method because the return type (an opaque
`impl Future`) varies per implementation.
## What we tried
Official documentation of edition 2024 supports async fn
in traits natively, async-trait is NOT needed." That's true
for static dispatch (generics), but wrong for `dyn` dispatch.
## The fix
Made `handle()` a regular sync `fn`. The trait is
deliberately synchronous:
```rust
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent)
-> Result<Vec<HookResponse>, PiklError>;
}
```
This works because handlers don't actually need to `await`
anything at the call site:
- **Exec hooks** spawn a subprocess via `tokio::spawn` and
return immediately. The process runs in the background.
- **Handler hooks** write to an internal channel via
`try_send` (non-blocking) and return. A background task
handles the actual I/O.
Both return `Ok(vec![])` instantly. Responses from handler
hooks flow back through the action channel asynchronously.
## Takeaway
`async fn in trait` (edition 2024) is for generic/static
dispatch. If you need `dyn Trait`, you still need either:
1. A sync method (if the work can be spawned internally)
2. A boxed future return type
3. The `async-trait` crate
Option 1 is the simplest when the handler doesn't need to
return data from async work. If responses are needed, they
go through a separate channel rather than the return value.
## Related: tokio test-util for debounce tests
Tests that verify debounce timing need `tokio::time::pause`
and `tokio::time::advance`, which require the `test-util`
feature on tokio in dev-dependencies. Use
`#[tokio::test(start_paused = true)]` and
`tokio::time::sleep()` (which auto-advances in paused mode)
rather than manual `pause()` + `advance()` + `yield_now()`
chains, which are fragile with spawned tasks.

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