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

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

View File

@@ -1,15 +1,303 @@
//! 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 },
}
/// 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,
}
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,
}
}
}
/// 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 optionally return responses.
/// Exec hooks return empty vecs. Handler hooks send
/// responses back through the action channel asynchronously
/// and also return empty vecs.
///
/// 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<Vec<HookResponse>, 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) => 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());
}
// -- 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);
}
}