Skip to content
24 changes: 21 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ is unit-testable with stubs.
| `herdr` | The herdr CLI seam (`$HERDR_BIN_PATH`): read-only queries (list git worktrees / which workspaces have an active agent) plus a best-effort host **layout** command (`pane zoom --current --on`/`--off`, the `Z` full-screen toggle). Neither touches file or git state; an absent or failing herdr degrades gracefully (git-only picker; in-pane zoom only). |
| `worktree` | Enumerate the repo's git worktrees (`git worktree list --porcelain`) and overlay herdr's agent-active workspace + per-row agent status, feeding the switch-worktree picker. |
| `tree` | The rooted, `.gitignore`-aware file tree: filters (gitignored, changed-only, hidden/dotfiles), cursor, expansion, status markers, and the `]` / `[` changed-file jump. Optionally folds a chain of single-child directories into one row (`compact_dirs`). A folded row has to look inside a **collapsed** directory, which the tree never opens otherwise, so foldability is answered by a two-entry probe rather than a listing and the answer is memoized — re-probed wherever the controller re-reads git. Listings stay uncached, so a compacted frame reads exactly the directories an uncompacted one does. |
| `view_policy` | A pure decision: which view mode a file gets (changed → diff, markdown → rendered, else → syntax content) and the cycle order. |
| `render` | Produce the content-pane text: classify the file, delegate styling to an external CLI, and **neutralize escape sequences** before display. |
| `view_policy` | A pure decision: which view mode a file gets (changed → diff, markdown → rendered, media → Media, else → syntax content) and the cycle order. |
| `render` | Produce the content-pane text: classify the file, delegate styling to an external CLI, and **neutralize escape sequences** before display; also produce the Media view's PNG payload (`render_media`: native PNG bytes, or conversion / video frame 0 through the injected `image`/`video` commands). |
| `graphics` | The herdr graphics socket seam: the verified `pane.graphics.info`/`set`/`clear` JSON protocol over `$HERDR_SOCKET_PATH` (one request per connection), the 512 KiB decoded-image cap and ~1 MiB base64 drop, a hand-rolled base64 encoder, and the last-wins-collapsing `GraphicsWorker` that keeps 120+ ms host round-trips off the UI thread (each `set` is absolute state, so collapsing is correctness, not approximation). Media bytes reach the host base64-encoded **inside a JSON request — no ESC byte is ever written**, so AC-27's neutralizer and `tests/render_escape.rs` stay untouched. |
| `media` | Pure media decisions: `MediaKind` by extension, PNG IHDR parsing, aspect-preserving `fit` into the pane's cell grid, the pixel clock of `frame_budget` (also clamped to the host's decoded cap), and the `player` submodule (the ffmpeg decoder thread + bounded drop-oldest frame queue). |
| `presenter` | Draw the two-column (or zoomed / narrow) layout with ratatui, including persistent annotation markers and background-only styling; source-line backgrounds are applied beneath active line-select, ambient-selection, and search overlays, with a bounded one-cell cue for blank annotated lines. Scroll the tree/content and report viewport + pane geometry back for hit-testing. |
| `picker` | The modal worktree-switcher overlay state (rows, cursor, horizontal scroll) drawn over the layout; captures its own nav / confirm / cancel keys while open. |
| `proc` | Shared subprocess reaping: one `wait_bounded` (child wait + poll + timeout-kill) used by both the content renderer and the update check, so the timeout-kill semantics are defined once. |
Expand Down Expand Up @@ -92,6 +94,17 @@ retain file/title markers where applicable but never receive guessed source-line
- **Delegate rendering.** Markdown, diffs, and syntax highlighting are produced by best-in-class
external CLIs (`glow`, `delta`, `bat`): the viewer builds only the shell and ingests their
ANSI output. Each renderer is optional; a missing one degrades to plain text + a notice.
- **Media goes over the socket, not the terminal.** Images and video frames are handed to herdr
through its documented `pane.graphics.*` socket API as base64 inside JSON — never as escape
sequences on stdout — so a hostile image still cannot drive the terminal (AC-27, `tests/
render_escape.rs`, and SECURITY.md's "a malicious file cannot drive the terminal" guarantee all
stay intact). Placement is data (a cell rect), refreshed by the `media_shown` clear/set
discipline after every draw, so scrolling/resizing/zooming can never strand a stale frame.
Video is a deliberate scope stretch (the plan said so): it stays inside the same boundaries —
ffmpeg only reads, playback is keyboard-driven and paused by default.
- **The herdr host sets the pace.** herdr re-renders its whole client frame per `set` (~120 ms
fixed regardless of payload), which is why video targets **~8 fps** and why the graphics worker
exists at all. This ceiling is herdr's, documented as such so it doesn't read as a bug.
- **Git is first-class**, woven through the tree (status markers, colors, changed-only filter,
baseline toggle) and the content pane (diff view), not a separate mode.
- **In-memory, ephemeral state only**, including annotations, which start empty and are scoped to
Expand All @@ -104,7 +117,12 @@ retain file/title markers where applicable but never receive guessed source-line
Four untrusted inputs are handled defensively (see [SECURITY.md](SECURITY.md)):

1. **File content** is untrusted: fed to renderers on **stdin** (never as an argument), and the
renderer output is re-sanitized so no escape sequence can drive the terminal.
renderer output is re-sanitized so no escape sequence can drive the terminal. Media preserves
the stdin rule for images (raw bytes piped into the `image` converter). **One deliberate
exception:** you cannot `-ss`-seek a pipe, so video decoding passes the file **path** to
ffmpeg (as its own argv element, no shell). The path is the already-canonicalized in-root one
from the render classifier, so a hostile *filename* cannot inject; this is a documented
narrowing of this boundary, and the reason `video` is a template with explicit placeholders.
2. **The git repository** may be untrusted (an agent's worktree, a clone): every `git`
invocation is hardened against repo-controlled code execution (no external diff/textconv,
neutralized `core.fsmonitor`/`core.hooksPath`, scrubbed repo-redirecting env). This hardening
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

### Added
- **Media view: images and video rendered inline in the content pane** through herdr's documented `pane.graphics.*` socket API — the bytes travel base64 inside JSON, so no escape sequence ever reaches the terminal and a hostile image still cannot drive it. A `.png` is shown natively; other images convert via `ffmpeg`; video plays at the host-limited ~8 fps with `p` (play/pause), `{`/`}` (seek ±5s), and `0` (restart), starting paused on frame 0. Media outranks "changed": an edited image or video shows the media rather than a diff of compressed binary, and offers no diff views in the `v` cycle. A video also carries a caption (resolution, duration, codec, size) and a click/drag-to-seek progress bar. New config keys `image`, `video`, and `media_max_kib`; new remappable intents `media_play_pause`, `media_seek_back`, `media_seek_forward`, `media_restart`. → [usage](docs/usage.md#media-images-and-video) · [renderers](docs/renderers.md#media-images-and-video) · [keys](docs/keys.md) · [configuration](docs/configuration.md)

### Fixed
- Opening the viewer while a viewer pane is focused no longer roots the new one at the plugin's own install directory. herdr launches a plugin pane from the plugin root (its manifest command is relative), so a viewer pane's cwd *is* `~/.config/herdr/plugins/github/herdr-file-viewer-…` — and both `focused_pane_cwd` and `workspace_cwd` are derived from the focused pane, so the launch context offered nothing else. The viewer now recognises its own install directory and asks herdr for the workspace's other panes instead. → [usage](docs/usage.md)
- Agent skill: the launch instructions no longer tell agents to pass `--cwd`. herdr resolves the manifest's relative pane command against it, so the launch failed with `plugin_pane_open_failed` — or worse, inside a built plugin checkout, silently ran that checkout's binary. The skill and the `docs/usage.md` snippet now explain that the viewed root follows the *focused herdr pane's* directory, so an agent's own `cd` does not move it. Thanks @AntonyKor (#139) → [agent skill](skills/herdr-file-viewer/SKILL.md) · [usage](docs/usage.md#teach-your-agent)

## [1.15.0] - 2026-08-03
Expand Down
9 changes: 8 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
#markdown = "glow -s dark -w 0 -"
#diff = "delta"
#syntax = "bat --color=always --style=numbers --paging=never --file-name={name} -"
#image = "ffmpeg -loglevel error -i pipe:0 -sws_flags neighbor -vf scale={width}:{height}:force_original_aspect_ratio=decrease -f image2 -vcodec png pipe:1"
#video = "ffmpeg -loglevel error -re -ss {start} -i {name} -an -vf scale={width}:{height} -r {fps} -f image2pipe -vcodec png -"

# Media size cap, in KiB: how large an image/video file may be before the Media view
# shows its placeholder instead. Separate from the text preview cap (`preview_max_kib`,
# below) whose 1 MiB default is far too small for media. Default: 8192 (8 MiB).
#media_max_kib = 8192

# OS hand-off commands: `O` opens the selected entry with an application, `R`
# reveals it in a file manager. Defaults are the per-OS system openers
Expand Down Expand Up @@ -156,7 +163,7 @@
# Keybindings tab (e.g. refresh, nav_up, switch_worktree). A value is a KEY SPEC:
# a single string, or an array of strings. An entry REPLACES that action's
# default key(s), so list every key you want it to answer to. The full list of
# intent names (all 39 actions) is in docs/configuration.md (Keybindings) and the
# intent names (all 45 actions) is in docs/configuration.md (Keybindings) and the
# `?` overlay's Keybindings tab.
#
# Bindable keys: any single printable or shifted character (`g`, `<`, `?`, and
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ editor = "code --wait" # command to open a file with `e` (overrides $EDITOR
markdown = "glow -s dark -w 0 -" # override the markdown / diff / syntax renderers
diff = "delta" # (defaults: glow / delta / bat)
syntax = "bat --color=always --style=numbers --paging=never --file-name={name} -"
image = "ffmpeg -loglevel error -i pipe:0 -sws_flags neighbor -vf scale={width}:{height}:force_original_aspect_ratio=decrease -f image2 -vcodec png pipe:1" # non-PNG → PNG
video = "ffmpeg -loglevel error -re -ss {start} -i {name} -an -vf scale={width}:{height} -r {fps} -f image2pipe -vcodec png -" # frames

open = "xdg-open" # override the `O` open-with / `R` reveal-in-file-manager commands
reveal = "nautilus"
Expand All @@ -77,6 +79,7 @@ tree_position = "left" # which side the directory tree sits on: "left" (def

preview_max_lines = 10000 # show at most this many lines before a truncated preview (100–100000)
preview_max_kib = 1024 # ...or this size before truncating, in KiB (1024 = 1 MB; 64–65536)
media_max_kib = 8192 # media size cap, in KiB; separate from preview_max_kib (512–131072)
```

`update_check` governs release details and project spotlights. `false` disables all remote requests
Expand Down Expand Up @@ -107,6 +110,15 @@ One caveat for **diffs**: a diff is additionally bounded at ~4 MB by the git-cap
of `preview_max_kib`. So raising `preview_max_kib` above ~4 MB widens how much *file content* is shown
but not how much of a very large *diff* is (a diff past that bound is shown up to ~4 MB).

`image` and `video` are the **media** converters (see [external renderers](renderers.md)). `image`
turns any non-PNG image into PNG — it receives the raw file bytes on **stdin** and must write PNG on
stdout (like glow/bat's trailing `-`, the contract is "read stdin"; the default is an ffmpeg pipe).
`video` is a frame-extraction template with `{start}` / `{fps}` / `{width}` / `{height}` placeholders
(the pane's pixel budget is substituted for `{width}`/`{height}`) and `{name}` for the file path; the
default is an ffmpeg invocation. `media_max_kib` caps how large a media file may be before the Media
view shows a placeholder instead — it is **separate** from `preview_max_kib`, whose 1 MiB default is a
*text* budget far too small for images. Default `8192` (8 MiB), clamp `512–131072`.

`compact_dirs` changes the tree's **shape**, not what it shows. With it on, a chain of directories
that each hold nothing but one subdirectory is drawn as a single row — `src/main/java/br/com` instead
of six rows, each indented two columns further than the last. The row leads into the deepest
Expand Down Expand Up @@ -189,6 +201,10 @@ customized).
| | `toggle_zoom` | `z` | Hide the tree so content fills the frame, or restore the split |
| | `tree_scroll_left` | `H` | Scroll the tree pane left |
| | `tree_scroll_right` | `L` | Scroll the tree pane right |
| | `media_play_pause` | `p` | Toggle play/pause of the selected video |
| | `media_seek_back` | `{` | Seek the selected video back |
| | `media_seek_forward` | `}` | Seek the selected video forward |
| | `media_restart` | `0` | Restart the selected video from the beginning |
| **Git & filters** | `toggle_ignore` | `i` | Reveal or hide gitignored files |
| | `toggle_hidden` | `.` | Hide or reveal dot-prefixed (hidden) files and folders |
| | `toggle_changed_only` | `c` | Restrict the tree to changed files (baseline-aware), or restore the full tree |
Expand Down
4 changes: 4 additions & 0 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ is additive and on by default.
| `?` (Shift+`/`) | Open help with **What's New** details selected first, including updates and spotlights; `Esc` / `q` closes it |
| `u` | Dismiss the whole advisory status row for this session only; **What's New** stays available |
| `q` / `Esc` | Back out of zoom if zoomed; otherwise close the viewer and return to the prior pane. With annotations held, a confirm appears first (`y` copies them and quits, `q` quits and discards, `Esc` returns to the viewer): they are session-only, so quitting destroys them. Skip it with `confirm_discard = false` |
| `p` | **Play/pause** the selected video. Inert unless a video is selected in Media mode. Playback starts paused on the first frame, so selecting a video never starts motion unasked |
| `{` / `}` | **Seek** the selected video back / forward 5 seconds. Inert unless a video is selected |
| `0` | **Restart** the selected video from the beginning. Inert unless a video is selected |

These are the **default global** keys. Remap them with a `[keys]` table in the
[config file](configuration.md#keybindings). Keys handled inside line-select mode, the annotation
Expand Down Expand Up @@ -136,6 +139,7 @@ The viewer is keyboard-first; the mouse is additive and on by default:
| **Horizontal wheel / swipe** | Scroll the content, or the tree, sideways (terminal-dependent, see below) |
| **Drag** a scrollbar | Scroll that pane: drag ↕ on a vertical bar, ↔ on a horizontal bar; pressing the track jumps there |
| **Drag** the divider | Resize the tree / content split |
| **Click / drag** a video's progress bar | Seek to that position (the bar occupies the content pane's last row while a video is selected) |
| **Drag** over the content text | **Select and copy text**: the selection highlights character-by-character as you drag (auto-scrolling past an edge) and is copied to the clipboard on release; no mode needed. Works in wrapped views (prose/markdown) too. `Esc`, a click elsewhere, or switching files clears the highlight |

**`Shift`+drag is left to your terminal**, so its native select-and-copy still works while the
Expand Down
Loading