Skip to content

v0.10.0

Latest

Choose a tag to compare

@github-actions github-actions released this 18 Oct 15:35
· 2 commits to main since this release
b8a2732

[0.10.0] - 2026-10-18

The TUI is redesigned: seventeen themes held to WCAG contrast, nine of them new and three light, a footer that shows the keys for whatever is open, a mouse that works on the pane under it, and a no-color mode for NO_COLOR.

The query language now behaves like jq: query errors exit 3 or 5, arithmetic follows jq's rules, --query-output json always prints an array, and more. Scripts that use -q should check the Changed section before upgrading.

Fixed

  • Tables take their natural widths. A column was sized at 70% of its average cell and 30% of its widest, so Previous wrapped in a column beside Next with the pane half empty. A cell holding inline code also lost the space every other cell has and sat against the border
  • The footer shows the keys of what is open. It knew five modes and showed the main view's keys over help, the pickers, link search and the outline filter, where those keys type or drive the popup. At 60 columns it was cut off before ? help; hints now drop from the end and help stays
  • Keys that do nothing say why: f in a section with no links, which opened an empty list; ' with no bookmark; b and F with no history; n and N with no search; Enter on a palette that matches nothing. The todo filter says when nothing has an open todo instead of leaving an empty outline, and Esc clears it
  • Text in popups is readable in every theme. Hints were 1.4:1 in Nord, and help text was the terminal's default gray on light popups, since popups set a background but no text color. A light theme no longer has a dark title bar either: the frame is painted with the theme first
  • An unknown --theme is an error naming the themes, not a silent fallback to Ocean Dark
  • The 256-color fallback picks the nearest color. It rounded each channel to a multiple of 51, but the xterm cube's levels are 0, 95, 135, 175, 215 and 255
  • A paste into a search or the command palette is held to the field's length, as typing already was, so a long paste could not become a query that is re-run on every key
  • CLI misuse is reported instead of answered. -L 9 was accepted though there is no level 9; -q with -l or -s silently ignored the other flag; -l docs/ printed a made-up # Select a file heading; extra files were silently ignored; and with no input at all, treemd printed its usage and exited 0. These are errors now (exit 2 for usage, 1 for a directory), and extra files get a warning that only the first is read, in the TUI too. The same goes for any two of -l, --tree, -s, --count, --at-line and -q, where all but one used to be ignored, and for --filter, -L, -n and -o without -l or --tree, which were ignored while the TUI opened. -l -o tree prints the tree, as -o always listed it could. A file that cannot be read is named in the error
  • A fetched document stays remote even if the working directory has a directory named http:, and an uppercase HTTP:// URL is fetched rather than read as a file name. The URL was resolved as a path first, which could make the page count as a local file, its links and images included
  • Bidirectional override characters in a heading are shown, not applied, when the CLI prints to a terminal. They reorder how a line is drawn, so a heading could display as something other than what it says
  • --setup-completions finds the right file and checks for treemd's own line. It took any COMPLETE= line and any mention of treemd for completions already set up, ignored $ZDOTDIR for zsh and $XDG_CONFIG_HOME for fish, and failed when fish's config directory did not exist yet. On macOS, $XDG_CONFIG_HOME is also honored for treemd's own config
  • treemd doc.md > out prints the document. With no terminal to draw on, the TUI wrote its escape sequences into the file or pipe. It now prints the document, as bat and less do
  • github:owner/repo finds the README whatever it is named. It fetched README.md at the root, so a repository with readme.md, README.markdown or docs/README.md failed to open. GitHub is asked for the repository's README, falling back to README.md past its rate limit
  • Input is held to 100 MB however it arrives. A file over the limit is refused rather than read whole. Stdin had the limit but read a line whole before checking it, so a stream without newlines was never stopped, and a file was checked by its reported size, which is 0 for /dev/zero, a FIFO or <(command): treemd -l /dev/zero used gigabytes in a fraction of a second. Every read now stops one byte past the limit, including the files the TUI opens later through a link, the file picker or a reload
  • Scrolling and moving through a long section no longer re-renders it every frame. Each frame parsed, highlighted, filtered and wrapped the whole section again, so on a large document every keypress and every scroll step took about a second. The section is now laid out once and kept until something it depends on changes, and a frame draws only the rows on screen: on a 21,000-line document a scroll frame takes about a millisecond
  • A selected element below wrapped text is scrolled into view. Interactive mode placed elements by counting each paragraph as one line, so anything below a paragraph that wrapped onto several rows was thought to be on screen when it was not, and selecting it left it out of sight. Elements are now placed by where they were drawn
  • With frontmatter hidden, the pointer marks the link Enter follows. The interactive index read the section with its frontmatter while the pane showed it without, so their block numbering differed and the pointer sat on one link while Enter opened another
  • Search scrolls to the match. Matches were found in a plain-text conversion of the source and scrolled to by that line's number, which was not where the match was drawn, so a match late in a long paragraph stayed off screen. Search now runs over what the pane draws, so the match count and the highlighted current match also agree
  • Content past row 65,535 can be reached. The scroll offset was 16-bit, so the end of a very long section could not be scrolled to
  • Piping a document into treemd works on macOS. cat notes.md | treemd drew the first screen and then exited with "Failed to initialize input reader". With stdin taken by the pipe, keys are read from /dev/tty, which macOS's kqueue cannot watch. crossterm's use-dev-tty feature reads it with poll instead
  • Keys are no longer swallowed in a terminal that does not answer the image probe. ratatui-image asks the terminal which image protocols it supports and reads the answer on a separate thread. When none came, the thread was left reading the keyboard, so every key from then on went to it, q included, and treemd could not be quit. treemd now checks that the terminal answers queries at all before probing, and uses halfblocks when it does not. Inside tmux, where tmux answers for itself, the probe is also skipped when no terminal is attached to the session
  • treemd no longer leaves tmux passthrough on. In tmux, the image library turns the pane's allow-passthrough on, which lets any program in the pane write escape sequences straight to the outer terminal, and left it on after treemd exited. The pane's own setting is put back on exit
  • Closing the terminal or sending SIGTERM restores it. treemd was killed with raw mode, the alternate screen and mouse reporting still on, so the shell it returned to received mouse movements as typed input. It now exits through its own cleanup, including during startup, and within two seconds when it is busy with a large document
  • Ctrl+C in the editor is the editor's. While e had an editor open, Ctrl+C reached treemd as well and killed it, losing unsaved table edits and leaving the cursor hidden. treemd now ignores it, as git does
  • Ctrl+L, resuming after Ctrl+Z, and returning from the editor no longer end the session in a terminal that does not answer queries. The drawing library's screen clear started asking the terminal for the cursor position in a recent release, and treemd exited when no answer came
  • The command palette and the link picker keep the selection in view. The palette drew every command in one block, so on a small terminal the selection moved off the bottom after a few steps. The link picker worked out its scroll in lines while drawing them wrapped, so below a few long links it scrolled the selection out of view. The palette footer also said Tab navigates; it completes the command name
  • Keys act on the screen they are pressed in. In interactive mode g, G, Home and End moved the outline behind it, switching section, after which Enter acted on an element from the old one; they now go to the first and last element. PageUp, PageDown, d and u did nothing there and now page through the section. In help, g and G moved the outline instead of the help; with the outline focused, PageUp and PageDown did nothing
  • Ctrl+C works in every mode. It quit only from the main view. It now quits wherever q does, and cancels wherever text is typed or a question asked, as Escape does
  • Reading a search's results works. After Enter accepted a search, j, k, d, u and q did nothing, and the mouse wheel jumped between matches. They scroll and quit now, with the matches still highlighted and n and N still stepping through them
  • Saving respects how a file is kept. A read-only file was saved over whenever its directory was writable, since the new contents were renamed into place; it is refused now. A hard-linked file was split from its other names, which kept the old contents, and a dangling symlink, such as a config pointing into a dotfiles checkout not yet set up, was replaced by a regular file. Both are written through now. A file in a directory that takes no new files, read-only or a single file mounted into a container, could not be saved at all; it is written in place
  • A file with bare CR line endings has its headings. A lone \r ends a line in CommonMark, as in files saved with classic Mac line endings, but it was read as nothing, so every heading after the first ran into one line: -l listed # Atext## B. It is read as a line ending now. treemd does not save edits to such a file, since writing it back would change every line ending, and says so
  • Headings with the same name are kept apart in the outline. Collapsing one ### Added in a changelog collapsed every one, the open-todo filter kept all of them, and a bookmark marked all of them and went to the first. A link to #added-1 now reaches the second Added, as on GitHub, and a link to a heading under a collapsed one opens it; outline search finds headings under collapsed ones too. A link written with GitHub's anchor, such as #snake_case, which keeps the _ treemd's own anchors drop, finds its heading as well
  • i starts at the element on screen. It compared the scroll position with line counts that ignore wrapping, so below a few wrapped paragraphs it picked an element pages further down and scrolled there
  • A table cursor or an opened <details> stays in its section. That state was kept by an element's position in its section, so another section's element in the same position took it over
  • y in interactive mode copies the selected element, a link's target, a code block, a table as markdown, an image's source, as the help and status line say. It copied the whole section
  • p in link mode lists the parent section's links. It moved to the parent but kept listing the child's
  • Back and Forward keep the scroll position, and e from interactive mode opens the editor on the element's line, not a line or more above it
  • A count applies only to the next command. 3, Enter, j moved three rows
  • The mouse wheel scrolls whatever is in front. It was tied to moving through the outline, so with the help screen, the command palette or a picker open it moved the document behind them. It now acts as the arrow keys of the open screen
  • The FILTER chip shows while the outline is filtered. It waited for a mode nothing ever entered, so it never appeared
  • A code block with a very long line no longer stalls the frame. Highlighting ran over the whole line, and on minified code or a pasted blob of hundreds of kilobytes that took long enough to freeze the display. A line over 4 KB is drawn plain
  • A timed status message goes away on time. It was only cleared when something else redrew the screen, so with no input it stayed until the next keypress
  • Tabs in ordinary text show as spacing. The terminal buffer drops control characters, so a tab outside a code block vanished and joined the words on either side
  • Help scrolls to its end and no further. Its scroll limit was the number of help lines before wrapping and its page size was the document pane's height, so it stopped short or overran depending on the terminal size and the keybindings
  • The cursor is shown again after a crash or a signal, and while suspended or in the editor. Restoring the terminal relied on the drawing library showing it on a normal exit
  • Every image in a section with more than 32 is drawn. The image cache held 32 and evicted the oldest each time it decoded another, so a gallery section evicted its own first images while decoding its last, and they stayed blank. Images the current section shows are never evicted now. A replaced image file is also decoded again instead of shown as it was, and one that fails to decode is not retried on every section change
  • A huge image is refused instead of freezing treemd. A still image was decoded on the UI thread with only the image library's 512 MB memory limit, so a 470 KB PNG declaring 11000x11000 froze input for seconds and held 840 MB. Images over 64 megapixels, from the size in their header, are refused before decoding. Inline images are also decoded on a background thread now, so a section with large photos shows "Loading image…" in their place and keeps responding to keys, where every image was read before the section could be drawn; leaving the section skips the decodes that have not started
  • Stepping back in the GIF viewer no longer freezes it. Every step other than the next frame decoded the GIF from its start on the UI thread, which on a long or large recording took tens of seconds, with every key waiting. That walk runs in the background now, and a newer step cancels an older one. Opening the viewer also no longer decompresses every frame just to read their timing
  • Resizing no longer queues a mermaid render per width. Each width the pane passed through rendered every diagram in full before the final width's turn; renders for an old width are now skipped. Rendered diagrams are also held to 32, rather than kept for the whole session
  • iTerm2 plays a GIF over 16 MB through treemd's own decoder. Native playback writes the whole file to the terminal on the UI thread, and again whenever the image moves, which froze the session for large files, over ssh especially
  • An image no longer covers the text after it. An image whose top had scrolled out of view was pinned to the top of the pane at full height, over the text below, and images were drawn taller than the rows reserved for them. They now scroll out of view and stay within their rows
  • A nested list draws each item on its own row, with its own marker. Three shapes were wrong before: a nested ordered list drew bullets instead of numbers, a paragraph under a nested item was appended to its parent's row (• abpara under a), and a nested list inside a blockquote was folded into its parent's text (│ • qqi). A nested task keeps its checkbox
  • A list item's indented continuation inside a blockquote keeps its own block. > - step followed by an indented text reported steptext as the item. The item is step now and text is its paragraph, the same as outside a quote (turbovault#77)
  • The pointer on a selected link in a nested list sits on that link's row. Following a table of contents in interactive mode put the pointer on the top-level entry, whichever of its children was selected
  • .list reports lists in document order. A nested list is reported right after the list that holds it rather than before
  • Piped input is read again without -. cat doc.md | treemd -q '.h', curl … | treemd and tree | treemd ignored stdin whenever no file was named: from a directory holding any markdown file they opened the file picker, so a query ran against its placeholder and reported a Select a file heading, and from any other directory treemd printed its usage and exited. Only treemd - worked. This came in with the startup file picker in 0.5.5, which checked the directory before checking stdin
  • Queries and -o json are linear in the number of headings. Each heading's line number was counted from the start of the document, so a 900 KB file with 16,000 headings took 20 seconds to query and a few hundred thousand headings did not finish. The same file now takes a fifth of a second
  • Piping into head no longer panics. treemd -l big.md | head -1 printed "failed printing to stdout: Broken pipe" and exited 101 once the reader went away; it now ends quietly, as any filter does
  • Fetching a remote document times out. A server that accepted the connection and never answered hung treemd indefinitely. The connection now has 10 seconds and the whole request 30, and an https:// request no longer follows a redirect down to plain http://. The 10 MB limit on a fetched document now applies after decompression: counted on the bytes received, a 40 KB gzip response that expanded to gigabytes got through
  • A document with no headings reports none. The CLI gave it a made-up # Input heading, the one the TUI uses so its outline has something to select, which also pushed every line number down by two
  • A byte order mark no longer hides the first heading. A file starting with a UTF-8 BOM lost its first heading from -l, --tree, -q and -s. The BOM is kept in the content, so a checkbox toggled in the TUI still writes it back
  • Heading line numbers agree everywhere. .line in a query counted one too many for a heading that does not start its line, such as > # quoted, so it disagreed with -n. The -o json section position is the line its body starts on, which for a setext heading is two lines down, not one
  • Code blocks under a heading keep their document line numbers. .h2[Install] > .code | .start_line numbered blocks from the section's first line, so it disagreed with .code | .start_line for the same block
  • -L and --filter apply together. -l -L 2 --filter api listed every level-2 heading. -o json is the whole document and ignores both, and now says so on stderr instead of silently printing everything
  • --query-output jsonp is accepted, as the help says
  • langs and types list their keys in sorted order rather than a different order on every run
  • Terminal escape sequences in a document are shown, not sent, when the CLI prints to a terminal. A heading or code block carrying ESC ]0;… or ESC [2J retitled the window or cleared the screen when listed with -l, --tree, -s or -q. They now print the way cat -v shows them, ^[]0;…. Piped output is unchanged, byte for byte
  • Whole numbers print as integers in JSON. A count was 1.0 where an element's own fields print 1. A large number in plain output printed as 9223372036854775807 rather than itself
  • A panic in image or diagram rendering no longer breaks the terminal. The image encoder and the mermaid renderer were each wrapped to turn a panic into an error, but the panic hook ran first and restored the terminal, so the TUI carried on in cooked mode on the main screen with the panic text printed over it. A panic in that work is now an error for that one image or diagram and nothing else
  • An image path naming a FIFO, a terminal or a device no longer freezes treemd. Opening one blocked until something wrote to it, on the UI thread with raw mode on, so Ctrl-C could not end the session. Only a regular file under 128 MB is opened
  • A GIF declaring a huge canvas is refused before it is allocated. The canvas size comes from the header, so a 62-byte file could take gigabytes the moment the document opened. A canvas over 16 megapixels is not drawn
  • The image viewer streams a GIF instead of decoding every frame up front. A screen recording used to expand into every frame at full size before the viewer opened, around 1.7 GB for a 1200x800, 450-frame README recording. The viewer now holds one frame and decodes the next as playback reaches it; stepping back reopens the stream
  • A slow mermaid diagram no longer freezes treemd. Diagrams were laid out inside the frame, on the UI thread, and layout cannot be interrupted: a few kilobytes of densely connected nodes took a minute and a half, during which treemd took no input at all, Ctrl-C included, from a single block in a README. Diagrams are now laid out on a worker thread, with a placeholder in their place until they are ready, and a source over 32 KB is not laid out
  • Inline images take far less memory. Each was kept at full resolution, tens of megabytes for a photo, for a pane that shows a few hundred pixels of it, and the cache holds up to 32. An inline image is kept at most 2048 pixels on its longest side; the image viewer still decodes its own copy at full resolution. An inline GIF also no longer holds an open decoder and a frame canvas for playback that never happened, since inline GIFs show their first frame
  • Large mermaid diagrams are drawn within a fixed budget. A diagram was rasterized at the pane's full width however tall it was, so a fifty-node chain at a wide pane took over a gigabyte and several seconds, and again on every width change. It is now held to 16 megapixels and drawn narrower if it would exceed that
  • Saving no longer resets a file's permissions. A checkbox toggle or a table edit replaced the file with one readable only by its owner, so a 0644 note became 0600 after one save. The original permissions are kept, and the write is synced to disk before it replaces the file
  • A save no longer overwrites a change made outside treemd. The in-memory copy was written back whatever had happened to the file since it was loaded, so an edit made in another editor, while a table edit was pending, was silently undone. treemd now refuses the save and says the file changed on disk
  • One bad keybinding no longer discards the whole config. A misspelled action name failed the parse of the entire file, so the theme and every other setting fell back to defaults, and a key that did not parse dropped every custom binding. Each bad entry is now skipped and reported, and the rest applies. The warnings appear in the status bar at startup; they used to be printed just before the alternate screen opened, where they could not be seen
  • Saving a setting keeps the rest of the config file as written. Changing the theme or outline width rewrote the whole file from scratch, which dropped comments, keys treemd did not recognize, and any keybinding it could not read, and filled in every default. Only the value that changed is written now, in place, including in a section written inline (ui = { theme = "Nord" }), which used to be replaced and lose its other keys. Changing a setting and then changing it back is saved too, and with no config file yet the first save writes only what differs from the defaults
  • A config file with a typo, or one treemd cannot read, is no longer replaced with defaults. It failed to parse, treemd ran on defaults, and the first theme or width change saved those defaults over it, losing every setting in the file. The same happened to a file that could not be read at all, such as one with a Latin-1 comment, saved as UTF-16, or without read permission. Such a config is now left alone for the session, and the warning says so
  • The config file is written atomically, through a symlink. It was written in place, so a crash mid-write left it truncated, and a config symlinked from a dotfiles repository is now updated where it lives rather than replaced by a copy
  • Searching text with characters whose case changes their length no longer crashes. Typing İ in outline or content search over a document containing İstanbul panicked, and with other queries the highlight landed on the wrong letters. Matching now reports positions in the original text
  • A document read from stdin or a URL is not saved to a file named <stdin>. Toggling a checkbox in one wrote that file into the working directory. It now says there is nowhere to save
  • Editing a table cell changes that cell, not one in another table. The table was picked by its position among the section's tables as the parser counts them, then found in the file by a separate line-based check that disagreed: it skipped tables written without outer pipes and counted tables inside list items. With either before it, an edit landed in a different table and was saved there. The cell is now located with the same parser, and an edit whose cell in the file does not match the one on screen is refused
  • A table cell edit no longer crashes treemd or garbles the sections after it. The edit changed the text but kept the old positions of the headings after it, which every section is cut out by. Shortening a cell crashed treemd on the next screen update and lost the edit; lengthening one cut the next section short, or crashed when the cut fell inside a multibyte character, and showed text from the table above in it. The document is now re-read after every edit and undo
  • Ordinary documents are no longer rewritten by the LaTeX filter. hide_latex is on by default and ran on every document, LaTeX or not. It collapsed every run of spaces, so nested lists and indented code showed flat, and since the list on screen no longer matched the file, toggling a nested checkbox toggled a different one. It also read $5 and $10 and $HOME and $PATH as math and dropped their dollar signs, and stripped backslashed words such as C:\Users\name\docs. The filter now runs only on documents with LaTeX in them (environments, document commands, or $$ display math), leaves indentation and hard line breaks alone, and treats $ as math only the way pandoc does. The LaTeX hint now also shows for the first file opened, not only later ones
  • A section that opens with a --- rule shows its text. Frontmatter was stripped from every section, so a section starting with a horizontal rule was taken for frontmatter and hidden up to the next rule. Only the start of the document is checked now, the closing line has to be exactly --- or ..., and a byte order mark no longer hides real frontmatter
  • Unsaved table edits are no longer lost or misapplied. e opened the editor and then reloaded the file, discarding buffered edits even when the editor changed nothing; it now asks for a save or undo first. Opening another file from the file picker skipped the unsaved-changes prompt that following a link shows, so the edits stayed pending against the new file. Discarding edits left them in the text, where the next checkbox toggle saved them anyway. And a change made on disk while edits were pending was skipped and never loaded, after which every save was refused; it now loads once the edits are saved or undone
  • A checkbox toggle stays in its section when another section has the same name. After the save, the selection was restored by heading text, so in a document with two ## Tasks it jumped to the first, and the next toggle changed that one
  • e no longer hands a piped or fetched document's placeholder to the editor, which created a file named <stdin> or fetched the URL again outside treemd's limits
  • A file without headings, or starting with a byte order mark, can be edited. The TUI put an Input heading on top of a document with no headings, and the check missed headings after a byte order mark, so the real heading stopped being one. The text then differed from the file, and every checkbox toggle was refused as a change made on disk. Files are shown as they are; only piped text without headings gets the heading
  • Undoing a table edit restores the cell exactly. Undo wrote back the parser's text of the cell, which has escapes resolved, so x \| y came back as x | y and split into two cells. Undoing an edit to the first header cell of a table written without outer pipes also put the cell back one space to the right
  • Editing a cell keeps its formatting. The edit started from the parser's text, with **bold** and *italic* already stripped, so saving any change dropped them. It now starts from the cell as written
  • .name reads a key when the input has one, even if it is also an element name. Element selectors took priority everywhere, so {p: 1} | .p printed every paragraph, .h | {a: .text} | .a printed every link, and .fm | .image printed the document's images rather than the front matter's image. A key or property on the input now wins; on the document the selector still does, so .h2 and . | .code are unchanged. The document's heading_count is no longer also reachable as .headings, which was always the heading selector
  • A selector piped from a heading selects within that heading's section. .h2[0] | .code returned every code block in the document, not the ones under that h2. It now means the same as .h2[0] >> .code, so .h | {title: .text, code: [.code]} lists each section's own blocks. .frontmatter belongs to the document and is the same from anywhere
  • > and >> scope tables, lists, images, paragraphs and blockquotes. .h2[Install] > .table returned nothing whatever the section held, because those elements carry no source position. The section is now parsed on its own to find them
  • . can be followed by an operator or brackets. select(. == "x"), . + 1 and .[] were parse errors, since only the end of the query and a few closing tokens were accepted after a bare dot
  • Collection functions see a piped stream whole. .h | count printed 1 once per heading, and sort_by, group_by, join, unique, first, last, nth, limit, skip, reverse, min, max, add, any, all and levels all ran once per item, although the docs use exactly these forms. After a selector or anything else that yields a stream they now run once on all of it, including an empty one, so .h[Missing] | count is 0. Ordering functions hand back a stream, so .h | sort_by(.level) | .text works. levels, langs and types count what they are given rather than the whole document, so .h2 | levels is {"h2": 3}. A single value is unaffected: .h1[0].text | count still counts characters, and [.h] | count still works, but .h1.text | count now counts the h1s
  • group_by("level") and sort_by("level") use the named property. The string was evaluated as a value, the same for every element, so group_by put everything in one group and sort_by changed nothing. A string argument now names a property, the same as .level
  • An element looks the same in an object as in JSON output. {a: .h1[0]} | text printed the heading with its byte offset and its whole section body and no type, from a second serializer that --query-output json did not use. Both now print the tagged form, and a number over 2^63 in plain text prints as itself
  • [external], [anchor], [relative] and [wikilink] filter text on anything but a link. They were link-type filters everywhere, so .h[external] matched nothing rather than "External APIs". A keyword in brackets, as in .h[end], was a parse error; it is a word to search for like any other
  • sort orders numbers as numbers, and unique keeps distinct values. Both compared values as text, so [10, 9, 1] | sort gave 1 10 9 and [.h] | unique merged two headings that shared a title. sort and sort_by now use jq's order (null, booleans, numbers, strings, arrays, objects), and unique drops only values that are the same, type included
  • An index after a stream of arrays applies to each array. .table[0].rows[][0] returned the first row's cells, not the first cell of every row as the docs promise, because the index picked from the stream of rows. It now indexes each row, as jq does. An index after a stream of anything else still picks from the stream, so (.h | select(.level == 2))[0] is the first match
  • A query error's caret points at the right column after non-ASCII text. It was placed by byte count, so in .h["日本"] ! it landed several columns past the !
  • Arity errors say how many arguments a function takes. count(1) reported "expects 0..=0 arguments"; it now says "takes no arguments, got 1"
  • A query error no longer ends in a blank line
  • _pipe and _index are no longer callable. They are the parser's internal names for | and [n], and typing them worked as undocumented functions. They now report "Unknown function"
  • <, <=, > and >= follow jq's order across types. Values of different types compared as equal, so for the same pair < and > were false while <= and >= were true, and select(.text >= 5) kept every heading. They now use the order sort already used, null, booleans, numbers, strings, arrays, objects, so 1 < "a" is true as in jq
  • -l -o json reports the lines blocks are on. Block lines were counted from the line right after the heading, while the body had its leading blank lines trimmed, so nearly every block's start_line and end_line was one short, and more after a setext heading or several blank lines. -q was already right
  • A lone carriage return no longer crashes -l -o json or -q. A setext underline ended by a lone \r sent the section body past the next heading, and slicing it panicked
  • A section no longer ends with the next heading's > or -. When the next heading sat in a quote or a list item, the section was cut at its #, keeping the markers before it on that line, in -s, .content, md and the JSON raw
  • .fm is only the frontmatter that opens the document. A key: value block between two --- rules further down was reported as frontmatter, over the real one if there was one, and a byte order mark hid the real frontmatter and any table, quote or list right after it
  • Section-scoped .code and .link are linear. Each heading counted lines from the top of the document and scanned every block, so .h1 >> .code took 38 seconds on 20,000 headings; it takes a tenth of one
  • Frontmatter with more than 64 YAML aliases is not parsed. Aliases repeat what they point at, and a 225 KB block expanded to 5 GB of memory and 8 seconds on every query, whether or not the query read the frontmatter
  • == no longer treats different types as equal. Values of different types were compared by their text, so 1 == "1" and null == "" were true. As in jq they are now false, and arrays, objects and elements are equal when they hold the same values
  • An if without else passes its input through. It returned null when the condition was false, so .h | .text | if . == "A" then "first" end printed a blank line for every other heading. It now returns the input unchanged, as jq does
  • nth with an index out of range returns nothing. An index further back than the start gave the first element, and nth(-1e300) overflowed, which crashed a debug build
  • not works as a pipe stage. .draft | not was a parse error, because not always expected something after it to negate. It now negates its input, as in jq, and not .draft still works

Security

  • rustls 0.23.46, for four advisories published on 2026-10-14, RUSTSEC-2026-0346 to -0349. treemd uses rustls only to fetch a document by URL or github:
  • A document fetched from the web reaches nothing on this machine. Its relative links and image paths were resolved against the local filesystem, so a page could display a local image by its absolute path, or open, or offer to create, a file in the working directory when a link was followed. Those links and images name places on its own site; they are not followed or loaded now
  • A piped document stays in the working directory. curl ... | treemd could show any image on disk by its absolute path, and its links reached anywhere in the working directory's git repository. Its images and links now resolve inside the working directory only. security.trust_piped_documents = true in the config restores the old behavior
  • A link to a non-markdown file is held to the same boundary as any other link. A markdown link refused absolute paths and .., but a link to an existing file without a markdown extension skipped those checks and opened in $EDITOR, so [x](/etc/hosts) or [x](../secret.txt) in a document opened that file. Every file link now resolves through one check
  • Creating a linked file cannot escape through a symlinked directory. The boundary was only checked once the file existed, so following [x](docs/new.md) with docs a symlink out of the repository, and confirming the create prompt, wrote the file wherever docs pointed. The deepest existing part of the path is now resolved and checked before the prompt, for markdown links and wikilinks alike
  • Only web links are handed to the system opener in link-follow mode. Interactive mode already refused other schemes; link-follow mode passed any external link on, including a mailto: carrying attach=. Other schemes are copied to the clipboard instead

Added

  • Nine themes, light ones included: One Dark, Rose Pine, Kanagawa, Everforest, GitHub Dark, Catppuccin Latte, Solarized Light, GitHub Light, and Terminal, which uses the terminal's own background and 16 colors. Every theme's text is held to WCAG contrast against what it is drawn on, in 24-bit and 256 colors, by a test. The theme picker shows a swatch of each
  • A monochrome mode for NO_COLOR. With NO_COLOR set, or --color-mode none, treemd draws in the terminal's own colors and marks the selection, chips and the current match in reverse video and other matches with underline. An explicit color mode still wins
  • The mouse works on the pane under it. The wheel scrolls the text or moves through the outline, whichever the pointer is over, rather than whichever has the focus; a click selects a heading, a second click folds it, and a click on the text focuses it
  • Ctrl+d, Ctrl+u, Ctrl+f and Ctrl+b page, as in vim and less
  • Ctrl+e / Ctrl+y scroll the content a line without moving the outline selection, as in vim. The actions existed with no key bound to them
  • -e / --exit-status makes a query that matches nothing exit 1, so a script can tell an empty result from a match. Without it an empty result still exits 0, as jq does
  • Ctrl+c quits, asking first when there are unsaved edits. Raw mode made it an ordinary key, bound to nothing
  • Ctrl+z suspends to the shell, and fg brings treemd back as it was. Raw mode made it an ordinary key as well. In interactive mode it is still undo
  • The image viewer's keys can be rebound, under [keybindings.ImageViewer], with the actions PreviousFrame, NextFrame and TogglePlayback. They were wired into the event loop past the keymap, which is also why Ctrl+c did nothing in the viewer; it closes it now, as Esc and q do
  • GitHub's heading anchors, as an option. slug_style = "github" under [content] makes the anchor Y copies, the id and slug in -l -o json, and .slug and slugify in queries the way github.com does: _ is kept, and a repeated heading is numbered, so the second ## Setup is setup-1. treemd's own anchors stay the default, under which repeated headings share an id
  • Keys with a dash or a space can be read: .fm["draft-of"], .fm."draft-of" and .["key"]. .fm["key"] matched nothing, and the other two were parse errors
  • A bare bracket filter runs to the ], so .code[c++], .code[c#], .code[objective-c] and .h[Getting Started] work unquoted. The first was a parse error, # an unexpected character, and in .h[-d] the - was dropped
  • .children and .parent on a heading. Both were documented and reported "Property not found". .children is the array of headings directly under it, the same as .h1 > .h, and .parent is the nearest heading above it at a shallower level, or null

Changed

  • A cleaner screen. The title bar is one row. Panes are rounded, the focused pane's title is a chip, so focus shows in shape as well as color, and the text has a column of space each side. Scrollbars are a thumb on the border and only appear when something overflows. The search bar is one row. The status bar shows the mode, a breadcrumb of the selected heading, unsaved edits, the todo filter and the position, and no longer the theme and outline width. Popups share one rounded frame, the pickers are lists that keep the selection in view, and the link and file pickers are sized to what they hold
  • q closes help, the theme picker and the file picker, as it closes a pager, instead of quitting. Ctrl+c does what q does
  • Code blocks follow the theme. code_theme defaults to auto, so a light theme gets light code blocks; naming a code theme still pins it
  • The file picker opens in the open file's folder, not the working directory. The help lists every key, two per action, and the palette lists reading commands before editing ones
  • Image protocol detection is left to ratatui-image. When the terminal did not answer the capability probe, treemd guessed a protocol from environment variables, Kitty from KITTY_WINDOW_ID included, which stays set in a tmux session started inside Kitty and was taken without the font size an image needs to be placed in its cells, so images could be drawn outside them. ratatui-image already falls back from the environment where it is safe to. The iTerm2 override, which exists because iTerm2 answers the Kitty probe it cannot fully render, is no longer applied inside tmux, zellij or screen, and neither is native iTerm2 GIF playback
  • Links can lead anywhere in the document's repository. A link containing .. was refused, so docs/guide.md could not follow ../README.md back, and following a link into a subdirectory moved the boundary down with it. Links now resolve within the git work tree the document is in, or the directory treemd was opened on when there is none. A link starting with / is relative to that root, the way GitHub reads it, and a symlink is followed if it points inside it. Opening a file from the file picker sets the root anew
  • A nested list is its own .list entry. It used to be text in its parent item's .content, so - outer holding - inner reported one list whose item read outer plus the inner line. It now reports two lists of one item each. Anything reading .content for nested items needs updating
  • turbovault-parser 2.1.0 to 4.0.1. The 3.0.0 and 4.0.0 majors are the MCP server's tool surface and do not change the parser API treemd uses. Nested lists are real blocks since 2.2.0 (turbovault#79), with the quoted continuation fix above. Since 3.3.0 a heading's image is no longer part of its text, so # Title ![a](a.png) is Title in the outline, --list, .h1 and its anchor, where it read Title a (turbovault#78); 3.3.0 also makes deeply nested lists linear to parse. 4.0.1 (turbovault#120) takes a code block's language from the first word of its info string, so a rustdoc ```rust,no_run block is highlighted as Rust and found by .code[rust]; closes an empty fenced block, which swallowed the paragraph after it; and keeps a table's cells inside a blockquote, where they came out empty
  • ratatui-image 11.0.8 to 11.1.0, and every other dependency to its latest compatible release. That moves wayland-scanner off quick-xml 0.39, so RUSTSEC-2026-0194 and RUSTSEC-2026-0195 no longer apply and the audit runs with no ignores
  • GIFs in the Kitty graphics protocol are animated by treemd rather than by the terminal. The native path sent every frame to replace frame one rather than add a frame, and sized the image from its screen position, so it only ever showed a stretched last frame; it also needed every frame decoded in memory up front. Playback now goes through ratatui-image like every other terminal. iTerm2 still animates the original file natively
  • A small mermaid diagram is enlarged at most 2x instead of being stretched to fill the pane, so a two-node diagram no longer takes up dozens of rows
  • Query errors exit 3 or 5, as in jq. A query that does not parse exits 3, and one that fails while running exits 5. Both exited 1, which -e also uses for no results, so a script could not tell a typo from an empty match. An unknown --query-output format is a usage error, exit 2
  • Arithmetic follows jq. An operator its operands do not support is an error: "a" - 1, {} + 1 and -"a" gave null, or for + the two values' text run together (.text + 1 was Intro1), so a typo passed as a result. + now also merges objects and treats null as nothing to add, - removes items from an array, / splits a string, and % works on whole numbers (5.9 % 3 is 2). tostring turns a number into text for joining: .text + " " + (.level | tostring)
  • A slice of an array is an array, and a missing key is null, as in jq. .fm.tags[1:] was a stream of the tags, so | count counted 1; an index past the end of an array gave nothing rather than null; and a key the front matter does not have was an error, so .fm.draft // false failed on a page without draft. An element's properties are fixed, so .h | .txt is still an error. A slice of selector results, such as .h2[0:3], is still a stream, and a string now slices by character
  • not .level == 2 means not (.level == 2). The prefix not bound tightest, so it read as (not .level) == 2, which is never true
  • .h | count > 3 gives one answer. A stage that starts with a collection function works on the whole stream, as the bare function does; it ran once per value and printed false for each heading. null | length is 0, as in jq, not 1
  • --query-output json and json-pretty always print an array. One result printed as a bare value and several as an array, so .link and .link[0] produced different shapes and a script had to guess which it would get. Every result set is now an array, [] when there are no results. Anything that read a single result as a bare object needs to take the first element. jsonl is unchanged, one value per line

Documentation

  • The README is rewritten. It opens with a screenshot of the current UI and a gallery of themes, both rendered by the test suite, in place of a 4.3 MB recording of the old one. It lists every key the TUI binds, Ctrl+l, Ctrl+z undo, link search and the file picker's keys among those it missed, and every config setting with its default. Callouts were listed as planned though they ship, / searches the current section rather than the whole document, tree_style defaults to compact and only affects --tree, and on macOS ~/.config/treemd/config.toml is used when it exists. The roadmap and the comparison table, both out of date, are gone
  • --help keeps its lists as written. clap joined the example lines and value lists into one run-on line. --color-mode lists none, --query-output names json-pretty, and -o describes its values once
  • --query-help covers the whole language. It listed any and all as taking no argument, called .h2["Installation"] an exact match when it ignores case, and left out .para, .frontmatter, keys, values, has, type, empty, the operators, //, conditionals and object construction
  • The query guide describes the language that ships. It claimed to be verified against 0.7.0, listed a REPL and shell completion for query syntax as done, documented .content as both including and excluding subsections (it includes them), and gave a grammar in which a comma bound tighter than a pipe and a regex production that does not exist. The grammar now matches the parser, and the guide covers streams and collection functions, selectors after a pipe, operators and the JSON array output
  • docs/QUERY_IMPLEMENTATION.md is removed. It was the design written before the query engine existed and described a different one, built on pulldown-cmark, logos and rustyline with a --repl flag. Nothing linked to it. The query guide and src/query are the reference
  • The agent skill describes -s, --filter and position.line correctly. It said -s needs an exact match (it ignores case), called --filter fuzzy (it is a case-insensitive substring), and said -o json's position.line carries the same information as -n (it is the line the section body starts on, below the heading). Its jq examples read JSON query output as a bare object; they take the first element of the array now, and its tql reference matches --query-help. It also listed eight of the themes, promised a multi-file picker for treemd *.md, and said -o is ignored beside -s or -q, where it is a usage error
  • The library docs show only the parser and the query language. The config, input, keybindings and tui modules, and the App and Config re-exports, exist for the treemd binary but were documented as library API. They remain public, so nothing that uses them breaks, but are hidden from docs.rs and may change in any release

Internal

  • app.rs is split by concern, and what has the keyboard is decided in one place. The 7,800-line file is now src/tui/app/, with key dispatch, the outline, search, the palette, link navigation, the file picker, editing, interactive mode and images each in a module of their own. Help and the theme picker are one overlay, the outline search is one OutlineSearch state instead of two flags, and typing, pasting and Backspace all ask the keybinding mode which field they go to, rather than each checking the flags again
  • Property tests over generated input. tests/properties.rs parses generated documents built from the constructs that have broken before and runs generated queries against them, neither of which may panic. The content pane's wrapping is held to never exceeding the pane or losing text, and a table edit to changing one line and undoing exactly. That last one found the pipe-less header bug above. PROPTEST_CASES sets how long they search
  • The integration tests clean up after themselves. Each run left a temporary directory per test behind
  • Screenshots of every screen in every theme. just screenshots renders eight screens in each theme, in monochrome and in a 60-column terminal as SVGs, to review a UI change across all of them. just readme-images makes the README's images from them
  • The Linux glibc binaries run on glibc 2.28 and newer. They were built on the newest Ubuntu runner and needed its glibc, 2.39, so the 0.9.1 downloads failed with "GLIBC_2.39 not found" on Debian 12, Ubuntu 22.04 and RHEL 9. Both are now built with cargo-zigbuild against glibc 2.28, a release fails if either uses a newer glibc symbol, and the x86_64 one is run in Debian 10 before it ships
  • A release cannot ship with a target missing. Packaging checks that all seven binaries are there, and build artifacts are kept for a week rather than a day, so one target can be rebuilt later without the others expiring. Every job has a time limit, and the release packaging also runs on every pull request, twice, to show it works and gives the same bytes
  • just release-preflight checks that the commit being tagged is origin/main, so a tag cannot carry unpushed local commits, and just release-tag says when to publish to crates.io: after the release workflow passes
  • Every GitHub Action is pinned to a commit SHA. A tag such as @v7 can be moved to different code after the fact, and the release workflow runs with write access and the signing secrets. Dependabot keeps the pins and their version comments current. cross is installed with --locked too
  • cargo-deny replaces cargo-audit in CI. It reads the same RustSec advisories, with no ignores, and also holds dependencies to an allow-list of licenses, to crates.io as the only source, and to no * version requirements. deny.toml has the rules and just deny runs them locally
  • Every pull request builds the musl binary and runs it on Alpine. It was only built at release time, after the tag was pushed, so a musl breakage like #42 could only be found in a release. scripts/smoke-test.sh checks that a binary starts, reports its version and lists a document's headings exactly
  • A release builds nothing until CI passes on the tagged commit. A tag push used to build and publish straight away, whatever state the commit was in, and a tag cannot be re-cut. The release workflow now runs the full CI suite on that commit first, and its preflight and just release-preflight both run cargo publish --dry-run --locked
  • A tagged release refuses to ship an unsigned or unnotarized macOS binary. A failed certificate import used to fall back to ad-hoc signing and publish anyway, and a notarization Apple rejected still passed because notarytool exits 0 either way. On a tag both now fail the run; ad-hoc signing is left for manual runs without the secrets. The secrets reach the script through env: rather than being pasted into it
  • Each release binary is run before it ships. --version and a heading listing run on Linux x86_64, macOS (x86_64 under Rosetta when the runner has it), Windows, and for x86_64 musl inside an Alpine container. The aarch64 Linux builds are cross-compiled on x86_64 runners and are not run
  • Release archives are reproducible and include the man page and license. Every archive is now assembled in one job by scripts/package-release.sh, with timestamps from the release commit, fixed owners, order and modes, and gzip -n, rather than by each runner's own tar or Compress-Archive. The .tar.gz archives gain treemd.1 and every archive gains LICENSE; the binary keeps its name and place. SHA256SUMS is generated with errors failing the run, where || true used to hide them. treemd only has dynamic shell completions, which call the installed binary at completion time, so there are no static completion files to ship
  • Release archives carry signed build provenance. Each archive in SHA256SUMS gets a GitHub artifact attestation naming the workflow, commit and tag it was built from, checkable with gh attestation verify. Only the packaging job can request the signing token
  • scripts/build-all.sh is removed. It had drifted from the release workflow, never built the musl targets, could not build the macOS and Windows targets from a single host, and piped every build through head -20, which hid failures. The release workflow is the one reference for building each target

What's Changed

Full Changelog: v0.9.1...v0.10.0