v0.6.0 - 2026-10-19
Added
-
The file tree opens text files in a viewer pane instead of the desktop. Double-clicking a supported file (source, config, docs,
md/markdown,txt/log/diff/patch, and the extensionlessMakefile/Dockerfile/LICENSE/Justfile) now asks the workspace for a read-onlycode_viewerpane carrying that path; opening the same file again focuses the pane that already shows it instead of duplicating it, and a new Reload button re-reads it after a change on disk (an open never re-reads, because a viewer’s scroll, selection and rendered/source mode belong to its document). Everything the closedViewableFilestable does not name keeps the existing confirmation-then-system path, so binaries, images and a downloaded.desktopbehave exactly as before, and the per-tree Open files: Viewer pane | System application setting (default Viewer) can leave even supported files on the system route. Panes still never spawn panes: the file tree emitsPaneBody.request_pane, and the workspace - the only spawner - attaches and activates the result. -
Agents can search and read the knowledge base through the pane API.
gpty vault search <vault> <pattern>andgpty vault read <vault> <path>- mirrored as thevault-search/vault-readMCP tools - expose the wiki’s vault to anything driving the control socket, with nothing new to configure: the vault’s absolute directory is the only handle. Search re-indexes first, so a note edited outside gPTY (Obsidian, an editor, another agent) is visible to the next query rather than waiting for a pane to reopen the vault; it answers{results: [{path, title, snippet}], total, truncated}, wheretruncatedsays the 2000-note walk cap made the index partial. Read answers the note document{path, text, truncated, crlf, mtime, size}under the note editor’s own rules - the vault-relative.mdvocabulary, the regular-file gate, and the 1 MiB display cut - and neither method writes to the vault. Both are answered natively in Rust on a blocking pool rather than through the GDScript dispatcher: a vault is a directory rather than workspace state, and a walk plus SQLite query must not stall the frame that polls the pane API. A refusal is a JSON-RPC error carrying the stableNoteErrorcode (not_found,bad_path,outside_vault, …) indata.code. The commands are queries and never auto-spawn a GUI. -
The wiki pane draws the vault’s link graph. A new Graph button shows the vault’s own structure as a canvas - nodes are the indexed notes plus a ghost for every unresolved
[[target]], edges are their mentions aggregated per note pair (a mutual pair is one edge and is drawn double-headed when Arrows is on) - with logarithmic, clamped thickness so one chatty note cannot drown the drawing, and pan/zoom/drag plus a deterministic layout a Relayout button restores. Nothing is stored and nothing is written: the graph is queried fromvaults.dbon every visit (every note mutation already re-indexes), positions live only in the canvas for the session, so a drag survives a re-query, clicking a note opens it behind the same Save/Discard/Cancel ask as the list, and clicking a ghost runs the preview’s own mention resolution - offer to create a missing note, report an ambiguous stem, never guess. The query caps itself at 10,000 edges per class and 2,000 ghosts and says “truncated” rather than drawing part of a vault as if it were all of it. -
The link graph accepts declared weights. Measured weight is only mentions - two notes can be deeply related and name each other once, or name-drop ten times and share nothing - so an edge’s weight can now be declared: right-click an edge in the graph canvas, or use
gpty vault weight <vault> <src> <target> --weight N(mirrored as thevault-weightMCP tool), to set or clear one number between 1 and 100. A declaration overrides the measured count for edge thickness, the Relayout springs and link-panel ordering - display only, never which edges exist, what a click reaches or any trust decision - and lives in the user-owneduser://link_weights.json, not the rebuildablevaults.db: a schema bump that re-indexes the vault cannot erase authored work. The canvas sets both directions of the pair it draws, an agent writes exactly one directed pair, and declarations whose note left the vault are pruned lazily when a complete graph is shown (never from a truncated answer). A renamed note’s declarations are not re-keyed yet - that is a queued item - so renaming a curated note currently loses its weights on the next graph view. -
Wikilinks are indexed (the data half).
[[note]]mentions are now parsed as the index pass reads each note and stored as edges invaults.db: one row per occurrence, with the line it was written on, resolved against the note set by stem - or by path when the target contains/- with exact case first, then a unique case-insensitive match, and a stem two notes share never silently picked. A fenced block, an inline code span or a backslash-escaped opener stays literal text, so a note documenting the syntax creates no edges. The edges are maintained rather than derived on the fly: an edited note rewrites its own, and any pass that adds or removes a note re-derives the vault’s - a stub that appears resolves the links that pointed at it, and a vanished target leaves its occurrence behind as unresolved. The store schema moves touser_version=2, which rebuilds the derived tables instead of migrating them (the vault is the source of truth; the next pass re-reads it).GptyTerminal.vault_links(vault, note)answers a note’s outgoing links, its backlinks (the same edges read in reverse) and its unresolved targets, with the candidates an ambiguous stem could not choose between. The pane’s view of these edges - the link panel and following in place - is the pane half below. -
The wiki pane creates, renames, and deletes notes. The editor used to only touch notes that already existed; now a New note button on the list (default
Untitled.md, a bare name gains.md) asks for a path and opens the empty note, Rename and Delete sit on the open note, and an unresolved[[mention]]- or its row in the link panel - offers to create exactly the note it names. An ambiguous stem never gets one: it stays a report, because creating either candidate would be a guess. The verbs are the vault module’s, held to the save’s rules: the path follows the walk’s vocabulary, a create or rename may only target a directory that already exists inside the vault (never one symlinked out), and the destination is claimed withcreate_new, so an existing note answersexistsand is never replaced - a collision re-opens the same question with the typed name. Rename moves the vault entry (a symlinked note moves as a link, its target untouched) and preserves the file’s mtime/size, so the open buffer and its save stamp stay valid; a dirty buffer’s recovery copy is re-keyed to the new path, and a confirmed Delete (which warns about unsaved work) drops it. Every verb re-indexes, so a new note is searchable at once and a moved one stops satisfying links that named its old stem - the link table’s ownremoved/addedre-resolution answers that, with no second bookkeeping. -
Wikilinks are followed in the wiki pane (the pane half). Mentions now render in the preview -
[[target]],[[target|alias]]and[[target#heading]]- with the alias as the label and the heading kept as text, and clicking one follows it in place, behind the same Save/Discard/Cancel ask a Back makes (no history stack: the notes list is the way back). The occurrence set is the index’s own: the renderer reuses the parser that writes thelinkstable, so a fenced block, an inline code span and an escaped opener stay literal, and each accepted mention is replaced through a sentinel before Markdown parsing, so emphasis, headings and tables around it are untouched and the label is never re-parsed. The destination comes fromGptyTerminal.vault_link_resolve- the same resolution rules the index used, over the same note set - so a stem two notes share answers with its candidates instead of silently picking one, and a mention typed but not yet saved follows too. A new link panel under the note lists each outgoing occurrence with the line it was written on (target · line 3), each backlink the same way (src · line 3), and marks an unresolved target with the candidates an ambiguous stem could not choose between; resolved and backlink rows open their note in place, a missing target offers to create the note it names, an ambiguous one stays a report (creating either candidate would be a guess), and the panel is capped at 50 rows per section with the rest counted. Mentions are a wiki-pane vocabulary:GptyMarkdown.rendertakes awikilinksflag that every other caller leaves off - Inspector, Reasoning and code previews keep literal[[…]]- only the renderer can emit thegpty-note:scheme (a document’s own Markdown link still admits only http/https/mailto), andMarkdownViewmerely announces the click; it never opens anything. -
Wikilink navigation lands where the link points. A
[[note#heading]]fragment was parsed but only kept as label text, so following a mention always landed at the top of the target note; it now rides the link’s URL (percent-encoded, the literal#the one separator) intonote_link_clicked(target, fragment), and the pane scrolls to that heading once the note renders - exact text first, then case-insensitive, with a pureMarkdownAnchorsscanner reading the source outside frontmatter and fenced code so a note documenting Markdown grows no anchors. The link panel carries the same navigation: an outgoing row shows and follows its fragment, and a backlink row opens the linking note at the mention’s line (derived from the nearest heading above it) instead of stopping at the top.vault_linksderives fragments from the stored body per occurrence, sovaults.dbstill stores edges only. A mention written inside a Markdown link’s label or an image’s alt now yields to that container and renders as plain label text - nested[url]tags left the click target undefined, and the outer link is what the author explicitly wrote. Display only: a fragment says where in the resolved note to scroll, never which note. -
The wiki pane edits notes - live preview, explicit save. A note opens into an editor beside the sanitized Markdown preview (Edit / Split / Preview, default Split), and the preview follows the buffer as you type, so unsaved work is what you see. Saving is an explicit act - the Save button or Ctrl+S - with unsaved changes marked and Save disabled while there are none; leaving a dirty note asks before discarding. The write goes through
GptyTerminal.vault_note_write, the same module that defines what a note is, so an edit can only reach an existing note inside the vault: the path must be one the walk could have listed, the resolved file must stay under the vault root (a symlink out of the vault is refused, one to a sibling note is written through), the replacement is atomic and keeps the note’s own mode, and a file that changed on disk since it was loaded is never overwritten without an explicit confirmation. CRLF endings survive an edit, a note past the 1 MiB read cap stays read-only (saving a truncated read would drop the rest), and every save re-indexes the vault, so the new text is searchable at once. The note read and write are documented in the gdext README; the toolbar names the open file. -
Wiki pane type (v0.6.0 groundwork). A seventh pane type,
wiki, registered across the vocabulary (PaneTypes.ALL, the RustPaneTypeenum, the type→body map) and therefore spawnable from the sidebar, the palette (new wiki),gpty new-pane -t wiki, and layout/profile restore. It opens a vault at an absolute directory path - validated at use exactly likefile_tree’s root, so a file-supplied path is never trusted - and lists the vault’s Markdown notes (recursive, hidden entries skipped, bounded in entries and depth). Activating a note renders it read-only through the sanitized Markdown pipeline; editing, the full-text index, wikilinks, the link graph and declared weights landed with their own items. The note read and the code viewer now share one guard,TextRead.read_prefix(absolute paths only, regular-file gate, 1 MiB cap, torn-UTF8-safe decode) - the security rule has a single definition. -
Vault index: the wiki pane’s notes are indexed with full-text search. A new
gpty_core::vaultstore (SQLite + FTS5, atstate_dir()/vaults.db- never inside the vault, which stays plain.mdfiles the app only reads) is now the one definition of what a note is and what searching one costs. The pane’s list and its new search box both come from it:vault_indexwalks the vault, re-reads only the notes whose mtime or size changed, drops rows for deleted notes and answers the sorted list;vault_searchreturns best-rank hits with title and a body snippet, using the same query sanitizer as scrollback search and porter stemming (notes are prose, unlike terminal lines). Both are staticGptyTerminalmethods answering JSON, witherrorreserved for “the query could not run” so “no match” stays distinguishable from “not indexed”. The store reuses the scrollback engine’s hardening - WAL,user_versiongating, external-content FTS5 with the three triggers, owner-only mode, one process-wide write lock - with its own(vault + relative path)key; a truncated walk never prunes, and index bodies are cut at 1 MiB with the torn UTF-8 tail dropped exactly where the reader cuts, so a hit can never name text the view does not show. -
Unsaved wiki edits survive a crash. The editor holds its buffer in memory and writes only on an explicit save, so until now every keystroke since the last save died with the process - and a SIGKILL, a crash or an outside kill has no close guard to ask about it. A dirty buffer is now snapshotted as a recovery copy outside the vault (
GptyTerminal.vault_scratch_write;state_dir()/scratch, owner-only, atomic, keyed by the vault and the note path), refreshed at most every 1.5 s while the buffer stays dirty - a throttle, not a per-keystroke debounce, because a debounce that restarts on every keystroke never fires during a long typing run - and dropped the moment the buffer reaches the file or the user discards it. Reopening the note asks (Restore / Discard / Escape for “not now”) and never applies anything on its own. The recovered buffer carries the stamp of the revision it was typed against, so a file that changed while gPTY was away still gets the existing conflict question before anything is written, and a copy whose record names another vault or note answers “no copy”, so a stale file can never restore the wrong text.vault_note_writeremains the only write that reaches a vault.
Fixed
-
A note that mentions itself follows its own rename. The “Update references?” pass deliberately skipped the renamed note - its buffer is the editor’s - so a hub or index note whose text named its own old stem kept that one mention unresolved, and create-from-link would offer the old name back. The pane now passes its buffer’s cleanliness to the pass: a clean buffer makes the note’s own mentions candidates along with every other note’s, the preview counts them, and the apply answers the rewritten text and fresh stamp (
renamed_note), which the editor adopts in place - the buffer becomes what landed, stays clean, and a save right after does not see a stale stamp. A dirty buffer is still never written under: the pass excludes its note and updates only the other notes, so the unsaved edit stays exactly as typed.vault_link_rewrite_preview/vault_link_rewritetake the state asinclude_renamed. -
Renaming a note keeps its declared link weights. A declaration is keyed on vault-relative note paths, so a rename used to leave every authored weight naming the old path - and the next complete graph pruned those entries as a vanished note, silently erasing the curation. The wiki pane now re-keys both endpoints immediately after a successful rename (
LinkWeightsStore.rekey_note, one write): declarations authored on the note, pointing at it, or naming it on both sides move with it, unrelated declarations are untouched, and a stale row already stored under the destination (a rename requires that note to be absent) merges with the moved one, which wins each shared target. The re-key runs before the optional “Update references?” question and is independent of its answer - a declaration is not a mention, so declining to respell other notes cannot discard the weights - while a refused or cancelled rename leaves the store exactly as it was. A note moved outside gPTY still looks like a delete to the index and is pruned; that limitation is queued. -
pane-statuswithout a pane now actually reports every pane’s status. The no-argument form is documented as the agent status list, but its--jsonbranch printed the rawlistPanesenvelope - nostatusanywhere - and the default branch built the aggregate in the CLI by querying each pane, which meant the MCPpane-statustool (dispatching straight topaneStatus) could not answer its own no-argument form at all. The aggregate is now the API’s:paneStatuswith no target answers{panes: [{pane_id, label, type, status}]}for every tile, withstatus: nullfor non-terminal panes instead of dropping them, and both output modes render that one document (--jsonin the usual response envelope). The single-pane contract is otherwise unchanged, except that targeting a real non-terminal pane now says it “has no status (not a terminal)” rather than claiming it was not found. A GUT contract test drives the real handler for both shapes, and a CLI roundtrip pins that the no-pane mode sends one empty request rather than fanning out. -
--jsonno longer reports a failed command as success.format_responseprinted the JSON-RPC error envelope and returnedOk, sogpty vault read … missing.md --jsonexited 0 while the same command without the flag exited 1 - every IPC-backed command inherited it, which defeats the machine-readable mode for a script that branches on the exit status and contradicted v0.5.6’sdaemon status --json(shaped JSON and exit 1). The envelope is still printed first, so stdout stays parseable, and the command then fails onresp.errorin both modes; the custom-output commands (list-panes,layout list, the all-panespane-status) share onecheck_errorrule instead of re-deciding it, and the all-panes path now reports the RPC refusal rather than its “no result” symptom. The live smoke asserts both halves - nonzero exit and thenot_foundcode indata.code. The MCP server is unchanged: it builds its own in-band errors. -
Held pane-API responses are no longer cut off at 30 s. The control server wrapped the whole connection - request read, handler, response write - in one 30 s deadline, so a method whose answer is deliberately held died there no matter what it promised:
pane-wait --timeout-ms 60000was unreachable past 30 s (the client read an empty socket while the GUI still held the wait) and the plugin install review could not outlive 30 s either, even though its dialog is designed to close itself at 300 s. The deadline is now split: reading the request and writing the response each keep a 30 s I/O bound (a stalled client still cannot hold a slot), while the handler dispatches under its own registered budget - 30 s by default, and fallback + 5 s for held answers (paneWait75 s,pluginInstall305 s), so the handler’s own fallback answers first and the server budget only frees a wedged connection. An overrun now answers-32000 handler timed outinstead of dropping the socket. Verified live against a headless GUI:gpty pane-wait … --timeout-ms 33000answered{matched: false, timed_out: true}at 33.0 s. -
Pasted text no longer disappears while the shell highlights it. Bash’s readline echoes a bracketed paste inside
ESC[7m/ESC[27m(inverse video), and the renderer swapped only the glyph color - the pasted characters were painted in the cell’s own background color, so a paste read as blank cells with the cursor advancing past them, until the next arrow key made readline repaint the line unhighlighted (measured against bash 5.2 under a real PTY; the same opacity hit any inverse cell, not just a paste).gpty_corenow resolves the swap once, inCellInfo::paint_colors, before the renderer-facing cache is packed, so the pane draws the colors it is handed -fgis always the glyph color andbgalways its background - and pays no per-cell branch for it.godot/tests/integration/test_inverse_paint.gddrives a real child’sESC[7moutput into the pane’s cache and fails on the raw-color packer;inverse_cells_expose_swapped_paint_colorspins the rule. -
Renaming a note can now bring its mentions along. A rename moved the file but not the
[[mentions]]that named it by its old stem or path, so they silently stopped resolving - the referring notes were untouched, the link panel showed the target as missing, and create-from-link would have offered to recreate the old name. The pane now offers the update as its own step after a rename, with the count (“5 mention(s) in 1 note(s)”), and writes nothing unless you answer Update: only the occurrences that actually resolved to the old name are respelled - a stem two notes shared was ambiguous and stays untouched - while alias and#fragment are preserved, CRLF and every other byte stay as they were, and fenced code, code spans and escaped openers are not links to begin with. The renamed note’s own buffer is never rewritten, a new name a[[link]]cannot spell refuses the pass instead of writing something that parses as different syntax, and a note over the read cap is skipped rather than truncated to its prefix. Every write is the save path’s (containment, atomic replacement, a stale-stamp refusal), the apply re-plans in the same call so an edit made while the question was open is read fresh, and the re-index makes the new backlinks show up in the panel at once.gpty_core::links::retargetandgpty_core::vault’spreview_link_rewrite/rewrite_links_after_renamecarry the rules, with five core unit tests (styles, ambiguity, CRLF, unspellable names, the read cap) and two pane tests (offer/apply, decline). -
The GUT gate no longer collides with a running workspace - and no longer writes into your real user data.
scripts/gut-checknow runs the suite on a private endpoint and profile: one 0700 temp directory (removed by the trap) thatXDG_RUNTIME_DIR,XDG_DATA_HOMEandXDG_STATE_HOMEpoint at, withGPTY_SOCKETaimed into it andGPTY_SECRETcleared.GPTY_SOCKETmoves both listeners at once, because the event socket’s path is derived from the control one - a pipe name on Windows - so the old failure is gone on every platform: the suite spawns real panes, one instance owns each endpoint, and a developer’s open workspace used to make every pane-spawning test refuse its bind and quit the test instance mid-suite, ending the run with no Run Summary (which the gate correctly reports as untrustworthy). Verified against that exact setup - a live GUI holding/run/user/1000/gpty{,-events}.sock-gut-check: OK - 50 scripts, 560 tests, all passed, and./scripts/ci-checkpassed end to end with no hand-set environment; the running GUI kept answering the CLI, and the developer’s real data and state directories were left untouched (a full run adds no probe vault and does not touchvaults.db). Windows needed a different lever, because Godot resolvesuser://through the Known Folder API and no environment variable redirects it: the gate now also exportsGPTY_TEST_USER_DIR, the one rootBasePersistenceManagerresolves every store through and the GDExtension resolveshistory.dbthrough, so stores, scrollback and test-created paths all land in the same temp directory;LOCALAPPDATA/APPDATAare sandboxed for the Rust state/data directories; Godot’s file logging is off by default, so GUT writes no engine log (the smoke, which needs a failure-time engine log, passes--log-file). The live smoke uses that seam too and no longer backs up or restores the real concept and plugin stores. Tests that need a path useTestPaths.user_path, and the suite runs the seam on Linux as well, so the Windows path cannot drift unnoticed. -
The wiki pane’s index opens on a fresh Windows profile.
state_dir()on Windows returned%LOCALAPPDATA%\gptywithout creating it, while the Unix arm had always created its directory - sovaults.dbopened on Linux and macOS, but the first vault index on Windows failed withunable to open database file, leaving the note list empty. The live smoke hid it because it seeds a plugin record into that directory before launching the GUI.local_app_data()now creates the directory on first use, the same contract as the Unixprivate_state_dir(pinned bylocal_app_dir_is_created_on_first_use). The Windows GUT run is what surfaced it: the empty list failed a wiki test, and the gate’s-d(Godot’s--debug) turned the out-of-bounds follow-up into a 1.7 GB flood of interactive debugger prompts; the gate no longer passes-dand caps the run atGUT_TIMEOUT(600 s). -
A note with YAML frontmatter is no longer labelled by its file stem.
note_titlescanned the body for the first heading but took the first non-empty line as proof that there was none: an Obsidian-shaped note (---/title: …/---/# Real heading) opens with---, so every such note was labelled - and, sincenotes_ftsindexestitlebesidebody, ranked - by its file stem in search results, heading and all. A leading closed frontmatter block (---…---or..., CRLF files included) is now skipped before the heading scan; an unterminated---is not frontmatter and behaves exactly as before, and notitle:key is read - that belongs with a properties UI, if one is ever built. The block itself stays ordinary visible Markdown and stays indexed, so searching a property’s value still finds the note. -
A large note’s preview no longer freezes the app. Two costs stacked behind the live preview of a big note. The renderer emitted Godot’s
[ul]/[ol]list tags, and Godot lays a list out superlinearly in its item count - measured 38.9 s to shape 1.7 MiB of two-item lists, and 146.7 s for a real 1 MiB note, on top of the ~0.3 s the render and parse actually cost - while the pane re-rendered through a 75 ms throttle for as long as typing continued, so every burst paid that price again. Items now render as•/N.lines inside one[indent]per list, which is identical on screen (checked side by side) and makes the shape pass linear - 0.82 s for that same 1.7 MiB, 0.26–0.97 s for a 256 KiB–1 MiB note in the shipped release build - and the editor’s preview renders on settle (0.4 s of quiet,MarkdownView.set_markdown_settled) instead of on the streaming throttle, so a typing run queues no render at all and one lands when you stop. Side effect of the numbering being the renderer’s own: an ordered list that starts at 3 now renders3.. Costs measured with the newgodot/tests/bench/markdown_bench.gdharness: one preview update is ~0.32 / 0.55 / 1.10 s for 256 KiB / 512 KiB / 1 MiB (where the 1 MiB case used to freeze for minutes);editmode still renders nothing. -
Recovery copies of a vault that went away are reclaimed. An unsaved buffer’s copy is only ever read, offered or cleared through the key of the note being opened, so a copy whose vault was moved, renamed or deleted was unreachable forever - and nothing reclaimed it, while every vault move orphaned one copy per unsaved note. A prune now rides each copy write (the editor’s 1.5 s throttle is the only writer, and the prune runs before the write is located, so even a write refused for a vault that is no longer a directory tidies the directory): a copy is dropped only when its own record names a vault that is definitively gone - the path is absent, or exists as something other than a directory - and the check errs toward keeping, so a vault that is merely unreadable (an unmounted network path, a permission error) keeps its copies, as does a note that merely was not listed. A record gpty cannot read or parse, and any file whose name gpty did not build, is never touched; the
.gpty-scratch-*.tmpremains of a write that died mid-rename are reclaimed on the same pass once older than an hour. -
A zero-hit wiki search left the pane with no way back. Every placeholder - the search box included - hid the whole top row, so searching for something the vault does not contain replaced the pane with “No notes match “…”” and nothing else: no list, no Back, no box to amend the query in. The search box now stays whenever the vault itself is usable - amend the query or clear it (the box’s clear button) and the notes return - and is hidden only for a vault that cannot be opened, or while a note is open. Found in a live build;
test_a_search_with_no_match_says_sonow asserts both the box’s reachability and the way back. -
Unsaved wiki edits can no longer be lost to a close. The editor’s guard only covered in-pane navigation: closing the pane (
Ctrl+Shift+W, the titlebar’s X, the sidebar’s close button, an IPCkillPane), changing its type, resetting the workspace, activating a profile, closing a workspace, reloading the layout set and quitting the app all destroyed a dirty buffer without a word. Every one of those paths now asks the pane first -PaneBody.request_close- and one answer drives the whole action: Save (writes, then continues), Discard, or Cancel, which cancels the reset, the swap, the workspace close or the quit as a whole. The app’s quit is now explicit (auto_accept_quitis off) and runs through the same walk from the window manager’s close request, the titlebar’s close button and the shutdown flag (daemon stop, a refused socket bind), with a pane-per-pane ask across every workspace; the editor’s Back button gained the same Save answer it lacked. An IPC caller, which cannot answer a dialog, is refused instead -killPaneandlayoutLoadanswer an error naming the pane - the rulelayoutLoadalready applied to untrusted profiles. A save that fails (or an overwrite the user declines) cancels the close rather than proceeding over it. -
A note with CRLF endings opened as unsaved work. The editor works in LF while the text “dirty” is measured against was the file’s own - CRLF and all - so every CRLF note came up dirty the instant it was opened: the Save button lit for an edit nobody had made and the close guard asked about it. Found while building the recovery copy above, which turned the mislabel into a false question (“unsaved changes recovered” for the file the pane had just read, written out as a copy nobody typed):
_saved_textis now normalized into the editor’s space at load, so dirty means the same thing for both kinds of note.test_a_crlf_note_opens_cleanpins it. -
The Markdown preview stopped at 512 KiB without saying so.
GptyMarkdown.rendercut its input at a cap of its own - half the 1 MiB every pane reads under - so a 900 KiB note rendered its first 512 KiB and nothing else, silently, and the wiki pane’s own truncation notice (appended after the body) sat past the very cut it was announcing, which is exactly the size it exists for;code_viewer’s rendered view did the same. The cap is now the number the panes read under - one 1 MiB constant, pinned by a seam test that reads the GDScript declaration and fails if the two drift - and the cut travels with the text: the render answer is JSON ({text, truncated, shown, size}), andMarkdownViewshows it as a banner above the body, for a source past the cap (a pasted buffer, a routed capture, a long model turn) and for a cut the caller made (set_source_notice, which the panes now use instead of appending their notice to the source - a note at the read cap is at the render cap too, so that appended notice was the first thing the cut dropped). The text views keep their notice at the end of the text they hold. Cost, measured before raising the cap: the Rust render is ~14 ms/MiB release (~107 ms debug) and a live preview’s whole path - render plus the label’s own BBCode parse - is ~74 / 157 / 336 ms for 256 KiB / 512 KiB / 1 MiB in the debug build; that is a preview cost only, sinceeditmode renders nothing.