The boundaries chosen for v0.1, and why. This records the architecture work requested by the historical v0.1 product direction, after inspecting upstream source rather than relying on assumptions. Current product scope is governed by the Product Contract.
mt-doc — the document engine — has no GPUI dependency. That is the only
structural rule this codebase enforces strictly, because it is the one that
determines whether the model can later be reused by CLI tooling, MCP tools,
agent tools, headless rendering, or indexing.
Everything else is a module in mt-app, split by what it owns rather than by
layer. There is no traits/, no services/, no dependency-injection container.
Filesystem
│
▼
┌──────────────────────────────────────────┐
│ mt-doc │
│ doctype what kind of artifact │
│ frontmatter YAML header extraction │
│ doc source + blocks + outline │
│ outline headings + MDX structure │
│ block renderer dispatch keys │
│ diagnostic problems, never panics │
│ skill Agent Skills model │
│ harness where skills live, per tool │
│ instruction agent instruction files │
│ walk which files are openable │
│ search find across documents │
│ translate what is translatable │
└────────────────────┬─────────────────────┘
│
┌───────────────┼───────────────┬──────────────┐
│ │ │ │
mt-app::fs mt-app::renderer mt-app::web mt-app::views
load/save diagram + math WebView GPUI
encoding registry + trust ↓
conflicts │
│
┌─────────────────────────────────────────┤
│ │
workspace ────────────────────────────┐ panels
the wiring layer │ explorer · harness
├── tabs open set, preview │ search · document
├── history back / forward │ settings_page
└── web_surface the OS child window
Each of those four under workspace was a cluster of fields inside it. They
moved out because nothing else touched them — which is also what made their
rules testable without a window. tabs is the clearest case: "closing a tab to
the left must not switch documents" was a real defect, and it is now an
assertion rather than something you find by clicking.
GPUI widgets are not the source of document semantics. Document owns the
source text and everything derived from it; views read it. The single mutation
path is set_source, so derived state cannot drift from the text.
Every derived structure carries byte spans back into the original source. That is what makes "open and preview never modify the file" a property rather than a hope, and what lets translation reassemble a document losslessly.
The native path and the WebView path both consume the same Document. Neither
re-parses independently. both_render_paths_agree_on_document_structure asserts
this: if they diverged into parallel models, that test fails.
Native is the fast path. WebView is the compatibility path. Feature parity is explicitly not a goal, and Markdown never round-trips through the WebView.
BlockRenderer is looked up by Block::renderer_id(). Adding Graphviz means:
implement the trait, register it, add the fence language to
DiagramKind::from_lang. The Markdown parser, both renderers, and every view
are untouched. a_new_renderer_needs_no_core_changes asserts exactly this.
Availability is part of the trait, so a renderer needing an external binary reports its absence as an actionable install hint rather than a mysterious failure — and the document architecture stays uninfected by that dependency.
Nothing in the pipeline returns Result to the UI for content problems. A
malformed frontmatter, an invalid Mermaid diagram, unparseable MDX — all become
Diagnostic values attached to the document or block, with the original source
preserved. A broken file always opens and can always be repaired in the editor.
Third-party renderers run inside catch_unwind: a panic in a dependency becomes
a diagnostic, not a lost session.
Inspecting upstream source first, as the historical v0.1 brief required, changed
the plan substantially.
gpui-component already ships:
MarkdownExtensions— a block parser/renderer registry with an MDX parse mode. The renderer registry requested by the historical brief at the view layer already exists upstream; this codebase supplies the block backends and letsTextViewdispatch.EditorState/Editor— a rope-backed code editor with tree-sitter highlighting, undo/redo, and search already keybound. No editor was written.TreeState/tree()— virtualized tree. The explorer supplies items.TabBar,h_resizable,TitleBar,ListItem, theming.gpui-wry— a WebView element (Windows and macOS).
So mt-app is mostly wiring plus the four things upstream does not have:
document semantics, diagram backends, filesystem safety, and translation.
Verified against crates.io rather than assumed:
| Technology | Choice | Note |
|---|---|---|
| Mermaid | mermaid-svg 0.7 |
Pure Rust, 23 diagram types |
| D2 | d2-little 0.7.1-1 |
Pure Rust including layout. The version needs the exact prerelease tag; 0.7.2/0.7.3 are yanked |
| Math | ratex-parser / -layout / -types / -font 0.1.14 |
Pure Rust, no JS engine. The SVG is emitted here rather than by ratex-svg — see below |
| PlantUML | plantuml CLI |
plantuml-little depends on graphviz-anywhere, which fails at build time on Windows. Rejected |
Rejected for math: latex2mathml (abandoned 2020), pulldown-latex, Temml
and math-core (all MathML only — resvg drops every non-SVG-namespaced element
at usvg/parser/svgtree/parse.rs:139, so the native path would show nothing),
mathjax_svg 3.2 (embeds V8), ReX (69/90 on a KaTeX corpus against RaTeX's
89/90, and unpublished).
ratex-svg is the obvious dependency and is deliberately absent. The only way
to get <path> rather than <text> out of it is its standalone feature, and
standalone reaches ratex-unicode-font through two independent edges — a
direct optional dependency, and a non-optional dependency of
ratex-font-loader. No feature combination avoids it.
That crate does three things this application cannot accept: it prints with
eprintln! past the log crate, so RUST_LOG cannot filter it; it hardcodes
five distro-specific font paths, which is how a font loader works on the
author's machine and nowhere else; and it reads a system CJK font it never
frees — measured 4.5MB to 52.0MB resident on the first CJK glyph, retained for
the process.
Emitting the SVG in renderer.rs instead is about 250 lines and removes all
three at the source. Measured after: 4.34MB at startup, 4.55MB after a thousand
formulas, zero lines on stderr, and no platform font path in the binary.
The emitter writes <path> for every glyph a KaTeX face covers — which is all
of mathematics — and falls through to <text> for the rest, meaning CJK inside
\text and emoji. Those resolve against the font database gpui already
populates from the system, so the two render paths keep sharing one SVG string.
Every fill is currentColor unless \textcolor or \color said otherwise,
which is what lets one rendered formula serve twelve themes and follow the OS
light/dark switch without re-rendering.
The fonts are not embedded. This application embeds no font it can instead
ship beside the executable; assets.rs is the one exception, and a different
case, because gpui requests those two by exact path and diagram labels come out
blank without them. The nineteen KaTeX faces live in fonts/katex/,
package-release.sh stages them next to the binary, and
renderer.rs::font_dir_candidates searches there first. When none of the
candidate directories holds all nineteen, availability() reports Missing
with an install hint and every formula becomes a diagnostic — the same shape
PlantUML has always used for a missing binary.
ratex-parser is vendored under a [patch.crates-io], carrying one
25-line clamp. \begin{alignat}{N} allocates N * 2 64-byte values with no
bound, so a 45-byte document requests 68GB and dies with an allocation abort —
the one failure class the catch_unwind in RendererRegistry::render cannot
contain. The clamp has to live inside the parser because it macro-expands before
reading the argument: \begin {alignat}{1e9} with a space, a comment between
the two, \def\N{1000000000}...{\N} and a macro-supplied environment name all
defeat a guard written over the source text. See
vendor/ratex-parser/README.markturbo.md.
markdown-rs parses MDX constructs, so native mode identifies JSX elements,
ESM statements, and expressions, builds an outline from them, and never
corrupts the document. It does not evaluate them: there is no JS engine in the
document engine at all, which is also why opening executable content cannot run
it.
markdown-rs gates ESM behind an mdx_esm_parse callback. We supply one that
accepts every statement, because the structural boundary is a blank line, which
the parser already finds — and the WebView path, running a real compiler, stays
the authority on JS validity.
MDX can contain executable code and a local HTML file can reference its own directory, so the boundary had to be decided now even though no MDX runtime ships in v0.1.
Everything markturbo renders itself is served to the WebView as a data: URL
— an opaque origin with no filesystem access, no file:// reach, and no ambient
credentials — under a CSP where default-src 'none' blocks all network
access at both trust levels. Restricted, the default for every document,
additionally blocks scripts.
Trust is explicit and per-document, and it grants a different power to each of the two types it applies to:
| Restricted | Trusted | |
|---|---|---|
| MDX | data:, no scripts, no network |
data:, scripts run, still no network |
| HTML | data: with an injected CSP |
file://, loaded from disk |
The HTML row is the actual boundary, and it is deliberate: a trusted .html is
loaded through web::to_file_url so its relative images and stylesheets
resolve, which is the only reason a user would trust one. That gives it a real
origin, read access to whatever the user can read, and whatever CSP the file
itself carries — which may be none. to_file_url's doc comment says so in those
words, and only_a_trusted_document_is_given_filesystem_access pins it.
So the honest summary is not "content can never reach anything". It is: nothing reaches the filesystem or the network unless the user trusted that specific document, and for HTML, trusting is exactly the act of handing it the disk.
The filesystem is the source of truth, and agents write to it concurrently. A
loaded file has a FileStamp; before a normal Save, the current named object
is opened and its metadata, bytes, and SHA-256 are collected from that same
handle. This detects a changed document even when its mtime and length happen
to match the loaded version. Windows also records the file object ID, so a
delete-and-recreate at the same path is a conflict even if the replacement has
the same bytes and timestamps. A mismatch refuses the normal Save and the user
chooses reload, overwrite, recreation, conversion, or Save As as the case
requires.
Save As has a separate two-step authorization. The first filesystem operation
is create-only and refuses every existing directory entry, including a symbolic
link. Only an explicit Replace decision calls the overwrite path, which
captures and rechecks the selected destination before commit. Cancelling either
the picker or Replace leaves the editor and destination byte-identical. A
successful Save As consumes the single verified LoadedFile returned by the
filesystem layer, migrates tab and recovery identity to the new path, and
invalidates asynchronous results that still belong to the old source.
Those choices are explicit capabilities, not independent bool flags. A
SaveAuthorization begins as a normal Save; an overwrite captures the exact
current FileStamp of the resolved destination, and a missing-file decision
records permission to recreate only a currently absent regular file. UTF-8
conversion composes with either source decision without replacing it. Before
commit the save rechecks that exact expected stamp, or that the approved path
is still absent. Therefore a later external writer, or a path that reappears
after a recreation decision, becomes a new conflict rather than inheriting an
earlier answer. The document keeps that authorization only until an edit,
successful save, source-identity change, or new conflict resets it.
The replacement output is staged in a randomly named sibling file, synced, and
then installed with guarded ReplaceFileW handling on Windows. The backup is
verified against the expected outgoing object before the replacement is
accepted. If a race cannot be proved safe, the editor remains dirty and reports
the preserved artifacts rather than silently treating the Save as successful.
Win32 is not compare-and-swap and the filesystem is not universal version
history; the implementation detects and contains the races it can establish,
rather than claiming an impossible guarantee for every concurrent actor.
Saving through a supported symbolic link preserves the link itself and updates its resolved target. The watcher maps changes observed on that resolved target back to documents opened through the link. The workspace root remains the primary recursive watch, but Open File and Save As register an additional parent when a document lives outside that root. Explorer and Harness refreshes remain scoped to the primary root; document conflict detection receives events from every registered document parent.
Three properties of the bytes survive a round trip: line endings, BOM,
and encoding. The third was the expensive one to get wrong. Reading every
file through String::from_utf8_lossy meant a GBK or Shift-JIS document became
a wall of U+FFFD in the editor — and since a save writes the buffer back, one
Ctrl+S replaced the file with those replacement characters irrecoverably. A BOM
now decides the encoding outright, valid UTF-8 is taken at face value, and only
bytes that are neither reach the detector. The encoding travels with the loaded
file so the save re-encodes in it.
encoding_rs has no UTF-16 encoder — Encoding::encode silently emits UTF-8
for those two, which would write a UTF-8 body under a UTF-16 BOM and produce a
file nothing can read. They encode their own code units instead.
Closing a dirty tab, closing the window, and replacing the workspace all pass through one lifecycle decision. The decision walks every affected dirty buffer and requires Save, Discard, or Cancel before the destructive action can proceed. A Save must succeed before its buffer is released. The lifecycle model also represents memory-origin documents. Saving one during a destructive decision suspends that exact request while Save As runs; only a successful write resumes the close or workspace replacement. Picker cancellation, Replace cancellation, write failure, or a changed editor snapshot keeps the document open on the same recovery path.
The destructive coordinator revalidates the affected documents immediately before destruction. If another document becomes dirty, or a prompted document's revision changes while a prompt is open, it is returned to the decision queue and prompted for its current revision before the destructive action proceeds.
Asynchronous text changes have a different boundary. A BufferSnapshot
captures the editor revision and exact text used by destructive lifecycle
decisions. An AsyncSnapshot adds a source generation that advances after a
successful Save As. Translation and background reparsing use this stronger
identity: a result may land only when the revision, text, and source generation
still match. An edit or Save As while work is in flight therefore leaves the
current document untouched and asks the user to run the operation again.
Recovery is optional application state, not a workspace format and not an
autosave of the source file. On Windows, encrypted checkpoint records live by
default in %LOCALAPPDATA%\markturbo\recovery; MARKTURBO_DATA_DIR redirects
the application-data root, making the candidate recovery location
%MARKTURBO_DATA_DIR%\recovery. The override must be absolute. Recovery storage
accepts only local fixed, removable, or RAM volumes: UNC paths, mapped/remote
drives, and every unsupported drive type are rejected before storage creation.
It revalidates the nearest existing ancestor before creation and the canonical
volume after creation. WebView and log paths may still use the absolute data
override; an unavailable recovery store is reported while editing and
source-file Save continue. DPAPI protects each record for the current user, and
a sibling temporary file plus sync and rename makes each completed checkpoint
atomic. Non-Windows builds deliberately have no plaintext fallback.
Workspace construction opens the requested folder and file before it starts recovery. A deferred task opens the store, decrypts and verifies records, and parses recovered Markdown on the background executor; the result returns through the same fallible window-update path used by other async UI work. A document edited while that work runs still creates checkpoint timing state immediately. If its two-second dispatch deadline arrives before the store is ready, the workspace keeps a persistent protection warning. When the store arrives, it attaches the generation token without moving the original edit or ten-second durable deadline. A matching tab accepts recovery only if it is still clean and has not advanced from the identity captured when startup began. A clean file opened while the scan is running may also accept its matching record, while any edit or reload keeps the current tab untouched. Existing watcher conflict state is preserved. Watcher events still mark affected documents during this interval, but startup recovery is also an auto-reload barrier: automatic reload remains off until the scan has completed, even when the setting is enabled. Successful Save and confirmed Discard decisions made before the store is ready queue their recovery keys. The startup result filters those keys before it restores records. Once it has attached an available store, it durably retires the queued keys and only then resumes a waiting destructive action. If the store remains unavailable, the document stays open and the failure is reported; editing and source-file Save remain available. Restored dirty records are already durable, so they do not immediately rewrite their checkpoint; they are instead scheduled for a ten-second refresh from their restored durable baseline.
The Windows production store validates the local-volume policy before it
creates the root, then takes lifetime ownership before it opens any record. It
holds a directory handle opened on the canonical root itself, rejects a final
reparse point, verifies the root's stable file-object ID after canonicalization
and before each operation, and retains an exclusive .markturbo-recovery.lock
handle. The lock deliberately rejects another markturbo instance instead of
letting two processes coordinate the same recovery directory. That instance
starts with recovery unavailable, reports why, and continues with editing and
source files intact. This is a recovery-storage interlock, not a lock on a
workspace or source document.
After an edit, a single earliest-deadline scheduler tracks the oldest edit not
covered by a durable checkpoint. It dispatches after two seconds without an
edit, but continuous edits cannot postpone that oldest uncovered edit beyond
the same two-second dispatch budget. Each attempt retains the exact snapshot
time and an absolute durable deadline: at most eight seconds after dispatch and
never later than ten seconds after the oldest edit it covers. Periodic refresh
is anchored to the durable baseline rather than completion, so a slow worker
cannot add a second ten-second delay. The background batch records
store_returned_at as soon as the store returns; deadline success or failure is
judged against that timestamp, rather than a later UI callback.
All documents due on one wake-up enter one ordered physical batch, while each attempt owns its cancellation flag. A later edit, Save, Discard, or source-identity change cancels only that stale snapshot without taking the recovery mutation lock; other due documents continue to a durable checkpoint instead of being starved by activity elsewhere. Protection and retention work observe cancellation between bounded waves and before publication. A retention scan stops early only when every current attempt is cancelled. A transaction journal is the durable linearization boundary: cancellation before it prevents publication, while a transaction that has crossed it finishes under the normal transaction rules. There is only one physical checkpoint worker per workspace. If it remains occupied past a logical attempt's deadline, the editor reports the protection warning and retains the latest due schedule rather than spawning an independent retry. Once that batch returns, the worker slot is released and the scheduler immediately coalesces repeated edits into one follow-up batch for the latest snapshot. A late or stale completion cannot clear a warning for a newer revision; only a current successful checkpoint can do so.
The store verifies the recovery root, takes the mutation lock, and performs one retention scan for each successful batch. DPAPI decode and checkpoint protection use bounded parallel waves of at most four workers per stage; when those stages overlap, their combined active work is capped at eight calls. Each wave is also limited by the store byte bound. Quota decisions and durable writes remain serial and preserve dispatch order.
Each request carries a generation token, so a checkpoint already in flight cannot recreate a record after Save or Discard retires it. Capability state (generations, active keys, and pending retirements) uses a short lock independent from the recovery-root I/O lock. After a successful Save or confirmed Discard, the destructive action writes and syncs a durable retirement marker before it destroys the document or window. That marker is the non-restorable linearization point: recovery hides every marked canonical record across interruption, while background cleanup serializes with checkpoint I/O and retries after failure. A post-persist marker-sync failure is retried by opening, decoding, and syncing the exact existing marker; a different marker is rejected rather than replaced. A multi-document destructive action writes one versioned batch marker, so all confirmed Discard decisions become durable together or none do. Startup reconciliation removes marked canonical records before deleting the marker; cleanup failure leaves the marker in place and reports the condition instead of exposing retired text. An unreadable, unsupported, or path/key-misbound marker fails recovery closed rather than exposing a possibly retired record; that recovery failure does not make editing or source-file Save unavailable.
The workspace keeps action-scoped queued intent separate from durable cleanup
ownership. pending_recovery_retirements records the originating document when
known, so a tab-close action waits only for its own pending key; an unknown
origin remains fail-closed for every destructive action. A pending entry means a
newer Save or Discard still needs its own durable marker; it is not an owner.
recovery_retirements and
recovery_retirement_batches hold the exact single-key or batch ticket that
owns cleanup already made durable. An owner and queued intent for the same key
therefore mean that a later decision is waiting behind the current owner. When
that exact owner finishes, the workspace replays the queued intent to publish a
fresh marker. An edit cancels only the queued intent, while the durable owner
continues cleanup. Completion callbacks compare the complete ticket identity,
so a stale callback cannot remove or replace a newer owner.
A destructive action remains open while any relevant queued intent lacks a durable marker. If an existing owner or retry must finish first, the action resumes through the retirement continuation and rechecks the full key set; it proceeds only after every relevant queued intent has acquired a durable owner.
The store registers active recovery keys under the capability lock. When it chooses an eviction candidate it combines those live keys with the checkpoint dispatch snapshot. A document that becomes dirty after work was queued is therefore not treated as inactive merely because the worker has an older view of the tab set. Before an eviction transaction performs recovery-root I/O, it reserves every victim under that capability lock and then releases the lock. A document activated while its record is reserved immediately receives a visible protection warning and waits for a later checkpoint; it is never evicted as inactive. An active or already-reserved candidate defers the transaction.
Retention is capped at 50 records, 32 MiB per record, and 128 MiB total; records expire after seven days. Startup and completed checkpoint batches prune expired records. Retention scans keep only the key, timestamp, path, and byte size after validating a record; full recovered text and save metadata remain in memory only for an actual recovery scan. A current same-key replacement first accounts for the old file's metadata, count, and bytes, then treats it as non-evictable without decrypting stale ciphertext that the new atomic write will replace. Any real preparation or persistence failure falls back to a full scan, so malformed and expired data is still reported and pruned; normal cancellation does not pay that fallback cost before the latest text can catch up. A same-key replacement that needs no eviction uses one atomic prepared-file replacement. A write that evicts another record keeps the recovery transaction. Active records are never eviction candidates, and a quota failure is visible to the user. Retention scans do not read source documents; the recovery scan still reads the source as needed to compute restore conflicts.
Replacing a checkpoint or evicting records is a small recovery-store transaction. The store writes a validated journal, stages the affected old records, publishes the new record, then writes a commit marker before cleanup. On startup and before maintenance, it reconciles any journal: a published transaction is finished (including the case inferred from its on-disk state), while an uncommitted one is rolled back. If a fault leaves ambiguous or conflicting artifacts, recovery reports the condition rather than presenting them as ordinary records. The journal protects recovery retention only; it does not promise a multi-file transaction for user documents.
After a successful Save or confirmed Discard, the matching recovery record is
durably retired before long physical cleanup. Recovery
stores the source path, encoding, BOM, line endings, conflict stamp, source
identity, and decode-error state with the text. On restore, a changed,
unreadable, or missing source remains a conflicted dirty buffer and cannot
overwrite implicitly. Malformed, oversized, expired, unreadable, unavailable,
or failed recovery data is reported without blocking the editor or modifying a
workspace file. At startup, valid file-backed records from the process-global
per-user store are restored into the startup window without filtering their
source paths against self.root. Tab deduplication compares the stored paths
lexically, so equivalent canonical, relative, or link spellings can still be
distinct. A record from another workspace can therefore appear in that startup
window; matching-workspace recovery scoping does not exist yet. In-memory record
restoration creates an independent pathless Markdown tab and preserves the
record's process-independent recovery key until Save As retires it.
Settings are TOML, at whatever dirs::config_dir() returns, plus markturbo.
Both halves of that were once hand-rolled and both were changed for the same
reason — the file is a user-facing artifact, not an internal cache.
TOML over JSON because a settings file is something people edit: it takes
comments, and it does not fail on a trailing comma. The format imposes one
constraint on the code, which is worth knowing before adding a field: every
scalar must be written before any table. AppSettings therefore keeps its
ordinary scalar preferences first and the sole [[recent-targets]] array table
last. the_settings_document_serializes_recent_targets_last_and_reads_back
holds that ordering and round trip.
The directory comes from dirs rather than four cfg branches. Windows and
Linux land where they did; macOS moved from ~/.config/markturbo to
~/Library/Application Support/markturbo, which is the correction — macOS is
not an XDG platform, and a file in ~/.config there is invisible to every macOS
convention for finding, backing up, or migrating application data.
There is no migration. An existing settings.json is not read and not deleted;
the user starts from defaults. That is the standing rule for this stage rather
than an oversight, and the packaged RUNNING.md says so where a user will see
it.
AppSettings::update is the only writer, and global_mut already pushes a
NotifyGlobalObservers effect — so the notification was always being sent and
nobody was listening. A single observe_global subscription is the backstop for
the writers that are not the settings page.
A no-argument launch deliberately shows the Welcome state; markturbo . is the
explicit terminal form for opening the current directory, and any other path
argument still bypasses Welcome. Don't show this again persists one scalar
preference and immediately opens a pathless Markdown buffer; later no-argument
launches do the same. Welcome stores at most ten MRU file or workspace paths,
their kind, and a display name. It stores no document content. Missing or
mismatched entries remain visible and removable, while valid entries re-enter
the same file/workspace open paths used by pickers and drag-and-drop.
The ten-target bound is covered in settings state by
settings::tests::recent_targets_are_mru_deduplicated_and_capped and across a
restart by the native acceptance harness scenario recent_bound_restart_stale
in scripts/goal-03-native-acceptance.py, which loads eleven isolated persisted
targets, requires exactly ten Welcome recent controls, restarts, and proves a
missing entry remains visibly marked and inert.
markdown-rs is superlinear in the number of blocks: on this repo's fixtures,
10x the input costs ~65x the time (~13s for 100K lines). This is upstream — the
same curve appears with the parser's own default constructs, and a single huge
paragraph with the same byte count scales linearly.
We cannot fix that, but we can make it not matter: DocumentView reparses on a
background executor after a 180ms debounce, and discards results that a newer
edit has superseded. The editor and the previous parse stay live meanwhile.
a_huge_document_is_slow_enough_to_require_background_parsing documents the
measurement that justifies the machinery — and will fail if it stops being true.
A prompt saying "don't translate code" is not a guarantee. Instead the document
engine splits a scope into translatable and verbatim segments that tile the
range exactly — concatenating them reproduces the source byte-for-byte — and
sends only prose. Fenced code, inline code, math, diagram source, MDX, link
targets, autolinks, frontmatter, block markup (#, -, >, 1.), and table
pipes are all verbatim.
markup_survives_a_provider_that_rewrites_everything asserts the strong form:
even a provider returning unrelated text cannot damage document structure.
Per the historical v0.1 non-goals: no knowledge graph, backlinks, canvas, PKM system, proprietary format, WYSIWYG, collaboration, sync, accounts, plugin marketplace, extension runtime, terminal, debugger, LSP IDE, git client, vector DB, RAG, or AI chat sidebar. Agents live in Codex, Claude Code, Cursor, and the terminal; this app owns the workspace they consume.
Also not built, and why:
- A dock/panel system. The workspace owns one two-row, three-track frame:
title and body resolve the same retained, user-owned panel widths, while the current
window width only clamps that render. Boundary handles update those owned
widths directly. A nested
h_resizablewould introduce a second, previous- frame geometry source and can let title and body diverge after a window-state transition.DockArearemains available if freeform panels are ever wanted. - A generic plugin platform. The renderer registry is the extension point that was actually needed.
This section used to say translation shells out to curl, and that adding a
TLS stack for one optional endpoint was not worth the build cost. That was a
defensible answer to the question as it stood — one endpoint, one wire format.
The question changed to many providers, robustly, and the reasoning did not
survive it.
What the original decision underweighted was not aesthetics. It was this:
post_json had zero test coverage, in a file with 307 lines of tests.
Everything around it was tested — provider selection, key precedence, endpoint
construction, response shapes — and the one part that talked to the network was
not, because testing it needed a real curl binary and a real server. "What
does a 401 turn into for the user?" was unanswerable without going online. A
library client is pointed at a local TcpListener, so that question now has a
test.
Two lesser failures came with it. --max-time 120 is a total transfer
timeout, so it cannot distinguish a dead host from a large document that is
legitimately still streaming — and it is uncancellable, so a user who clicked
Translate waited the full two minutes for a typo'd hostname. And the error text
was curl's, not the application's.
The replacement is genai, a multi-provider client covering Anthropic,
OpenAI (both Chat Completions and Responses), Gemini, Ollama, Bedrock, Vertex,
Groq, DeepSeek, xAI, OpenRouter and more — so provider knowledge that used to
live in this repository as hand-written request shapes and response extractors
now lives upstream, where it is maintained.
The cost, stated plainly: 41 crates that were not being compiled before,
including tokio, hyper, reqwest, tower, mio, and ring. That is a
real trade and it is bigger than the one this section originally declined to
make.
An earlier draft of this paragraph claimed the async stack was already paid for
— that tokio, hyper and rustls arrived via gpui-component-assets and so
cost nothing here. That was wrong, and it is worth recording why, because the
mistake is easy to repeat: gpui-component-assets declares reqwest only under
[target.'cfg(target_family = "wasm")'], so on every target this application
actually builds for, none of it was ever compiled. cargo tree --target all
folds the wasm branch in and reports a smaller delta; measuring the target you
ship is the only number that means anything. Measured on
x86_64-pc-windows-msvc, the mt-app dependency set goes from 510 to 551.
Two things bound it:
- No C toolchain.
genai's default feature reachesreqwest/rustls, which selectsaws-lc-rsand thereforeaws-lc-sys— a C library needingcmakeandnasmon Windows. Selectingreqwest/rustls-no-providerwithrustls'sringprovider instead removes everyaws-lccrate from the graph. The trade is that nothing installs a crypto provider automatically, sorustls::crypto::ring::default_provider().install_default()runs inside the sameOnceLockthat builds the client, immediately beforeClient::builder()— which is the only ordering that works, because reqwest panics during client construction rather than at request time when no provider is installed. - No second runtime in the UI.
genaiis async and this application has no async runtime but GPUI's.TranslationServicetherefore stays synchronous: one sharedtokioruntime, built withrt-multi-threadand a single worker, is driven withblock_onfrom the background task that already runs the translation. Multi-thread rather than current-thread is not a preference: a current-thread runtime is driven only by whichever thread callsblock_on, and GPUI hands each background task an arbitrary pool thread.mt-docnever learns thattokioexists, which is the boundary that matters — see the hard rule above.
Ordered by how cheap they are:
- A diagram technology — implement
BlockRenderer, register it, add the fence language. Nothing else changes. - A skill discovery convention — one row in
harness::HARNESSES, which is whatskill::discovery_rootsreads. A harness that shares an existing directory costs nothing further; dedup collapses the overlap. - A translation wire format — add a
Providervariant and map it to agenai::adapter::AdapterKind. A vendor that speaks an existing format needs only a base URL, which is a settings change, not a code one. - A document type — a
DocTypevariant plus its recognition rule. - A split layout — a
Layoutvariant plus its entry inavailable_for.Layoutnames its own renderer (Layout::preview), so "which pane shows what" is answerable from the layout alone rather than from a second control.
- PlantUML needs a local install. Everything else is self-contained.
- The WebView is Windows/macOS only, upstream. Linux gets an explanation in the Web pane rather than a broken one; native rendering is unaffected.
- MDX components render as placeholders in both trust levels. The trust boundary and CSP are in place for when a runtime ships.
- Live web preview pauses above 512KB; the editor and native preview stay live.
- Translation is the one feature that leaves the machine on its own, and only
when the user invokes it with a key configured. What crosses the wire is prose
the user asked to have translated. Document content is separate: it cannot
fetch anything at any trust level — except a trusted HTML file, which is
loaded from
file://outside markturbo's CSP and is therefore bounded by the browser engine rather than by us. See the trust boundary above.