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/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/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..7716c78 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,75 @@ 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`. +#[must_use] +pub fn parse_context_cwd(text: &str) -> Option { + 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 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 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_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_pane`] for this process's environment. +#[must_use] +pub fn running_in_plugin_pane() -> bool { + let env = std::env::var("HERDR_ENV").ok(); + 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`. #[must_use] pub fn config_path() -> Option { @@ -46,6 +115,90 @@ pub fn parse_theme_name(text: &str) -> Option { mod tests { use super::*; + #[test] + 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] + 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/src/images.rs b/crates/wiki-reader-core/src/images.rs index 55f8e92..37f1672 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 pane, which reports no cell size (herdr 0.9.x). + NoGraphicsHerdrPlugin, } 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::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 new file mode 100644 index 0000000..722bbc5 --- /dev/null +++ b/crates/wiki-reader-core/tests/herdr_plugin.rs @@ -0,0 +1,86 @@ +//! 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() +} + +fn argv(value: &toml::Value) -> Vec<&str> { + value["command"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect() +} + +#[test] +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); + 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")); + 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"]); + } + 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 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(), 3); + for (action, id, entrypoint) in [ + (&actions[1], "open-overlay", "reader-overlay"), + (&actions[2], "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..ffd7f9c 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_plugin_pane_names_itself_in_the_no_graphics_reason() { + let plugin = render_images_fixture_with( + RenderOpts { + herdr: true, + herdr_plugin_pane: true, + ..RenderOpts::default() + }, + 200, + ); + assert!( + 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"), + "{}", + plugin.lines.join("\n") + ); + // The same fixture outside a plugin pane 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_plugin_pane: 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_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 { + 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 (plugin, expected) in [ + ( + true, + "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_plugin_pane: plugin, + ..RenderOpts::default() + }; + let doc = render_with(src, None, &key, &index, 200, &opts); + assert!( + doc.lines.iter().any(|l| l.contains(expected)), + "plugin={plugin}:\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..743dc9b 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 pane (any placement): only changes the wording of the + /// "no graphics" fallback reason, never what is drawn. + 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. @@ -228,6 +233,7 @@ impl Default for RenderOpts { graphics: false, tmux: false, herdr: false, + herdr_plugin_pane: 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_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; @@ -437,6 +444,7 @@ struct LayoutState<'a> { graphics: bool, tmux: bool, herdr: bool, + herdr_plugin_pane: 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_plugin_pane: 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_plugin_pane => { + ImagePlan::Placeholder(ImageReject::NoGraphicsHerdrPlugin) + } + 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_plugin_pane { + ImageReject::NoGraphicsHerdrPlugin + } else { + ImageReject::NoGraphics + } + .to_string(), + ) + } _ => None, }; let reason = reason_owned.as_deref(); 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 3dc2510..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}; @@ -17,8 +18,14 @@ 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, + /// 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, @@ -31,13 +38,37 @@ 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(); + 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}"); 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 +94,65 @@ 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 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 0fe08e9..0f71466 100644 --- a/crates/wiki-reader/src/tui/app/mod.rs +++ b/crates/wiki-reader/src/tui/app/mod.rs @@ -491,6 +491,7 @@ impl App { graphics: cell_px.is_some(), tmux: env.tmux, herdr: env.herdr, + 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 new file mode 100644 index 0000000..e62ce95 --- /dev/null +++ b/integrations/herdr/README.md @@ -0,0 +1,78 @@ +# Herdr plugin + +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 action invoke wiki-reader.open +``` + +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+shift+r" +type = "plugin_action" +command = "wiki-reader.open" +description = "open wiki reader" +``` + +## 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 +``` + +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 new file mode 100644 index 0000000..268b246 --- /dev/null +++ b/integrations/herdr/herdr-plugin.toml @@ -0,0 +1,42 @@ +id = "wiki-reader" +name = "Wiki Reader" +version = "0.1.0" +min_herdr_version = "0.9.0" +platforms = ["linux", "macos"] + +# 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-overlay" +title = "Wiki Reader (overlay)" +placement = "overlay" +command = ["wiki-reader", "--herdr-context"] + +[[panes]] +id = "reader-popup" +title = "Wiki Reader (popup)" +placement = "popup" +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 = ["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, 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 e5fa736..7740991 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -129,6 +129,14 @@ 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. 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 | +| 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 | | 2026-10-03 | bite | Operator, running the P3-09 split pane (installed 0.1.3): images survive theme switches, but some diagram images do not; switching pages and back reloads them and scrolling past refreshes them. Same symptom family as P2-56 | P2-60 | | 2026-10-03 | fix | P2-60: cause found in `ratatui-image`'s Kitty path. The upload is embedded in the picture's first cell on its first render and is never repeated, so a picture first drawn under a popup (the options window is open while the theme applies) never reached the terminal; a page switch or a scroll builds a new encode, which explains the refresh. Popups record their panels; pictures wait until their first cell is visible. Two regression tests (Kitty picker, popup open while pictures first render; diagrams switched under the options window) fail without the fix. Distinct from P2-56 (palette-keyed size cache) | P2-60 | diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index bf2fef9..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 | | 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 | | 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 bbea263..8a8aed2 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. @@ -74,22 +74,149 @@ 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. + +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. +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 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 +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. 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. + +**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 +`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; 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. 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.