Repository navigation
[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
Previouswrapped in a column besideNextwith 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:
fin a section with no links, which opened an empty list;'with no bookmark;bandFwith no history;nandNwith 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, andEscclears 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
--themeis 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 9was accepted though there is no level 9;-qwith-lor-ssilently ignored the other flag;-l docs/printed a made-up# Select a fileheading; 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-lineand-q, where all but one used to be ignored, and for--filter,-L,-nand-owithout-lor--tree, which were ignored while the TUI opened.-l -o treeprints the tree, as-oalways 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 uppercaseHTTP://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-completionsfinds the right file and checks for treemd's own line. It took anyCOMPLETE=line and any mention of treemd for completions already set up, ignored$ZDOTDIRfor zsh and$XDG_CONFIG_HOMEfor fish, and failed when fish's config directory did not exist yet. On macOS,$XDG_CONFIG_HOMEis also honored for treemd's own configtreemd doc.md > outprints the document. With no terminal to draw on, the TUI wrote its escape sequences into the file or pipe. It now prints the document, asbatandlessdogithub:owner/repofinds the README whatever it is named. It fetchedREADME.mdat the root, so a repository withreadme.md,README.markdownordocs/README.mdfailed to open. GitHub is asked for the repository's README, falling back toREADME.mdpast 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/zeroused 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 | treemddrew 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'suse-dev-ttyfeature reads it withpollinstead - 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,
qincluded, 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-passthroughon, 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
ehad 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, asgitdoes - 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,dandudid nothing there and now page through the section. In help,gandGmoved 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
qdoes, 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,uandqdid nothing, and the mouse wheel jumped between matches. They scroll and quit now, with the matches still highlighted andnandNstill 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
\rends 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:-llisted# 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
### Addedin 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-1now reaches the secondAdded, 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 istarts 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 yin 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 sectionpin 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
efrom 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,jmoved 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.
> - stepfollowed by an indentedtextreportedsteptextas the item. The item isstepnow andtextis 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
.listreports 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 … | treemdandtree | treemdignored 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 aSelect a fileheading, and from any other directory treemd printed its usage and exited. Onlytreemd -worked. This came in with the startup file picker in 0.5.5, which checked the directory before checking stdin - Queries and
-o jsonare 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
headno longer panics.treemd -l big.md | head -1printed "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 plainhttp://. 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
# Inputheading, 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,-qand-s. The BOM is kept in the content, so a checkbox toggled in the TUI still writes it back - Heading line numbers agree everywhere.
.linein 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 jsonsection 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_linenumbered blocks from the section's first line, so it disagreed with.code | .start_linefor the same block -Land--filterapply together.-l -L 2 --filter apilisted every level-2 heading.-o jsonis the whole document and ignores both, and now says so on stderr instead of silently printing everything--query-output jsonpis accepted, as the help sayslangsandtypeslist 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;…orESC [2Jretitled the window or cleared the screen when listed with-l,--tree,-sor-q. They now print the waycat -vshows them,^[]0;…. Piped output is unchanged, byte for byte - Whole numbers print as integers in JSON. A count was
1.0where an element's own fields print1. A large number in plain output printed as9223372036854775807rather 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
0644note became0600after 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İstanbulpanicked, 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_latexis 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 $10and$HOME and $PATHas math and dropped their dollar signs, and stripped backslashed words such asC:\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.
eopened 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
## Tasksit jumped to the first, and the next toggle changed that one eno 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
Inputheading 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 \| ycame back asx | yand 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 .namereads a key when the input has one, even if it is also an element name. Element selectors took priority everywhere, so{p: 1} | .pprinted every paragraph,.h | {a: .text} | .aprinted every link, and.fm | .imageprinted the document's images rather than the front matter'simage. A key or property on the input now wins; on the document the selector still does, so.h2and. | .codeare unchanged. The document'sheading_countis 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] | .codereturned 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..frontmatterbelongs to the document and is the same from anywhere >and>>scope tables, lists, images, paragraphs and blockquotes..h2[Install] > .tablereturned 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"),. + 1and.[]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 | countprinted1once per heading, andsort_by,group_by,join,unique,first,last,nth,limit,skip,reverse,min,max,add,any,allandlevelsall 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] | countis0. Ordering functions hand back a stream, so.h | sort_by(.level) | .textworks.levels,langsandtypescount what they are given rather than the whole document, so.h2 | levelsis{"h2": 3}. A single value is unaffected:.h1[0].text | countstill counts characters, and[.h] | countstill works, but.h1.text | countnow counts the h1s group_by("level")andsort_by("level")use the named property. The string was evaluated as a value, the same for every element, sogroup_byput everything in one group andsort_bychanged 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]} | textprinted the heading with its byte offset and its whole section body and notype, from a second serializer that--query-output jsondid 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 othersortorders numbers as numbers, anduniquekeeps distinct values. Both compared values as text, so[10, 9, 1] | sortgave1 10 9and[.h] | uniquemerged two headings that shared a title.sortandsort_bynow use jq's order (null, booleans, numbers, strings, arrays, objects), anduniquedrops 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
_pipeand_indexare 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, andselect(.text >= 5)kept every heading. They now use the ordersortalready used, null, booleans, numbers, strings, arrays, objects, so1 < "a"is true as in jq-l -o jsonreports 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'sstart_lineandend_linewas one short, and more after a setext heading or several blank lines.-qwas already right- A lone carriage return no longer crashes
-l -o jsonor-q. A setext underline ended by a lone\rsent 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,mdand the JSONraw .fmis only the frontmatter that opens the document. Akey: valueblock 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
.codeand.linkare linear. Each heading counted lines from the top of the document and scanned every block, so.h1 >> .codetook 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, so1 == "1"andnull == ""were true. As in jq they are now false, and arrays, objects and elements are equal when they hold the same values- An
ifwithoutelsepasses its input through. It returned null when the condition was false, so.h | .text | if . == "A" then "first" endprinted a blank line for every other heading. It now returns the input unchanged, as jq does nthwith an index out of range returns nothing. An index further back than the start gave the first element, andnth(-1e300)overflowed, which crashed a debug buildnotworks as a pipe stage..draft | notwas a parse error, becausenotalways expected something after it to negate. It now negates its input, as in jq, andnot .draftstill 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 ... | treemdcould 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 = truein 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)withdocsa symlink out of the repository, and confirming the create prompt, wrote the file whereverdocspointed. 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:carryingattach=. 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. WithNO_COLORset, 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+fandCtrl+bpage, as in vim and lessCtrl+e/Ctrl+yscroll the content a line without moving the outline selection, as in vim. The actions existed with no key bound to them-e/--exit-statusmakes 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 doesCtrl+cquits, asking first when there are unsaved edits. Raw mode made it an ordinary key, bound to nothingCtrl+zsuspends to the shell, andfgbrings 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 actionsPreviousFrame,NextFrameandTogglePlayback. They were wired into the event loop past the keymap, which is also whyCtrl+cdid nothing in the viewer; it closes it now, asEscandqdo - GitHub's heading anchors, as an option.
slug_style = "github"under[content]makes the anchorYcopies, theidandslugin-l -o json, and.slugandslugifyin queries the way github.com does:_is kept, and a repeated heading is numbered, so the second## Setupissetup-1. treemd's own anchors stay the default, under which repeated headings share anid - 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 .childrenand.parenton a heading. Both were documented and reported "Property not found"..childrenis the array of headings directly under it, the same as.h1 > .h, and.parentis the nearest heading above it at a shallower level, ornull
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
qcloses help, the theme picker and the file picker, as it closes a pager, instead of quitting.Ctrl+cdoes whatqdoes- Code blocks follow the theme.
code_themedefaults toauto, 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_IDincluded, 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, sodocs/guide.mdcould not follow../README.mdback, 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
.listentry. It used to be text in its parent item's.content, so- outerholding- innerreported one list whose item readouterplus the inner line. It now reports two lists of one item each. Anything reading.contentfor 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 isTitlein the outline,--list,.h1and its anchor, where it readTitle 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_runblock 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
-ealso uses for no results, so a script could not tell a typo from an empty match. An unknown--query-outputformat is a usage error, exit 2 - Arithmetic follows jq. An operator its operands do not support is an error:
"a" - 1,{} + 1and-"a"gavenull, or for+the two values' text run together (.text + 1wasIntro1), so a typo passed as a result.+now also merges objects and treatsnullas nothing to add,-removes items from an array,/splits a string, and%works on whole numbers (5.9 % 3is2).tostringturns 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| countcounted 1; an index past the end of an array gave nothing rather thannull; and a key the front matter does not have was an error, so.fm.draft // falsefailed on a page withoutdraft. An element's properties are fixed, so.h | .txtis 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 == 2meansnot (.level == 2). The prefixnotbound tightest, so it read as(not .level) == 2, which is never true.h | count > 3gives 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 printedfalsefor each heading.null | lengthis 0, as in jq, not 1--query-output jsonandjson-prettyalways print an array. One result printed as a bare value and several as an array, so.linkand.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.jsonlis 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+zundo, 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_styledefaults tocompactand only affects--tree, and on macOS~/.config/treemd/config.tomlis used when it exists. The roadmap and the comparison table, both out of date, are gone --helpkeeps its lists as written. clap joined the example lines and value lists into one run-on line.--color-modelistsnone,--query-outputnamesjson-pretty, and-odescribes its values once--query-helpcovers the whole language. It listedanyandallas 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
.contentas both including and excluding subsections (it includes them), and gave a grammar in which a comma bound tighter than a pipe and aregexproduction 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.mdis 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--replflag. Nothing linked to it. The query guide andsrc/queryare the reference- The agent skill describes
-s,--filterandposition.linecorrectly. It said-sneeds an exact match (it ignores case), called--filterfuzzy (it is a case-insensitive substring), and said-o json'sposition.linecarries the same information as-n(it is the line the section body starts on, below the heading). Itsjqexamples 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 fortreemd *.md, and said-ois ignored beside-sor-q, where it is a usage error - The library docs show only the parser and the query language. The
config,input,keybindingsandtuimodules, and theAppandConfigre-exports, exist for thetreemdbinary 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.rsis split by concern, and what has the keyboard is decided in one place. The 7,800-line file is nowsrc/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 oneoverlay, the outline search is oneOutlineSearchstate 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.rsparses 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_CASESsets 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 screenshotsrenders 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-imagesmakes 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-preflightchecks that the commit being tagged isorigin/main, so a tag cannot carry unpushed local commits, andjust release-tagsays when to publish to crates.io: after the release workflow passes- Every GitHub Action is pinned to a commit SHA. A tag such as
@v7can 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.crossis installed with--lockedtoo - 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.tomlhas the rules andjust denyruns 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.shchecks 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-preflightboth runcargo 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
notarytoolexits 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 throughenv:rather than being pasted into it - Each release binary is run before it ships.
--versionand 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, andgzip -n, rather than by each runner's owntarorCompress-Archive. The.tar.gzarchives gaintreemd.1and every archive gainsLICENSE; the binary keeps its name and place.SHA256SUMSis generated with errors failing the run, where|| trueused 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
SHA256SUMSgets a GitHub artifact attestation naming the workflow, commit and tag it was built from, checkable withgh attestation verify. Only the packaging job can request the signing token scripts/build-all.shis 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 throughhead -20, which hid failures. The release workflow is the one reference for building each target
What's Changed
- chore(deps): bump the cargo-patch group across 1 directory with 5 updates by @dependabot[bot] in #90
- Audit hardening: dependencies, TUI correctness and speed, query fixes, CI and release by @nicholasjpaterno in #95
- release: 0.10.0 by @nicholasjpaterno in #96
Full Changelog: v0.9.1...v0.10.0