Skip to content

Audit hardening: dependencies, TUI correctness and speed, query fixes, CI and release - #94

Closed
nicholasjpaterno wants to merge 90 commits into
mainfrom
audit/hardening
Closed

nicholasjpaterno wants to merge 90 commits into
mainfrom
audit/hardening

Conversation

@nicholasjpaterno

Copy link
Copy Markdown
Contributor

Brings every dependency to latest, including turbovault-parser 3.2.0 and ratatui-image 11.1, and fixes what an audit of the TUI, image handling, CLI, query language and release pipeline turned up. CHANGELOG.md under Unreleased has an entry per change with the mechanism behind it.

Supersedes #91, #92 and #93. #92 doesn't render nested lists correctly on its own; the renderer fix it needs is in here.

TUI

The content pane is laid out once per section and width, and a frame draws only the visible rows. A scroll frame on a 21,000-line document takes about 1 ms where it used to re-render the section. Selection, images, diagrams and search highlights are placed from the same row map, so they no longer drift below wrapped text.

Saves keep the file's permissions, refuse to overwrite a change made on disk, and write atomically. Config saves edit the file in place and keep comments; a bad keybinding is skipped with a warning instead of discarding the file.

Table edits find the cell with pulldown-cmark, the parser that selected it, and undo restores it byte for byte.

Mermaid layout runs off the UI thread. GIFs stream in the viewer. Image files and GIF canvases are size-capped.

File links resolve within the document's git work tree. Fetched documents can't reach local files.

Ctrl+Z suspends, SIGTERM and SIGHUP restore the terminal, and cat doc.md | treemd works on macOS (crossterm's use-dev-tty; kqueue rejects /dev/tty).

A terminal that doesn't answer ratatui-image's capability probe no longer loses every key to the thread the probe leaves reading stdin. ratatui-image 12 fixes this upstream; the check can go once 12.0 is stable.

CLI

Piped stdin is read again when no file is named. Misuse exits 2. With stdout not a terminal, the document is printed. Control characters are escaped when printing to a terminal. Line numbering is linear, so a query that took 20 s on a large file takes 0.2 s. -e/--exit-status exits 1 when a query matches nothing. github: finds the README whatever it's named.

Query language

25 fixes toward jq semantics, .children and .parent on headings, and the guide and --query-help rewritten against what ships.

Behavior changes to call out in the release notes:

  • JSON query output is always an array
  • X | count returns one number for the stream
  • A nested list is its own .list entry
  • == is type-strict
  • The config, input, keybindings and tui modules are hidden from the library docs (still public)

CI and release

Actions pinned to commit SHAs, cargo-deny in place of cargo-audit, the musl binary built and run on Alpine for every PR, CI run on the tagged commit before a release builds, macOS signing and notarization failing closed, reproducible archives with the man page and license, and build provenance attestations.

Before merging: if branch protection names required checks, "Security audit" is now "cargo-deny", and "musl on Alpine" is new. Tag releases now need all six Apple signing secrets set.

Testing

fmt, clippy and rustdoc with -D warnings, tests with all features and with none, MSRV 1.90, and cargo deny check all pass locally. New coverage: TestBackend screen tests for the content pane and popups, property tests over generated documents, queries, wrapping and table edits (run at 20,000 cases each locally; they found the pipe-less header undo bug fixed here), 40 CLI and 41 query integration tests. The TUI was driven in a pty on macOS with a file argument and with piped input, images on and off.

Not exercised: Ctrl+Z then fg in an interactive shell, and image rendering in Kitty now that native Kitty GIF playback is gone.

Follow-ups, not in this PR: splitting src/tui/app.rs (6,800 lines) and folding its mode flags into one mode model, and picking up turbovault-parser's next release for the heading alt text fix (turbovault#78).

🤖 Generated with Claude Code

nicholasjpaterno and others added 30 commits October 11, 2026 16:40
turbovault-parser 2.2.0 makes a nested list a real block in its item
(turbovault#79) and keeps a quoted item's continuation as its own
paragraph (turbovault#77). The 3.0.0 major is the MCP wire format and
leaves the parser API alone.

The item's inline run still carries everything nested in it, which the
top-level renderer drew on the item's row, so a nested list printed
`outerinnerdeep`. own_inline takes the nested part back off, both list
renderers draw from it, and a nested task keeps its checkbox. A
selected link in a nested item gets its pointer on its own row.

.list pushed a list after walking its items, so nested lists came out
innermost first. It pushes first now, as a blockquote already did.

The contract fixture gains a nested list and a quoted continuation, the
two shapes this upgrade changed and the snapshot had no way to see.

Supersedes #91, #92 and #93.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lockfile refresh moved wayland-scanner to quick-xml 0.41, which
RUSTSEC-2026-0194 and RUSTSEC-2026-0195 do not cover, and no 0.39 copy
remains. cargo audit against this lockfile reports no vulnerabilities
with nothing ignored, so the CI job and the justfile stop ignoring them
and any advisory fails the build again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The startup file picker, added in 0.5.5, took the no-argument branch
before anything checked stdin. From a directory holding markdown it
opened the picker, so `cat doc.md | treemd -q '.h'` ran the query
against the picker's placeholder and printed `# Select a file`. From
any other directory it printed usage and exited 0. Only `treemd -`
still read stdin.

Piped stdin now wins when no file is named, ahead of the picker, which
is what the README, `--help` and the skill all document. The test runs
from a directory with markdown in it and from one without, the two
ways this failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI half of the audit, each confirmed against the built binary:

- Every heading's line was counted from the start of the document,
  quadratic in headings: 16k headings took 20 s to query and as long
  for -o json. Document::heading_lines is the one sequential pass the
  line-range code already did, and the query engine and the JSON
  builder use it. It also counts newlines rather than lines(), which
  was one too many for `> # quoted`.
- Rust ignores SIGPIPE, so `-l big.md | head -1` panicked. The default
  disposition is restored for the process.
- ureq has no timeouts by default. The fetch gets 10 s to connect and
  30 s overall, and https stays https through redirects.
- The `# Input` heading for a heading-less document is TUI-only now,
  decided by the same launches_tui test that starts the TUI.
- A UTF-8 BOM hid the first heading. Parsing skips it and offsets
  still index the content with it.
- Scoped `.code` re-parsed the section and numbered blocks from its
  first line. Block lines are document-true since parser 2.1, so the
  global list is filtered by line range instead.
- -L silently dropped --filter; both apply. -o json ignores both and
  now says so.
- jsonp is accepted, langs and types are sorted, and whole numbers
  print as integers without clamping large ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A document is untrusted, and -l, --tree, -s and -q printed its text
verbatim. On a terminal, `# Title ESC]0;PWNED BEL ESC[2J` retitled the
window and cleared the screen. The TUI never had this, because ratatui
drops control characters from its buffer.

cli::terminal::display caret-escapes C0, DEL and C1 the way `cat -v`
does when stdout is a terminal, keeping newlines, tabs and CRLF, and
passes piped output through untouched. Checked under a pty: no raw ESC
reaches the terminal, and a pipe still gets all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panic hook restored the terminal before anything else, and Rust
runs the hook before catch_unwind regains control. So a panic that the
image worker or the mermaid renderer was written to absorb still left
the alternate screen, dropped raw mode, and printed its message over
the frame, and treemd went on drawing into a terminal in cooked mode.

catch_isolated marks its extent on the thread and the hook leaves a
panic inside it alone. The image worker and both mermaid stages use it;
rasterization had no isolation at all before.

A mermaid raster is also bounded now. It was scaled to the pane width
with no limit, so a fifty-node chain at a wide pane was 2000x77000
pixels and over a gigabyte, redone on every width change. It is held
to 16 MP, drawn narrower when it would exceed that, and a small diagram
is enlarged at most 2x instead of being stretched to fill the pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An image path comes from the document. `![x](/some/fifo)` blocked in
open() until something wrote to the pipe, on the UI thread with raw
mode on, so the session froze and Ctrl-C could not end it. A terminal
or a device file did the same. Every image is now opened through
open_image_file, which requires a regular file under 128 MB and checks
that before opening.

A GIF declares its canvas size in its header, and the canvas was
allocated from it before any frame was read: a 62-byte file declaring
12000x12000 took over a gigabyte the moment the document opened, and
the format allows 17 GB. A canvas over 16 MP is refused first.

The four GIF decoder setups and three still-image decodes collapse into
read_gif and decode_still, and a decode error now says what failed
rather than "Unsupported format".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The viewer decoded every frame of a GIF at full size before it opened,
which for a 1200x800, 450-frame README recording is around 1.7 GB. It
keeps the delays and a StreamingGif now: playback decodes the next
frame as it reaches it, and stepping back reopens the stream and walks
to the frame once.

Every frame was kept for the native Kitty transmitter, which could not
work. Each a=f frame carried r=1, which in the graphics protocol edits
frame one rather than adding a frame, and a=T was given the cursor
position as c/r, which is the display size in cells. So it showed one
stretched frame, and it was chosen from the environment alone, which
KITTY_WINDOW_ID inherited into tmux satisfies. ratatui-image already
draws Kitty frames correctly, so playback goes through it like every
other terminal. iTerm2 keeps its native path, which sends the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_mermaid_if_needed ran the layout and rasterization inside
terminal.draw. Layout cannot be interrupted and grows much faster than
its input: the security audit measured a 4 KB diagram of 50 densely
connected nodes at 92 seconds, all of it with the UI taking no input,
from one fenced block in a document.

MermaidWorker does the work on its own thread, started by the first
diagram. A request is tagged with its source hash and the pane width,
so a result that arrives after the width changed is dropped and the
current width's request, already queued, replaces it. The frame shows
"rendering mermaid diagram" until the image is ready, and the event
loop polls while any are pending, through the same hooks as image
encoding. A source over 32 KB is refused without layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every save went through NamedTempFile::persist, which leaves the file
owner-only, so one checkbox toggle turned a 0644 note into 0600. It also
wrote the in-memory copy whatever had happened on disk since load: with
a table edit pending the watcher deliberately skips reloading, so an
edit made in another editor was silently undone by the next save.

fsutil::write_atomic is the one write: through a symlink rather than
over it, the original's permissions kept, synced before and after the
rename. App::write_document is the one TUI save. It refuses unless the
document is a real file (a stdin document wrote `<stdin>` into the
working directory) and unless the disk still matches the hash taken
when treemd last read or wrote it.

Config::save uses write_atomic too, and a config that failed to parse
keeps no path, so changing the theme no longer saves defaults over the
user's file.

Search matched in a lowercased copy and sliced the original at those
offsets. Lowercasing changes the length of `İ` and friends, so a
Turkish query panicked, and others highlighted the wrong letters.
find_ignore_case uses a case-insensitive regex of the escaped query,
which reports positions in the text it searched, and the outline
filter, content highlighter and match list all use it.

Ctrl+C was an ordinary key in raw mode and bound to nothing; it quits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A markdown link refused absolute paths and `..` in load_file_internal,
but resolve_relative_file_link's non-markdown branch joined the link
onto the document's directory and handed any existing result to the
editor, so `[x](/etc/hosts)` opened /etc/hosts. And the boundary
check canonicalized the full path inside an `if let (Ok, Ok)`, which
passes silently when the target does not exist yet: with `docs` a
symlink out of the repository, `[x](docs/new.md)` created the file
wherever `docs` pointed once the prompt was confirmed. Wikilinks built
their create path with a bare join as well.

confine_link_path is the one check, used by markdown links, the
non-markdown branch, and wikilinks, before any prompt: no absolute,
root, prefix or `..` component, no symlink as the last component, and
the deepest part of the path that exists resolved through its
symlinks and required to sit under the document's directory. The walk
uses symlink_metadata so a dangling symlink fails to resolve rather
than being stepped over and later written through.

Link-follow mode now opens only http and https, the rule interactive
mode already had, through one is_openable_url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app picked a table by its index among the section's top-level
tables as turbovault-parser reports them. edits.rs then found tables in
the file with its own rule, a line that starts and ends with a pipe,
which misses pipe-less tables and counts ones nested in list items. One
of either before the target shifted the index, and the edit, and then
the save, went to a different table.

Tables are now located by pulldown-cmark, the parser turbovault runs,
with its options, already in the build at the same version. Its offset
iterator gives each top-level table's byte range; the row and cell are
found inside it by unescaped pipes, with or without outer pipes. As a
last guard, the cell found in the file has to have the same letters as
the one on screen, or the edit is refused.

The edit buffer starts from the cell's source, so bold and italic,
which the parser strips from its text, survive an edit. Undo writes the
original source back verbatim; writing the parser's text turned
`x \| y` into `x | y`, a second cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-default-features build was only checked, never tested, so the
path without mermaid and dynamic completions ran nowhere. It is tested
now, and so is MSRV, which was check-only as well.

`cargo doc` with warnings denied failed on three intra-doc links in the
keybindings module docs and nothing ran it. They are fixed and the lint
job builds the docs.

A weekly run catches a new advisory or a toolchain change on a quiet
repository, rather than on the next push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every link with `..` was refused and the boundary was the current
document's directory, which moved down with each link followed, so
`docs/guide.md` could never link back to `../README.md`.

The boundary is now a link root, set when a document is opened
deliberately (startup or the file picker): its git work tree, or its
directory when there is none. confine_link_path resolves a link one
component at a time against the real filesystem, canonicalizing what
exists so symlinks are judged by their target and `..` steps up from
the real location, keeping what does not exist yet as written, and
requires the result under the root. A leading `/` is root-relative, as
GitHub reads it. Symlinks pointing inside the root are followed now;
ones pointing out are still refused, existing or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each frame filtered, parsed, highlighted and wrapped the whole section,
which on a large document cost about a second per keypress and per
scroll step. And every position on screen came from a separate count of
lines that knew nothing of wrapping or of the filtering the pane does,
so the selection, images, mermaid diagrams and search matches were all
placed at counted lines rather than drawn rows.

content_view wraps the rendered lines to the pane width itself, with
grapheme widths, and keeps a row map: where each line and each
top-level block was drawn. Three cached stages (displayed text,
rendered lines, wrapped rows with search highlighting) each rebuild
only when an input changes, and a frame draws only the visible rows.
On a 21,000-line document a scroll frame takes about a millisecond.

The interactive index parses the same displayed text as the renderer,
so frontmatter no longer shifts its block numbering against the
pane's, and each element is placed relative to where the renderer
really drew its block, then mapped through the wrapping. Scrolling to
the selection happens in the frame, against the current layout. Search
runs over the drawn lines, so matches, highlighting and scrolling
agree. The scroll offset is a usize, so nothing past row 65,535 is out
of reach. Images that scroll off the top are no longer pinned over the
text below, and are drawn no taller than their reserved rows.

The checkbox toggle maps the selected item to the source by its place
among task items, since hiding frontmatter or display math can remove
blocks but never list items. ratatui's unstable line-count feature is
no longer needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tag like `@v7` or `@stable` is a mutable pointer: whoever controls the
action's repository can move it to different code, and the next run
picks that up silently. The release workflow runs with contents: write
and the Apple signing secrets, so that is a supply-chain path straight
into a published, immutable release.

Every `uses:` now names a commit SHA with the release it belongs to in a
trailing comment, which is the form Dependabot's github-actions updates
recognise and keep current. dtolnay/rust-toolchain publishes no release
tags, so it is pinned to a commit on master and given the toolchain
explicitly, the pattern its README documents.

`cargo install cross --git ... --rev` resolved cross's dependencies
fresh on every run; --locked makes it use the lockfile at that rev.
Checkouts no longer persist the token into .git/config, since no job
pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo-deny's advisories check reads the same RustSec database
cargo-audit does, so running both would be the same check twice. deny
adds what audit cannot: a license allow-list, a ban on `*` version
requirements, and crates.io as the only permitted source, which keeps a
git or alternate-registry dependency from slipping in through a PR.

deny.toml allows exactly the licenses the current tree needs, checked
across every target and feature, and ignores no advisory. Unmaintained
notices fail only for direct dependencies: the two current ones,
bincode under syntect and ttf-parser under mermaid-rs-renderer, are
transitive and have no fix on our side, and cargo-audit only ever
warned on them.

The action runs a prebuilt cargo-deny (0.20.2 at the pinned SHA), so
the job no longer compiles a tool from source on every run, and it
needs no checks: write. `just audit` and `just deny` run the same
checks locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
x86_64-unknown-linux-musl is what Alpine users install (#42 was a
musl/Alpine breakage), but it was only ever built by the release
workflow, after the tag was pushed and could no longer be changed. A
musl-only failure was therefore always found in a release.

The new job builds it the way the release does, with cross at the same
pinned rev, and runs scripts/smoke-test.sh on it inside an alpine:3
container with the checkout mounted read-only. The script is plain
POSIX sh so the same file works there, on the release runners, and
under Git Bash on Windows. It checks that `--version` answers and that
`-l` lists a small document's headings exactly. A debug build keeps
the job quick; the release build gets the same test in the release
workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tag push went straight to building and publishing. Nothing checked
that CI had passed, or even run, on the tagged commit, and since a
pushed tag is immutable here, a release cut from a red commit could not
be withdrawn, only superseded.

rust.yml is now callable (workflow_call) and release.yml runs it as a
job that the build matrix needs, alongside the preflight. That was
chosen over a preflight that queries the commit's check runs through
the API: it does not depend on a push-to-main run having happened, or
having finished, before the tag, and it is the same suite PRs get, not a
list of check names to keep in sync.

The preflight also runs `cargo publish --dry-run --locked`, as does
`just release-preflight`, so a crates.io packaging problem is found
before the tag rather than after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The certificate import had continue-on-error: true and the signing
step fell back to ad-hoc signing whenever no Developer ID identity was
found, so an expired certificate or a missing secret produced a
published release whose macOS binaries Gatekeeper blocks, with only a
log line to show for it. Notarization failed open too: `notarytool
submit --wait` exits 0 when Apple's verdict is Invalid.

On a tag, the import now fails the job, a missing identity is an
error, and the notarization status is read from notarytool's JSON and
must be Accepted, with Apple's log printed when it is not. Ad-hoc
signing remains for workflow_dispatch runs, where there may be no
secrets and nothing is published.

The secrets are passed through env: instead of being expanded into the
script text, and codesign drops --deep, which is meant for bundles and
does nothing for a single binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… page

No release binary was ever executed before it was published, so a
binary that built but could not start, as musl on Alpine once could not
(#42), went straight out. Each build job now runs scripts/smoke-test.sh
on its output after signing: natively on Linux x86_64, arm64 macOS and
Windows, under Rosetta for x86_64 macOS when the runner has it, and in
an alpine:3 container for x86_64 musl. aarch64 Linux is cross-compiled
on an x86_64 runner and cannot be run there.

Archives were made per runner with whatever tar was installed (bsdtar
on macOS, Compress-Archive on Windows), stamped with build-time mtimes
and the runner's owners, so no two runs produced the same bytes and no
one could check a published archive against a rebuild. Build jobs now
upload only the binary, and one package job assembles every archive
with GNU tar (--sort=name, owner 0, --mtime from the release commit's
time as SOURCE_DATE_EPOCH), gzip -n, and zip -X with pinned
timestamps. Archive names and the binary's name inside are unchanged.

The archives now carry the man page, generated once from the Linux
x86_64 build since it comes from the CLI definition, and LICENSE, which
MIT asks to travel with the binary. Completions are not shipped: treemd
only has clap's dynamic completions, which call back into the installed
binary, so there is no static file to package.

SHA256SUMS was generated through `| grep -v ... || true`, so a failed
sha256sum produced an empty or partial file and a green run. The
script now fails on any error. The per-binary .sha256 files keep their
meaning, the hash of the binary inside the archive, and the README now
says which file covers what.

The release build no longer restores a rust-cache: a tag run can only
read caches from its own ref or the default branch, so it would rarely
hit, and anything it did restore would be compiled into a signed,
published binary. Two dead steps are gone: one wrote a
.cargo/config.toml linker setting for aarch64-gnu that cross never
reads, since it builds inside its own image with its own toolchain, and
one computed an artifact name nothing used. Matrix values reach the
scripts through env:.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing tied a downloaded archive to the workflow and commit that built
it: the checksums sit beside the archives in the same release, so
anyone able to replace one could replace the other. A release with
Developer ID signing covers only the macOS binaries.

The package job now runs actions/attest-build-provenance over every
archive listed in SHA256SUMS on tag runs, producing a Sigstore-signed
SLSA provenance statement per archive that `gh attestation verify`
checks against this repository. id-token: write and attestations: write
are granted to that job alone; the release job keeps only contents:
write, and the build jobs, which hold the Apple secrets, get neither.

The README shows how to check a download against SHA256SUMS and its
attestation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/build-all.sh had drifted from release.yml: it never built the
musl targets, tried to build macOS and Windows targets from whatever
single host ran it, which cannot work, and piped each build through
`head -20`, so under `set -e` a failed build still went on to copy
whatever binary was left in target/. The release workflow is the one
place each target's build is defined; the README's build-from-source
section now shows the plain cargo build and points there.

The `install` and `ci` recipes printed emoji; they print plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SIGTERM, or SIGHUP from a closed terminal, killed treemd at the default
disposition with raw mode, the alternate screen and mouse reporting
still on, so the shell it returned to read mouse escapes as input. Both
are now flags, through signal-hook (already in the tree via
crossterm), and the event loop exits through its normal cleanup.

Raw mode also makes Ctrl+Z an ordinary key, so it never suspended.
Normal mode binds it to a new Suspend action, which releases the
terminal the way launching the editor does, raises SIGTSTP, and takes
the terminal back when the shell resumes it. The editor launch and
suspend share release_terminal and reclaim_terminal. Interactive modes
keep Ctrl+Z as undo.

Checked under a pty: SIGTERM and SIGHUP each end the session with the
alternate screen left, and Ctrl+Z leaves it before stopping. Resuming
needs a job-control shell, which the harness is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e in place

One misspelled action under [keybindings] failed the parse of the
entire config, so the theme and every other setting fell back to their
defaults; one key that did not parse made Keybindings::from_config fail
and dropped every custom binding. The warning went to stderr just
before the alternate screen opened, where nobody saw it.

The config is now parsed as a table with [keybindings] taken out and
read entry by entry by KeybindingsConfig::from_toml_lenient, which
keeps every valid binding and records a warning for each bad mode, key
or action. Warnings are kept on Config and shown in the status bar at
startup.

Saving serialized the whole struct over the file, dropping comments,
unknown keys, any binding that had not parsed, and writing out every
default. It now edits the user's document with toml_edit, writing only
values that differ from the config as loaded and keeping each changed
value's trailing comment. A file that does not exist yet is still
written whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the capability probe got no answer, treemd picked a protocol from
environment variables, Kitty from KITTY_WINDOW_ID among them. That
variable stays set in a tmux session started inside Kitty, and the
guess was taken without a font size, which a protocol needs to place an
image in its cells; ratatui-image deliberately keeps halfblocks in that
case, and since 11.0.7 does its own environment fallback where it is
safe to. The fallback and its two helpers are gone.

The iTerm2 override stays, since iTerm2 answers the Kitty probe with a
Kitty implementation ratatui-image cannot drive, but not inside tmux,
zellij or screen, where LC_TERMINAL can outlive the terminal that set
it. Native iTerm2 GIF playback uses the same check, which used to look
at TMUX alone; it now also asks the picker, which knows about tmux even
when the variable was not passed through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
builtins/collection.rs and builtins/string.rs held only a comment
saying they were reserved for later. Every builtin lives in
builtins/mod.rs, so the stubs were two more places to look for code
that is not there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parser turned any `.name` that is also an element alias (`p`,
`a`, `image`, `code`, `headings`, ...) into a selector over the whole
document, wherever it appeared. So `{p: 1} | .p` printed every
paragraph, `.h | {a: .text} | .a` every link, and `.fm | .image` the
document's images, while `.fm.image` worked because the postfix form
always meant a property.

The selector now carries the name as written, and when the input is
not the document and has a key or property by that name, the
evaluator reads it instead. On the document the selector wins, so
`.h2` and `. | .code` are unchanged. That made the document's
`headings` property alias unreachable, so it is gone; `heading_count`
is the property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An element selector applied to a heading value inside a pipe ignored
the heading and selected from the whole document, so `.h2[0] | .code`
returned every code block. It now selects within the heading's
section, the same as `>>`.

`>` and `>>` handled headings, code and links and silently returned
nothing for images, tables, lists, paragraphs and blockquotes, which
carry no source position. For those the section is parsed on its own;
none of them holds a line number that a separate parse could get
wrong, which is what went wrong for code before.

The scoping logic moves out of eval_hierarchy into scoped_elements so
the pipe and the operators share it, and the direct-child walk for
headings is one pass instead of rescanning for intermediates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare `.` was the identity only before the end of the query, `|`,
`,`, `>`, `>>`, `)` or `]`; anything else had to be a name. So
`select(. == "x")`, `. + 1` and `.[]` failed with "Expected
identifier", although the docs use the identity in exactly these
positions. A dot not followed by a name is now always the identity,
and the postfix loop handles any brackets after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nicholasjpaterno and others added 24 commits October 13, 2026 12:27
…t needs it

A table cell edit replaced the document text but kept the parse made
before it, and sections are cut from the text at those heading
offsets. Every section after the edit was sliced at stale positions: a
shorter cell panicked on the next frame and lost the edit, a longer
one cut the next section short or split a character. Edits and undo
now re-parse, keeping the outline unless the headings changed.

hide_latex is on by default and ran on every document. It collapsed
every run of spaces, flattening nested lists and indented code (and a
checkbox toggle, mapping the displayed list back to the file, then
toggled the wrong item), read `$5 and $10` as math, and stripped
backslashed words from paths. It now runs only on documents that
contain LaTeX, collapses spaces only between words, and reads `$` the
way pandoc does. Detection also runs for the first document, so its
hint shows.

Frontmatter was stripped from every section, so a section opening with
a `---` rule lost its text up to the next rule. Only the whole document
is checked now, the closing line must be exactly `---` or `...`, and a
byte order mark no longer hides it.

The HashMap import is gated with the mermaid code that uses it, so the
minimal build passes clippy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--all-targets` skips doc tests, so the library examples were never
run, and clippy only saw the full feature set, where an import used
only by mermaid code looked used. `just gate` runs what has to pass
before a commit, and `just ci` builds on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls from startup

Terminal::clear in ratatui-core 0.1.2 asks the terminal for the cursor
position first, so in a terminal that does not answer, Ctrl+L, resuming
after Ctrl+Z and returning from the editor ended the session with an
error. Resizing to the current size clears and resets the buffers the
same way without asking.

While the editor runs the terminal is in its normal mode, and Ctrl+C
sent SIGINT to treemd too, killing it with unsaved edits and the cursor
hidden. SIGINT and SIGQUIT now set a flag that is dropped when the
editor returns, and otherwise end the session cleanly. The cursor is
shown whenever the terminal is handed back.

Signal handling moves to its own module and is installed before raw
mode, so a signal during startup (the image probe can take seconds)
no longer leaves the terminal changed. The event loop cannot check the
flag while the UI thread is busy, so after SIGTERM or SIGHUP a watcher
restores the terminal and exits if the loop has not within two seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and reload

`e` reloaded the file after the editor and discarded buffered edits,
even when nothing changed; it now asks for a save or undo first, and
refuses a document that is not a file. The file picker opened a file
without the unsaved-changes prompt links get, leaving edits pending
against the new file; it goes through the same prompt, with its own
navigation variant since its path is absolute. Discarding cleared the
edit list but left the edits in the text for the next save to write;
it now undoes them. A change on disk skipped because of pending edits
was never loaded and every later save was refused; it now runs once
the edits are gone. A reload restores the selected heading by position
when it still reads the same, so namesake sections stay apart.

A file is no longer wrapped in an `Input` heading when it has none (or
the heading sat behind a byte order mark). The wrapped text differed
from the file, so every save was refused as a change made on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nline tables

A config that could not be read (a Latin-1 comment, UTF-16, no read
permission) was treated as no file yet, and the first setting change
wrote defaults over it. Only a missing file starts from defaults now;
any other read error is handled like a parse error, with a warning and
no saves to that path.

The comparison point for a save was fixed at load, so changing a
setting and changing it back saved nothing the second time. It moves
to what was written after each save. A section written as an inline
table was taken for a missing table and replaced, losing its other
keys; tables of either kind are edited in place. With no file yet, the
first save wrote every default, pinning them; it now writes only what
differs from the defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns, and scoping cost

- -l -o json numbered blocks from the line after the heading while
  handing the parser a body with its leading blank lines trimmed, so
  start_line and end_line were short by those lines. The trimmed
  body's own offset and line are used now.
- body_start looked only for `\n`, so a setext underline ended by a lone
  `\r` sent it past the next heading and slicing panicked. A lone `\r`
  is a line ending, as in CommonMark, and the body never starts past
  the next heading.
- Sections ended at the next heading's `#`, so before `> # Q` or
  `- # Q` they kept a stray `>` or `-`. They end at its line's start,
  and -s uses the same end as everything else.
- `.fm` came from parsing the whole document, which took a key/value
  block between two rules further down for frontmatter, and a byte
  order mark hid the real one and the block after it. Frontmatter is
  now only the block opening the document, found by one function the
  TUI filter shares, and a block with more than 64 YAML aliases is not
  parsed: a small one expanded to 5 GB on every query.
- Comparisons across types returned "equal", so `<` was false and `<=`
  true for the same pair. They use jq's order, as sort does.
- Section-scoped .code and .link counted lines from the top and scanned
  every block per heading; they binary-search instead, from 38 s to
  0.13 s for `.h1 >> .code` on 20,000 headings.

The property tests now generate lone carriage returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpression

Stdin read a line whole before checking it, so a stream with no
newlines grew without bound, and files were checked by their reported
size, 0 for /dev/zero, FIFOs and process substitution. All reads now go
through one reader that stops a byte past 100 MB, and the TUI's later
reads (link follow, picker, reload) use it too.

ureq's body limit counts bytes received before decompression, so a
small gzip response expanding to gigabytes passed it. The decoded text
is capped as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he UI thread

- The image cache evicted its oldest entry on every insert, which in a
  section with more than 32 images was one of that section's own, so
  its first images were never drawn. Entries the section shows are not
  evicted. Cached images remember their file's mtime and length and are
  decoded again when it changes; failed decodes are remembered the same
  way instead of retried on every refresh.
- Stills are refused over 64 megapixels from their header. The image
  crate's default was a 512 MB memory budget, which an 11000x11000 PNG
  of 470 KB stays under, decoded on the UI thread.
- A GIF frame other than the next one is walked to on a thread of its
  own, cancelled by a newer seek, and handed back with its decoder so
  playback continues from it. Frame delays are read without decoding.
- The mermaid worker skips queued jobs for a width other than the
  latest, and rendered diagrams are capped at 32.
- Native iTerm2 playback is limited to 16 MB files.
- In tmux, ratatui-image turns the pane's allow-passthrough on with any
  picker, halfblocks included, and leaves it on. The setting is noted
  before the first picker and restored on every exit path, which the
  normal exit now shares with panics and signals. A session with no
  attached terminal skips the probe, which nothing would answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ings stay apart

- First, Last, PageUp and PageDown are shared by Normal, Help and
  Interactive mode and acted on the outline in all of them. They act on
  help, on the section's elements, and in an accepted search on the
  content now; with the outline focused, paging moves the selection.
- Ctrl+C is bound in every mode: quit where q quits, cancel where text
  is typed or a question asked.
- An accepted search had no reading keys and the wheel stepped through
  matches; j/k/d/u/g/G/q are bound there and the wheel scrolls.
- Collapse state and bookmarks are keyed by heading text and the
  heading's ordinal among namesakes, and the todo filter by index, so
  repeated headings are no longer one heading. Anchor jumps search all
  headings, expand collapsed ancestors and resolve GitHub's `-N`
  suffixes; outline search ignores collapse.
- Entering interactive mode, and paging in it, pick the element by the
  rows elements are drawn on, not by unwrapped line counts.
- Per-element state is cleared on a section change.
- y in interactive mode copies the selected element; the table copy
  shares its markdown with the table-only copy.
- p in link mode refreshes the link list.
- Back/Forward and an external reload lay the section out before
  restoring the scroll, which the reload path used to dodge by skipping
  the re-index.
- The editor line for an element counts from the displayed text's first
  line.
- A count is cleared after every action.
- strip_latex restored code placeholders with a full replace per span,
  quadratic in a section full of inline code; one pass now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ller CLI fixes

- The mode flags form one clap group, so two of them are an error
  instead of all but one being ignored by hidden precedence. --filter,
  -L, -n and -o require -l or --tree; without one they were ignored and
  the TUI opened. -l -o tree prints the tree, and -o's help describes
  what json prints in each mode.
- Remote detection is one function, case-insensitive on the scheme, and
  a remote spec is never canonicalized, which could resolve it against
  a local `http:` directory and treat the page as a local file.
- Extra files get the "only the first" warning in the TUI too, and the
  help no longer offers a file picker for several files, which never
  existed. A file that fails to read is named in the error.
- Bidi overrides and isolates are escaped in CLI output to a terminal.
- --setup-completions matches its own line exactly, honors ZDOTDIR and
  XDG_CONFIG_HOME, and creates fish's directory. treemd's own config
  honors XDG_CONFIG_HOME on macOS.
- README: the --tree with --section tip never worked and is replaced;
  link mode's back/forward keys are Normal mode's; the interactive keys
  are listed as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…packaging in CI

The gnu binaries were built natively on ubuntu-latest (aarch64 through
cross's image) and linked against its glibc, 2.39, so they failed on
Debian 12, Ubuntu 22.04 and RHEL 9. Both are built with cargo-zigbuild
against glibc 2.28 now; a step fails the build if any symbol is newer,
and the x86_64 binary is smoke-tested in debian:10. Verified locally:
zigbuild builds both, and each one's newest symbol is GLIBC_2.28.

Packaging asserts all seven targets are present, build artifacts are
kept 7 days instead of 1, every job has a timeout, and Alpine is pinned
to 3.22. A CI job packages one target twice and diffs the results, so
the script is exercised before a tag rather than by one. The README's
reproducibility claim is scoped to the same tool versions and names the
glibc floor.

release-preflight checks HEAD against origin/main; release-tag says when
to publish to crates.io.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Query errors exit 3 to parse and 5 to run, as in jq, apart from -e's 1.
Mismatched operands are an error rather than null or joined text; + merges
objects, - removes array items, / splits strings, % works on whole numbers.
A slice of an array is an array, an index past its end is null, and a
missing key on front matter or an object is null.

Keys with dashes read with .fm["k"], .fm."k" and .["k"]. A bare bracket
filter runs to the ], so .code[c++] and .h[Getting Started] work. Prefix
not takes a whole comparison, and a stage led by count reduces the stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g directory

A piped document could load any image on disk by absolute path, and its
links reached anywhere in the working directory's git repository. Both now
stay in the working directory itself. security.trust_piped_documents in
the config restores the old behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w either

slug_style = "github" under [content] makes copied anchors, -l -o json ids
and slugs, and the query language's .slug and slugify the way github.com
does: _ is kept and repeated headings are numbered. treemd's own anchors
stay the default. Following a link finds a heading by either kind of
anchor, so #snake_case copied from GitHub works whatever the setting.

Uses the github-slugger crate, a port of GitHub's slugger tested against
its fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lone \r ends a line in CommonMark, but the parser read it as nothing, so
in a file with classic Mac line endings every heading after the first ran
into one line. parse_markdown turns each lone \r into \n, which keeps
offsets indexing the file. Saving edits to such a file is refused with the
reason, since writing it back would change every line ending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read-only file was renamed over whenever its directory was writable, so
it is refused. A hard-linked file is written in place, keeping its other
names in step, and a dangling symlink is written through to the file it
names rather than replaced. Where the directory takes no new files, a
read-only directory or a file bind-mounted on its own, the file is written
in place rather than not at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every image in a section was decoded on the UI thread before the section
could be drawn, so a few large photos froze input for seconds. The image
worker gets a decode thread: a section queues its images and draws
"Loading image..." in their place until each arrives. Leaving the section
skips decodes that have not started, and each image is asked for once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doc_search_status, get_selected_interactive_element, mark_metrics_dirty,
selected_command and show_save_width_confirmation had no callers. Done
before splitting app.rs, so the move carries only live code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app.rs had grown to 7,800 lines. Its methods move, unchanged, into
src/tui/app/ by what they do: actions (key dispatch), outline, search,
palette, navigation (links, anchors, history), file_picker, editing,
elements (interactive mode) and images. Each module's tests move with it.
mod.rs keeps the App struct, its constructor and what is shared.

Nothing is rewritten: private methods become pub(super) so sibling
modules can still call them, and moved types are re-exported from mod.rs
at their old paths. git diff --color-moved shows the moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its keys were matched in the event loop ahead of the keymap, so they could
not be rebound and Ctrl+C did nothing there. ImageViewer is a keybinding
mode with PreviousFrame, NextFrame and TogglePlayback actions, and
current_keybinding_mode answers it first. Ctrl+C closes the viewer, as Esc
and q do. Text typed while it is open no longer reaches a search behind it,
and the count prefix reads the keybinding mode too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
show_help and show_theme_picker were two flags for the same thing, a popup
in front of the current mode, and nothing kept both from being set.
overlay: Option<Overlay> holds whichever is open, so only one can be, and
the keybinding mode and the renderer each read it in one match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
show_search and outline_search_active described three states with four
combinations, one meaningless. OutlineSearch is Off, Typing or Applied,
and every reader matches on it. The four copies of "cycle through the
applied search's matches" (Tab, Shift+Tab, n, N) are one method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typing, pasting and Backspace each re-derived which field was active from
the mode flags, in three slightly different ways. TextInput names the
field, text_input() reads it from the keybinding mode, and all three use
it. Each field has one length limit, for typing and paste alike; a paste
used to have none. The per-field push and pop helpers are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nicholasjpaterno

Copy link
Copy Markdown
Contributor Author

Closing this in favor of a new PR with the same changes. The commits are rewritten without the co-author trailers; the code is identical.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant