From b14545db7c4e3327874e7c9bd44535817bb706b2 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 17:11:05 -0700 Subject: [PATCH 1/8] P3-09: add context-aware Herdr popup plugin --- Cargo.lock | 1 + Cargo.toml | 1 + crates/wiki-reader-core/Cargo.toml | 1 + crates/wiki-reader-core/src/herdr.rs | 54 +++++++++++++- crates/wiki-reader-core/tests/herdr_plugin.rs | 71 ++++++++++++++++++ crates/wiki-reader/src/main.rs | 72 +++++++++++++++++-- integrations/herdr/README.md | 55 ++++++++++++++ integrations/herdr/herdr-plugin.toml | 19 +++++ wiki/roadmap/dogfood-log.md | 7 ++ wiki/roadmap/phase-3-alpha.md | 2 +- 10 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 crates/wiki-reader-core/tests/herdr_plugin.rs create mode 100644 integrations/herdr/README.md create mode 100644 integrations/herdr/herdr-plugin.toml diff --git a/Cargo.lock b/Cargo.lock index 1b9470c..911d719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3021,6 +3021,7 @@ dependencies = [ "percent-encoding", "pulldown-cmark", "serde", + "serde_json", "serde_norway", "tempfile", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 481d176..c08d6a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ ratatui = "0.30.2" ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] } image = { version = "0.25.6", default-features = false, features = ["png", "jpeg", "gif", "webp"] } serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.151" serde_norway = "0.9.42" toml = "0.9.8" toml_edit = "0.25.15" diff --git a/crates/wiki-reader-core/Cargo.toml b/crates/wiki-reader-core/Cargo.toml index 2648d79..f51c384 100644 --- a/crates/wiki-reader-core/Cargo.toml +++ b/crates/wiki-reader-core/Cargo.toml @@ -14,6 +14,7 @@ thiserror = { workspace = true } ignore = { workspace = true } globset = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } serde_norway = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } diff --git a/crates/wiki-reader-core/src/herdr.rs b/crates/wiki-reader-core/src/herdr.rs index c7bf293..d7fe0c7 100644 --- a/crates/wiki-reader-core/src/herdr.rs +++ b/crates/wiki-reader-core/src/herdr.rs @@ -1,4 +1,4 @@ -//! Reading herdr's own settings (read-only). +//! Reading herdr's settings and plugin launch context (read-only). //! //! herdr does not tell child programs which theme it uses (no environment variable or socket //! call), but the choice is in its `config.toml`, so wiki-reader reads the theme name from there @@ -6,6 +6,20 @@ use std::path::{Path, PathBuf}; +/// Collection cwd from Herdr's plugin context: focused pane first, then workspace. +/// +/// Missing, malformed or empty fields return `None`. This only parses the context; +/// the launcher checks that the selected path is a directory before using it. +#[must_use] +pub fn parse_context_cwd(text: &str) -> Option { + let context: serde_json::Value = serde_json::from_str(text).ok()?; + ["focused_pane_cwd", "workspace_cwd"] + .iter() + .filter_map(|key| context.get(*key)?.as_str()) + .find(|cwd| !cwd.trim().is_empty()) + .map(PathBuf::from) +} + /// herdr's config file: `$XDG_CONFIG_HOME/herdr/config.toml`, else `~/.config/herdr/config.toml`. #[must_use] pub fn config_path() -> Option { @@ -46,6 +60,44 @@ pub fn parse_theme_name(text: &str) -> Option { mod tests { use super::*; + #[test] + fn context_prefers_focused_pane_and_preserves_path() { + assert_eq!( + parse_context_cwd( + r#"{"focused_pane_cwd":"/a collection/with spaces", "workspace_cwd":"/workspace", "extra":true}"# + ), + Some(PathBuf::from("/a collection/with spaces")) + ); + } + + #[test] + fn context_falls_back_to_workspace() { + for focused in ["null", "42", "\"\"", "\" \""] { + let text = format!(r#"{{"focused_pane_cwd":{focused},"workspace_cwd":"/workspace"}}"#); + assert_eq!(parse_context_cwd(&text), Some(PathBuf::from("/workspace"))); + } + assert_eq!( + parse_context_cwd(r#"{"workspace_cwd":"/workspace"}"#), + Some(PathBuf::from("/workspace")) + ); + } + + #[test] + fn context_rejects_malformed_or_missing_paths() { + for text in [ + "", + "not json", + "{", + "null", + "[]", + "{}", + r#"{"workspace_cwd":false}"#, + r#"{"workspace_cwd":" "}"#, + ] { + assert_eq!(parse_context_cwd(text), None, "{text}"); + } + } + #[test] fn reads_the_theme_name() { let text = "onboarding = false\n[ui]\nagent_panel_sort = \"spaces\"\n[theme]\nname = \"vesper\"\nauto_switch = false\n"; diff --git a/crates/wiki-reader-core/tests/herdr_plugin.rs b/crates/wiki-reader-core/tests/herdr_plugin.rs new file mode 100644 index 0000000..cbbbdf0 --- /dev/null +++ b/crates/wiki-reader-core/tests/herdr_plugin.rs @@ -0,0 +1,71 @@ +//! Contract checks for the shipped plugin; never links to a live Herdr session. + +fn manifest() -> toml::Table { + toml::from_str(include_str!( + "../../../integrations/herdr/herdr-plugin.toml" + )) + .unwrap() +} + +#[test] +fn popup_manifest_uses_context_launcher() { + let manifest = manifest(); + assert_eq!(manifest["id"].as_str(), Some("wiki-reader")); + assert_eq!(manifest["min_herdr_version"].as_str(), Some("0.9.0")); + assert_eq!(manifest["platforms"].as_array().unwrap().len(), 2); + let panes = manifest["panes"].as_array().unwrap(); + assert_eq!(panes.len(), 1); + let pane = &panes[0]; + assert_eq!(pane["id"].as_str(), Some("reader")); + assert_eq!(pane["placement"].as_str(), Some("popup")); + assert_eq!(pane["width"].as_str(), Some("80%")); + assert_eq!(pane["height"].as_str(), Some("80%")); + let command: Vec<_> = pane["command"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(command, ["wiki-reader", "--herdr-context"]); +} + +#[cfg(unix)] +#[test] +fn action_uses_inherited_binary_and_propagates_failure() { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let manifest = manifest(); + let actions = manifest["actions"].as_array().unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0]["id"].as_str(), Some("open")); + let argv: Vec<_> = actions[0]["command"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + let dir = tempfile::tempdir().unwrap(); + // Spaces and shell metacharacters must stay part of the binary path. + let binary = dir.path().join("herdr binary; not a shell command"); + std::fs::write(&binary, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap(); + let output = Command::new(argv[0]) + .args(&argv[1..]) + .env("HERDR_BIN_PATH", &binary) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "plugin\npane\nopen\n--plugin\nwiki-reader\n--entrypoint\nreader\n" + ); + let status = Command::new(argv[0]) + .args(&argv[1..]) + .env("HERDR_BIN_PATH", dir.path().join("missing")) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); +} diff --git a/crates/wiki-reader/src/main.rs b/crates/wiki-reader/src/main.rs index 3dc2510..3201e8a 100644 --- a/crates/wiki-reader/src/main.rs +++ b/crates/wiki-reader/src/main.rs @@ -17,8 +17,10 @@ use clap::Parser; )] struct Args { /// Collection root to open (defaults to the current directory). - #[arg(default_value = ".")] - root: PathBuf, + root: Option, + /// Use Herdr's focused pane or workspace cwd when no root is supplied. + #[arg(long)] + herdr_context: bool, /// Optional config TOML (overrides XDG and `/.wiki-reader.toml`). #[arg(long = "config", value_name = "PATH")] config: Option, @@ -31,13 +33,28 @@ fn check_root(root: &Path) -> Result<(), String> { } } +fn resolve_root(args: &Args, context: Option<&str>) -> PathBuf { + if let Some(root) = &args.root { + return root.clone(); + } + if args.herdr_context + && let Some(root) = context.and_then(wiki_reader_core::herdr::parse_context_cwd) + && root.is_dir() + { + return root; + } + PathBuf::from(".") +} + fn main() -> ExitCode { let args = Args::parse(); - if let Err(msg) = check_root(&args.root) { + let context = std::env::var("HERDR_PLUGIN_CONTEXT_JSON").ok(); + let root = resolve_root(&args, context.as_deref()); + if let Err(msg) = check_root(&root) { eprintln!("wiki-reader: {msg}"); return ExitCode::FAILURE; } - match tui::app::run(&args.root, args.config.as_deref()) { + match tui::app::run(&root, args.config.as_deref()) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("wiki-reader: {err}"); @@ -63,6 +80,53 @@ mod tests { assert!(err.contains("not a directory"), "got: {err}"); } + #[test] + fn root_defaults_to_current_directory_without_flag() { + let args = Args::try_parse_from(["wiki-reader"]).unwrap(); + assert_eq!( + resolve_root(&args, Some(r#"{"workspace_cwd":"/"}"#)), + PathBuf::from(".") + ); + } + + #[test] + fn explicit_root_wins_even_when_invalid() { + let args = Args::try_parse_from([ + "wiki-reader", + "--herdr-context", + "/nope-wiki-reader-missing", + ]) + .unwrap(); + let root = resolve_root(&args, Some(r#"{"workspace_cwd":"/"}"#)); + assert_eq!(root, PathBuf::from("/nope-wiki-reader-missing")); + assert!(check_root(&root).is_err()); + } + + #[test] + fn context_root_uses_existing_focused_or_workspace_directory() { + let args = Args::try_parse_from(["wiki-reader", "--herdr-context"]).unwrap(); + for field in ["focused_pane_cwd", "workspace_cwd"] { + let context = format!(r#"{{"{field}":"/"}}"#); + assert_eq!(resolve_root(&args, Some(&context)), PathBuf::from("/")); + } + } + + #[test] + fn unusable_context_silently_defaults_to_current_directory() { + let args = Args::try_parse_from(["wiki-reader", "--herdr-context"]).unwrap(); + let file = tempfile::NamedTempFile::new().unwrap(); + let file_context = format!(r#"{{"focused_pane_cwd":"{}"}}"#, file.path().display()); + for context in [ + None, + Some("not json"), + Some("{}"), + Some(r#"{"focused_pane_cwd":"/nope-wiki-reader-missing","workspace_cwd":"/"}"#), + Some(file_context.as_str()), + ] { + assert_eq!(resolve_root(&args, context), PathBuf::from(".")); + } + } + #[test] fn version_flag_reports_pkg_version() { use clap::CommandFactory; diff --git a/integrations/herdr/README.md b/integrations/herdr/README.md new file mode 100644 index 0000000..fd1e06f --- /dev/null +++ b/integrations/herdr/README.md @@ -0,0 +1,55 @@ +# Herdr popup plugin + +Requires Herdr **0.9.0+** on Linux or macOS and a wiki-reader binary supporting +`--herdr-context` on Herdr's `PATH`. The installed v0.1.3 binary does not support +this flag; build/install the implementation revision until v0.1.4 is released. +The manifest installs no build commands, startup hooks or event hooks. + +From the repository root, after reviewing `herdr-plugin.toml`: + +```bash +cargo install --locked --path crates/wiki-reader +herdr plugin link "$PWD/integrations/herdr" +herdr plugin pane open --plugin wiki-reader --entrypoint reader +# Equivalent action, suitable for binding a key: +herdr plugin action invoke wiki-reader.open +``` + +The popup is 80% of the terminal's width and height. `q` exits wiki-reader and +closes it; `Esc` dismisses wiki-reader's own overlays rather than exiting. +Herdr 0.9.0's CLI help omits popup placement, but the manifest works on that +version (see the [spike evidence](../../wiki/roadmap/spikes/p3-s2-herdr-integration.md)). +Overlay and split graphics are not promised by this plugin. + +Herdr starts plugin commands in the plugin directory. `--herdr-context` reads +`HERDR_PLUGIN_CONTEXT_JSON`: an explicit collection root wins; otherwise it uses +`focused_pane_cwd`, then `workspace_cwd` when the focused cwd is absent or empty. +Missing/malformed context or a selected path that is not a directory silently +falls back to `.` (the plugin directory). Without the flag, the root still +defaults to `.` and explicit invalid roots still fail normally. + +Popup processes have no `HERDR_PANE_ID`. They must not publish metadata to the +underlying focused pane, which may host an unrelated agent. + +Optional binding in Herdr's config (choose a key that is free in your setup): + +```toml +[[keys.command]] +key = "prefix+w" +type = "plugin_action" +command = "wiki-reader.open" +description = "open wiki reader" +``` + +The action invokes `"$HERDR_BIN_PATH" plugin pane open`; no socket client or +extra runtime beyond the POSIX shell is required. A missing wiki-reader binary +cannot open the reader. Inspect Herdr's plugin logs for launch failures: + +```bash +herdr plugin log list --plugin wiki-reader +herdr plugin unlink wiki-reader +``` + +Unlinking unregisters the plugin without deleting the checkout or Herdr's +plugin config/state directories. Linking or changing keys is an operator action; +these commands are not run automatically by wiki-reader. diff --git a/integrations/herdr/herdr-plugin.toml b/integrations/herdr/herdr-plugin.toml new file mode 100644 index 0000000..8bff4bc --- /dev/null +++ b/integrations/herdr/herdr-plugin.toml @@ -0,0 +1,19 @@ +id = "wiki-reader" +name = "Wiki Reader" +version = "0.1.0" +min_herdr_version = "0.9.0" +platforms = ["linux", "macos"] + +[[panes]] +id = "reader" +title = "Wiki Reader" +placement = "popup" +width = "80%" +height = "80%" +command = ["wiki-reader", "--herdr-context"] + +[[actions]] +id = "open" +title = "Open Wiki Reader" +contexts = ["workspace"] +command = ["sh", "-c", 'exec "$HERDR_BIN_PATH" plugin pane open --plugin wiki-reader --entrypoint reader'] diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index 840e79f..443825c 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -125,6 +125,13 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | 2026-10-03 | decision | #126 publication record merged as `731d43f`; main synced and installed binary reports 0.1.3. Phase 2 remains on fixes-only hold until the ≈ 2026-10-13 adoption verdict; no phase exit or release authorised by this housekeeping | P3-08 | | 2026-10-03 | decision | [P3-S2 herdr spike](spikes/p3-s2-herdr-integration.md): 0.9.0 popup launches with context cwd; operator confirmed real images, Help/Esc/Ctrl+Enter/q and no leftover fragments. Plain non-agent split metadata stored; operator confirmed title or token visible in the sidebar. Confirm feasibility for P3-09 popup and P3-10 ordinary-pane publishing; no metadata from popups (no pane ID). Overlay/split keys passed automation, graphics checks remain unverified. Scratch panes closed and global plugin unlinked; P4-05 theme-name following can use a config watcher without a plugin | P3-09, P3-10, P4-05 | +## Next release (unreleased) + +| Date | Kind | Note | Task IDs | +|------|------|------|----------| +| 2026-10-03 | decision | #127 merged as `0182a96`; main synced. Begin herdr integration with the context-aware popup launcher, then ordinary-pane metadata. Intended release v0.1.4; no release or installed-binary upgrade yet. Phase 2 adoption clock unchanged | P3-09, P3-10 | +| 2026-10-03 | fix | P3-09 implementation: optional root and `--herdr-context`, focused/workspace cwd parsing with silent invalid-context fallback, explicit serde_json dependency (no locked version changes), 80% popup manifest and bindable action using HERDR_BIN_PATH. Parser/root and manifest/action failure tests added; `scripts/check.sh` passed, including ADR-0006 guard. Shipping-plugin manual pass pending; not linked, released or installed | P3-09 | + ## Related - [Phase 2](phase-2-mvp.md) diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index bf2fef9..af4c028 100644 --- a/wiki/roadmap/phase-3-alpha.md +++ b/wiki/roadmap/phase-3-alpha.md @@ -72,7 +72,7 @@ Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fal | P3-04 | Optional header ‹ › buttons | U4 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-07 | | P3-05 | Link hover preview popover | W6 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-08 | | P3-03 | Nav label options | U3 | done | completed by P3-16; two modes, literal filenames/folders and legacy config migration | -| P3-09 | herdr: launch as a herdr plugin pane | | todo | [P3-S2](spikes/p3-s2-herdr-integration.md) confirms popup feasibility on 0.9.0 (context cwd, keys, Kitty images and cleanup); implement next. Overlay/split graphics checks remain unverified | +| P3-09 | herdr: launch as a herdr plugin pane | | active | Context-aware launcher and [popup manifest](../../integrations/herdr/README.md) implemented; shipping-plugin manual pass pending. [P3-S2](spikes/p3-s2-herdr-integration.md) confirms popup feasibility on 0.9.0 (context cwd, keys, Kitty images and cleanup). Overlay/split graphics checks remain unverified | | P3-10 | herdr: publish the current page to herdr's sidebar | | todo | [P3-S2](spikes/p3-s2-herdr-integration.md): plain-pane title/token stored and sidebar visibility operator-confirmed; implement best-effort publishing for ordinary panes only. Popups have no pane ID; TTL/renew/clear still need tests | | P3-13 | Options window | U5 | done | feature request 2026-10-02. A popup like markdown-reader's, opened by a key and a footer/header button, that edits config from inside the app: **theme** selector (dark / light / herdr, live preview), **nav position** (left / right, P3-11), **nav labels** (titles ↔ filenames, `nav.labels`), **Mermaid** settings (`diagrams` tier: auto / image / text / source), **image** settings, **copy path** format (`copy.path`: relative / absolute, key added by P2-55). Implemented: `,` / `c` or the layout footer ⚙ (moved by P3-18) opens the overlay; writes via [ADR-0018](../decisions/0018-config-write-path.md); new `[images] enabled \| max_slot_rows`; live apply for every row (images re-enable after a disable needs a restart when the startup probe was skipped). Reworked 2026-10-02 (local time) after dogfood: grouped radio rows in the style of markdown-reader's settings window, `,` and `c` both open and close it, and the copy-path key is listed in Help as "Copy file path" | | P3-14 | Table viewer | U6 | done | feature request 2026-10-02. Open a large table in a modal / window (like markdown-reader): scroll in both axes with a fixed header row and first column, **filter** rows by text, **sort** by a column, copy a cell or row (reuse the tab-separated table copy from P2-R38). Entry point: the existing "expand table" block action (BA), plus `Enter` on a focused table. Also the shell for P3-15. Implemented: `RenderedDoc::tables` (`DocTable`) from `render.rs`; `ExpandTable` block action and `Enter` open `tui/table_viewer.rs` inside `tui/modal_viewer.rs`. Keys: arrows / `hjkl`, `PgUp` / `PgDn`, `g` / `G`, `/` filter, `s` sort, `y` cell, `Y` row. See [UI spec: Modal viewers](../product/ui-spec.md#modal-viewers) | From cb61100daab621fc88e71b93261b1d685a57b041 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 17:24:51 -0700 Subject: [PATCH 2/8] P3-09: record popup graphics acceptance blocker --- integrations/herdr/README.md | 6 ++++ wiki/roadmap/dogfood-log.md | 3 +- wiki/roadmap/phase-3-alpha.md | 2 +- .../roadmap/spikes/p3-s2-herdr-integration.md | 28 +++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/integrations/herdr/README.md b/integrations/herdr/README.md index fd1e06f..2089170 100644 --- a/integrations/herdr/README.md +++ b/integrations/herdr/README.md @@ -21,6 +21,12 @@ Herdr 0.9.0's CLI help omits popup placement, but the manifest works on that version (see the [spike evidence](../../wiki/roadmap/spikes/p3-s2-herdr-integration.md)). Overlay and split graphics are not promised by this plugin. +**Shipping acceptance is currently blocked:** the implementation retest on +Herdr 0.9.0 passes collection selection and keys, but falls back to text because +the popup supplies no cell metrics. Kitty is confirmed; ratatui-image drops that +result without a measured font size. See the [retest evidence](../../wiki/roadmap/spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03). +A longer probe timeout did not help. + Herdr starts plugin commands in the plugin directory. `--herdr-context` reads `HERDR_PLUGIN_CONTEXT_JSON`: an explicit collection root wins; otherwise it uses `focused_pane_cwd`, then `workspace_cwd` when the focused cwd is absent or empty. diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index 443825c..d20da98 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -130,7 +130,8 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | Date | Kind | Note | Task IDs | |------|------|------|----------| | 2026-10-03 | decision | #127 merged as `0182a96`; main synced. Begin herdr integration with the context-aware popup launcher, then ordinary-pane metadata. Intended release v0.1.4; no release or installed-binary upgrade yet. Phase 2 adoption clock unchanged | P3-09, P3-10 | -| 2026-10-03 | fix | P3-09 implementation: optional root and `--herdr-context`, focused/workspace cwd parsing with silent invalid-context fallback, explicit serde_json dependency (no locked version changes), 80% popup manifest and bindable action using HERDR_BIN_PATH. Parser/root and manifest/action failure tests added; `scripts/check.sh` passed, including ADR-0006 guard. Shipping-plugin manual pass pending; not linked, released or installed | P3-09 | +| 2026-10-03 | fix | P3-09 implementation: optional root and `--herdr-context`, focused/workspace cwd parsing with silent invalid-context fallback, explicit serde_json dependency (no locked version changes), 80% popup manifest and bindable action using HERDR_BIN_PATH. Parser/root and manifest/action failure tests added; `scripts/check.sh` passed, including ADR-0006 guard. Initial implementation gate passed before manual testing; no release or dogfood upgrade | P3-09 | +| 2026-10-03 | bite | P3-09 operator popup check with a temporary linked copy and local binary: correct collection, Help/Esc/Ctrl+Enter/q and clean dismissal pass; images do not render and Mermaid falls back to text. Screenshots show `no graphics protocol`. Explicit image config and a 2-second probe timeout still fail. Raw diagnostic confirms Kitty but no cell-size reply; the PTY provides no usable pixel-size fallback, so ratatui-image returns its default Halfblocks/10×20 picker. [Retest evidence](spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03) records the geometry gap. Shipping-plugin acceptance blocked; temporary plugin unlinked, no user config or installed binary changes | P3-09 | ## Related diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index af4c028..6a4143c 100644 --- a/wiki/roadmap/phase-3-alpha.md +++ b/wiki/roadmap/phase-3-alpha.md @@ -72,7 +72,7 @@ Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fal | P3-04 | Optional header ‹ › buttons | U4 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-07 | | P3-05 | Link hover preview popover | W6 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-08 | | P3-03 | Nav label options | U3 | done | completed by P3-16; two modes, literal filenames/folders and legacy config migration | -| P3-09 | herdr: launch as a herdr plugin pane | | active | Context-aware launcher and [popup manifest](../../integrations/herdr/README.md) implemented; shipping-plugin manual pass pending. [P3-S2](spikes/p3-s2-herdr-integration.md) confirms popup feasibility on 0.9.0 (context cwd, keys, Kitty images and cleanup). Overlay/split graphics checks remain unverified | +| P3-09 | herdr: launch as a herdr plugin pane | | active | Context-aware launcher and [popup manifest](../../integrations/herdr/README.md) implemented; manual keys/context/dismissal pass, but graphics acceptance blocked by missing popup cell metrics ([retest](spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03)). [P3-S2](spikes/p3-s2-herdr-integration.md) confirms popup feasibility on 0.9.0 (context cwd, keys, Kitty images and cleanup). Overlay/split graphics checks remain unverified | | P3-10 | herdr: publish the current page to herdr's sidebar | | todo | [P3-S2](spikes/p3-s2-herdr-integration.md): plain-pane title/token stored and sidebar visibility operator-confirmed; implement best-effort publishing for ordinary panes only. Popups have no pane ID; TTL/renew/clear still need tests | | P3-13 | Options window | U5 | done | feature request 2026-10-02. A popup like markdown-reader's, opened by a key and a footer/header button, that edits config from inside the app: **theme** selector (dark / light / herdr, live preview), **nav position** (left / right, P3-11), **nav labels** (titles ↔ filenames, `nav.labels`), **Mermaid** settings (`diagrams` tier: auto / image / text / source), **image** settings, **copy path** format (`copy.path`: relative / absolute, key added by P2-55). Implemented: `,` / `c` or the layout footer ⚙ (moved by P3-18) opens the overlay; writes via [ADR-0018](../decisions/0018-config-write-path.md); new `[images] enabled \| max_slot_rows`; live apply for every row (images re-enable after a disable needs a restart when the startup probe was skipped). Reworked 2026-10-02 (local time) after dogfood: grouped radio rows in the style of markdown-reader's settings window, `,` and `c` both open and close it, and the copy-path key is listed in Help as "Copy file path" | | P3-14 | Table viewer | U6 | done | feature request 2026-10-02. Open a large table in a modal / window (like markdown-reader): scroll in both axes with a fixed header row and first column, **filter** rows by text, **sort** by a column, copy a cell or row (reuse the tab-separated table copy from P2-R38). Entry point: the existing "expand table" block action (BA), plus `Enter` on a focused table. Also the shell for P3-15. Implemented: `RenderedDoc::tables` (`DocTable`) from `render.rs`; `ExpandTable` block action and `Enter` open `tui/table_viewer.rs` inside `tui/modal_viewer.rs`. Keys: arrows / `hjkl`, `PgUp` / `PgDn`, `g` / `G`, `/` filter, `s` sort, `y` cell, `Y` row. See [UI spec: Modal viewers](../product/ui-spec.md#modal-viewers) | diff --git a/wiki/roadmap/spikes/p3-s2-herdr-integration.md b/wiki/roadmap/spikes/p3-s2-herdr-integration.md index bbea263..5dd8c7b 100644 --- a/wiki/roadmap/spikes/p3-s2-herdr-integration.md +++ b/wiki/roadmap/spikes/p3-s2-herdr-integration.md @@ -74,6 +74,34 @@ Implementation must be best-effort and off the UI thread, use the reader's inher This is a source/design finding, not a live-watch implementation or runtime test. A config watcher alone cannot observe terminal appearance changes under `auto_switch`, and custom palette mapping is separate work. Honour config replacement/atomic writes and retain the previous theme if re-reading fails. Also check `HERDR_CONFIG_PATH` handling when implementing: the current reader helper uses XDG/HOME rather than that override. +## P3-09 implementation retest — 2026-10-03 + +The operator tested the context-aware launcher from draft #128 with a temporary +linked copy, using the local debug binary by absolute path rather than replacing +the installed v0.1.3. Correct collection selection, Help/Esc/Ctrl+Enter/q and clean +dismissal passed. **Images failed**: placeholders say `no graphics protocol`, and +Mermaid uses text. Explicit `diagrams = "image"`, enabled images and a 2-second +probe timeout did not change the result. This blocks shipping acceptance despite +the earlier popup spike's visual pass; do not generalise that pass to this run. + +A diagnostic popup captured the raw startup query response: + +```text +query: ESC_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA ESC\\ ESC[c ESC[16t ESC[5n +reply: ESC_Gi=31;OK ESC\\ ESC[?62;22c ESC[0n +``` + +Kitty is confirmed, but the cell-size query has no reply. Separate `CSI 14t`, +`CSI 18t` and `CSI 16t` queries likewise produced only the final status reply. +`ratatui-image` 11.1.0 returns its default Halfblocks picker with a 10×20 font: +its source discards the confirmed protocol when no font size can be obtained +from the response or the PTY ioctl. That 10×20 is a library default, not measured +popup geometry. Client/server remain Herdr 0.9.0, protocol 22, no stale binary. + +No forced protocol, guessed cell geometry, Herdr upgrade, user config change or +dogfood binary replacement was performed. Resolve the capability/geometry gap +before completing P3-09; the initial feasibility verdict is not shipping proof. + ## Matrix | Placement / surface | Launch and context | Keys | Kitty images / cleanup | Status | From fd12911287c39d35a0c8af7dac49c1558b7630a5 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 17:34:36 -0700 Subject: [PATCH 3/8] P3-09: identify upstream popup geometry update gap --- wiki/roadmap/dogfood-log.md | 1 + .../roadmap/spikes/p3-s2-herdr-integration.md | 31 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index d20da98..e978aa0 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -132,6 +132,7 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | 2026-10-03 | decision | #127 merged as `0182a96`; main synced. Begin herdr integration with the context-aware popup launcher, then ordinary-pane metadata. Intended release v0.1.4; no release or installed-binary upgrade yet. Phase 2 adoption clock unchanged | P3-09, P3-10 | | 2026-10-03 | fix | P3-09 implementation: optional root and `--herdr-context`, focused/workspace cwd parsing with silent invalid-context fallback, explicit serde_json dependency (no locked version changes), 80% popup manifest and bindable action using HERDR_BIN_PATH. Parser/root and manifest/action failure tests added; `scripts/check.sh` passed, including ADR-0006 guard. Initial implementation gate passed before manual testing; no release or dogfood upgrade | P3-09 | | 2026-10-03 | bite | P3-09 operator popup check with a temporary linked copy and local binary: correct collection, Help/Esc/Ctrl+Enter/q and clean dismissal pass; images do not render and Mermaid falls back to text. Screenshots show `no graphics protocol`. Explicit image config and a 2-second probe timeout still fail. Raw diagnostic confirms Kitty but no cell-size reply; the PTY provides no usable pixel-size fallback, so ratatui-image returns its default Halfblocks/10×20 picker. [Retest evidence](spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03) records the geometry gap. Shipping-plugin acceptance blocked; temporary plugin unlinked, no user config or installed binary changes | P3-09 | +| 2026-10-03 | decision | Geometry investigation: delaying the popup probe by 300 ms still fails. Herdr 0.9.0 starts the popup without pixel metrics, omits PluginPaneOpen from geometry-change classification, and normal client-shell drawing does not resize runtimes. The classifier omission remains in inspected 0.9.3/master. Record an upstream initial-geometry/invalidation fix proposal and immediate-query regression requirements; no patched build or upgrade tested. Second diagnostic plugin unlinked; P3-09 remains blocked, no release | P3-09 | ## Related diff --git a/wiki/roadmap/spikes/p3-s2-herdr-integration.md b/wiki/roadmap/spikes/p3-s2-herdr-integration.md index 5dd8c7b..1556f88 100644 --- a/wiki/roadmap/spikes/p3-s2-herdr-integration.md +++ b/wiki/roadmap/spikes/p3-s2-herdr-integration.md @@ -98,9 +98,36 @@ its source discards the confirmed protocol when no font size can be obtained from the response or the PTY ioctl. That 10×20 is a library default, not measured popup geometry. Client/server remain Herdr 0.9.0, protocol 22, no stale binary. +A second diagnostic waited 300 ms after entering the alternate screen before +querying. It still returned Halfblocks/10×20 with no capabilities. A fixed +startup sleep is therefore not an evidenced workaround. + +Inspection of Herdr's tagged 0.9.0 source identifies a geometry-update gap: + +- [Popup creation](https://github.com/herdrdev/herdr/blob/v0.9.0/src/app/popup.rs) starts the child with rows/columns but no initial cell metrics; the PTY/runtime start with zero pixel geometry. +- [`public_request_may_change_geometry` and `shell_endpoint_claims_geometry`](https://github.com/herdrdev/herdr/blob/v0.9.0/src/server/headless/client_views.rs) omit `PluginPaneOpen`. Geometry reapplication resizes the popup, but opening it does not request that reapplication. +- [Normal client-shell rendering](https://github.com/herdrdev/herdr/blob/v0.9.0/src/server/headless/render.rs) calls `render_client_shell_pane_surface` with `resize_panes = false`; merely drawing the new popup does not initialise its geometry. +- Herdr's own `xtwinops_size_queries_stay_silent_without_pixel_geometry` test in [pane terminal tests](https://github.com/herdrdev/herdr/blob/v0.9.0/src/pane/terminal.rs) asserts the silent replies we observed. The positive tests expect `CSI 6;height;width t` after a resize with real metrics. + +The omission also remains in the inspected v0.9.3 and upstream master request +classifiers; an upgrade alone is not a verified fix. Proposed host-side fix: +initialise the popup PTY **and** virtual terminal with the owning client's +known cell geometry before child execution/probe handling, classify popup opens +as geometry-changing requests, and preserve updates on resize. Unknown geometry +must remain unknown, not guessed. A post-spawn resize alone still needs a test +for a child that queries immediately, because it can race startup. + +Required upstream regression: launch a popup child that immediately sends the +Kitty/cell-size/status queries, assert confirmed Kitty and the actual host cell +size without a sleep or window resize, and repeat through both CLI/plugin action +and client keybinding paths. Check resize and multi-client ownership as well. +No patched Herdr build was run; this is a source-backed fix proposal, not a +validated implementation. + No forced protocol, guessed cell geometry, Herdr upgrade, user config change or -dogfood binary replacement was performed. Resolve the capability/geometry gap -before completing P3-09; the initial feasibility verdict is not shipping proof. +dogfood binary replacement was performed. Both diagnostic runs' temporary +plugin was unlinked; `plugin list` was empty again. Resolve the capability/geometry +gap before completing P3-09; the initial feasibility verdict is not shipping proof. ## Matrix From 7dedb5b7b4459e2f54d1b4d6da28f29f7f216bb2 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 18:00:38 -0700 Subject: [PATCH 4/8] P3-09: record local host geometry patch validation --- wiki/roadmap/dogfood-log.md | 1 + .../roadmap/spikes/p3-s2-herdr-integration.md | 35 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index e978aa0..78b7958 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -133,6 +133,7 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | 2026-10-03 | fix | P3-09 implementation: optional root and `--herdr-context`, focused/workspace cwd parsing with silent invalid-context fallback, explicit serde_json dependency (no locked version changes), 80% popup manifest and bindable action using HERDR_BIN_PATH. Parser/root and manifest/action failure tests added; `scripts/check.sh` passed, including ADR-0006 guard. Initial implementation gate passed before manual testing; no release or dogfood upgrade | P3-09 | | 2026-10-03 | bite | P3-09 operator popup check with a temporary linked copy and local binary: correct collection, Help/Esc/Ctrl+Enter/q and clean dismissal pass; images do not render and Mermaid falls back to text. Screenshots show `no graphics protocol`. Explicit image config and a 2-second probe timeout still fail. Raw diagnostic confirms Kitty but no cell-size reply; the PTY provides no usable pixel-size fallback, so ratatui-image returns its default Halfblocks/10×20 picker. [Retest evidence](spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03) records the geometry gap. Shipping-plugin acceptance blocked; temporary plugin unlinked, no user config or installed binary changes | P3-09 | | 2026-10-03 | decision | Geometry investigation: delaying the popup probe by 300 ms still fails. Herdr 0.9.0 starts the popup without pixel metrics, omits PluginPaneOpen from geometry-change classification, and normal client-shell drawing does not resize runtimes. The classifier omission remains in inspected 0.9.3/master. Record an upstream initial-geometry/invalidation fix proposal and immediate-query regression requirements; no patched build or upgrade tested. Second diagnostic plugin unlinked; P3-09 remains blocked, no release | P3-09 | +| 2026-10-03 | fix | Prepared a local, uncommitted Herdr geometry patch against upstream master `5da0a01e`: initial PTY/virtual-terminal metrics, plugin-open geometry invalidation and client/controller ownership. Five regressions pass; disabling the fix makes four fail. Native suite passes 3713 tests (12 skipped), plus fmt, native clippy and architecture/maintenance/integration/docs checks. Full upstream `just check` unavailable (`just` missing; Windows not validated). Patch and validation notes exported under `/tmp`; no upstream PR, install or live restart. P3-09 graphics acceptance and v0.1.4 remain blocked | P3-09 | ## Related diff --git a/wiki/roadmap/spikes/p3-s2-herdr-integration.md b/wiki/roadmap/spikes/p3-s2-herdr-integration.md index 1556f88..75539c2 100644 --- a/wiki/roadmap/spikes/p3-s2-herdr-integration.md +++ b/wiki/roadmap/spikes/p3-s2-herdr-integration.md @@ -121,14 +121,45 @@ Required upstream regression: launch a popup child that immediately sends the Kitty/cell-size/status queries, assert confirmed Kitty and the actual host cell size without a sleep or window resize, and repeat through both CLI/plugin action and client keybinding paths. Check resize and multi-client ownership as well. -No patched Herdr build was run; this is a source-backed fix proposal, not a -validated implementation. +The initial investigation did not run a patched build; the subsequent local +patch below has native automated coverage, not real-popup visual acceptance. No forced protocol, guessed cell geometry, Herdr upgrade, user config change or dogfood binary replacement was performed. Both diagnostic runs' temporary plugin was unlinked; `plugin list` was empty again. Resolve the capability/geometry gap before completing P3-09; the initial feasibility verdict is not shipping proof. +### Local host patch — 2026-10-03 + +At the operator's request, prepared an uncommitted patch against upstream master +`5da0a01e1eedda054db0c81dd3a780000c40d9f0` (package 0.9.3), in the isolated checkout +`/tmp/wiki-reader-herdr-geometry`. Exported to +`/tmp/wiki-reader-herdr-popup-geometry.patch`; validation notes are in +`/tmp/wiki-reader-herdr-popup-geometry-validation.md`. These are local scratch +artifacts, not distributed wiki-reader code. + +The patch initialises popup virtual-terminal and PTY pixel geometry before the +child starts, keeps size bookkeeping consistent, and classifies plugin pane +opens as geometry-changing on public and client endpoint paths. Public calls +use the tab's geometry controller; client endpoint calls use the invoking client, +restoring the foreground projection afterward. No dependencies, wire fields, +startup sleeps, forced protocol or guessed font metrics were added. + +Five new regressions cover immediate argv/shell child cell-size queries plus +PTY ioctl pixel extents, unknown geometry, public/controller versus client +ownership, and request invalidation. Disabling initial metrics and the new +classifiers makes the four bug-detection tests fail; restoring them passes all +five and the native nextest suite (**3713 passed, 12 skipped**). Formatting, +native all-target clippy (installed 1.96.0, not pinned 1.96.1), six architecture +checks, 150 Python maintenance checks and Bun integration/docs tests pass. + +Full `just check` remains unverified: `just` is missing (one workflow test cannot +run `just --dry-run`), and Windows cross-validation was not run. No SDK/license +downloads, install, restart or patched-client connection to the stable server +were performed. The patch remains uncommitted and unpublished; the authenticated +account is not on Herdr's approved-contributor list, so no upstream implementation +PR was opened. **P3-09 still needs real graphics acceptance on a fixed host.** + ## Matrix | Placement / surface | Launch and context | Keys | Kitty images / cleanup | Status | From 046ca15bef764feba90fd2901558b00137ccb266 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 18:15:50 -0700 Subject: [PATCH 5/8] P3-09: overlay default entrypoint and clearer herdr-popup graphics reason - Plugin manifest: `reader` is a zoomed overlay (a normal herdr pane, so terminal graphics and a pane id are available); `reader-popup` keeps the popup, which herdr 0.9.x starts without cell metrics. Actions `open` and `open-popup` map to them. - When images are off inside a herdr popup (HERDR_ENV=1, no HERDR_PANE_ID) the placeholder and diagram tier header say herdr popups report no cell size and suggest an overlay pane. Cell size is never guessed. - An explicit `diagrams = "image"` that falls back to text now shows the reason in the tier header; the previous arm could never run. Co-Authored-By: Claude Sonnet 5.5 --- crates/wiki-reader-core/src/herdr.rs | 27 +++++ crates/wiki-reader-core/src/images.rs | 5 + crates/wiki-reader-core/tests/herdr_plugin.rs | 104 ++++++++++-------- crates/wiki-reader-render/src/lib.rs | 97 +++++++++++++++- crates/wiki-reader-render/src/render.rs | 35 +++++- crates/wiki-reader/src/tui/app/mod.rs | 1 + integrations/herdr/herdr-plugin.toml | 15 +++ 7 files changed, 230 insertions(+), 54 deletions(-) diff --git a/crates/wiki-reader-core/src/herdr.rs b/crates/wiki-reader-core/src/herdr.rs index d7fe0c7..c7cf4d9 100644 --- a/crates/wiki-reader-core/src/herdr.rs +++ b/crates/wiki-reader-core/src/herdr.rs @@ -20,6 +20,24 @@ pub fn parse_context_cwd(text: &str) -> Option { .map(PathBuf::from) } +/// Whether this process looks like it runs in a herdr plugin popup: inside herdr +/// (`HERDR_ENV=1`) but without a pane of its own (`HERDR_PANE_ID` unset or empty). +/// +/// herdr 0.9.x starts popups without pixel metrics, so terminal graphics cannot be sized +/// there. Overlay, split and tab panes carry their own `HERDR_PANE_ID`. +#[must_use] +pub fn is_plugin_popup(herdr_env: Option<&str>, pane_id: Option<&str>) -> bool { + herdr_env == Some("1") && pane_id.is_none_or(|id| id.trim().is_empty()) +} + +/// [`is_plugin_popup`] for this process's environment. +#[must_use] +pub fn running_in_plugin_popup() -> bool { + let env = std::env::var("HERDR_ENV").ok(); + let pane = std::env::var("HERDR_PANE_ID").ok(); + is_plugin_popup(env.as_deref(), pane.as_deref()) +} + /// herdr's config file: `$XDG_CONFIG_HOME/herdr/config.toml`, else `~/.config/herdr/config.toml`. #[must_use] pub fn config_path() -> Option { @@ -60,6 +78,15 @@ pub fn parse_theme_name(text: &str) -> Option { mod tests { use super::*; + #[test] + fn popup_is_herdr_without_its_own_pane() { + assert!(is_plugin_popup(Some("1"), None)); + assert!(is_plugin_popup(Some("1"), Some(" "))); + assert!(!is_plugin_popup(Some("1"), Some("w1:p2"))); + assert!(!is_plugin_popup(None, None), "outside herdr"); + assert!(!is_plugin_popup(Some("0"), None)); + } + #[test] fn context_prefers_focused_pane_and_preserves_path() { assert_eq!( diff --git a/crates/wiki-reader-core/src/images.rs b/crates/wiki-reader-core/src/images.rs index 55f8e92..a74c10a 100644 --- a/crates/wiki-reader-core/src/images.rs +++ b/crates/wiki-reader-core/src/images.rs @@ -35,6 +35,8 @@ pub enum ImageReject { NoRoot, /// The terminal has no usable graphics protocol (the image tier is an upgrade). NoGraphics, + /// No graphics inside a herdr plugin popup, which reports no cell size (herdr 0.9.x). + NoGraphicsHerdrPopup, } impl fmt::Display for ImageReject { @@ -50,6 +52,9 @@ impl fmt::Display for ImageReject { Self::Unreadable => "unreadable image", Self::NoRoot => "no collection root", Self::NoGraphics => "no graphics protocol", + Self::NoGraphicsHerdrPopup => { + "no graphics protocol; herdr popups report no cell size, use an overlay pane" + } }) } } diff --git a/crates/wiki-reader-core/tests/herdr_plugin.rs b/crates/wiki-reader-core/tests/herdr_plugin.rs index cbbbdf0..efd7049 100644 --- a/crates/wiki-reader-core/tests/herdr_plugin.rs +++ b/crates/wiki-reader-core/tests/herdr_plugin.rs @@ -7,65 +7,77 @@ fn manifest() -> toml::Table { .unwrap() } +fn argv(value: &toml::Value) -> Vec<&str> { + value["command"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect() +} + #[test] -fn popup_manifest_uses_context_launcher() { +fn manifest_offers_an_overlay_default_and_a_popup_using_the_context_launcher() { let manifest = manifest(); assert_eq!(manifest["id"].as_str(), Some("wiki-reader")); assert_eq!(manifest["min_herdr_version"].as_str(), Some("0.9.0")); assert_eq!(manifest["platforms"].as_array().unwrap().len(), 2); let panes = manifest["panes"].as_array().unwrap(); - assert_eq!(panes.len(), 1); - let pane = &panes[0]; - assert_eq!(pane["id"].as_str(), Some("reader")); - assert_eq!(pane["placement"].as_str(), Some("popup")); - assert_eq!(pane["width"].as_str(), Some("80%")); - assert_eq!(pane["height"].as_str(), Some("80%")); - let command: Vec<_> = pane["command"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert_eq!(command, ["wiki-reader", "--herdr-context"]); + assert_eq!(panes.len(), 2); + let reader = &panes[0]; + assert_eq!(reader["id"].as_str(), Some("reader")); + assert_eq!( + reader["placement"].as_str(), + Some("overlay"), + "the default entrypoint is a normal pane so graphics and pane metadata work" + ); + let popup = &panes[1]; + assert_eq!(popup["id"].as_str(), Some("reader-popup")); + assert_eq!(popup["placement"].as_str(), Some("popup")); + assert_eq!(popup["width"].as_str(), Some("80%")); + assert_eq!(popup["height"].as_str(), Some("80%")); + for pane in panes { + assert_eq!(argv(pane), ["wiki-reader", "--herdr-context"]); + } } #[cfg(unix)] #[test] -fn action_uses_inherited_binary_and_propagates_failure() { +fn actions_use_inherited_binary_and_propagate_failure() { use std::os::unix::fs::PermissionsExt; use std::process::Command; let manifest = manifest(); let actions = manifest["actions"].as_array().unwrap(); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0]["id"].as_str(), Some("open")); - let argv: Vec<_> = actions[0]["command"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - let dir = tempfile::tempdir().unwrap(); - // Spaces and shell metacharacters must stay part of the binary path. - let binary = dir.path().join("herdr binary; not a shell command"); - std::fs::write(&binary, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap(); - std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap(); - let output = Command::new(argv[0]) - .args(&argv[1..]) - .env("HERDR_BIN_PATH", &binary) - .output() - .unwrap(); - assert_eq!(output.status.code(), Some(7)); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - "plugin\npane\nopen\n--plugin\nwiki-reader\n--entrypoint\nreader\n" - ); - let status = Command::new(argv[0]) - .args(&argv[1..]) - .env("HERDR_BIN_PATH", dir.path().join("missing")) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .unwrap(); - assert!(!status.success()); + assert_eq!(actions.len(), 2); + for (action, id, entrypoint) in [ + (&actions[0], "open", "reader"), + (&actions[1], "open-popup", "reader-popup"), + ] { + assert_eq!(action["id"].as_str(), Some(id)); + let argv = argv(action); + let dir = tempfile::tempdir().unwrap(); + // Spaces and shell metacharacters must stay part of the binary path. + let binary = dir.path().join("herdr binary; not a shell command"); + std::fs::write(&binary, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap(); + let output = Command::new(argv[0]) + .args(&argv[1..]) + .env("HERDR_BIN_PATH", &binary) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + format!("plugin\npane\nopen\n--plugin\nwiki-reader\n--entrypoint\n{entrypoint}\n") + ); + let status = Command::new(argv[0]) + .args(&argv[1..]) + .env("HERDR_BIN_PATH", dir.path().join("missing")) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); + } } diff --git a/crates/wiki-reader-render/src/lib.rs b/crates/wiki-reader-render/src/lib.rs index 42fe7e6..fcde929 100644 --- a/crates/wiki-reader-render/src/lib.rs +++ b/crates/wiki-reader-render/src/lib.rs @@ -1347,6 +1347,16 @@ mod tests { /// Render `fixtures/images/README.md` with the given graphics availability. fn render_images_fixture(cell_px: Option<(u16, u16)>, width: u16) -> RenderedDoc { + render_images_fixture_with( + RenderOpts { + cell_px, + ..RenderOpts::default() + }, + width, + ) + } + + fn render_images_fixture_with(mut opts: RenderOpts, width: u16) -> RenderedDoc { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/images"); let provider = FsProvider::open(&root).unwrap(); let index = wiki_reader_core::Index::build(&provider).unwrap(); @@ -1355,11 +1365,7 @@ mod tests { relative_path: std::path::PathBuf::from("README.md"), }; let src = provider.read(&key).unwrap(); - let opts = RenderOpts { - image_root: Some(provider.root().to_path_buf()), - cell_px, - ..RenderOpts::default() - }; + opts.image_root = Some(provider.root().to_path_buf()); render_with(&src, index.pages.get(&key), &key, &index, width, &opts) } @@ -1428,6 +1434,87 @@ mod tests { ); } + #[test] + fn herdr_popup_names_itself_in_the_no_graphics_reason() { + let popup = render_images_fixture_with( + RenderOpts { + herdr: true, + herdr_popup: true, + ..RenderOpts::default() + }, + 200, + ); + assert!( + popup.lines.iter().any(|l| l + == "[image: Small grid] img/small.png — no graphics protocol; herdr popups report no cell size, use an overlay pane"), + "{}", + popup.lines.join("\n") + ); + // The same fixture outside a popup keeps the plain wording. + let plain = render_images_fixture_with( + RenderOpts { + herdr: true, + ..RenderOpts::default() + }, + 200, + ); + assert!( + plain + .lines + .iter() + .any(|l| l == "[image: Small grid] img/small.png — no graphics protocol") + ); + // Graphics that work are never reworded. + let ok = render_images_fixture_with( + RenderOpts { + herdr: true, + herdr_popup: true, + cell_px: Some((8, 17)), + graphics: true, + ..RenderOpts::default() + }, + 200, + ); + assert!(!ok.image_slots.is_empty()); + assert!(!ok.lines.join("\n").contains("overlay pane")); + } + + #[test] + fn herdr_popup_names_itself_in_the_diagram_tier_reason() { + let src = "```mermaid\nflowchart LR\n A --> B\n```\n"; + let key = empty_key(); + let index = wiki_reader_core::Index { + collection_id: "t".into(), + pages: HashMap::default(), + edges: vec![], + by_from: HashMap::default(), + by_to: HashMap::default(), + by_id: HashMap::default(), + by_path: HashMap::default(), + diagnostics: vec![], + }; + for (popup, expected) in [ + ( + true, + "diagram (text; no graphics protocol; herdr popups report no cell size, use an overlay pane)", + ), + (false, "diagram (text; no graphics protocol)"), + ] { + let opts = RenderOpts { + diagram_mode: wiki_reader_core::config::DiagramMode::Image, + herdr: true, + herdr_popup: popup, + ..RenderOpts::default() + }; + let doc = render_with(src, None, &key, &index, 200, &opts); + assert!( + doc.lines.iter().any(|l| l.contains(expected)), + "popup={popup}:\n{}", + doc.lines.join("\n") + ); + } + } + #[test] fn rejected_images_show_the_reason_even_with_graphics() { let doc = render_images_fixture(Some((8, 17)), 100); diff --git a/crates/wiki-reader-render/src/render.rs b/crates/wiki-reader-render/src/render.rs index 00ca91d..7f7e317 100644 --- a/crates/wiki-reader-render/src/render.rs +++ b/crates/wiki-reader-render/src/render.rs @@ -10,6 +10,7 @@ use pulldown_cmark::{ }; use unicode_width::UnicodeWidthStr; use wiki_reader_core::Index; +use wiki_reader_core::images::ImageReject; use wiki_reader_core::index::Page; use wiki_reader_core::nav::{Target, resolve}; use wiki_reader_core::parse::{self, github_slug}; @@ -193,6 +194,7 @@ fn content_block_id(kind: BlockActionKind, text: &str) -> u32 { /// Optional expansion state for re-layout. #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] // independent environment/probe flags pub struct RenderOpts { /// Expanded block-action ids (frontmatter / tables). pub expanded: std::collections::HashSet, @@ -210,6 +212,9 @@ pub struct RenderOpts { pub tmux: bool, /// `HERDR_ENV=1` — Kitty-only image preference when graphics are confirmed. pub herdr: bool, + /// Running in a herdr plugin popup (no pane id): only changes the wording of the + /// "no graphics" fallback reason, never what is drawn. + pub herdr_popup: bool, /// Mermaid colours (part of the size/slot cache key), filled from the active theme. pub diagram_palette: DiagramPalette, /// Shared Mermaid natural-size cache filled by the image worker. @@ -228,6 +233,7 @@ impl Default for RenderOpts { graphics: false, tmux: false, herdr: false, + herdr_popup: false, diagram_palette: DiagramPalette::default(), diagram_sizes: empty_diagram_size_cache(), max_slot_rows: crate::MAX_SLOT_ROWS, @@ -278,6 +284,7 @@ pub fn render_with( state.graphics = opts.graphics; state.tmux = opts.tmux; state.herdr = opts.herdr; + state.herdr_popup = opts.herdr_popup; state.diagram_palette = opts.diagram_palette; state.diagram_sizes = Arc::clone(&opts.diagram_sizes); state.max_slot_rows = opts.max_slot_rows; @@ -437,6 +444,7 @@ struct LayoutState<'a> { graphics: bool, tmux: bool, herdr: bool, + herdr_popup: bool, diagram_palette: DiagramPalette, diagram_sizes: Arc, max_slot_rows: u16, @@ -512,6 +520,7 @@ impl<'a> LayoutState<'a> { graphics: false, tmux: false, herdr: false, + herdr_popup: false, diagram_palette: DiagramPalette::default(), diagram_sizes: empty_diagram_size_cache(), max_slot_rows: crate::MAX_SLOT_ROWS, @@ -577,6 +586,12 @@ impl<'a> LayoutState<'a> { max_cols, self.max_slot_rows, ); + let plan = match plan { + ImagePlan::Placeholder(ImageReject::NoGraphics) if self.herdr_popup => { + ImagePlan::Placeholder(ImageReject::NoGraphicsHerdrPopup) + } + other => other, + }; match plan { ImagePlan::Slot { source, cols, rows } => { let line = u32::try_from(self.styled.len()).unwrap_or(0); @@ -694,9 +709,6 @@ impl<'a> LayoutState<'a> { }); } else { let reason_owned: Option = match tier { - DiagramTier::Image if self.cell_px.is_none() || !self.graphics => { - Some("no graphics protocol".into()) - } DiagramTier::Image => match self.diagram_sizes.get(hash, palette) { Some(DiagramSize::Text(DiagramTextReason::Failed(msg))) => Some(msg), Some( @@ -713,6 +725,23 @@ impl<'a> LayoutState<'a> { { Some("tmux: text tier".into()) } + // An explicit `diagrams = "image"` that fell back because the terminal gave no + // usable graphics says why; `auto` stays quiet (the text tier is the default). + DiagramTier::Text + if matches!( + self.diagram_mode, + wiki_reader_core::config::DiagramMode::Image + ) && !env.tmux => + { + Some( + if self.herdr_popup { + ImageReject::NoGraphicsHerdrPopup + } else { + ImageReject::NoGraphics + } + .to_string(), + ) + } _ => None, }; let reason = reason_owned.as_deref(); diff --git a/crates/wiki-reader/src/tui/app/mod.rs b/crates/wiki-reader/src/tui/app/mod.rs index 163bf69..40eac2d 100644 --- a/crates/wiki-reader/src/tui/app/mod.rs +++ b/crates/wiki-reader/src/tui/app/mod.rs @@ -487,6 +487,7 @@ impl App { graphics: cell_px.is_some(), tmux: env.tmux, herdr: env.herdr, + herdr_popup: wiki_reader_core::herdr::running_in_plugin_popup(), diagram_palette: self.theme.diagram, diagram_sizes: self.images.diagram_sizes(), max_slot_rows: self.images_max_slot_rows, diff --git a/integrations/herdr/herdr-plugin.toml b/integrations/herdr/herdr-plugin.toml index 8bff4bc..3f88587 100644 --- a/integrations/herdr/herdr-plugin.toml +++ b/integrations/herdr/herdr-plugin.toml @@ -4,9 +4,18 @@ version = "0.1.0" min_herdr_version = "0.9.0" platforms = ["linux", "macos"] +# Default: a zoomed overlay. It is a normal herdr pane, so terminal graphics work and the +# reader has its own HERDR_PANE_ID. [[panes]] id = "reader" title = "Wiki Reader" +placement = "overlay" +command = ["wiki-reader", "--herdr-context"] + +# Popup: no images on herdr 0.9.x (popups report no cell size); text and keys work. +[[panes]] +id = "reader-popup" +title = "Wiki Reader (popup)" placement = "popup" width = "80%" height = "80%" @@ -17,3 +26,9 @@ id = "open" title = "Open Wiki Reader" contexts = ["workspace"] command = ["sh", "-c", 'exec "$HERDR_BIN_PATH" plugin pane open --plugin wiki-reader --entrypoint reader'] + +[[actions]] +id = "open-popup" +title = "Open Wiki Reader (popup)" +contexts = ["workspace"] +command = ["sh", "-c", 'exec "$HERDR_BIN_PATH" plugin pane open --plugin wiki-reader --entrypoint reader-popup'] From d99353dcd25ed7a101876b6b8db2aeb7fe711d1b Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 18:38:42 -0700 Subject: [PATCH 6/8] P3-09: open the reader in an ordinary split pane; plugin panes are text-only Retests show every herdr 0.9.x plugin pane, overlay included, starts without cell metrics, so the reader cannot draw images there (an ordinary pane detects Kitty at 8x17 and draws). Rather than depend on a herdr fix: - `wiki-reader --herdr-split` (the plugin's default `open` action) splits the focused pane in its cwd via `herdr pane split`, then submits `exec wiki-reader` with `herdr pane run`. Std-only launcher with an injectable herdr runner; failures name the pane left open. - Manifest: panes `reader-overlay` and `reader-popup` stay as text-only actions `open-overlay` and `open-popup`. - Fallback text now covers any plugin pane (HERDR_ENV=1 plus HERDR_PLUGIN_ENTRYPOINT_ID): "plugin panes report no cell size, open the reader in a normal pane". Cell size is never guessed. - Core: PluginContext parser (focused pane id and cwd) and the split response parser. - Docs: plugin README, README note, integrations, spike retest and corrected decision, roadmap and dogfood log. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 7 + crates/wiki-reader-core/src/herdr.rs | 122 ++++++++-- crates/wiki-reader-core/src/images.rs | 8 +- crates/wiki-reader-core/tests/herdr_plugin.rs | 27 ++- crates/wiki-reader-render/src/lib.rs | 26 +- crates/wiki-reader-render/src/render.rs | 20 +- crates/wiki-reader/src/herdr.rs | 223 ++++++++++++++++++ crates/wiki-reader/src/main.rs | 26 ++ crates/wiki-reader/src/tui/app/mod.rs | 2 +- integrations/herdr/README.md | 90 ++++--- integrations/herdr/herdr-plugin.toml | 22 +- wiki/architecture/integrations.md | 2 +- wiki/roadmap/dogfood-log.md | 2 + wiki/roadmap/phase-3-alpha.md | 2 +- .../roadmap/spikes/p3-s2-herdr-integration.md | 59 ++++- 15 files changed, 516 insertions(+), 122 deletions(-) create mode 100644 crates/wiki-reader/src/herdr.rs diff --git a/README.md b/README.md index 38bf914..c865008 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,13 @@ wiki-reader fixtures/worked-example # q to quit, ? for help ``` +### In herdr + +[`integrations/herdr/`](integrations/herdr/README.md) is a herdr plugin: bind a key to its +`wiki-reader.open` action to open the reader in a new split pane in the focused pane's +directory. (Images need an ordinary pane, which that action uses; herdr 0.9.x plugin panes +such as overlays and popups show text only.) + ## Checks ```bash diff --git a/crates/wiki-reader-core/src/herdr.rs b/crates/wiki-reader-core/src/herdr.rs index c7cf4d9..7716c78 100644 --- a/crates/wiki-reader-core/src/herdr.rs +++ b/crates/wiki-reader-core/src/herdr.rs @@ -6,36 +6,73 @@ use std::path::{Path, PathBuf}; +/// What the launcher needs from herdr's plugin context (`HERDR_PLUGIN_CONTEXT_JSON`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PluginContext { + /// The pane that had focus when the plugin ran, if known. + pub focused_pane_id: Option, + /// Collection cwd: the focused pane's cwd first, then the workspace's. + pub cwd: Option, +} + +/// Parse the plugin context. `None` for text that is not a JSON object; absent or empty +/// fields are left unset. This only parses: callers check that the cwd is a directory. +#[must_use] +pub fn parse_context(text: &str) -> Option { + let context: serde_json::Value = serde_json::from_str(text).ok()?; + let object = context.as_object()?; + let string = |key: &str| { + object + .get(key)? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + }; + Some(PluginContext { + focused_pane_id: string("focused_pane_id").map(str::to_owned), + cwd: string("focused_pane_cwd") + .or_else(|| string("workspace_cwd")) + .map(PathBuf::from), + }) +} + /// Collection cwd from Herdr's plugin context: focused pane first, then workspace. /// -/// Missing, malformed or empty fields return `None`. This only parses the context; -/// the launcher checks that the selected path is a directory before using it. +/// Missing, malformed or empty fields return `None`. #[must_use] pub fn parse_context_cwd(text: &str) -> Option { - let context: serde_json::Value = serde_json::from_str(text).ok()?; - ["focused_pane_cwd", "workspace_cwd"] - .iter() - .filter_map(|key| context.get(*key)?.as_str()) - .find(|cwd| !cwd.trim().is_empty()) - .map(PathBuf::from) + parse_context(text)?.cwd +} + +/// The new pane's id from a `herdr pane split` JSON response (`.result.pane.pane_id`). +#[must_use] +pub fn parse_split_pane_id(text: &str) -> Option { + let response: serde_json::Value = serde_json::from_str(text).ok()?; + let id = response + .get("result")? + .get("pane")? + .get("pane_id")? + .as_str()? + .trim(); + (!id.is_empty()).then(|| id.to_owned()) } -/// Whether this process looks like it runs in a herdr plugin popup: inside herdr -/// (`HERDR_ENV=1`) but without a pane of its own (`HERDR_PANE_ID` unset or empty). +/// Whether this process was started by a herdr plugin pane entrypoint of any placement: +/// inside herdr (`HERDR_ENV=1`) with `HERDR_PLUGIN_ENTRYPOINT_ID` set. /// -/// herdr 0.9.x starts popups without pixel metrics, so terminal graphics cannot be sized -/// there. Overlay, split and tab panes carry their own `HERDR_PANE_ID`. +/// herdr 0.9.x starts those panes without pixel metrics and never answers the terminal's +/// cell-size query, so graphics cannot be sized there. Ordinary shell panes answer. #[must_use] -pub fn is_plugin_popup(herdr_env: Option<&str>, pane_id: Option<&str>) -> bool { - herdr_env == Some("1") && pane_id.is_none_or(|id| id.trim().is_empty()) +pub fn is_plugin_pane(herdr_env: Option<&str>, entrypoint_id: Option<&str>) -> bool { + herdr_env == Some("1") && entrypoint_id.is_some_and(|id| !id.trim().is_empty()) } -/// [`is_plugin_popup`] for this process's environment. +/// [`is_plugin_pane`] for this process's environment. #[must_use] -pub fn running_in_plugin_popup() -> bool { +pub fn running_in_plugin_pane() -> bool { let env = std::env::var("HERDR_ENV").ok(); - let pane = std::env::var("HERDR_PANE_ID").ok(); - is_plugin_popup(env.as_deref(), pane.as_deref()) + let entrypoint = std::env::var("HERDR_PLUGIN_ENTRYPOINT_ID").ok(); + is_plugin_pane(env.as_deref(), entrypoint.as_deref()) } /// herdr's config file: `$XDG_CONFIG_HOME/herdr/config.toml`, else `~/.config/herdr/config.toml`. @@ -79,12 +116,49 @@ mod tests { use super::*; #[test] - fn popup_is_herdr_without_its_own_pane() { - assert!(is_plugin_popup(Some("1"), None)); - assert!(is_plugin_popup(Some("1"), Some(" "))); - assert!(!is_plugin_popup(Some("1"), Some("w1:p2"))); - assert!(!is_plugin_popup(None, None), "outside herdr"); - assert!(!is_plugin_popup(Some("0"), None)); + fn plugin_pane_is_herdr_with_an_entrypoint_id() { + assert!(is_plugin_pane(Some("1"), Some("reader-popup"))); + assert!(is_plugin_pane(Some("1"), Some("overlay"))); + assert!(!is_plugin_pane(Some("1"), None), "an ordinary herdr pane"); + assert!(!is_plugin_pane(Some("1"), Some(" "))); + assert!(!is_plugin_pane(None, Some("overlay")), "outside herdr"); + assert!(!is_plugin_pane(Some("0"), Some("overlay"))); + } + + #[test] + fn context_reads_the_focused_pane_and_cwd() { + let context = parse_context( + r#"{"focused_pane_id":" w1:p2 ","focused_pane_cwd":"/a b","workspace_cwd":"/w","x":1}"#, + ) + .unwrap(); + assert_eq!(context.focused_pane_id.as_deref(), Some("w1:p2")); + assert_eq!(context.cwd, Some(PathBuf::from("/a b"))); + let partial = parse_context(r#"{"workspace_cwd":"/w"}"#).unwrap(); + assert_eq!(partial.focused_pane_id, None); + assert_eq!(partial.cwd, Some(PathBuf::from("/w"))); + assert_eq!(parse_context("{}"), Some(PluginContext::default())); + for text in ["", "not json", "null", "[]", "42"] { + assert_eq!(parse_context(text), None, "{text}"); + } + } + + #[test] + fn split_response_yields_the_new_pane_id() { + assert_eq!( + parse_split_pane_id(r#"{"id":"cli:pane:split","result":{"pane":{"pane_id":"w30:pZ"},"type":"pane_info"}}"#) + .as_deref(), + Some("w30:pZ") + ); + for text in [ + "", + "{}", + r#"{"result":{}}"#, + r#"{"result":{"pane":{"pane_id":""}}}"#, + r#"{"result":{"pane":{"pane_id":7}}}"#, + r#"{"error":{"code":"x"}}"#, + ] { + assert_eq!(parse_split_pane_id(text), None, "{text}"); + } } #[test] diff --git a/crates/wiki-reader-core/src/images.rs b/crates/wiki-reader-core/src/images.rs index a74c10a..37f1672 100644 --- a/crates/wiki-reader-core/src/images.rs +++ b/crates/wiki-reader-core/src/images.rs @@ -35,8 +35,8 @@ pub enum ImageReject { NoRoot, /// The terminal has no usable graphics protocol (the image tier is an upgrade). NoGraphics, - /// No graphics inside a herdr plugin popup, which reports no cell size (herdr 0.9.x). - NoGraphicsHerdrPopup, + /// No graphics inside a herdr plugin pane, which reports no cell size (herdr 0.9.x). + NoGraphicsHerdrPlugin, } impl fmt::Display for ImageReject { @@ -52,8 +52,8 @@ impl fmt::Display for ImageReject { Self::Unreadable => "unreadable image", Self::NoRoot => "no collection root", Self::NoGraphics => "no graphics protocol", - Self::NoGraphicsHerdrPopup => { - "no graphics protocol; herdr popups report no cell size, use an overlay pane" + Self::NoGraphicsHerdrPlugin => { + "no graphics protocol; herdr plugin panes report no cell size, open the reader in a normal pane" } }) } diff --git a/crates/wiki-reader-core/tests/herdr_plugin.rs b/crates/wiki-reader-core/tests/herdr_plugin.rs index efd7049..722bbc5 100644 --- a/crates/wiki-reader-core/tests/herdr_plugin.rs +++ b/crates/wiki-reader-core/tests/herdr_plugin.rs @@ -17,20 +17,15 @@ fn argv(value: &toml::Value) -> Vec<&str> { } #[test] -fn manifest_offers_an_overlay_default_and_a_popup_using_the_context_launcher() { +fn manifest_offers_text_tier_plugin_panes_and_a_split_launcher() { let manifest = manifest(); assert_eq!(manifest["id"].as_str(), Some("wiki-reader")); assert_eq!(manifest["min_herdr_version"].as_str(), Some("0.9.0")); assert_eq!(manifest["platforms"].as_array().unwrap().len(), 2); let panes = manifest["panes"].as_array().unwrap(); assert_eq!(panes.len(), 2); - let reader = &panes[0]; - assert_eq!(reader["id"].as_str(), Some("reader")); - assert_eq!( - reader["placement"].as_str(), - Some("overlay"), - "the default entrypoint is a normal pane so graphics and pane metadata work" - ); + assert_eq!(panes[0]["id"].as_str(), Some("reader-overlay")); + assert_eq!(panes[0]["placement"].as_str(), Some("overlay")); let popup = &panes[1]; assert_eq!(popup["id"].as_str(), Some("reader-popup")); assert_eq!(popup["placement"].as_str(), Some("popup")); @@ -39,20 +34,28 @@ fn manifest_offers_an_overlay_default_and_a_popup_using_the_context_launcher() { for pane in panes { assert_eq!(argv(pane), ["wiki-reader", "--herdr-context"]); } + let actions = manifest["actions"].as_array().unwrap(); + let ids: Vec<_> = actions.iter().map(|a| a["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["open", "open-overlay", "open-popup"]); + assert_eq!( + argv(&actions[0]), + ["wiki-reader", "--herdr-split"], + "the default action opens an ordinary pane, which can draw images" + ); } #[cfg(unix)] #[test] -fn actions_use_inherited_binary_and_propagate_failure() { +fn plugin_pane_actions_use_inherited_binary_and_propagate_failure() { use std::os::unix::fs::PermissionsExt; use std::process::Command; let manifest = manifest(); let actions = manifest["actions"].as_array().unwrap(); - assert_eq!(actions.len(), 2); + assert_eq!(actions.len(), 3); for (action, id, entrypoint) in [ - (&actions[0], "open", "reader"), - (&actions[1], "open-popup", "reader-popup"), + (&actions[1], "open-overlay", "reader-overlay"), + (&actions[2], "open-popup", "reader-popup"), ] { assert_eq!(action["id"].as_str(), Some(id)); let argv = argv(action); diff --git a/crates/wiki-reader-render/src/lib.rs b/crates/wiki-reader-render/src/lib.rs index fcde929..ffd7f9c 100644 --- a/crates/wiki-reader-render/src/lib.rs +++ b/crates/wiki-reader-render/src/lib.rs @@ -1435,22 +1435,22 @@ mod tests { } #[test] - fn herdr_popup_names_itself_in_the_no_graphics_reason() { - let popup = render_images_fixture_with( + fn herdr_plugin_pane_names_itself_in_the_no_graphics_reason() { + let plugin = render_images_fixture_with( RenderOpts { herdr: true, - herdr_popup: true, + herdr_plugin_pane: true, ..RenderOpts::default() }, 200, ); assert!( - popup.lines.iter().any(|l| l - == "[image: Small grid] img/small.png — no graphics protocol; herdr popups report no cell size, use an overlay pane"), + plugin.lines.iter().any(|l| l + == "[image: Small grid] img/small.png — no graphics protocol; herdr plugin panes report no cell size, open the reader in a normal pane"), "{}", - popup.lines.join("\n") + plugin.lines.join("\n") ); - // The same fixture outside a popup keeps the plain wording. + // The same fixture outside a plugin pane keeps the plain wording. let plain = render_images_fixture_with( RenderOpts { herdr: true, @@ -1468,7 +1468,7 @@ mod tests { let ok = render_images_fixture_with( RenderOpts { herdr: true, - herdr_popup: true, + herdr_plugin_pane: true, cell_px: Some((8, 17)), graphics: true, ..RenderOpts::default() @@ -1480,7 +1480,7 @@ mod tests { } #[test] - fn herdr_popup_names_itself_in_the_diagram_tier_reason() { + fn herdr_plugin_pane_names_itself_in_the_diagram_tier_reason() { let src = "```mermaid\nflowchart LR\n A --> B\n```\n"; let key = empty_key(); let index = wiki_reader_core::Index { @@ -1493,23 +1493,23 @@ mod tests { by_path: HashMap::default(), diagnostics: vec![], }; - for (popup, expected) in [ + for (plugin, expected) in [ ( true, - "diagram (text; no graphics protocol; herdr popups report no cell size, use an overlay pane)", + "diagram (text; no graphics protocol; herdr plugin panes report no cell size, open the reader in a normal pane)", ), (false, "diagram (text; no graphics protocol)"), ] { let opts = RenderOpts { diagram_mode: wiki_reader_core::config::DiagramMode::Image, herdr: true, - herdr_popup: popup, + herdr_plugin_pane: plugin, ..RenderOpts::default() }; let doc = render_with(src, None, &key, &index, 200, &opts); assert!( doc.lines.iter().any(|l| l.contains(expected)), - "popup={popup}:\n{}", + "plugin={plugin}:\n{}", doc.lines.join("\n") ); } diff --git a/crates/wiki-reader-render/src/render.rs b/crates/wiki-reader-render/src/render.rs index 7f7e317..743dc9b 100644 --- a/crates/wiki-reader-render/src/render.rs +++ b/crates/wiki-reader-render/src/render.rs @@ -212,9 +212,9 @@ pub struct RenderOpts { pub tmux: bool, /// `HERDR_ENV=1` — Kitty-only image preference when graphics are confirmed. pub herdr: bool, - /// Running in a herdr plugin popup (no pane id): only changes the wording of the + /// Running in a herdr plugin pane (any placement): only changes the wording of the /// "no graphics" fallback reason, never what is drawn. - pub herdr_popup: bool, + pub herdr_plugin_pane: bool, /// Mermaid colours (part of the size/slot cache key), filled from the active theme. pub diagram_palette: DiagramPalette, /// Shared Mermaid natural-size cache filled by the image worker. @@ -233,7 +233,7 @@ impl Default for RenderOpts { graphics: false, tmux: false, herdr: false, - herdr_popup: false, + herdr_plugin_pane: false, diagram_palette: DiagramPalette::default(), diagram_sizes: empty_diagram_size_cache(), max_slot_rows: crate::MAX_SLOT_ROWS, @@ -284,7 +284,7 @@ pub fn render_with( state.graphics = opts.graphics; state.tmux = opts.tmux; state.herdr = opts.herdr; - state.herdr_popup = opts.herdr_popup; + state.herdr_plugin_pane = opts.herdr_plugin_pane; state.diagram_palette = opts.diagram_palette; state.diagram_sizes = Arc::clone(&opts.diagram_sizes); state.max_slot_rows = opts.max_slot_rows; @@ -444,7 +444,7 @@ struct LayoutState<'a> { graphics: bool, tmux: bool, herdr: bool, - herdr_popup: bool, + herdr_plugin_pane: bool, diagram_palette: DiagramPalette, diagram_sizes: Arc, max_slot_rows: u16, @@ -520,7 +520,7 @@ impl<'a> LayoutState<'a> { graphics: false, tmux: false, herdr: false, - herdr_popup: false, + herdr_plugin_pane: false, diagram_palette: DiagramPalette::default(), diagram_sizes: empty_diagram_size_cache(), max_slot_rows: crate::MAX_SLOT_ROWS, @@ -587,8 +587,8 @@ impl<'a> LayoutState<'a> { self.max_slot_rows, ); let plan = match plan { - ImagePlan::Placeholder(ImageReject::NoGraphics) if self.herdr_popup => { - ImagePlan::Placeholder(ImageReject::NoGraphicsHerdrPopup) + ImagePlan::Placeholder(ImageReject::NoGraphics) if self.herdr_plugin_pane => { + ImagePlan::Placeholder(ImageReject::NoGraphicsHerdrPlugin) } other => other, }; @@ -734,8 +734,8 @@ impl<'a> LayoutState<'a> { ) && !env.tmux => { Some( - if self.herdr_popup { - ImageReject::NoGraphicsHerdrPopup + if self.herdr_plugin_pane { + ImageReject::NoGraphicsHerdrPlugin } else { ImageReject::NoGraphics } diff --git a/crates/wiki-reader/src/herdr.rs b/crates/wiki-reader/src/herdr.rs new file mode 100644 index 0000000..35d9920 --- /dev/null +++ b/crates/wiki-reader/src/herdr.rs @@ -0,0 +1,223 @@ +//! Talking to herdr from the reader binary (std only, never on the UI thread). +//! +//! herdr 0.9.x starts plugin panes (overlay, popup, split, tab) without cell metrics, so they +//! cannot draw terminal graphics. [`open_split`] therefore opens the reader in an *ordinary* +//! shell pane, which does answer the terminal's queries. + +use std::path::PathBuf; +use std::process::Command; + +use wiki_reader_core::herdr::{parse_context, parse_split_pane_id}; + +/// What the shell pane runs. `exec` replaces the shell, so quitting the reader closes the pane. +const RUN_COMMAND: &str = "exec wiki-reader"; + +/// How the launcher reaches herdr. Real use runs the CLI; tests record the calls. +pub trait Herdr { + /// Run `herdr `: stdout on success, a one-line message on failure. + fn run(&self, args: &[String]) -> Result; +} + +/// The `herdr` CLI: `$HERDR_BIN_PATH` (set for plugin commands and herdr panes), else `herdr`. +pub struct HerdrCli { + bin: PathBuf, +} + +impl HerdrCli { + #[must_use] + pub fn from_env() -> Self { + let bin = std::env::var_os("HERDR_BIN_PATH") + .filter(|path| !path.is_empty()) + .map_or_else(|| PathBuf::from("herdr"), PathBuf::from); + Self { bin } + } +} + +impl Herdr for HerdrCli { + fn run(&self, args: &[String]) -> Result { + let output = Command::new(&self.bin) + .args(args) + .output() + .map_err(|err| format!("cannot run {}: {err}", self.bin.display()))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let message = stderr.lines().find(|line| !line.trim().is_empty()); + Err(format!( + "herdr {} failed: {}", + args.iter().take(2).cloned().collect::>().join(" "), + message.unwrap_or("no error message") + )) + } +} + +/// Open the reader in a new ordinary split pane to the right of the focused pane, in that +/// pane's cwd, focus it, and return the new pane's id. +/// +/// `context_json` is `HERDR_PLUGIN_CONTEXT_JSON`. A missing or malformed context is not an +/// error: herdr then splits its own focused pane and applies its `terminal.new_cwd` policy. +pub fn open_split(herdr: &dyn Herdr, context_json: Option<&str>) -> Result { + let context = context_json.and_then(parse_context).unwrap_or_default(); + let mut split: Vec = vec!["pane".into(), "split".into()]; + if let Some(pane) = &context.focused_pane_id { + split.push(pane.clone()); + } + split.extend(["--direction".into(), "right".into()]); + if let Some(cwd) = context.cwd.as_ref().filter(|cwd| cwd.is_dir()) { + split.extend(["--cwd".into(), cwd.to_string_lossy().into_owned()]); + } + split.push("--focus".into()); + let response = herdr.run(&split)?; + let pane = parse_split_pane_id(&response) + .ok_or_else(|| "herdr pane split returned no pane id".to_owned())?; + herdr + .run(&[ + "pane".into(), + "run".into(), + pane.clone(), + RUN_COMMAND.into(), + ]) + .map_err(|err| format!("{err} (the new pane {pane} was left open)"))?; + Ok(pane) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// Replies in order and records every call. + struct Fake { + calls: RefCell>>, + replies: RefCell>>, + } + + impl Fake { + fn new(replies: Vec>) -> Self { + Self { + calls: RefCell::default(), + replies: RefCell::new(replies.into_iter().rev().collect()), + } + } + fn calls(&self) -> Vec> { + self.calls.borrow().clone() + } + } + + impl Herdr for Fake { + fn run(&self, args: &[String]) -> Result { + self.calls.borrow_mut().push(args.to_vec()); + self.replies.borrow_mut().pop().expect("unexpected call") + } + } + + const SPLIT_OK: &str = r#"{"id":"cli:pane:split","result":{"pane":{"pane_id":"w1:p9"}}}"#; + + #[test] + fn splits_the_focused_pane_in_its_cwd_then_runs_the_reader() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_string_lossy().into_owned(); + let context = format!(r#"{{"focused_pane_id":"w1:p2","focused_pane_cwd":"{cwd}"}}"#); + let fake = Fake::new(vec![Ok(SPLIT_OK.into()), Ok(String::new())]); + assert_eq!(open_split(&fake, Some(&context)).as_deref(), Ok("w1:p9")); + assert_eq!( + fake.calls(), + [ + vec![ + "pane", + "split", + "w1:p2", + "--direction", + "right", + "--cwd", + &cwd, + "--focus" + ], + vec!["pane", "run", "w1:p9", "exec wiki-reader"], + ] + ); + } + + #[test] + fn paths_with_spaces_stay_one_argument() { + let dir = tempfile::tempdir().unwrap(); + let spaced = dir.path().join("a collection; rm -rf"); + std::fs::create_dir(&spaced).unwrap(); + let context = format!(r#"{{"focused_pane_cwd":"{}"}}"#, spaced.display()); + let fake = Fake::new(vec![Ok(SPLIT_OK.into()), Ok(String::new())]); + open_split(&fake, Some(&context)).unwrap(); + let split = &fake.calls()[0]; + let at = split.iter().position(|a| a == "--cwd").unwrap(); + assert_eq!(split[at + 1], spaced.to_string_lossy()); + } + + #[test] + fn missing_or_unusable_context_lets_herdr_choose_the_target_and_cwd() { + let file = tempfile::NamedTempFile::new().unwrap(); + let not_a_dir = format!(r#"{{"focused_pane_cwd":"{}"}}"#, file.path().display()); + for context in [None, Some("not json"), Some("{}"), Some(not_a_dir.as_str())] { + let fake = Fake::new(vec![Ok(SPLIT_OK.into()), Ok(String::new())]); + open_split(&fake, context).unwrap(); + assert_eq!( + fake.calls()[0], + ["pane", "split", "--direction", "right", "--focus"], + "{context:?}" + ); + } + } + + #[test] + fn split_failure_or_a_response_without_a_pane_id_stops_before_running_anything() { + let fake = Fake::new(vec![Err("herdr pane split failed: no workspace".into())]); + assert_eq!( + open_split(&fake, None), + Err("herdr pane split failed: no workspace".into()) + ); + assert_eq!(fake.calls().len(), 1); + for response in ["", "{}", r#"{"result":{"pane":{}}}"#] { + let fake = Fake::new(vec![Ok(response.into())]); + assert_eq!( + open_split(&fake, None), + Err("herdr pane split returned no pane id".into()) + ); + assert_eq!(fake.calls().len(), 1, "{response}"); + } + } + + #[test] + fn run_failure_names_the_pane_that_was_left_open() { + let fake = Fake::new(vec![ + Ok(SPLIT_OK.into()), + Err("herdr pane run failed: gone".into()), + ]); + let err = open_split(&fake, None).unwrap_err(); + assert!(err.contains("herdr pane run failed: gone"), "{err}"); + assert!(err.contains("w1:p9"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn cli_reports_spawn_and_exit_failures() { + use std::os::unix::fs::PermissionsExt; + let missing = HerdrCli { + bin: PathBuf::from("/nope-wiki-reader-missing/herdr"), + }; + assert!( + missing + .run(&["pane".into()]) + .unwrap_err() + .starts_with("cannot run") + ); + + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("herdr"); + std::fs::write(&script, "#!/bin/sh\necho 'boom: bad pane' >&2\nexit 3\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + let failing = HerdrCli { bin: script }; + assert_eq!( + failing.run(&["pane".into(), "split".into()]).unwrap_err(), + "herdr pane split failed: boom: bad pane" + ); + } +} diff --git a/crates/wiki-reader/src/main.rs b/crates/wiki-reader/src/main.rs index 3201e8a..e5de6c7 100644 --- a/crates/wiki-reader/src/main.rs +++ b/crates/wiki-reader/src/main.rs @@ -2,6 +2,7 @@ //! //! See [architecture overview](../../wiki/architecture/overview.md). +mod herdr; mod tui; use std::path::{Path, PathBuf}; @@ -21,6 +22,10 @@ struct Args { /// Use Herdr's focused pane or workspace cwd when no root is supplied. #[arg(long)] herdr_context: bool, + /// From a herdr plugin action: open the reader in a new ordinary split pane (which, unlike + /// plugin panes, can draw images) next to the focused pane, then exit. + #[arg(long, conflicts_with_all = ["root", "herdr_context", "config"])] + herdr_split: bool, /// Optional config TOML (overrides XDG and `/.wiki-reader.toml`). #[arg(long = "config", value_name = "PATH")] config: Option, @@ -49,6 +54,15 @@ fn resolve_root(args: &Args, context: Option<&str>) -> PathBuf { fn main() -> ExitCode { let args = Args::parse(); let context = std::env::var("HERDR_PLUGIN_CONTEXT_JSON").ok(); + if args.herdr_split { + return match herdr::open_split(&herdr::HerdrCli::from_env(), context.as_deref()) { + Ok(_) => ExitCode::SUCCESS, + Err(msg) => { + eprintln!("wiki-reader: {msg}"); + ExitCode::FAILURE + } + }; + } let root = resolve_root(&args, context.as_deref()); if let Err(msg) = check_root(&root) { eprintln!("wiki-reader: {msg}"); @@ -127,6 +141,18 @@ mod tests { } } + #[test] + fn herdr_split_is_exclusive_with_the_reader_arguments() { + assert!(Args::try_parse_from(["wiki-reader", "--herdr-split"]).is_ok()); + for other in [ + vec!["wiki-reader", "--herdr-split", "docs"], + vec!["wiki-reader", "--herdr-split", "--herdr-context"], + vec!["wiki-reader", "--herdr-split", "--config", "c.toml"], + ] { + assert!(Args::try_parse_from(other).is_err()); + } + } + #[test] fn version_flag_reports_pkg_version() { use clap::CommandFactory; diff --git a/crates/wiki-reader/src/tui/app/mod.rs b/crates/wiki-reader/src/tui/app/mod.rs index 40eac2d..dfffc39 100644 --- a/crates/wiki-reader/src/tui/app/mod.rs +++ b/crates/wiki-reader/src/tui/app/mod.rs @@ -487,7 +487,7 @@ impl App { graphics: cell_px.is_some(), tmux: env.tmux, herdr: env.herdr, - herdr_popup: wiki_reader_core::herdr::running_in_plugin_popup(), + herdr_plugin_pane: wiki_reader_core::herdr::running_in_plugin_pane(), diagram_palette: self.theme.diagram, diagram_sizes: self.images.diagram_sizes(), max_slot_rows: self.images_max_slot_rows, diff --git a/integrations/herdr/README.md b/integrations/herdr/README.md index 2089170..8ca907c 100644 --- a/integrations/herdr/README.md +++ b/integrations/herdr/README.md @@ -1,43 +1,21 @@ -# Herdr popup plugin +# Herdr plugin -Requires Herdr **0.9.0+** on Linux or macOS and a wiki-reader binary supporting -`--herdr-context` on Herdr's `PATH`. The installed v0.1.3 binary does not support -this flag; build/install the implementation revision until v0.1.4 is released. -The manifest installs no build commands, startup hooks or event hooks. +Opens wiki-reader from a [herdr](https://herdr.dev) key or command, in the focused pane's +directory. Requires herdr **0.9.0+** on Linux or macOS and a wiki-reader binary that supports +`--herdr-split` and `--herdr-context` (v0.1.4 or later; the v0.1.3 binary does not) on `PATH`. +The manifest declares no build commands, startup hooks or event hooks. + +## Install From the repository root, after reviewing `herdr-plugin.toml`: ```bash cargo install --locked --path crates/wiki-reader herdr plugin link "$PWD/integrations/herdr" -herdr plugin pane open --plugin wiki-reader --entrypoint reader -# Equivalent action, suitable for binding a key: herdr plugin action invoke wiki-reader.open ``` -The popup is 80% of the terminal's width and height. `q` exits wiki-reader and -closes it; `Esc` dismisses wiki-reader's own overlays rather than exiting. -Herdr 0.9.0's CLI help omits popup placement, but the manifest works on that -version (see the [spike evidence](../../wiki/roadmap/spikes/p3-s2-herdr-integration.md)). -Overlay and split graphics are not promised by this plugin. - -**Shipping acceptance is currently blocked:** the implementation retest on -Herdr 0.9.0 passes collection selection and keys, but falls back to text because -the popup supplies no cell metrics. Kitty is confirmed; ratatui-image drops that -result without a measured font size. See the [retest evidence](../../wiki/roadmap/spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03). -A longer probe timeout did not help. - -Herdr starts plugin commands in the plugin directory. `--herdr-context` reads -`HERDR_PLUGIN_CONTEXT_JSON`: an explicit collection root wins; otherwise it uses -`focused_pane_cwd`, then `workspace_cwd` when the focused cwd is absent or empty. -Missing/malformed context or a selected path that is not a directory silently -falls back to `.` (the plugin directory). Without the flag, the root still -defaults to `.` and explicit invalid roots still fail normally. - -Popup processes have no `HERDR_PANE_ID`. They must not publish metadata to the -underlying focused pane, which may host an unrelated agent. - -Optional binding in Herdr's config (choose a key that is free in your setup): +To bind a key, add this to herdr's config (choose a key that is free in your setup): ```toml [[keys.command]] @@ -47,15 +25,53 @@ command = "wiki-reader.open" description = "open wiki reader" ``` -The action invokes `"$HERDR_BIN_PATH" plugin pane open`; no socket client or -extra runtime beyond the POSIX shell is required. A missing wiki-reader binary -cannot open the reader. Inspect Herdr's plugin logs for launch failures: +## Actions + +| Action | Opens | Images | +|--------|-------|--------| +| `wiki-reader.open` (default) | An **ordinary split pane** to the right of the focused pane, in its directory | yes | +| `wiki-reader.open-overlay` | A zoomed plugin overlay | no (text only) | +| `wiki-reader.open-popup` | An 80% × 80% plugin popup | no (text only) | + +**Why the default is a split pane.** On herdr 0.9.x, every pane started for a plugin command +(overlay, popup, split or tab) gets no terminal cell metrics and never answers the cell-size +query, so wiki-reader cannot size or draw images there; it shows text placeholders and diagrams +as text, and says so ("herdr plugin panes report no cell size, open the reader in a normal +pane"). An ordinary shell pane answers, so `open` creates one with `herdr pane split` and runs +`exec wiki-reader` in it, which means quitting the reader (`q`) closes the pane. wiki-reader never +guesses a cell size. See the +[spike evidence](../../wiki/roadmap/spikes/p3-s2-herdr-integration.md#p3-09-overlay-and-control-retest--2026-10-03). + +The overlay and popup actions keep the keys, context and dismissal working, so they are fine for +a quick text read. Herdr 0.9.0's `--placement` help omits `popup`, but the manifest works on that +version. Popup commands have no `HERDR_PANE_ID` of their own, so a popup never publishes +page metadata. + +## How the launcher chooses the directory + +Herdr starts plugin commands in the plugin directory and passes `HERDR_PLUGIN_CONTEXT_JSON`. +`--herdr-context` (overlay and popup) and `--herdr-split` (the default action) use its +`focused_pane_cwd`, then `workspace_cwd`. An explicit collection root wins over the context. +Missing or malformed context, or a path that is not a directory, falls back to the current +directory for `--herdr-context`; `--herdr-split` then lets herdr pick the target pane and apply +its own `terminal.new_cwd` policy. `--herdr-split` cannot be combined with a root, `--config` +or `--herdr-context`. + +## Troubleshooting + +`wiki-reader.open` runs `wiki-reader --herdr-split` as the plugin command, then types +`exec wiki-reader` into the new shell, so wiki-reader must be on `PATH` both for herdr's plugin +environment and in your shell. If it is missing, the action fails (the new split pane may remain +open and show the shell's error). Inspect plugin command output with: ```bash herdr plugin log list --plugin wiki-reader -herdr plugin unlink wiki-reader ``` -Unlinking unregisters the plugin without deleting the checkout or Herdr's -plugin config/state directories. Linking or changing keys is an operator action; -these commands are not run automatically by wiki-reader. +Unlinking unregisters the plugin without deleting the checkout or herdr's plugin config and +state directories. Linking, unlinking and changing keys are operator actions; wiki-reader never +runs them for you: + +```bash +herdr plugin unlink wiki-reader +``` diff --git a/integrations/herdr/herdr-plugin.toml b/integrations/herdr/herdr-plugin.toml index 3f88587..268b246 100644 --- a/integrations/herdr/herdr-plugin.toml +++ b/integrations/herdr/herdr-plugin.toml @@ -4,15 +4,16 @@ version = "0.1.0" min_herdr_version = "0.9.0" platforms = ["linux", "macos"] -# Default: a zoomed overlay. It is a normal herdr pane, so terminal graphics work and the -# reader has its own HERDR_PANE_ID. +# Plugin panes (overlay, popup) cannot draw images on herdr 0.9.x: herdr starts them without +# cell metrics and never answers the terminal's cell-size query. Text, keys and diagrams as +# text work. The `open` action below opens an ordinary split pane instead, which does draw +# images. [[panes]] -id = "reader" -title = "Wiki Reader" +id = "reader-overlay" +title = "Wiki Reader (overlay)" placement = "overlay" command = ["wiki-reader", "--herdr-context"] -# Popup: no images on herdr 0.9.x (popups report no cell size); text and keys work. [[panes]] id = "reader-popup" title = "Wiki Reader (popup)" @@ -21,14 +22,21 @@ width = "80%" height = "80%" command = ["wiki-reader", "--herdr-context"] +# Default: the reader in a new ordinary split pane next to the focused pane, in its cwd. [[actions]] id = "open" title = "Open Wiki Reader" contexts = ["workspace"] -command = ["sh", "-c", 'exec "$HERDR_BIN_PATH" plugin pane open --plugin wiki-reader --entrypoint reader'] +command = ["wiki-reader", "--herdr-split"] + +[[actions]] +id = "open-overlay" +title = "Open Wiki Reader (overlay, text only)" +contexts = ["workspace"] +command = ["sh", "-c", 'exec "$HERDR_BIN_PATH" plugin pane open --plugin wiki-reader --entrypoint reader-overlay'] [[actions]] id = "open-popup" -title = "Open Wiki Reader (popup)" +title = "Open Wiki Reader (popup, text only)" contexts = ["workspace"] command = ["sh", "-c", 'exec "$HERDR_BIN_PATH" plugin pane open --plugin wiki-reader --entrypoint reader-popup'] diff --git a/wiki/architecture/integrations.md b/wiki/architecture/integrations.md index 1d5363f..00e08d6 100644 --- a/wiki/architecture/integrations.md +++ b/wiki/architecture/integrations.md @@ -43,7 +43,7 @@ herdr is the host environment, so it's the integration most likely to pay off ea | Env awareness | Detect `HERDR_ENV`, choose Kitty-only graphics or the text tier | Phase 2 | | Context signals (Phase 4, widget sidebar) | `herdr pane list --workspace $HERDR_WORKSPACE_ID` → sibling cwds and agent states | Phase 4 | | Publish state | `herdr pane report-metadata --token page=… --token wu=…` so herdr's sidebar shows what the wiki pane is on | Alpha | -| Plugin | A herdr plugin manifest with a pane entrypoint that opens wiki-reader as a split or popup for the current workspace | Alpha | +| Plugin | [`integrations/herdr/`](../../integrations/herdr/README.md): an action that opens wiki-reader in a new ordinary split pane in the focused pane's directory (images work), plus text-only overlay and popup actions; herdr 0.9.x gives plugin panes no cell metrics | Alpha (P3-09) | ### Pane setup (today) diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index 78b7958..17414d6 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -134,6 +134,8 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | 2026-10-03 | bite | P3-09 operator popup check with a temporary linked copy and local binary: correct collection, Help/Esc/Ctrl+Enter/q and clean dismissal pass; images do not render and Mermaid falls back to text. Screenshots show `no graphics protocol`. Explicit image config and a 2-second probe timeout still fail. Raw diagnostic confirms Kitty but no cell-size reply; the PTY provides no usable pixel-size fallback, so ratatui-image returns its default Halfblocks/10×20 picker. [Retest evidence](spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03) records the geometry gap. Shipping-plugin acceptance blocked; temporary plugin unlinked, no user config or installed binary changes | P3-09 | | 2026-10-03 | decision | Geometry investigation: delaying the popup probe by 300 ms still fails. Herdr 0.9.0 starts the popup without pixel metrics, omits PluginPaneOpen from geometry-change classification, and normal client-shell drawing does not resize runtimes. The classifier omission remains in inspected 0.9.3/master. Record an upstream initial-geometry/invalidation fix proposal and immediate-query regression requirements; no patched build or upgrade tested. Second diagnostic plugin unlinked; P3-09 remains blocked, no release | P3-09 | | 2026-10-03 | fix | Prepared a local, uncommitted Herdr geometry patch against upstream master `5da0a01e`: initial PTY/virtual-terminal metrics, plugin-open geometry invalidation and client/controller ownership. Five regressions pass; disabling the fix makes four fail. Native suite passes 3713 tests (12 skipped), plus fmt, native clippy and architecture/maintenance/integration/docs checks. Full upstream `just check` unavailable (`just` missing; Windows not validated). Patch and validation notes exported under `/tmp`; no upstream PR, install or live restart. P3-09 graphics acceptance and v0.1.4 remain blocked | P3-09 | +| 2026-10-03 | bite | P3-09 operator retest of the overlay entrypoint and the `image-protocol` example in an ordinary pane, a plugin overlay and a plugin popup: the ordinary pane detects Kitty with an 8×17 cell size and draws; both plugin placements get Halfblocks, a 10×20 default font and no capabilities. Overlay is no better than popup, so the overlay-as-default idea fails; the reader itself is not regressed | P3-09 | +| 2026-10-03 | decision | P3-09 ships an ordinary-pane split launcher as the default action (`wiki-reader --herdr-split`: `herdr pane split` then `pane run "exec wiki-reader"`), with overlay and popup kept as text-only actions, so images work today without depending on a herdr fix. Fallback text now says plugin panes report no cell size and suggests a normal pane (detected from `HERDR_PLUGIN_ENTRYPOINT_ID`); cell size is never guessed. An explicit `diagrams = "image"` that falls back to text now shows its reason in the tier header (the old arm was unreachable) | P3-09 | ## Related diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index 6a4143c..2a97cd4 100644 --- a/wiki/roadmap/phase-3-alpha.md +++ b/wiki/roadmap/phase-3-alpha.md @@ -72,7 +72,7 @@ Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fal | P3-04 | Optional header ‹ › buttons | U4 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-07 | | P3-05 | Link hover preview popover | W6 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-08 | | P3-03 | Nav label options | U3 | done | completed by P3-16; two modes, literal filenames/folders and legacy config migration | -| P3-09 | herdr: launch as a herdr plugin pane | | active | Context-aware launcher and [popup manifest](../../integrations/herdr/README.md) implemented; manual keys/context/dismissal pass, but graphics acceptance blocked by missing popup cell metrics ([retest](spikes/p3-s2-herdr-integration.md#p3-09-implementation-retest--2026-10-03)). [P3-S2](spikes/p3-s2-herdr-integration.md) confirms popup feasibility on 0.9.0 (context cwd, keys, Kitty images and cleanup). Overlay/split graphics checks remain unverified | +| P3-09 | herdr: launch as a herdr plugin pane | | active | Implemented in #128 (unreleased): `--herdr-context` and `--herdr-split`, an `integrations/herdr/` plugin whose default action opens the reader in an ordinary split pane (images work) plus text-only overlay and popup actions. [P3-S2](spikes/p3-s2-herdr-integration.md) retests show every herdr 0.9.x plugin pane, overlay included, lacks cell metrics, so plugin panes cannot draw images; wiki-reader names that in its fallback text and never guesses a cell size. Pending: operator pass of the `open` action in a real herdr, then close | | P3-10 | herdr: publish the current page to herdr's sidebar | | todo | [P3-S2](spikes/p3-s2-herdr-integration.md): plain-pane title/token stored and sidebar visibility operator-confirmed; implement best-effort publishing for ordinary panes only. Popups have no pane ID; TTL/renew/clear still need tests | | P3-13 | Options window | U5 | done | feature request 2026-10-02. A popup like markdown-reader's, opened by a key and a footer/header button, that edits config from inside the app: **theme** selector (dark / light / herdr, live preview), **nav position** (left / right, P3-11), **nav labels** (titles ↔ filenames, `nav.labels`), **Mermaid** settings (`diagrams` tier: auto / image / text / source), **image** settings, **copy path** format (`copy.path`: relative / absolute, key added by P2-55). Implemented: `,` / `c` or the layout footer ⚙ (moved by P3-18) opens the overlay; writes via [ADR-0018](../decisions/0018-config-write-path.md); new `[images] enabled \| max_slot_rows`; live apply for every row (images re-enable after a disable needs a restart when the startup probe was skipped). Reworked 2026-10-02 (local time) after dogfood: grouped radio rows in the style of markdown-reader's settings window, `,` and `c` both open and close it, and the copy-path key is listed in Help as "Copy file path" | | P3-14 | Table viewer | U6 | done | feature request 2026-10-02. Open a large table in a modal / window (like markdown-reader): scroll in both axes with a fixed header row and first column, **filter** rows by text, **sort** by a column, copy a cell or row (reuse the tab-separated table copy from P2-R38). Entry point: the existing "expand table" block action (BA), plus `Enter` on a focused table. Also the shell for P3-15. Implemented: `RenderedDoc::tables` (`DocTable`) from `render.rs`; `ExpandTable` block action and `Enter` open `tui/table_viewer.rs` inside `tui/modal_viewer.rs`. Keys: arrows / `hjkl`, `PgUp` / `PgDn`, `g` / `G`, `/` filter, `s` sort, `y` cell, `Y` row. See [UI spec: Modal viewers](../product/ui-spec.md#modal-viewers) | diff --git a/wiki/roadmap/spikes/p3-s2-herdr-integration.md b/wiki/roadmap/spikes/p3-s2-herdr-integration.md index 75539c2..16615b2 100644 --- a/wiki/roadmap/spikes/p3-s2-herdr-integration.md +++ b/wiki/roadmap/spikes/p3-s2-herdr-integration.md @@ -36,7 +36,7 @@ No user or collection config was changed. Temporary context logs and the bundled ### P3-09 — plugin pane -**Decision: confirm feasibility; implement the popup path.** The operator verified popup images, key delivery and clean dismissal on the real install. This is spike evidence, not completion of the shipping plugin task. +**Decision (revised after the retests below): plugin panes are viable for text, but not for images on herdr 0.9.x. Ship an ordinary-pane split launcher as the default and keep overlay and popup as text-only options.** The first spike pass reported real popup images; the shipping-plugin retest and the overlay and control runs below show that plugin panes of every placement get no cell metrics. Do not rely on the first popup image pass. Manifest fields `id`, `name`, `version`, `min_herdr_version`, `platforms` and `[[panes]]` command argv are accepted. The 0.9.0 CLI help omits popup from `--placement`, but a manifest with `placement = "popup"`, `width = "80%"`, `height = "80%"` links and opens successfully. Do not infer lack of server support from that incomplete help. @@ -132,11 +132,10 @@ gap before completing P3-09; the initial feasibility verdict is not shipping pro ### Local host patch — 2026-10-03 At the operator's request, prepared an uncommitted patch against upstream master -`5da0a01e1eedda054db0c81dd3a780000c40d9f0` (package 0.9.3), in the isolated checkout -`/tmp/wiki-reader-herdr-geometry`. Exported to -`/tmp/wiki-reader-herdr-popup-geometry.patch`; validation notes are in -`/tmp/wiki-reader-herdr-popup-geometry-validation.md`. These are local scratch -artifacts, not distributed wiki-reader code. +`5da0a01e1eedda054db0c81dd3a780000c40d9f0` (package 0.9.3), in an isolated scratch +checkout outside this repository. The patch and its validation notes are local +scratch artifacts: they are not part of wiki-reader, are not distributed and may no +longer exist. This section is the durable summary. The patch initialises popup virtual-terminal and PTY pixel geometry before the child starts, keeps size bookkeeping consistent, and classifies plugin pane @@ -158,24 +157,60 @@ run `just --dry-run`), and Windows cross-validation was not run. No SDK/license downloads, install, restart or patched-client connection to the stable server were performed. The patch remains uncommitted and unpublished; the authenticated account is not on Herdr's approved-contributor list, so no upstream implementation -PR was opened. **P3-09 still needs real graphics acceptance on a fixed host.** +PR was opened. wiki-reader does not depend on this patch. + +## P3-09 overlay and control retest — 2026-10-03 + +The operator repeated the check with the context-aware launcher in an **overlay**, and +ran the repository's `image-protocol` example (it prints the probe result on screen) in +an ordinary pane and in an overlay and a popup of a scratch plugin. herdr 0.9.0, Ghostty. + +| Where it ran | `detected` / `selected` | Font | Capabilities | Picture | +|--------------|-------------------------|------|--------------|---------| +| Ordinary herdr pane (control) | Kitty / Kitty | 8×17 | Kitty, CellSize(8, 17) | drawn | +| Plugin overlay | Halfblocks / Halfblocks | 10×20 (library default) | none | halfblock fallback only | +| Plugin popup | Halfblocks / Halfblocks | 10×20 (library default) | none | halfblock fallback only | + +Findings: + +- The reader is not regressed: the same probe works in an ordinary pane. +- **Overlay is no better than popup.** The reader in the overlay showed the plain "no + graphics protocol" reason and rendered Mermaid as text, as in the popup. Every pane + that herdr starts for a plugin command lacks the terminal replies, whatever its placement. + The local patch notes agree: ordinary command panes start with zero pixel metrics. +- Both plugin panes carry `HERDR_PLUGIN_ENTRYPOINT_ID` (checked in the spike's logged + environments); only the overlay has `HERDR_PANE_ID`. wiki-reader therefore detects a plugin + pane from `HERDR_ENV=1` plus `HERDR_PLUGIN_ENTRYPOINT_ID`, not from the missing pane id, + and its "no graphics" placeholders and diagram headers now say plugin panes report no cell + size and suggest a normal pane. Cell size is still never guessed. +- herdr's CLI can open an ordinary shell pane: `herdr pane split --direction right + --cwd --focus` returns the new pane id (`.result.pane.pane_id`), and + `herdr pane run ` submits a command to it. Both are documented in the 0.9.3 + CLI reference. The control run shows such panes answer the queries. + +**Decision:** the plugin's default action (`open`) runs `wiki-reader --herdr-split`, which +splits the focused pane, sets the new pane's cwd to the focused pane's, and runs +`exec wiki-reader` in it (so quitting closes the pane). Overlay and popup stay as +`open-overlay` and `open-popup`, documented as text-only until herdr starts plugin panes +with cell metrics. The ordinary-pane route has its own pane id, so P3-10 publishing works there. ## Matrix | Placement / surface | Launch and context | Keys | Kitty images / cleanup | Status | |---------------------|--------------------|------|------------------------|--------| -| Popup, 80% × 80% | logged caller cwd; no pane ID | operator: Help, Esc, Ctrl+Enter, q pass | operator: real images, no fragments after Help or exit | verified | -| Overlay | logged caller cwd; own pane ID; focus restored on close | automated: Help appears, Esc dismisses, Ctrl+Enter adds a tab | operator could not verify this placement now | partial; no graphics claim | -| Split | logged caller cwd; own pane ID; socket target workaround | automated: Help appears, Esc dismisses, Ctrl+Enter adds tabs | explicit-image split not visually checked | partial; no graphics claim | +| Popup, 80% × 80% | logged caller cwd; no pane ID | operator: Help, Esc, Ctrl+Enter, q pass | first pass reported images; the shipping-plugin retest and the example both show no cell metrics, so no images | keys verified; images fail | +| Overlay | logged caller cwd; own pane ID; focus restored on close | operator: all keys pass | retest: no cell metrics, no images, Mermaid as text | keys verified; images fail | +| Plugin split | logged caller cwd; own pane ID; socket target workaround | automated: Help appears, Esc dismisses, Ctrl+Enter adds tabs | not visually checked; expected to match overlay | partial; no graphics claim | +| Ordinary shell pane (control, and the `open` action's target) | caller cwd via `pane split --cwd` | normal | example: Kitty, 8×17, picture drawn | verified for the probe; the `open` action itself is checked in the P3-09 manual pass | | Plain-pane metadata | API stores title and page token without agent registration | not applicable | operator: title or token visible in sidebar | display feasibility verified | -The earlier [P3-S1](p3-s1-image-protocol.md) verified Kitty in ordinary Herdr panes. It does not substitute for the outstanding placement-specific visual checks here. Direct Ghostty, iTerm2 and tmux were not re-tested in this spike. +The earlier [P3-S1](p3-s1-image-protocol.md) verified Kitty in ordinary Herdr panes, which the control run reconfirms. Direct Ghostty, iTerm2 and tmux were not re-tested in this spike. ## Cleanup and implementation handoff All test panes created by the spike were closed (the operator exited the verified popup); the scratch plugin was unlinked. `herdr plugin list` returned **No plugins installed**, matching the pre-spike state. Herdr retains plugin config/state directories after unlink by design; the scratch plugin stored no durable state there. -- P3-09: ship a small manifest/launcher using context cwd; use popup as the verified default. Test missing/malformed context and command failure. Document the 0.9.0 help mismatch; do not promise unverified overlay/split media behavior. +- P3-09: the default action opens an ordinary split pane (`wiki-reader --herdr-split`); overlay and popup are text-only options. Test missing/malformed context and command failure. Document the 0.9.0 `--placement` help omitting popup and the plugin-pane graphics limit. The `/tmp` evidence files from the investigation are scratch and not preserved. - P3-10: publish from ordinary reader panes only; popup omission must be explicit in docs. Confirm title versus token display, update/renew/clear behavior and unsupported-server fallback before closing the row. - P4-05: config-watch seam is feasible independently of the plugin. Keep appearance/custom-palette limitations explicit. - No task row is marked done by this spike, no release is cut, and the Phase 2 adoption clock remains unchanged. From 94ba2b12b69b43c8bf8b065f2d922e7324b97d01 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 19:25:20 -0700 Subject: [PATCH 7/8] docs: plugin README key example must not shadow herdr's prefix+w Co-Authored-By: Claude Sonnet 5.5 --- integrations/herdr/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integrations/herdr/README.md b/integrations/herdr/README.md index 8ca907c..e62ce95 100644 --- a/integrations/herdr/README.md +++ b/integrations/herdr/README.md @@ -15,11 +15,12 @@ herdr plugin link "$PWD/integrations/herdr" herdr plugin action invoke wiki-reader.open ``` -To bind a key, add this to herdr's config (choose a key that is free in your setup): +To bind a key, add this to herdr's config. Pick one that is free: `prefix+w` is herdr's default +for workspace navigation, and `prefix+?` lists every active binding. ```toml [[keys.command]] -key = "prefix+w" +key = "prefix+shift+r" type = "plugin_action" command = "wiki-reader.open" description = "open wiki reader" From 40c0f596945815265d36fffc8547e1f37ec545bf Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Sat, 3 Oct 2026 19:35:44 -0700 Subject: [PATCH 8/8] docs: record operator acceptance of the herdr launcher (P3-09) Co-Authored-By: Claude Sonnet 5.5 --- wiki/roadmap/dogfood-log.md | 1 + wiki/roadmap/phase-3-alpha.md | 2 +- wiki/roadmap/spikes/p3-s2-herdr-integration.md | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index 17414d6..b9cfb63 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -136,6 +136,7 @@ Phase 2 stays `active` through two weeks of real use on a real collection. Clock | 2026-10-03 | fix | Prepared a local, uncommitted Herdr geometry patch against upstream master `5da0a01e`: initial PTY/virtual-terminal metrics, plugin-open geometry invalidation and client/controller ownership. Five regressions pass; disabling the fix makes four fail. Native suite passes 3713 tests (12 skipped), plus fmt, native clippy and architecture/maintenance/integration/docs checks. Full upstream `just check` unavailable (`just` missing; Windows not validated). Patch and validation notes exported under `/tmp`; no upstream PR, install or live restart. P3-09 graphics acceptance and v0.1.4 remain blocked | P3-09 | | 2026-10-03 | bite | P3-09 operator retest of the overlay entrypoint and the `image-protocol` example in an ordinary pane, a plugin overlay and a plugin popup: the ordinary pane detects Kitty with an 8×17 cell size and draws; both plugin placements get Halfblocks, a 10×20 default font and no capabilities. Overlay is no better than popup, so the overlay-as-default idea fails; the reader itself is not regressed | P3-09 | | 2026-10-03 | decision | P3-09 ships an ordinary-pane split launcher as the default action (`wiki-reader --herdr-split`: `herdr pane split` then `pane run "exec wiki-reader"`), with overlay and popup kept as text-only actions, so images work today without depending on a herdr fix. Fallback text now says plugin panes report no cell size and suggests a normal pane (detected from `HERDR_PLUGIN_ENTRYPOINT_ID`); cell size is never guessed. An explicit `diagrams = "image"` that falls back to text now shows its reason in the tier header (the old arm was unreachable) | P3-09 | +| 2026-10-03 | decision | P3-09 accepted by the operator on herdr 0.9.0: the `open` action's ordinary split pane draws images and diagrams (and they survive theme switches); the overlay and popup entrypoints open, take keys and dismiss but are text-only, showing the plugin-pane wording. Matches the retest prediction, so it is the documented limit, not a defect; images there wait for herdr to give plugin panes cell metrics | P3-09 | ## Related diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index 2a97cd4..fd25c63 100644 --- a/wiki/roadmap/phase-3-alpha.md +++ b/wiki/roadmap/phase-3-alpha.md @@ -72,7 +72,7 @@ Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fal | P3-04 | Optional header ‹ › buttons | U4 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-07 | | P3-05 | Link hover preview popover | W6 | deferred | Operator decision 2026-10-03: Phase 4 seed P4-08 | | P3-03 | Nav label options | U3 | done | completed by P3-16; two modes, literal filenames/folders and legacy config migration | -| P3-09 | herdr: launch as a herdr plugin pane | | active | Implemented in #128 (unreleased): `--herdr-context` and `--herdr-split`, an `integrations/herdr/` plugin whose default action opens the reader in an ordinary split pane (images work) plus text-only overlay and popup actions. [P3-S2](spikes/p3-s2-herdr-integration.md) retests show every herdr 0.9.x plugin pane, overlay included, lacks cell metrics, so plugin panes cannot draw images; wiki-reader names that in its fallback text and never guesses a cell size. Pending: operator pass of the `open` action in a real herdr, then close | +| P3-09 | herdr: launch as a herdr plugin pane | | done | Implemented in #128 (unreleased): `--herdr-context` and `--herdr-split`, an `integrations/herdr/` plugin whose default action opens the reader in an ordinary split pane (images work) plus text-only overlay and popup actions. [P3-S2](spikes/p3-s2-herdr-integration.md) retests show every herdr 0.9.x plugin pane, overlay included, lacks cell metrics, so plugin panes cannot draw images; wiki-reader names that in its fallback text and never guesses a cell size. Operator accepted 2026-10-03: the `open` action's split pane draws images and diagrams; overlay and popup open and take keys but are text-only on herdr 0.9.x, with the plugin-pane wording. Ships in v0.1.4 | | P3-10 | herdr: publish the current page to herdr's sidebar | | todo | [P3-S2](spikes/p3-s2-herdr-integration.md): plain-pane title/token stored and sidebar visibility operator-confirmed; implement best-effort publishing for ordinary panes only. Popups have no pane ID; TTL/renew/clear still need tests | | P3-13 | Options window | U5 | done | feature request 2026-10-02. A popup like markdown-reader's, opened by a key and a footer/header button, that edits config from inside the app: **theme** selector (dark / light / herdr, live preview), **nav position** (left / right, P3-11), **nav labels** (titles ↔ filenames, `nav.labels`), **Mermaid** settings (`diagrams` tier: auto / image / text / source), **image** settings, **copy path** format (`copy.path`: relative / absolute, key added by P2-55). Implemented: `,` / `c` or the layout footer ⚙ (moved by P3-18) opens the overlay; writes via [ADR-0018](../decisions/0018-config-write-path.md); new `[images] enabled \| max_slot_rows`; live apply for every row (images re-enable after a disable needs a restart when the startup probe was skipped). Reworked 2026-10-02 (local time) after dogfood: grouped radio rows in the style of markdown-reader's settings window, `,` and `c` both open and close it, and the copy-path key is listed in Help as "Copy file path" | | P3-14 | Table viewer | U6 | done | feature request 2026-10-02. Open a large table in a modal / window (like markdown-reader): scroll in both axes with a fixed header row and first column, **filter** rows by text, **sort** by a column, copy a cell or row (reuse the tab-separated table copy from P2-R38). Entry point: the existing "expand table" block action (BA), plus `Enter` on a focused table. Also the shell for P3-15. Implemented: `RenderedDoc::tables` (`DocTable`) from `render.rs`; `ExpandTable` block action and `Enter` open `tui/table_viewer.rs` inside `tui/modal_viewer.rs`. Keys: arrows / `hjkl`, `PgUp` / `PgDn`, `g` / `G`, `/` filter, `s` sort, `y` cell, `Y` row. See [UI spec: Modal viewers](../product/ui-spec.md#modal-viewers) | diff --git a/wiki/roadmap/spikes/p3-s2-herdr-integration.md b/wiki/roadmap/spikes/p3-s2-herdr-integration.md index 16615b2..8a8aed2 100644 --- a/wiki/roadmap/spikes/p3-s2-herdr-integration.md +++ b/wiki/roadmap/spikes/p3-s2-herdr-integration.md @@ -188,6 +188,12 @@ Findings: `herdr pane run ` submits a command to it. Both are documented in the 0.9.3 CLI reference. The control run shows such panes answer the queries. +**Operator acceptance (2026-10-03).** With the shipping manifest linked from a scratch copy: +the `open` action's split pane and a plain ordinary pane both draw images and diagrams. The +overlay and popup entrypoints open, take keys and dismiss cleanly but are text-only, with the +new "herdr plugin panes report no cell size, open the reader in a normal pane" wording in +both, as predicted. That is the accepted behaviour for plugin panes on herdr 0.9.x. + **Decision:** the plugin's default action (`open`) runs `wiki-reader --herdr-split`, which splits the focused pane, sets the new pane's cwd to the focused pane's, and runs `exec wiki-reader` in it (so quitting closes the pane). Overlay and popup stay as @@ -201,7 +207,7 @@ with cell metrics. The ordinary-pane route has its own pane id, so P3-10 publish | Popup, 80% × 80% | logged caller cwd; no pane ID | operator: Help, Esc, Ctrl+Enter, q pass | first pass reported images; the shipping-plugin retest and the example both show no cell metrics, so no images | keys verified; images fail | | Overlay | logged caller cwd; own pane ID; focus restored on close | operator: all keys pass | retest: no cell metrics, no images, Mermaid as text | keys verified; images fail | | Plugin split | logged caller cwd; own pane ID; socket target workaround | automated: Help appears, Esc dismisses, Ctrl+Enter adds tabs | not visually checked; expected to match overlay | partial; no graphics claim | -| Ordinary shell pane (control, and the `open` action's target) | caller cwd via `pane split --cwd` | normal | example: Kitty, 8×17, picture drawn | verified for the probe; the `open` action itself is checked in the P3-09 manual pass | +| Ordinary shell pane (control, and the `open` action's target) | caller cwd via `pane split --cwd` | normal | example: Kitty, 8×17, picture drawn; operator 2026-10-03: the `open` action's split pane draws images and diagrams, and they survive theme switches | verified | | Plain-pane metadata | API stores title and page token without agent registration | not applicable | operator: title or token visible in sidebar | display feasibility verified | The earlier [P3-S1](p3-s1-image-protocol.md) verified Kitty in ordinary Herdr panes, which the control run reconfirms. Direct Ghostty, iTerm2 and tmux were not re-tested in this spike.