From 2ac104a535d5a78dcbac17eaf30373b113bb6e20 Mon Sep 17 00:00:00 2001 From: RobPruzan Date: Sun, 9 Aug 2026 17:49:31 -0400 Subject: [PATCH 1/4] add support for herdr graphics api --- engine/crates/pixel-core/src/herdr.rs | 368 +++++++++++++++++++++++ engine/crates/pixel-core/src/lib.rs | 1 + engine/crates/pixel-core/src/terminal.rs | 36 ++- 3 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 engine/crates/pixel-core/src/herdr.rs diff --git a/engine/crates/pixel-core/src/herdr.rs b/engine/crates/pixel-core/src/herdr.rs new file mode 100644 index 0000000..0c4c963 --- /dev/null +++ b/engine/crates/pixel-core/src/herdr.rs @@ -0,0 +1,368 @@ +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +use crate::canvas::Canvas; +use crate::terminal::FrameFile; + +const ACK_TIMEOUT: Duration = Duration::from_secs(5); +const OPEN_TIMEOUT: Duration = Duration::from_secs(2); +const SLOTS: u64 = 3; + +pub(crate) struct Herdr { + frames: BufReader, + directory: PathBuf, + cell: (u32, u32), + files: Vec, + retired: Vec, + generation: u64, + seq: u64, +} + +impl Herdr { + pub(crate) fn open() -> Option { + Self::connect( + &std::env::var("HERDR_PANE_ID").ok()?, + &std::env::var("HERDR_SOCKET_PATH").ok()?, + ) + } + + fn connect(pane: &str, socket: &str) -> Option { + let info = request( + socket, + &format!( + r#"{{"id":"info","method":"pane.graphics.info","params":{{"pane_id":{}}}}}"#, + quote(pane) + ), + ) + .ok()?; + + if field_str(&info, "file_frame_transport")? != "direct-kitty" { + return None; + } + let directory = PathBuf::from(field_str(&info, "file_frame_directory")?); + let cell = ( + field_u32(&info, "cell_width_px")?, + field_u32(&info, "cell_height_px")?, + ); + if cell.0 == 0 || cell.1 == 0 { + return None; + } + + let stream = UnixStream::connect(socket).ok()?; + stream.set_read_timeout(Some(OPEN_TIMEOUT)).ok()?; + let mut frames = BufReader::new(stream); + write_line( + frames.get_mut(), + &format!( + r#"{{"id":"stream","method":"pane.graphics.stream","params":{{"pane_id":{},"layer_id":"primary","z_index":0}}}}"#, + // checkme: how does pane get here? + quote(pane) + ), + ) + .ok()?; + if !accepted(&read_line(&mut frames).ok()?) { + return None; + } + frames.get_ref().set_read_timeout(Some(ACK_TIMEOUT)).ok()?; + + crate::logging::info("herdr", "frames go straight to herdr as files"); + Some(Self { + frames, + directory, + cell, + files: Vec::new(), + retired: Vec::new(), + generation: 0, + seq: 0, + }) + } + + pub(crate) fn cell(&self) -> (u32, u32) { + self.cell + } + + pub(crate) fn present(&mut self, canvas: &Canvas) -> io::Result { + let path = crate::profiler::span("herdr.handoff", || self.write_frame(&canvas.pixels))?; + let header = format!( + r#"{{"format":"rgba","image_width":{},"image_height":{},"file":{{"path":{}}},"sequence":{},"revision":0,"placement":{{"viewport_col":0,"viewport_row":0,"grid_cols":{},"grid_rows":{}}}}}"#, + canvas.width, + canvas.height, + quote(&path), + self.seq, + canvas.width.div_ceil(self.cell.0).max(1), + canvas.height.div_ceil(self.cell.1).max(1), + ); + self.seq += 1; + write_line(self.frames.get_mut(), &header)?; + + let ack = crate::profiler::span("herdr.ack", || read_line(&mut self.frames))?; + if !accepted(&ack) { + return Err(io::Error::other(format!("herdr rejected a frame: {ack}"))); + } + self.retired.clear(); + Ok(header.len()) + } + + fn write_frame(&mut self, pixels: &[u8]) -> io::Result { + if self.files.first().is_none_or(|file| file.len() != pixels.len()) { + self.generation += 1; + self.retired = std::mem::take(&mut self.files); + for slot in 0..SLOTS { + self.files.push(FrameFile::create( + self.directory.join(format!( + "px-{}-{}-{slot}", + std::process::id(), + self.generation + )), + pixels.len(), + )?); + } + } + let file = &mut self.files[(self.seq % SLOTS) as usize]; + file.write(pixels); + Ok(file.path().to_string_lossy().into_owned()) + } +} + +fn request(socket: &str, line: &str) -> io::Result { + let stream = UnixStream::connect(socket)?; + stream.set_read_timeout(Some(OPEN_TIMEOUT))?; + let mut reader = BufReader::new(stream); + write_line(reader.get_mut(), line)?; + read_line(&mut reader) +} + +fn write_line(stream: &mut UnixStream, line: &str) -> io::Result<()> { + stream.write_all(line.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush() +} + +fn read_line(reader: &mut BufReader) -> io::Result { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Err(io::Error::other("herdr closed the connection")); + } + Ok(line) +} + +fn accepted(response: &str) -> bool { + response.contains(r#""result""#) && !response.contains(r#""error""#) +} + +fn quote(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + _ => out.push(ch), + } + } + out.push('"'); + out +} + +fn value_after<'a>(json: &'a str, key: &str) -> Option<&'a str> { + let at = json.find(&format!("\"{key}\":"))? + key.len() + 3; + Some(json[at..].trim_start()) +} + +fn field_str(json: &str, key: &str) -> Option { + let rest = value_after(json, key)?.strip_prefix('"')?; + let mut out = String::new(); + let mut chars = rest.chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => return Some(out), + '\\' => out.push(chars.next()?), + _ => out.push(ch), + } + } + None +} + +fn field_u32(json: &str, key: &str) -> Option { + let rest = value_after(json, key)?; + let end = rest.find(|c: char| !c.is_ascii_digit())?; + rest[..end].parse().ok() +} + +#[cfg_attr(not(test), expect(dead_code, reason = "herdr reports pane_visible; nothing skips rendering on it yet"))] +fn field_bool(json: &str, key: &str) -> Option { + match value_after(json, key)? { + rest if rest.starts_with("true") => Some(true), + rest if rest.starts_with("false") => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INFO: &str = r#"{"id":"info","result":{"type":"pane_graphics_info","cell_width_px":9,"cell_height_px":19,"pane_visible":true,"file_frame_directory":"/tmp/herdr/frames/source","file_frame_formats":["rgba","bgra"],"max_layers_per_pane":16,"pixel_mouse":false,"file_frame_transport":"direct-kitty"}}"#; + + #[test] + fn reads_the_fields_an_info_reply_carries() { + assert_eq!(field_u32(INFO, "cell_width_px"), Some(9)); + assert_eq!(field_u32(INFO, "cell_height_px"), Some(19)); + assert_eq!(field_bool(INFO, "pane_visible"), Some(true)); + assert_eq!( + field_str(INFO, "file_frame_directory").as_deref(), + Some("/tmp/herdr/frames/source") + ); + assert_eq!( + field_str(INFO, "file_frame_transport").as_deref(), + Some("direct-kitty") + ); + } + + #[test] + fn a_missing_field_is_absent_rather_than_wrong() { + assert_eq!(field_u32(INFO, "nope"), None); + assert_eq!(field_str(INFO, "nope"), None); + assert_eq!(field_bool(INFO, "nope"), None); + assert_eq!(field_bool(INFO, "file_frame_transport"), None); + } + + #[test] + fn an_info_reply_without_file_transport_is_not_ours_to_use() { + let plain = r#"{"id":"info","result":{"cell_width_px":9,"cell_height_px":19,"pane_visible":true}}"#; + assert_eq!(field_str(plain, "file_frame_transport"), None); + } + + #[test] + fn only_a_result_counts_as_accepted() { + assert!(accepted( + r#"{"id":"stream","result":{"type":"pane_graphics_frame_ack","sequence":0,"revision":0}}"# + )); + assert!(!accepted( + r#"{"id":"stream","error":{"code":"feature_disabled","message":"nope"}}"# + )); + } + + /// Answers like herdr does: one info reply per connection, then a stream + /// that acks every frame. Collects the frame headers it was sent. + fn fake_herdr( + directory: &std::path::Path, + transport: &str, + ) -> (PathBuf, std::sync::mpsc::Receiver) { + let socket = directory.join("herdr.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let info = format!( + r#"{{"id":"info","result":{{"cell_width_px":10,"cell_height_px":20,"pane_visible":true,"file_frame_directory":{},"file_frame_transport":"{transport}"}}}}"#, + quote(&directory.to_string_lossy()) + ); + std::thread::spawn(move || { + for connection in listener.incoming().take(2) { + let mut reader = BufReader::new(connection.unwrap()); + let mut opening = String::new(); + reader.read_line(&mut opening).unwrap(); + if opening.contains("pane.graphics.info") { + write_line(reader.get_mut(), &info).unwrap(); + continue; + } + write_line(reader.get_mut(), r#"{"id":"stream","result":{"type":"ok"}}"#).unwrap(); + let mut frame = String::new(); + while reader.read_line(&mut frame).map(|n| n > 0).unwrap_or(false) { + tx.send(frame.clone()).unwrap(); + let ack = r#"{"id":"stream","result":{"type":"pane_graphics_frame_ack"}}"#; + if write_line(reader.get_mut(), ack).is_err() { + break; + } + frame.clear(); + } + } + }); + (socket, rx) + } + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pixel-herdr-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_frame_reaches_herdr_as_a_file_the_header_points_at() { + let dir = scratch("present"); + let (socket, frames) = fake_herdr(&dir, "direct-kitty"); + let mut herdr = + Herdr::connect("w1:p1", &socket.to_string_lossy()).expect("herdr offered files"); + + assert_eq!(herdr.cell(), (10, 20)); + let canvas = Canvas::new(40, 40); + herdr.present(&canvas).unwrap(); + + let header = frames.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(header.contains(r#""format":"rgba""#)); + assert!(header.contains(r#""image_width":40,"image_height":40"#)); + assert!(header.contains(r#""grid_cols":4,"grid_rows":2"#)); + + let path = PathBuf::from(field_str(&header, "path").unwrap()); + assert_eq!(path.parent(), Some(dir.as_path())); + assert_eq!(std::fs::metadata(&path).unwrap().len(), 40 * 40 * 4); + } + + #[test] + fn a_resize_keeps_the_previous_pixels_until_the_next_frame_is_taken() { + let dir = scratch("resize"); + let (socket, frames) = fake_herdr(&dir, "direct-kitty"); + let mut herdr = Herdr::connect("w1:p1", &socket.to_string_lossy()).unwrap(); + + let before = PathBuf::from(herdr.write_frame(&vec![0; 40 * 40 * 4]).unwrap()); + let after = PathBuf::from(herdr.write_frame(&vec![0; 60 * 60 * 4]).unwrap()); + + assert_ne!(before, after, "a stale path must never find new pixels"); + assert!(before.exists(), "herdr may still replay the placement it has"); + assert_eq!(std::fs::metadata(&after).unwrap().len(), 60 * 60 * 4); + + herdr.present(&Canvas::new(60, 60)).unwrap(); + frames.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(!before.exists(), "herdr has the new frame, so it can go"); + } + + #[test] + fn frames_cycle_through_slots_so_herdr_reads_one_we_are_not_writing() { + let dir = scratch("slots"); + let (socket, frames) = fake_herdr(&dir, "direct-kitty"); + let mut herdr = Herdr::connect("w1:p1", &socket.to_string_lossy()).unwrap(); + + let canvas = Canvas::new(40, 40); + let mut paths = Vec::new(); + for _ in 0..SLOTS { + herdr.present(&canvas).unwrap(); + let header = frames.recv_timeout(Duration::from_secs(5)).unwrap(); + paths.push(field_str(&header, "path").unwrap()); + } + + paths.dedup(); + assert_eq!(paths.len(), SLOTS as usize, "consecutive frames shared a file"); + } + + #[test] + fn a_terminal_without_file_frames_is_left_to_the_caller() { + let dir = scratch("inline"); + let (socket, _frames) = fake_herdr(&dir, ""); + assert!(Herdr::connect("w1:p1", &socket.to_string_lossy()).is_none()); + } + + #[test] + fn nothing_listening_is_simply_not_herdr() { + let dir = scratch("absent"); + let socket = dir.join("missing.sock"); + assert!(Herdr::connect("w1:p1", &socket.to_string_lossy()).is_none()); + } + + #[test] + fn quoting_survives_a_path_with_characters_json_cares_about() { + assert_eq!(quote(r#"/tmp/a"b\c"#), r#""/tmp/a\"b\\c""#); + assert_eq!(field_str(&format!(r#"{{"p":{}}}"#, quote(r#"/a"b\c"#)), "p").as_deref(), Some(r#"/a"b\c"#)); + } +} diff --git a/engine/crates/pixel-core/src/lib.rs b/engine/crates/pixel-core/src/lib.rs index 243e52f..d093041 100644 --- a/engine/crates/pixel-core/src/lib.rs +++ b/engine/crates/pixel-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod clipboard_image; mod desc; mod engine; pub mod ghostty; +mod herdr; mod image_cache; mod kitty; pub mod logging; diff --git a/engine/crates/pixel-core/src/terminal.rs b/engine/crates/pixel-core/src/terminal.rs index b5365d9..3c257d1 100644 --- a/engine/crates/pixel-core/src/terminal.rs +++ b/engine/crates/pixel-core/src/terminal.rs @@ -234,6 +234,7 @@ pub struct Terminal { pending: Vec, lone_escape_since: Option, transport: FrameTransport, + herdr: Option, frame_files: Vec, frame_seq: u64, wrapper: Wrapper, @@ -345,6 +346,7 @@ impl Terminal { pending: Vec::new(), lone_escape_since: None, transport: FrameTransport::Inline, + herdr: None, frame_files: Vec::new(), frame_seq: 0, wrapper, @@ -367,6 +369,10 @@ impl Terminal { } terminal.mouse_pixels = !wrapper.relayed() && terminal.probe_mouse_pixels()?; terminal.clipboard_data = !wrapper.relayed() && terminal.probe_clipboard_data()?; + terminal.herdr = crate::herdr::Herdr::open(); + if let Some(cell) = terminal.herdr.as_ref().map(crate::herdr::Herdr::cell) { + terminal.cell = Some(cell); + } terminal.transport = terminal.probe_transport()?; terminal.color_scheme_updates = terminal.probe_color_scheme()?; if terminal.color_scheme_updates { @@ -512,6 +518,17 @@ impl Terminal { } pub fn draw(&mut self, canvas: &Canvas) -> io::Result { + if let Some(herdr) = self.herdr.as_mut() { + match herdr.present(canvas) { + Ok(written) => return Ok(written), + Err(err) => { + crate::logging::warn("herdr", format!("{err}, drawing it ourselves instead")); + self.herdr = None; + self.last_frame_size = None; + self.placeholders = None; + } + } + } let shrank = self .last_frame_size .is_some_and(|(w, h)| canvas.width < w || canvas.height < h); @@ -1062,11 +1079,8 @@ fn parse_probe_reply(buf: &[u8], needle: &[u8]) -> Option { Some(rest.starts_with(b"OK")) } -/// A frame the terminal reads off disk. The mapping is made once and rewritten every frame, so a -/// frame costs a copy into pages that are already resident rather than faulting in a fresh mapping. -/// Only worth it because the terminal leaves the file alone, unlike a shared memory object. #[allow(unsafe_code)] -struct FrameFile { +pub(crate) struct FrameFile { path: std::path::PathBuf, map: std::ptr::NonNull, len: usize, @@ -1074,8 +1088,10 @@ struct FrameFile { #[allow(unsafe_code, clippy::undocumented_unsafe_blocks)] impl FrameFile { - fn create(path: std::path::PathBuf, len: usize) -> io::Result { + pub(crate) fn create(path: std::path::PathBuf, len: usize) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; let file = std::fs::File::options() + .mode(0o600) .read(true) .write(true) .create(true) @@ -1098,9 +1114,17 @@ impl FrameFile { Ok(Self { path, map, len }) } - fn write(&mut self, data: &[u8]) { + pub(crate) fn write(&mut self, data: &[u8]) { unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), self.map.as_ptr(), self.len) }; } + + pub(crate) fn len(&self) -> usize { + self.len + } + + pub(crate) fn path(&self) -> &std::path::Path { + &self.path + } } #[allow(unsafe_code, clippy::undocumented_unsafe_blocks)] From bf85535141f39ed39b5b569629686d6ba6e842e8 Mon Sep 17 00:00:00 2001 From: RobPruzan Date: Sun, 9 Aug 2026 18:37:51 -0400 Subject: [PATCH 2/4] add herdr plugin and backend --- herdr-plugin/README.md | 1 + herdr-plugin/herdr-plugin.toml | 13 +++++++++ herdr-plugin/open-split.sh | 9 ++++++ terminals/src/terminals/herdr.ts | 47 ++++++++++++++++++++++++++++++++ terminals/src/terminals/index.ts | 13 ++++++++- 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 herdr-plugin/README.md create mode 100644 herdr-plugin/herdr-plugin.toml create mode 100755 herdr-plugin/open-split.sh create mode 100644 terminals/src/terminals/herdr.ts diff --git a/herdr-plugin/README.md b/herdr-plugin/README.md new file mode 100644 index 0000000..23dd460 --- /dev/null +++ b/herdr-plugin/README.md @@ -0,0 +1 @@ +# terminal-browser herdr plugin diff --git a/herdr-plugin/herdr-plugin.toml b/herdr-plugin/herdr-plugin.toml new file mode 100644 index 0000000..a856e6d --- /dev/null +++ b/herdr-plugin/herdr-plugin.toml @@ -0,0 +1,13 @@ +id = "zenbu-labs.terminal-browser" +name = "Terminal Browser" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Open a real browser split next to your herdr panes" +platforms = ["linux", "macos"] + +[[actions]] +id = "open-split" +title = "Open terminal-browser (right split)" +description = "Split the focused pane and open terminal-browser in it" +contexts = ["global"] +command = ["bash", "open-split.sh"] diff --git a/herdr-plugin/open-split.sh b/herdr-plugin/open-split.sh new file mode 100755 index 0000000..e1e0aed --- /dev/null +++ b/herdr-plugin/open-split.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v terminal-browser >/dev/null 2>&1; then + echo "terminal-browser is not installed — see https://github.com/zenbu-labs/terminal-browser" >&2 + exit 1 +fi + +exec terminal-browser open --split right diff --git a/terminals/src/terminals/herdr.ts b/terminals/src/terminals/herdr.ts new file mode 100644 index 0000000..ec9cce1 --- /dev/null +++ b/terminals/src/terminals/herdr.ts @@ -0,0 +1,47 @@ +import { shellQuote } from "../shared"; +import type { Detect, Direction } from "../terminal"; + +interface HerdrPaneSplitResult { + result: { pane: { pane_id: string } }; +} + +const NATIVE_DIRECTION: Record = { + right: "right", + left: "right", + down: "down", + up: "down", +}; + +const OPPOSITE: Record<"right" | "down", "left" | "up"> = { right: "left", down: "up" }; + +export const herdr: Detect = (env, run) => { + if (!env.HERDR_PANE_ID) return null; + + const bin = env.HERDR_BIN_PATH || "herdr"; + const herdr = (args: string[]) => run(bin, args); + + async function splitPane(args: string[]): Promise { + try { + return JSON.parse(await herdr(["pane", "split", ...args, "--right-click", "pane"])); + } catch (error) { + const stderr = String((error as { stderr?: unknown }).stderr ?? ""); + if (!stderr.includes("--right-click")) throw error; + return JSON.parse(await herdr(["pane", "split", ...args])); + } + } + + return { + name: "herdr", + getCurrentPane: async () => ({ id: env.HERDR_PANE_ID!, tab: env.HERDR_TAB_ID! }), + async split({ from, direction, command, size }) { + const native = NATIVE_DIRECTION[direction]; + const ratio = size ? ["--ratio", String(size)] : []; + const { result } = await splitPane(["--pane", from.id, "--direction", native, "--focus", ...ratio]); + const newPaneId = result.pane.pane_id; + if (direction === "left" || direction === "up") { + await herdr(["pane", "swap", "--pane", newPaneId, "--direction", OPPOSITE[native]]); + } + await herdr(["pane", "run", newPaneId, shellQuote(command)]); + }, + }; +}; diff --git a/terminals/src/terminals/index.ts b/terminals/src/terminals/index.ts index 084cc68..04a4f7b 100644 --- a/terminals/src/terminals/index.ts +++ b/terminals/src/terminals/index.ts @@ -1,6 +1,7 @@ import type { Detect } from "../terminal"; import { cmux } from "./cmux"; import { ghostty } from "./ghostty"; +import { herdr } from "./herdr"; import { kitty } from "./kitty"; import { supacode } from "./supacode"; import { tmux } from "./tmux"; @@ -11,4 +12,14 @@ import { wezterm } from "./wezterm"; /** * not fantastic, but ordering does matter */ -export const TERMINALS: Detect[] = [tmux, tty7, wezterm, kitty, cmux, supacode, ghostty, vscode]; +export const TERMINALS: Detect[] = [ + herdr, + tmux, + tty7, + wezterm, + kitty, + cmux, + supacode, + ghostty, + vscode, +]; From 9effc9726cf8e3e2118a367409268ac22c18a797 Mon Sep 17 00:00:00 2001 From: RobPruzan Date: Sun, 9 Aug 2026 18:52:08 -0400 Subject: [PATCH 3/4] enable kitty graphics in herdr before running --- engine/crates/pixel-core/src/terminal.rs | 6 -- terminals/src/detect.ts | 2 +- terminals/src/terminal.ts | 2 +- terminals/src/terminals/herdr.ts | 34 +++++++ terminals/test/fixtures/herdr-left.json | 32 +++++++ terminals/test/fixtures/herdr-right.json | 30 ++++++ terminals/test/terminals.test.js | 116 +++++++++++++++++++++++ 7 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 terminals/test/fixtures/herdr-left.json create mode 100644 terminals/test/fixtures/herdr-right.json diff --git a/engine/crates/pixel-core/src/terminal.rs b/engine/crates/pixel-core/src/terminal.rs index 3c257d1..e806a51 100644 --- a/engine/crates/pixel-core/src/terminal.rs +++ b/engine/crates/pixel-core/src/terminal.rs @@ -1038,13 +1038,10 @@ impl Terminal { const SHM_PROBE_ID: u32 = 299; const FILE_PROBE_ID: u32 = 300; -/// tmux adds a hop in each direction, so the answer needs longer than a direct terminal would take. const FRAME_PROBE_TIMEOUT_MS: u64 = 300; const FRAME_SLOTS: u64 = 8; -/// How a frame reaches the terminal, cheapest first. Inline means the pixels travel inside the -/// escape sequence, which costs a compress and a write proportional to the window. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FrameTransport { File, @@ -1067,9 +1064,6 @@ fn frame_image_id(relayed: bool) -> u32 { } } -/// The needle deliberately omits the `\x1b_` that starts the reply: tmux with extended-keys on -/// re-encodes the escape pairs of a passed-through reply as key events (`\x1b_` arrives as -/// `\x1b[27;3;95~`), leaving only the body intact. fn parse_probe_reply(buf: &[u8], needle: &[u8]) -> Option { let pos = buf.windows(needle.len()).position(|w| w == needle)?; let rest = &buf[pos + needle.len()..]; diff --git a/terminals/src/detect.ts b/terminals/src/detect.ts index 944df42..4d63444 100644 --- a/terminals/src/detect.ts +++ b/terminals/src/detect.ts @@ -23,7 +23,7 @@ export async function checkTerminal( terminal: Terminal | null, env: NodeJS.ProcessEnv = process.env, ): Promise { - terminal?.prepare?.(); + await terminal?.prepare?.(); if (env[SKIP_ENV]) return { terminal, graphics: "supported" }; const probed = await probeGraphics(terminal); const graphics = probed === "unknown" ? (terminal ? "supported" : "unsupported") : probed; diff --git a/terminals/src/terminal.ts b/terminals/src/terminal.ts index f4c0170..04e282b 100644 --- a/terminals/src/terminal.ts +++ b/terminals/src/terminal.ts @@ -39,7 +39,7 @@ export interface Terminal { /** * Allows code to be ran at startup, useful for preparing the terminal environment for terminal-browser */ - prepare?(): void; + prepare?(): void | Promise; /** * Allows terminal-browser to know which pane its being called from inside the terminal. * This gives terminal-browser the ability to know if a terminal-browser instance diff --git a/terminals/src/terminals/herdr.ts b/terminals/src/terminals/herdr.ts index ec9cce1..d96e50d 100644 --- a/terminals/src/terminals/herdr.ts +++ b/terminals/src/terminals/herdr.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { shellQuote } from "../shared"; import type { Detect, Direction } from "../terminal"; @@ -5,6 +9,23 @@ interface HerdrPaneSplitResult { result: { pane: { pane_id: string } }; } +function herdrConfigPath(env: NodeJS.ProcessEnv): string { + if (env.HERDR_CONFIG_PATH) return env.HERDR_CONFIG_PATH; + if (process.platform === "win32") { + return path.join(env.APPDATA ?? path.join(env.HOME ?? os.homedir(), "AppData", "Roaming"), "herdr", "config.toml"); + } + return path.join(env.HOME ?? os.homedir(), ".config", "herdr", "config.toml"); +} + +function enableKittyGraphics(config: string): string { + const flag = /^([ \t]*kitty_graphics[ \t]*=[ \t]*)false([ \t]*)$/m; + if (flag.test(config)) return config.replace(flag, "$1true$2"); + const header = /^\[experimental\][ \t]*$/m; + if (header.test(config)) return config.replace(header, "[experimental]\nkitty_graphics = true"); + const trimmed = config.replace(/\s+$/, ""); + return `${trimmed}${trimmed ? "\n\n" : ""}[experimental]\nkitty_graphics = true\n`; +} + const NATIVE_DIRECTION: Record = { right: "right", left: "right", @@ -30,8 +51,21 @@ export const herdr: Detect = (env, run) => { } } + async function prepare(): Promise { + try { + const configPath = herdrConfigPath(env); + const current = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : ""; + if (/^[ \t]*kitty_graphics[ \t]*=[ \t]*true[ \t]*$/m.test(current)) return; + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, enableKittyGraphics(current)); + await herdr(["server", "reload-config"]); + } catch { + } + } + return { name: "herdr", + prepare, getCurrentPane: async () => ({ id: env.HERDR_PANE_ID!, tab: env.HERDR_TAB_ID! }), async split({ from, direction, command, size }) { const native = NATIVE_DIRECTION[direction]; diff --git a/terminals/test/fixtures/herdr-left.json b/terminals/test/fixtures/herdr-left.json new file mode 100644 index 0000000..2676a2b --- /dev/null +++ b/terminals/test/fixtures/herdr-left.json @@ -0,0 +1,32 @@ +{ + "env": { + "HERDR_PANE_ID": "w1:p1", + "HERDR_TAB_ID": "w1:t1" + }, + "exec": { + "herdr pane split --pane w1:p1 --direction right --focus --right-click pane": "{\"result\":{\"pane\":{\"pane_id\":\"w1:p2\"}}}", + "herdr pane swap --pane w1:p2 --direction left": "{}", + "herdr pane run w1:p2 terminal-browser open": "" + }, + "expect": { + "name": "herdr", + "currentPane": { + "id": "w1:p1", + "tab": "w1:t1" + }, + "split": { + "request": { + "direction": "left", + "command": ["terminal-browser", "open"], + "size": null, + "from": { "id": "w1:p1", "tab": "w1:t1" }, + "tty": null + }, + "commands": [ + "herdr pane split --pane w1:p1 --direction right --focus --right-click pane", + "herdr pane swap --pane w1:p2 --direction left", + "herdr pane run w1:p2 terminal-browser open" + ] + } + } +} diff --git a/terminals/test/fixtures/herdr-right.json b/terminals/test/fixtures/herdr-right.json new file mode 100644 index 0000000..bb10d09 --- /dev/null +++ b/terminals/test/fixtures/herdr-right.json @@ -0,0 +1,30 @@ +{ + "env": { + "HERDR_PANE_ID": "w1:p1", + "HERDR_TAB_ID": "w1:t1" + }, + "exec": { + "herdr pane split --pane w1:p1 --direction right --focus --ratio 0.4 --right-click pane": "{\"result\":{\"pane\":{\"pane_id\":\"w1:p2\"}}}", + "herdr pane run w1:p2 terminal-browser open": "" + }, + "expect": { + "name": "herdr", + "currentPane": { + "id": "w1:p1", + "tab": "w1:t1" + }, + "split": { + "request": { + "direction": "right", + "command": ["terminal-browser", "open"], + "size": 0.4, + "from": { "id": "w1:p1", "tab": "w1:t1" }, + "tty": null + }, + "commands": [ + "herdr pane split --pane w1:p1 --direction right --focus --ratio 0.4 --right-click pane", + "herdr pane run w1:p2 terminal-browser open" + ] + } + } +} diff --git a/terminals/test/terminals.test.js b/terminals/test/terminals.test.js index f5cb054..c9fed13 100644 --- a/terminals/test/terminals.test.js +++ b/terminals/test/terminals.test.js @@ -1,5 +1,6 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const { test } = require("node:test"); @@ -98,3 +99,118 @@ test("plain ghostty is still ghostty", () => { assert.equal(detect(GHOSTTY_LOOKALIKE, async () => "")?.name, "ghostty"); }); +test("herdr falls back when the running herdr predates --right-click", async () => { + const env = { HERDR_PANE_ID: "w1:p1", HERDR_TAB_ID: "w1:t1" }; + const commands = []; + const run = async (bin, args) => { + commands.push([bin, ...args].join(" ")); + if (args.includes("--right-click")) { + const error = new Error("unknown option: --right-click"); + error.stderr = "unknown option: --right-click\n"; + throw error; + } + if (args[0] === "pane" && args[1] === "split") { + return JSON.stringify({ result: { pane: { pane_id: "w1:p2" } } }); + } + return ""; + }; + await detect(env, run).split({ + from: { id: "w1:p1", tab: "w1:t1" }, + direction: "right", + command: ["terminal-browser", "open"], + size: null, + tty: null, + }); + assert.deepEqual(commands, [ + "herdr pane split --pane w1:p1 --direction right --focus --right-click pane", + "herdr pane split --pane w1:p1 --direction right --focus", + "herdr pane run w1:p2 terminal-browser open", + ]); +}); + +function tempHerdrConfig(initialContent) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-cfg-")); + const configPath = path.join(dir, "config.toml"); + if (initialContent !== null) fs.writeFileSync(configPath, initialContent); + return configPath; +} + +test("herdr prepare enables kitty graphics from a blank config", async () => { + const configPath = tempHerdrConfig(null); + const env = { HERDR_PANE_ID: "w1:p1", HERDR_CONFIG_PATH: configPath }; + const { run, commands } = recorder({ + "herdr server reload-config": JSON.stringify({ result: { status: "applied" } }), + }); + await detect(env, run).prepare(); + assert.equal(fs.readFileSync(configPath, "utf8"), "[experimental]\nkitty_graphics = true\n"); + assert.deepEqual(commands, ["herdr server reload-config"]); +}); + +test("herdr prepare inserts into an existing experimental table instead of duplicating it", async () => { + const configPath = tempHerdrConfig( + "onboarding = false\n\n[experimental]\nreveal_hidden_cursor_for_cjk_ime = true\n", + ); + const env = { HERDR_PANE_ID: "w1:p1", HERDR_CONFIG_PATH: configPath }; + const { run } = recorder({ + "herdr server reload-config": JSON.stringify({ result: { status: "applied" } }), + }); + await detect(env, run).prepare(); + const content = fs.readFileSync(configPath, "utf8"); + assert.match(content, /\[experimental\]\nkitty_graphics = true\nreveal_hidden_cursor_for_cjk_ime = true/); + assert.equal((content.match(/\[experimental\]/g) ?? []).length, 1); +}); + +test("herdr prepare flips an explicit kitty_graphics = false instead of duplicating the key", async () => { + const configPath = tempHerdrConfig("[experimental]\nkitty_graphics = false\n"); + const env = { HERDR_PANE_ID: "w1:p1", HERDR_CONFIG_PATH: configPath }; + const { run } = recorder({ + "herdr server reload-config": JSON.stringify({ result: { status: "applied" } }), + }); + await detect(env, run).prepare(); + assert.equal(fs.readFileSync(configPath, "utf8"), "[experimental]\nkitty_graphics = true\n"); +}); + +test("herdr prepare leaves an already-enabled config alone and never reloads", async () => { + const configPath = tempHerdrConfig("[experimental]\nkitty_graphics = true\n"); + const env = { HERDR_PANE_ID: "w1:p1", HERDR_CONFIG_PATH: configPath }; + const { run, commands } = recorder({}); + await detect(env, run).prepare(); + assert.deepEqual(commands, []); +}); + +test("herdr prepare stays silent when reload-config rejects the edit", async () => { + const configPath = tempHerdrConfig(null); + const env = { HERDR_PANE_ID: "w1:p1", HERDR_CONFIG_PATH: configPath }; + const { run } = recorder({ + "herdr server reload-config": JSON.stringify({ + result: { status: "failed", diagnostics: ["config parse error"] }, + }), + }); + const originalError = console.error; + const warnings = []; + console.error = (message) => warnings.push(message); + try { + await assert.doesNotReject(detect(env, run).prepare()); + } finally { + console.error = originalError; + } + assert.deepEqual(warnings, []); +}); + +test("herdr prepare stays silent when herdr itself cannot be run", async () => { + const configPath = tempHerdrConfig(null); + const env = { HERDR_PANE_ID: "w1:p1", HERDR_CONFIG_PATH: configPath }; + const run = async () => { + throw new Error("spawn herdr ENOENT"); + }; + const originalError = console.error; + const warnings = []; + console.error = (message) => warnings.push(message); + try { + await assert.doesNotReject(detect(env, run).prepare()); + } finally { + console.error = originalError; + } + assert.deepEqual(warnings, []); +}); + From 01debb5801176c788c725903b1db3f54ba6b2a8c Mon Sep 17 00:00:00 2001 From: Rob Pruzan <97781863+RobPruzan@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:54 -0400 Subject: [PATCH 4/4] Update terminals/src/terminals/herdr.ts this is okay because the new graphics api lands at the same time so the fallback case is useless Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- terminals/src/terminals/herdr.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/terminals/src/terminals/herdr.ts b/terminals/src/terminals/herdr.ts index d96e50d..99188d3 100644 --- a/terminals/src/terminals/herdr.ts +++ b/terminals/src/terminals/herdr.ts @@ -42,13 +42,7 @@ export const herdr: Detect = (env, run) => { const herdr = (args: string[]) => run(bin, args); async function splitPane(args: string[]): Promise { - try { - return JSON.parse(await herdr(["pane", "split", ...args, "--right-click", "pane"])); - } catch (error) { - const stderr = String((error as { stderr?: unknown }).stderr ?? ""); - if (!stderr.includes("--right-click")) throw error; - return JSON.parse(await herdr(["pane", "split", ...args])); - } + return JSON.parse(await herdr(["pane", "split", ...args, "--right-click", "pane"])); } async function prepare(): Promise {