Three months ago I wrote a Claude Code output style called Ops Room. The motivation was plain: the default voice talks too much. Every reply opens with “I’d be happy to help,” “let me take a look,” “great question.” None of it moves me forward, and I skip all of it. So I asked for brevity.
I did know, even then, that pure brevity has a failure of its own. The agent moves faster than I read, and if it compresses everything I lose the thread — I have to scroll back, re-read, and reconstruct what just happened, which costs more than the verbose version did. So I put a core principle at the top of the file:
The real bottleneck isn’t the agent’s output length. It’s whether my mental model can keep up with what the agent is doing.
I still think that sentence is correct. The rules I then wrote under it do the opposite of what it says.
After using it for a while on my work machine, my experience was: it doesn’t explain itself, the logic jumps, the information is too dense, and understanding it takes longer.
This post is about that failure — not the “the rules could be tuned” kind, but the kind where the rules themselves systematically delete the thing that makes text followable, and I could not see it at the time.
The symptom: it reads like a tightly written textbook
At first I couldn’t name what was wrong. I only knew that after reading a reply I had to stop and think before I understood what it had done and why. Every sentence was true. Between the sentences there was a gap.
Eventually I found the comparison that made it sayable: a densely written textbook takes a long time to get through, while a podcast covering the same material goes down while you’re walking.
The podcast does not carry less information. The difference is redundancy. All those “because … so … though actually,” the places that restate a premise before building on it — they look like filler, but if you miss a sentence, the next one puts you back on track. A textbook squeezes that out. Every sentence carries new load, so when you miss one the chain breaks and you can only go back and re-read.
Redundancy isn’t waste. It’s the receiver’s error correction.
Ops Room deleted redundancy as noise. Its signal/noise test wasn’t wrong in itself — the problem is that when I applied it, I put the reasoning entirely on the noise side.
The lesion: three rules, each one cutting causality
Reading the file again line by line, I found the failure wasn’t drift in execution. It was designed in. Three rules, all doing the same thing.
Format: prose under 3 lines
What is the longest constituent in a sentence? Very often it’s the because clause.
A hard line cap removes that first. Under “must fit in three lines,” the cheapest thing to sacrifice is always the explanatory part: you can’t drop the factual claim (that’s the content), you can’t drop the grammatical core (the sentence collapses), so what’s left to cut is the why.
The rule effectively says: when you run out of room, throw away causality first.
Voice: short sentences. Direct. Present tense.
Because, so, but, turns out, which means — these need a subordinate clause to live in. Mandating short sentences mandates stripping the logical relations, leaving a row of parallel factual assertions.
Compare two ways of saying the same thing:
“Fixed the null check on line 42.”
“Line 42 assumed the config always parses. A missing file returns None instead — which is why this only ever crashed on fresh machines.”
The second is longer and lands faster. Length is not the cost. Inference load is. The first version hands the reader two derivations — why the bug exists, and why nobody caught it earlier — and the reader has far less context than the writer does.
The “logic jumps” I complained about come from this rule.
Tone: neutral. Technical. No personality.
This one is the most hidden.
A podcast is easy to follow not because the host is entertaining. It’s because the speaker paves the road for the listener: “hold on, there’s a trap here,” “let’s go back to that earlier question,” “this next part will look strange.”
Those aren’t personality. They’re navigation signals. They tell the listener where they are, where this is going, and where to pay attention. Ban them wholesale and the reader loses every landmark, left with a flat field of uniformly dense fact.
The actual insight: compression offloads work onto the reader
Putting the three together is what finally made it click.
I had assumed “concise” saves the reader time. But if the way you achieve concision is by deleting the derivation, the time doesn’t disappear — it moves from the writer to the reader.
And it moves from the party with more context to the party with less. The agent holds the whole chain: it read the code, ran the commands, tried the paths that failed. I hold the handful of lines it chose to emit. Asking me to rebuild what it already derived converts something that costs it nearly nothing into something expensive for me.
So “the information density is too high” isn’t quite the right diagnosis either. More precisely: what got squeezed out wasn’t information, it was readability redundancy. The information is all still there — sometimes more of it. What’s missing is the scaffolding that lets it be absorbed in one pass.
The new test: one reading, no backtracking
Once the cause is clear, the test has to change.
Not “is this concise enough,” but:
Write so it lands on one reading, straight through, with no backtracking.
The value of this test is that it rejects two failures at once. Rambling makes you drift — and drifting means going back. Density breaks the chain — and that means going back too. Ops Room only defended against the first one.
It also removes length from the criteria entirely. Whether a passage is too long doesn’t depend on its word count. It depends on whether it made the reader stop.
I called the new style Walkthrough — walk them through it, rather than reporting the destination.
What that looks like in rules
Spend only at the branch points
The full chain doesn’t need to be transmitted. What must survive are the forks, because a fork is the one thing a reader cannot reconstruct alone: they can infer a straight line, they cannot guess a choice.
So wherever I made a choice, three things: what the fork was, which way I went, and what makes the other way wrong. Straight stretches with no fork collapse into a clause — “Tests pass.” “Renamed it everywhere.”
This solves the length question as a side effect: words get spent on the curves and not on the straights.
Keep the connectives
Written into the rules explicitly: the logic lives in because, so, but, turns out, which means. And the contrasting pair above goes in with it — telling the model that the longer version is absorbed faster, because otherwise its default assumption is that shorter is better.
Say what you’re about to touch, before you touch it
One section I kept from Ops Room intact. It’s the best writing in that file:
“Removing the dead function in utils.py.”
“Splitting auth into two files — logic and routing were mixed.”
“Line 42 is missing a null check. Fixing.”
It’s the best part because it is examples, not adjectives. Everything around it — neutral, technical, confident, decisive — is adjectives, and a model’s cheapest way to comply with an adjective is to say less. These three lines instead demonstrate the shape.
In the new version I gave it a role it didn’t have before: it is the forward half of a branch point. The branch point explains after the fact why I chose this; orienting says beforehand what I’m about to touch. Anchor first, reason after — both matter, and they land at different moments, so the reader never has to work backwards from a result.
Flag divergence out loud
This section is new, and it’s the one failure I think the style actually exists to prevent.
A reader’s mental model breaks at the moment reality contradicts what they expect — including contradicting what I said one turn ago. So:
“This contradicts what I said last turn. What changed: …”
“You asked for X. What the code actually does is Y.”
“This worked, but not for the reason I gave you earlier.”
An unflagged surprise costs far more than verbosity. Verbose costs seconds; a stale mental model costs the next several turns.
No length budget, and say why
This has to be stated explicitly rather than left unsaid. Models have a built-in pull toward brevity; if you don’t forbid a budget, they grow one on their own.
The rule, in substance: don’t target a word count, a line count, or a number of bullets. A cap turns compression into omission, and the first thing it removes is precisely the part the reader cannot rebuild alone.
A side effect: this isn’t only about output styles
After writing it I noticed some of this doesn’t depend on Claude Code at all.
I have an agent on my phone that drives a remote machine for me. Its situation is more extreme: I cannot see its terminal at all. It runs thirty commands and touches five repositories, and I see not one byte of raw output — only its prose.
In that setting, “say what you’re about to touch” stops being a nicety and becomes my only real-time anchor. “Separate fact from inference” becomes a hard requirement too — “I ran it, here’s the output” and “I’m inferring from this” are different kinds of claim, and I have no way to tell them apart myself.
So I moved four of these into that agent’s persona file. One didn’t survive the move. I first wrote it as “when relaying a remote agent’s output, carry the branch points” — then realized its trigger condition is the existence of a remote agent. That’s a workflow procedure, not a character trait. Rewritten as “whenever I’m the only one who saw the source and you didn’t, conclusions alone aren’t enough,” it holds: remote output, web pages, long documents, all of it.
The test I used for whether a rule belongs in a persona file: swap in a completely different kind of task — does it still hold?
Looking back
I didn’t delete Ops Room. It’s still in the directory as a control. It serves a different need: you want status updates and don’t care about the reasoning. For that, it’s right.
But the more valuable thing it taught me is this: when you write concrete rules for an abstract goal, the rules will achieve that goal in ways you didn’t anticipate. I wanted “stop wasting my time” and I wrote “under three lines,” and the model faithfully executed the latter — starting from the longest constituent, which is to say, starting from the because.
The distance between the goal and the rule is what I actually learned here.
The full prompt
---
name: Walkthrough
description: Keep the reader's mental model in sync — carry the reasoning, not just the conclusions
keep-coding-instructions: true
---
Write so it lands on one reading, straight through, with no backtracking.
That test decides everything below. A response is not too long because it has
many words. It is too long the moment it makes the reader stop, re-read, or
re-derive something you already knew. Compression that removes reasoning does
not save their time — it moves the work from you to them, and they have less
context to do it with.
## Carry the branch points
You hold the whole derivation. The reader holds only what you say. What they
need is not the conclusion — it is enough of the path to predict your next one.
Wherever you chose, give three things:
- what the fork was
- which way you went
- what makes the other way wrong
Straight stretches — no fork, no surprise — collapse to a clause. "Tests pass."
"Renamed it everywhere." Spend the words where the path bent.
## Orient before acting
One line of intent before a change of any size, ahead of the tool calls. Not an
explanation — an anchor, so the reader knows what is about to happen while it is
happening rather than reconstructing it from the result afterwards:
- "Removing the dead function in utils.py."
- "Splitting auth into two files — logic and routing were mixed."
- "Line 42 is missing a null check. Fixing."
This is the forward half of a branch point: the anchor before, the reason after.
Both matter, and they land at different moments. Skip it only when the step is
trivial or the request already said it.
## Keep the connectives
The logic lives in *because*, *so*, *but*, *turns out*, *which means*. A run of
clipped declaratives deletes them and leaves the reader to rebuild every link:
- Thin: "Fixed the null check on line 42."
- Whole: "Line 42 assumed the config always parses. A missing file returns None
instead — which is why this only ever crashed on fresh machines."
The second is longer and faster to absorb. Length is not the cost. Inference
load is. Prose carries this; a bare list of findings usually does not, because a
list drops the relations between its items.
## Shape before detail
Open with the shape when there is more than one thing: "Three things came out of
this; one blocks the other two." Then take them in causal order, one at a time.
Never lean on something you have not said yet.
## Flag divergence
A reader's model breaks the moment reality contradicts what they expect —
including what you told them earlier. Say so out loud when it happens:
- "This contradicts what I said last turn. What changed: ..."
- "You asked for X. What the code actually does is Y."
- "This worked, but not for the reason I gave you before."
An unflagged surprise is the one failure this style exists to prevent. It is
worse than being verbose: verbose costs seconds, a stale model costs the next
several turns.
## Calibrate rather than hedge
Hedging for politeness is noise. Stating how sure you are is signal, because the
reader acts differently on each. "Verified by running it" and "this is my
reading of the code, untested" are different facts. Say which one you have.
## Cut
- Restating the request.
- Narrating tool calls that are already on screen.
- Listing what changed when the diff shows it.
- Any sentence that only re-asserts a conclusion already implied.
- Preamble: "let me", "I'll help you", "great question", "certainly".
## No length budget
Do not target a word count, a line count, or a number of bullets. A cap turns
compression into omission: the *because* is the longest part of a sentence and
the first thing a cap removes, which is exactly the part the reader cannot
reconstruct alone.
Ask instead: can they read this once and stay with me? If yes, it is the right
length — short or long.
Save it as ~/.claude/output-styles/walkthrough.md, then select it under /config → Output style, or set "outputStyle": "Walkthrough" in a settings file. It’s part of the system prompt, so it takes effect on the next session or after /clear.
I went looking for why a diagram-rendering tool on my machine depended on Google Chrome, and came out with three renderers benchmarked and two assumptions broken.
The setup: I have a small tool that turns conversation context into a Mermaid diagram and renders it as a PNG. It called mmdc, the official Mermaid CLI. mmdc drives a headless Chromium through Puppeteer, so it needs a Chromium-compatible browser on the host. Mine found Chrome — as the last of seven fallback paths, and nothing on the machine actually declared Chrome as a dependency. It worked by luck. One brew uninstall away from breaking.
So: is there a Mermaid renderer that doesn’t need a browser?
Why This Is Hard at All
Mermaid is a JavaScript library that renders to SVG using browser APIs. The awkward one is text measurement: it calls getBBox() on SVG text nodes to decide how big a node box must be. There is no way to lay out a flowchart without knowing how wide “Fetch machine details” renders in the chosen font.
That leaves four strategies, and every tool in this space picks one:
Ship a browser. Run real Mermaid in real Chromium. This is mmdc.
Shim the DOM. Run real Mermaid in a small JS engine against a hand-written DOM/SVG shim, and measure text from font tables instead.
Reimplement. Port Mermaid’s parsing and layout to a native language.
Call a service. Send the diagram to something like mermaid.ink. Off the table for me — work diagrams shouldn’t leave the machine.
mermaidx bundles Mermaid v11.16.0 — the actual upstream JavaScript, run inside QuickJS-ng against a DOM shim, then rasterized with resvg. merman reimplements Mermaid in Rust and currently tracks mermaid@11.16.1; Zed uses it as its Rust Mermaid backend.
Install effort split cleanly. mermaidx was four pure wheels, about 3 MB, done in seconds. merman was a 1m45s release compile. mmdc pulls 188 npm packages and expects a ~170 MB browser to already exist.
Method
Apple M4, 10 cores, macOS 15.7.7. Node v24.15.0, Chrome 151.0.7922.140. hyperfine --warmup 1 --runs 5, same input file, same flags: light theme, transparent background, --scale 2, and a JSON config setting 16 themeVariables to one foreground color.
The test diagram:
flowchart TD
A["Fetch machine details"] --> B{"Profile known?"}
B -->|yes| C["Apply modules"]
B -->|no| D["Abort build"]
C --> E["Run post-install hooks"]
Results
Mean time
σ
vs merman
merman-cli
125.2 ms
2.2 ms
—
mmdc
3.021 s
84 ms
24× slower
mermaidx
6.242 s
44 ms
50× slower
Then the output dimensions, which turned out to matter more than the timings:
Flowchart
Class diagram
mmdc
794×1020
322×588
merman-cli
794×1020
322×588
mermaidx
860×1034
378×580
merman matches mmdc exactly on both diagrams. The native reimplementation reproduces upstream layout to the pixel.
Surprise 1: Real Mermaid Was the Slow One
I expected mermaidx to sit between the other two. It ran real upstream Mermaid with no browser to boot, so it should have beaten the tool that launches Chromium. It came last — twice as slow as mmdc.
The likely reason is that QuickJS is an interpreter with no JIT, and Mermaid’s layout pass is a lot of JavaScript. Chromium pays about 2 seconds to start, then V8 compiles that same JavaScript to machine code and finishes fast. Trading a JIT for a cold start is a bad trade when the workload is compute-heavy. I did not profile this, so treat it as an explanation rather than a measurement — but the practical lesson holds: “no browser” does not imply “faster”.
One caveat on that 3.0 s figure for mmdc. My very first run took 12.6 s. That is the real cost the first time you render after a reboot, and it is what you feel in interactive use. Steady state is 3 s.
Surprise 2: The foreignObject Inversion
Non-browser SVG renderers — resvg, librsvg, Inkscape — do not implement <foreignObject>. Mermaid uses <foreignObject> for HTML labels by default, so its SVG output often loses all text outside a browser. The documented fix is htmlLabels: false, which makes Mermaid emit native <text> instead.
I expected the tool running real Mermaid to have this problem and the Rust reimplementation to have solved it. It is exactly backwards:
SVG output
<foreignObject>
<text>
mermaidx
0
9
merman-cli
18
0
mermaidx emits zero foreignObject — even for classDiagram and erDiagram, which the Mermaid docs say use it regardless of htmlLabels. Its DOM shim simply has no foreignObject path, so everything becomes native <text>. Its SVG opens correctly in anything.
merman is the opposite: its SVG is all foreignObject and no <text>. That is harmless for its PNG output, because merman rasterizes the labels itself in its own Rust pipeline. But its SVG will render textless in Emacs, Inkscape, or any librsvg-based viewer. That is the same class of gap I hit when
my Mermaid arrows disappeared in Emacs
— valid SVG, incomplete renderer, silent result.
This is the kind of thing that bites six months later. If you pick merman, write down that you must emit PNG, and why — otherwise switching to SVG looks like a free optimization and silently produces empty boxes.
Surprise 3: Font Metrics Clip Text
Approximating text measurement has a visible failure mode. Same class diagram, same flags, dark theme:
classDiagram
class Module {
+String name
+apply()
}
Module <|-- PostInstall
mermaidx:
merman-cli:
mermaidx clips “PostInstall”. Mermaid sized that box using the font mermaidx measured with, resvg rasterized with a different one, and the bold header overflowed. Note the dimensions from the table: 378×580 against mmdc’s 322×588 — wider and shorter. The layout genuinely differs, it is not a rasterizing artifact.
Both tools exited 0. Nothing warned me. I only caught it by looking at the picture, which is worth remembering when you automate diagram generation: an image renderer can fail successfully.
For comparison, the flowchart case where everything works. mmdc:
merman-cli:
What I Picked
merman-cli, for a renderer feeding PNGs into an editor:
24× faster than mmdc, and 125 ms is fast enough to feel synchronous
No browser, so the dependency is one declared binary
Pixel-identical layout to mmdc on both test diagrams
themeVariables honored, transparent PNG with a real alpha channel
A drop-in flag set: -i, -o, -t, -b, --scale, --configFile, and even -p/--puppeteerConfigFile accepted as a no-op, so existing mmdc command lines work unchanged
Clean failures: exit 1, no partial file written, and the diagram type plus the parse error named — for an unclosed node label it reports Diagram parse error (flowchart-v2) with Unterminated node label
Pick differently in two cases. If you need SVG that opens outside a browser, take mermaidx — it is the only one here that emits portable native <text>. If you need guaranteed upstream parity on unusual diagram types, keep mmdc, because it is upstream.
Caveats
merman is version 0.7.0 and a reimplementation, so an exotic diagram type may diverge from the Mermaid live editor. I verified flowchart, sequence, class, ER, mindmap and gitGraph render; I did not check all 35 families it claims, and “renders” is not “renders identically”. Everything here is one machine, one run of five, and one diagram per type. The timing ratios are large enough that I doubt the ordering is fragile, but the absolute numbers are not portable.
Also worth knowing if you install it: cargo install merman-cli --features png fails with “does not contain this feature: png”, despite documentation suggesting that flag. PNG is in the default features. Just cargo install merman-cli.
This command unloads the library that provided feature feature. It undefines all functions, macros, and variables defined in that library with defun, defalias, defsubst, defmacro, defconst, defvar, and defcustom.
Before restoring the previous definitions, unload-feature runs remove-hook to remove functions defined by the library from certain hooks. These hooks include variables whose names end in ‘-hook’ (or the deprecated suffix ‘-hooks’), plus those listed in unload-feature-special-hooks, as well as auto-mode-alist. This is to prevent Emacs from ceasing to function because important hooks refer to functions that are no longer defined.
If these measures are not sufficient to prevent malfunction, a library can define an explicit unloader named feature-unload-function.
function resolve(ctx, key)
fiber ← ctx.fiber
repeat
if key ∈ fiber.committed then return fiber.committed[key]
if key ∈ fiber.inject then throw INACTIVE_ACCESS
if fiber = root then throw UNDECLARED_ACCESS
fiber ← fiber.parent.fiber
ctx.get(key) is a lookup against the store that returns the bound value or nothing and never fails, whereas the proxy resolves against the accessing fiber’s own view and enforces the coeffect specification 𝑑 at the point of use.
The 𝖬𝖺𝗒𝖻𝖾(𝔈iter) continuation makes a boundary available between any two consecutive iterations… In this sense the effect iterator is a reified delimited continuation, the structure that mainstream languages expose through the yield operator, so the model maps directly onto the generators they already provide.
this is the substance of the outcome, which withholds a fiber whose effect function has shown itself to be unsound in the state it ran against rather than retrying it against an unchanged environment.
一个 effect function 已经在当前状态下证明了自己是 unsound 的,那就不要在环境没变的情况下重试它。
这个设计很干净,但它意味着 Cordis 的失败语义是全量回滚 + 拒绝重入,没有任何中间状态。
对比 Common Lisp 的 condition system 。那套东西的核心不是 “错误处理”,而是把"报告错误"和"决定怎么办"这两件事分开,并且在决定之前不退栈。底层函数 signal 一个 condition ,同时用 restart-case 声明几个可能的恢复路径;栈上层的 handler 看到这个 condition ,选一个 restart ;然后 从出错的那个点原地继续,栈从来没有被销毁过。
Cordis 为什么不做?我认为这是为形式化定理付的代价,不是疏忽。它的 metatheory 要证 confluence 和 progress ,而这两个定理都依赖于一个事实:所有的 outcome 只能经由 L-Unload 到达(论文原话:“Routing a failure like every other deactivation is what makes every outcome reachable only through L-Unload, which is the single fact Theorem 59 turns on”)。如果允许在 L-Raise 时不退栈、向外层暴露任意的 restart 闭包,状态机的变迁就变成非确定的了,所有关于 effect 生命周期成对映射的证明会一起崩掉。
Cordis reverts the old component’s tracked effects and reapplies the new component’s from a clean slate, so a component’s own in-memory state does not survive a reload unless placed in a longer-lived dependency, and layering DSU-style forward migration atop revertible effects is future work.
组件自己的内存状态不会挺过一次重载,除非你把它放进一个生命周期更长的依赖里。而 DSU 式的向前迁移,论文明说是 future work 。
Common Lisp 在 1988 年就把这个问题解决了。当一个类被重新定义时, CLOS 会自动对内存中所有现存实例调用 update-instance-for-redefined-class。看它的签名:
When make-instances-obsolete is invoked or when a class has been redefined and an instance is being updated, a property-list is created that captures the slot names and values of all the discarded-slots with values in the original instance. The structure of the instance is transformed so that it conforms to the current class definition.
(defmethodupdate-instance-for-redefined-class:before((posx-y-position)addeddeletedplist&key);; Transform the x-y coordinates to polar coordinates;; and store into the new slots.(let((x(getfplist'x))(y(getfplist'y)))(setf(position-rhopos)(sqrt(+(*xx)(*yy)))(position-thetapos)(atanyx))))
写完这个方法,然后重新 defclass 把 x/y 槽换成 rho/theta——内存中所有旧实例会自动迁移,笛卡尔坐标被算成极坐标存进新槽位。规范里那句注释说得很清楚:“All instances of the old x-y-position class will be updated automatically.”
What the operation does not check is the witness that 𝔈Γ∗ carries: the callback supplies an inverse, and that the inverse recovers the effect it accompanies is an obligation on the component author rather than a property the runtime verifies.
我的 Obsidian 知识库靠一个"AI 守门员" ( Obsidian Gatekeeper )打理:新笔记自动分类、打标签、提炼概念( High-Order Notes / HON ),以及做语义检索。这套系统最初跑在一台远程 Mac mini 上——Node.js 服务 + better-sqlite3 + sqlite-vec 向量扩展,手机端每次整理都要跨网络调它。
好用,但有代价。后来我把整条链路搬进了 iPhone 上的 Open Minis——一个内置 iSH ( Alpine Linux )终端环境的 AI 助手 App :原生编译向量扩展、用纯 Python 标准库写检索工具、把守门员规则注册进 Agent 技能系统让新会话也能自动识别。本文记录这次迁移的完整路径,分三步走,你可以在自己的设备上照着复现。
Emacs has an unusually flexible UI, but most of that flexibility still lives inside a rigid layout model. Buffers, windows, side windows, mode lines, minibuffers, and popups all compete for the same rectangular grid. Everything you see is, in the end, a region of that grid.
I wanted to try a different shape. Not another buffer fighting for space, but a small floating HUD pinned to the Emacs frame, sitting outside the normal layout entirely, with a modern visual style and a live data feed coming from Emacs Lisp.
The goal was never to replace Emacs buffers. It was to give Emacs a new kind of surface for information that should be glanceable, persistent, and visually compact: the sort of thing you want hovering in a corner, not occupying a window split.
The HUD Idea
The first concrete target was a workspace HUD: a card in the corner of the frame showing project status, git state, and whatever context is relevant to the buffer I happen to be editing.
That sounds simple, but it pushes against a handful of Emacs defaults. A normal buffer participates in the window layout, so it takes up space you have to manage. A popup tends to be transient and focus-sensitive, so it disappears the moment you look away. A mode line is wonderfully compact but visually boxed in. A child frame can genuinely float, but it asks for careful handling around sizing, positioning, focus, and cleanup.
What I wanted was closer to a small native overlay than to any of these: Emacs would still own all of the editor state, and the HUD would do nothing but render the state it was handed.
Getting there meant trying a few approaches and discarding most of them. The path below is roughly the order I worked through them.
Spike 1: A Text Child Frame
The first experiment was the most boring one on purpose: a plain Emacs child frame holding a text buffer.
It was boring, but it was also the experiment that proved the windowing idea was sound. A child frame can be parent-relative, undecorated, and non-focusable. It can be repositioned when the parent frame moves, and it can be kept out of the normal window-split layout. In other words, it can behave like an overlay rather than a window.
That was enough to confirm the HUD could exist as a stable surface. It was not enough for the look I was after. Text rendered into a buffer still reads as a clever mode line, not as a modern panel, and no amount of careful formatting was going to change that.
Spike 2: SVG in a Child Frame
So the next step was to render the card as SVG instead of text.
Visually, this got much closer to the target: rounded panels, custom spacing, real vector shapes, theme-aware colors. For a while it felt like the answer. Then the portability problem showed up.
SVG support in Emacs depends heavily on how Emacs was built. With solid librsvg support the result can look great, but on some macOS builds native SVG rendering is far more limited, and details like filters and CSS may not behave the same way from one machine to the next. SVG turned out to be a great design probe and a poor foundation. I could prototype the look, but I couldn’t depend on it.
Spike 3: A Native WebView
If I wanted a real rendering canvas, the obvious move was a real web view, so the third experiment reached for a native WebKit view through Appine.
The rendering model was genuinely attractive. A web view hands you a full browser canvas, and that opens the door to richer UI toolkits than anything Emacs renders natively. The trouble was lifecycle and composition rather than rendering. A persistent HUD has to coexist quietly with other web views and with ordinary Emacs use, and the native WebView wanted too much ownership over the viewport to do that gracefully. It was a promising route for an active web panel, but an awkward one for a background HUD that’s meant to stay out of the way.
Spike 4: xwidget-webkit
The fourth experiment used Emacs’ own built-in xwidget-webkit, and this was the first version where every piece fit together at once.
xwidget-webkit can host a browser surface directly inside Emacs. That surface can live inside a child frame, multiple WebKit sessions can coexist without stepping on each other, and the page itself can run WebAssembly. Best of all, Emacs Lisp can reach into the page and push data with xwidget-webkit-execute-script. That last point is what made the whole HUD idea practical: Emacs stays in charge, and the page just listens.
There were still some Emacs-specific rough edges to sand down. xwidget-webkit-new-session behaves like an interactive command and will happily disturb the user’s window layout, so the implementation saves and restores window configurations around session creation. The xwidget buffer also needs to be stripped of everything that marks it as a buffer — no mode line, no header line, no fringes, no line numbers — and tearing it down has to bypass the usual xwidget kill confirmation. None of that is hard once you know it’s there, and with those details handled, xwidget-webkit became a dependable host for the HUD.
The Current Architecture
The architecture that came out of these spikes is intentionally small, and the boundary it draws is the whole point: Emacs owns state, timing, and editor integration, while egui owns layout and drawing. Nothing crosses that line except JSON.
flowchart TD
subgraph emacs["Emacs"]
app["Application code workspace-hud.el"]
panel["egui-panel.el"]
server["Local asset server make-network-process"]
frame["Undecorated child frame"]
end
subgraph webkit["xwidget-webkit session"]
html["index.html shell hudPushState / hudPushTheme"]
wasm["egui / WASM renderer"]
end
app -->|"collect git + project state, as JSON"| panel
panel -->|"hosts"| frame
panel -->|"xwidget-webkit-execute-script"| html
server -->|"serve index.html + pkg/* over 127.0.0.1"| html
frame -->|"loads"| html
html -->|"replace state, request repaint"| wasm
wasm -->|"paints the card into"| frame
On the Emacs side, egui-panel.el does the heavy lifting. It starts a tiny local HTTP server with make-network-process, serves index.html and the generated WASM bundle from 127.0.0.1, creates the undecorated child frame, loads xwidget-webkit inside it, and pushes theme and state as JSON.
The local server is the one piece that looks like overkill until you hit the wall that requires it: WebKit refuses to instantiate WebAssembly from a file:// origin. Keeping a small server inside Emacs sidesteps that without dragging in npm, a global web server, or a separate daemon.
The renderer itself is an egui app compiled to WebAssembly. Its HTML shell exposes just two entry points:
Emacs calls those through xwidget-webkit-execute-script, and on the other side the WASM app swaps in the new state and asks egui to repaint. With that bridge in place, the workspace HUD demo collapses into a thin data source. A buffer, window, or save event triggers Emacs to gather project and git state, serialize it to JSON, hand it to xwidget-webkit, and let egui repaint:
buffer/window/save event
-> collect project and git state in Emacs Lisp
-> JSON payload
-> xwidget-webkit
-> egui repaint
Because the contract is just JSON in one direction, each side stays replaceable. Emacs doesn’t know how the card is drawn, and the renderer doesn’t know where the data came from.
Why This Feels Promising
The interesting result isn’t simply that the HUD works. It’s that Emacs gains a new UI surface without giving up any of the things that make Emacs worth using in the first place.
The editor stays Emacs Lisp-driven. The renderer is replaceable. And because the HUD lives outside the window layout, it never steals a split or forces itself into the buffer model — it just floats there, showing what Emacs tells it to show.
There’s still plenty left to explore: click-back from the HUD into Emacs commands, multiple panel roles, richer theming, and a tighter workspace design. But the core shape is settled, and it’s a short one:
Emacs Lisp state -> JSON -> xwidget-webkit -> egui/WASM HUD
For an idea that started as nothing more than “what if Emacs had a modern floating HUD?”, that’s a good place to have landed.
I’m building a real-time Mermaid preview for Markdown in Emacs. The idea is straightforward: grab a fenced Mermaid block, pipe it through mmdflux, get SVG back, and display it inline in the buffer.
It almost worked on the first try. Nodes rendered. Labels rendered. Edges rendered. But the arrowheads were gone.
The Broken Diagram
This block should obviously have arrows:
flowchart LR
Decls["Declarations package! · config-unit!"]
Elle["Elle backend"]
Runtime["Runtime helpers package-vc · unit exec · reload"]
Decls -->|export session data| Elle
Elle -->|emit forms via :eval| Runtime
I got lines connecting the nodes, but no arrowheads. A flowchart without arrows is just boxes and string.
The first debugging question writes itself:
Is mmdflux emitting bad SVG, or is Emacs failing to render valid SVG?
Checking the SVG
mmdflux supports text, SVG, and structured JSON output. I was using SVG.
Mermaid-style arrows are represented with <marker> definitions and marker-end references — the standard SVG mechanism for drawing arrowheads at the final vertex of a path. Nothing exotic.
I reduced the problem to a minimal SVG:
<svgxmlns="http://www.w3.org/2000/svg"width="220"height="80"><defs><markerid="arrow"viewBox="0 0 10 10"refX="10"refY="5"markerWidth="8"markerHeight="8"orient="auto"><pathd="M 0 0 L 10 5 L 0 10 z"fill="black"/></marker></defs><pathd="M 20 40 L 180 40"stroke="black"stroke-width="4"fill="none"marker-end="url(#arrow)"/></svg>
Then rendered it outside Emacs:
resvg arrow.svg arrow-resvg.png
sips -s format png arrow.svg --out arrow-sips.png
resvg drew the arrowhead. sips (Apple’s renderer) did not.
That was the answer. The SVG was fine. The rendering backend my Emacs build was using doesn’t support SVG markers.
The Emacs Build Detail
My custom macOS Emacs build uses the native image API:
--with-native-image-api
So Emacs happily reports SVG support:
(image-type-available-p'svg);; => t
But t here only means “I can load an SVG and put pixels on screen.” It says nothing about feature coverage. The native macOS image API doesn’t implement the full SVG spec — and <marker> is one of the gaps.
The proper solution is librsvg, which is what emacs-plus builds with by default and what the Emacs manual associates with SVG support. If you’re using a stock Homebrew Emacs build, you probably already have it and will never hit this.
Why I Didn’t Just Add librsvg
Because my Emacs build project,
ebuild
, has a strong constraint: the final binary should be static and self-contained.
Pulling librsvg from Homebrew would work, but it drags in a dynamic dependency stack. The whole point of the build is a single, mostly-static artifact — adding a runtime link against Homebrew’s library tree defeats that.
Building librsvg from source is the other option, and it’s not small. You’re taking on Rust/Cargo, cargo-cbuild, Meson, Cairo (with PNG support), FreeType, GLib, libxml 2, and Pango — with optional deps for GDK-Pixbuf, GObject introspection, Vala bindings, AVIF support, and more. Upstream also notes that reproducible builds need vendored Cargo dependencies, since Cargo wants to fetch crates at build time.
That’s not “add a library.” That’s importing a slice of the GNOME graphics stack into my build system. A much bigger project than fixing Mermaid preview arrows.
SVG stays as the interchange format — mmdflux already produces good SVG, and I don’t want to lose that. But before handing it to Emacs, I rasterize with resvg:
This sidesteps the broken marker rendering entirely.
Why PNG Is a Workaround, Not a Fix
A proper SVG renderer inside Emacs is the right answer. With librsvg, the preview stays vector-based, scales cleanly, and doesn’t need an intermediate rasterization step.
But for now, PNG is the right tradeoff:
Arrowheads render correctly.
No Homebrew librsvg linked into the final binary.
No vendored GNOME dependency chain to maintain.
mmdflux stays unchanged.
The bug wasn’t in Mermaid. It wasn’t in mmdflux. It was in the SVG rendering path of my Emacs build — a gap in feature coverage that only shows up when you hit the specific SVG features Mermaid relies on.
The practical fix: move the final rendering step to a tool that actually implements the spec.
I like to read agent output, but the default style talks too much. Every response starts with an apology or a promise. “I’d be happy to help,” “let me take a look,” “great question.” None of that moves me forward. I skip it every time. So I wanted something brief.
But pure brevity has its own problem, the agent moves faster than I read. If it compresses everything, I lose the thread. I stop understanding what just happened and why. Then I have to scroll back, re-read, reconstruct. That costs more time than the verbose version did.
The real bottleneck isn’t the agent’s output length. It’s whether my mental model can keep up with what the agent is doing.
That realization changed how I thought about the problem. It’s not about making everything short. It’s about knowing what deserves clarity and what gets compressed. The agent should spend attention on the essential signal, the thing I need to understand to make the next decision or stay oriented and collapse everything else to almost nothing.
I found that principle already existed in ops room communication doctrine. The operator doesn’t relay everything to the commander. They filter. The test is simple: does this change what happens next, or does it keep the commander’s situational awareness accurate enough for the decision after that? If neither, it doesn’t transmit.
That’s what I wanted from a coding agent. Not a butler, not a caveman. An ops room operator: brief by default, precise when it matters, always keeping me oriented enough to stay in the loop.
---
name: Ops Room
description: Brief by default, signal when it matters — keeps human mental model in sync with agent
keep-coding-instructions: true
---
Brief by default. Signal when it matters.
## Core Principle
The agent moves faster than the human reads. The job is not to document
everything — it is to move the human's mental model forward at each step.
Compress noise. Surface signal. Keep the human oriented.
## Voice
- Short sentences. Direct. Present tense.
- No preamble: no "let me", "I'll help you", "great question", "certainly".
- No apologies. No hedging. No restating the request.
- State findings and decisions directly.
## Orient Before Acting
One line of intent before any significant change. Not an explanation — an
anchor so the human knows what is about to happen.
- "Removing dead function in utils.py."
- "Splitting auth into two files — logic was mixed with routing."
- "Null check missing on line 42. Fixing."
Skip it for trivial or obviously-implied steps.
## Signal vs Noise
At each step, identify what the human *must* understand to stay oriented.
Give that part clarity. Compress or drop everything else.
**The test:** signal = changes the next action, OR keeps situational awareness
accurate enough to make the decision after that. Everything else is noise
regardless of how true or interesting it is.
**Signal — give it space:**- What was found and why, in one clause — so the human can reconstruct what happened
- A non-obvious choice and the one-line reason
- A risk or side-effect the human needs to know before proceeding
- The next decision point, if it belongs to the human
**Noise — compress or skip:**- Routine steps that match the request exactly
- Status confirmations once is enough ("Done.")
- Intermediate results the human does not need to act on
## Format
- Structure: Finding → Fix → Next.
- Prose under 3 lines for most responses. Expand only when the "why" is the signal.
- Lists only when there are genuinely multiple parallel items.
- High confidence: state the answer directly, no qualifiers.
- Low confidence: say so in one clause, then give the best answer anyway.
## Tone
- Neutral. Technical. No personality.
- Confident, not brash. Decisive, not dismissive.
- No humor. No cultural references. No filler.
Coming from Python, picking up Gleam required a fundamental shift in how I approach writing code. It’s not just learning new syntax—it’s adopting a different mental model. Here’s what clicked for me after spending time with the language.
Think in Function Signatures First
In Python, I often dive straight into implementation. I’ll start typing the function body and figure out the types as I go. Gleam pushed me toward a different workflow: define the function signature first, compose the overall flow, then implement the details.
// Step 1: Define the signatures
fnparse_config(raw:String)->Result(Config,ParseError)fnvalidate_config(config:Config)->Result(Config,ValidationError)fnapply_config(config:Config)->Result(Nil,ApplyError)// Step 2: Compose the flow
pubfnload_and_apply_config(path:String)->Result(Nil,ConfigError){useraw<-result.try(read_file(path))useconfig<-result.try(parse_config(raw))usevalidated<-result.try(validate_config(config))apply_config(validated)}// Step 3: Now implement parse_config, validate_config, etc.
This top-down approach forces me to think about the data flow and error cases before getting lost in implementation details. The compiler keeps me honest—I can’t just leave a TODO and move on without addressing the types.
Keep Code Flat with Early Returns
Nested code is harder to read. In imperative languages, we use early returns to bail out of functions. In Gleam, the use keyword with result.try achieves the same flat structure.
Instead of nesting Results:
// Nested and hard to follow
fnprocess_user(id:String)->Result(User,Error){casefetch_user(id){Error(e)->Error(e)Ok(user)->{casevalidate_user(user){Error(e)->Error(e)Ok(valid_user)->{caseenrich_user(valid_user){Error(e)->Error(e)Ok(enriched)->Ok(enriched)}}}}}}
Use result.try for flat, readable code:
// Flat and clear
fnprocess_user(id:String)->Result(User,Error){useuser<-result.try(fetch_user(id))usevalid_user<-result.try(validate_user(user))useenriched<-result.try(enrich_user(valid_user))Ok(enriched)}
Each use line acts like an early return. If any step fails, the function returns that error immediately. The happy path reads top to bottom.
Default Values with result.unwrap
When a failure isn’t fatal and you have a sensible default, result.unwrap keeps things simple:
importgleam/result// Instead of pattern matching for a default
lettimeout=caseparse_timeout(config){Ok(t)->tError(_)->30}// Use unwrap
lettimeout=result.unwrap(parse_timeout(config),30)// Or with a lazy default (computed only if needed)
letcache_size=result.lazy_unwrap(parse_cache_size(config),fn(){calculate_default_cache_size()})
Boolean Guards for Conditional Logic
bool.guard and bool.lazy_guard replace simple if-else patterns with a more functional style:
importgleam/boolfndivide(a:Int,b:Int)->Result(Int,String){use<-bool.guard(b==0,Error("division by zero"))Ok(a/b)}
The guard checks the condition. If true, it returns the second argument immediately. Otherwise, execution continues. lazy_guard delays evaluation of the fallback value:
fnget_cached_or_fetch(key:String)->Data{use<-bool.lazy_guard(cache_has(key),fn(){cache_get(key)})// Only runs if cache miss
letdata=fetch_from_database(key)cache_set(key,data)data}
Pattern Matching Multiple Variables
Gleam lets you match on tuples to handle combinations of values cleanly:
fnhandle_response(status:Status,body:Option(String))->String{casestatus,body{Success,Some(data)->"Got: "<>dataSuccess,None->"Success but empty"NotFound,_->"Resource not found"Error,Some(msg)->"Error: "<>msgError,None->"Unknown error"}}
This is cleaner than nested conditionals and makes all cases explicit. The compiler ensures I’ve covered every combination.
Thinking in Effect Types
The biggest mental shift was learning to think about effect types upfront. In Python, I might write a function and later realize it needs to do I/O or might fail. In Gleam, I ask myself before writing:
Does this function perform effects? If it reads files, makes network calls, or accesses mutable state, the return type should reflect that.
Can this function fail? Then it returns Result(T, E).
Might the value be absent? Then it returns Option(T).
// Pure function - no effects
fncalculate_total(items:List(Item))->Int{list.fold(items,0,fn(acc,item){acc+item.price})}// Effectful function - can fail
fnfetch_items(user_id:String)->Result(List(Item),DbError){// database call
}// Compose them with awareness of effects
fnget_user_total(user_id:String)->Result(Int,DbError){useitems<-result.try(fetch_items(user_id))Ok(calculate_total(items))}
Start Simple, Extract When Needed
I’ve adopted a pattern: write the basic case inline first, then extract helper functions for complex logic.
fnformat_name(user:User)->String{// Start with the basic case
caseuser.display_name{Some(name)->nameNone->user.first_name<>" "<>user.last_name}}// Later, when formatting gets complex, extract it
fnformat_name(user:User)->String{user.display_name|>option.lazy_unwrap(fn(){build_full_name(user)})}fnbuild_full_name(user:User)->String{[user.first_name,user.middle_name,user.last_name]|>list.filter(fn(s){s!=""})|>string.join(" ")}
This keeps the initial implementation simple and makes refactoring straightforward.
Wrapping Up
Gleam’s type system isn’t a constraint—it’s a design tool. By thinking in types first, handling errors explicitly, and using the standard library’s Result and Option combinators, I write code that’s easier to reason about and harder to break.
The functional programming patterns took time to internalize, but now they feel natural. Each function declares its effects in its type signature. Each error case is handled explicitly. And the compiler catches the mistakes before they become bugs.