HomeAboutPostsTagsProjectsRSS
┌─
ARTICLE
─┐

└─
─┘

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:

window.hudPushState(json)
window.hudPushTheme(json)

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.

The project is available at GitHub - nohzafk/emacs-workspace-hud: A floating workspace status HUD for Emacs, showing Git, LSP, and diagnostic state in a WebAssembly-powered egui card. · GitHub , and it’s also extensible to add more sections to the HUD.

┌─
ARTICLE
─┐

└─
─┘

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:

<svg xmlns="http://www.w3.org/2000/svg" width="220" height="80">
  <defs>
    <marker id="arrow"
            viewBox="0 0 10 10"
            refX="10" refY="5"
            markerWidth="8" markerHeight="8"
            orient="auto">
      <path d="M 0 0 L 10 5 L 0 10 z" fill="black"/>
    </marker>
  </defs>
  <path d="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.

So I chose a workaround.

The Workaround: Rasterize Before Display

The pipeline becomes:

Mermaid source → mmdflux → SVG → resvg → PNG → Emacs buffer

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:

resvg diagram.svg diagram.png

Then display the PNG:

(create-image png-file 'png nil
              :ascent 'center
              :scale 1
              :max-width max-width
              :max-height max-height)

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.

valid SVG + capable rasterizer = correct preview

Sources

┌─
ARTICLE
─┐

└─
─┘

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.
┌─
ARTICLE
─┐

└─
─┘

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
fn parse_config(raw: String) -> Result(Config, ParseError)

fn validate_config(config: Config) -> Result(Config, ValidationError)

fn apply_config(config: Config) -> Result(Nil, ApplyError)

// Step 2: Compose the flow
pub fn load_and_apply_config(path: String) -> Result(Nil, ConfigError) {
  use raw <- result.try(read_file(path))
  use config <- result.try(parse_config(raw))
  use validated <- 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
fn process_user(id: String) -> Result(User, Error) {
  case fetch_user(id) {
    Error(e) -> Error(e)
    Ok(user) -> {
      case validate_user(user) {
        Error(e) -> Error(e)
        Ok(valid_user) -> {
          case enrich_user(valid_user) {
            Error(e) -> Error(e)
            Ok(enriched) -> Ok(enriched)
          }
        }
      }
    }
  }
}

Use result.try for flat, readable code:

// Flat and clear
fn process_user(id: String) -> Result(User, Error) {
  use user <- result.try(fetch_user(id))
  use valid_user <- result.try(validate_user(user))
  use enriched <- 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:

import gleam/result

// Instead of pattern matching for a default
let timeout = case parse_timeout(config) {
  Ok(t) -> t
  Error(_) -> 30
}

// Use unwrap
let timeout = result.unwrap(parse_timeout(config), 30)

// Or with a lazy default (computed only if needed)
let cache_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:

import gleam/bool

fn divide(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:

fn get_cached_or_fetch(key: String) -> Data {
  use <- bool.lazy_guard(cache_has(key), fn() { cache_get(key) })
  // Only runs if cache miss
  let data = 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:

fn handle_response(status: Status, body: Option(String)) -> String {
  case status, body {
    Success, Some(data) -> "Got: " <> data
    Success, None -> "Success but empty"
    NotFound, _ -> "Resource not found"
    Error, Some(msg) -> "Error: " <> msg
    Error, 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
fn calculate_total(items: List(Item)) -> Int {
  list.fold(items, 0, fn(acc, item) { acc + item.price })
}

// Effectful function - can fail
fn fetch_items(user_id: String) -> Result(List(Item), DbError) {
  // database call
}

// Compose them with awareness of effects
fn get_user_total(user_id: String) -> Result(Int, DbError) {
  use items <- 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.

fn format_name(user: User) -> String {
  // Start with the basic case
  case user.display_name {
    Some(name) -> name
    None -> user.first_name <> " " <> user.last_name
  }
}

// Later, when formatting gets complex, extract it
fn format_name(user: User) -> String {
  user.display_name
  |> option.lazy_unwrap(fn() { build_full_name(user) })
}

fn build_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.

┌─
ARTICLE
─┐

└─
─┘

Emacs’s markdown-mode offers several preview options, but finding one that “just works” took some exploration.

The xwidget-webkit Approach

My first attempt used markdown-live-preview-window-function with xwidget-webkit—Emacs’s embedded WebKit browser. The idea: render markdown to HTML and display it in a split window.

(defun my/markdown-live-preview-window-xwidget (file)
  (xwidget-webkit-browse-url (concat "file://" file))
  (xwidget-webkit-buffer))

Four problems killed this approach:

  1. External dependency — HTML generation requires markdown (default) or pandoc binary
  2. Emacs build requirement — xwidget support must be compiled in (--with-xwidgets), which isn’t universal
  3. Temp file pollution — Live preview generates HTML files that require cleanup
  4. Complexity — Managing the xwidget buffer lifecycle adds code I’d rather not maintain

The grip-mode Solution

grip-mode provides GitHub-flavored markdown preview using a local server. The key insight: use go-grip instead of Python’s grip to avoid GitHub API rate limits and work fully offline.

Setup

Install go-grip:

go install github.com/chrishrb/go-grip@latest

Configure Emacs:

(use-package grip-mode
  :config
  (setopt grip-command 'go-grip)
  (setopt grip-preview-use-webkit nil)
  (setopt grip-update-after-change nil)
  :bind (:map markdown-mode-map
         ("C-c C-c p" . grip-mode)))

Now C-c C-c p launches a local server and opens the preview in your default browser. The preview updates on save.

Why go-grip Over Python grip

The original Python grip uses GitHub’s Markdown API, which has rate limits (60 requests/hour unauthenticated). You can add a GitHub token, but that’s extra configuration.

go-grip renders locally using a Go markdown library—no network requests, no rate limits, no authentication.

Comparison

Concernxwidget-webkitgrip-mode + go-grip
External binaryRequires pandocgo-grip (single binary)
Emacs buildRequires –with-xwidgetsAny build
Temp filesGenerates HTMLNone
RenderingBasic HTMLFull GFM
OfflineYesYes

Sometimes the best solution is a small, focused tool that does exactly one thing well.

┌─
ARTICLE
─┐

└─
─┘

I frequently work with escaped SQL strings from API logs and debugging sessions. The typical workflow involved copying the string, finding an online unescaper, pasting, copying the result, then finding a SQL formatter, pasting again… you get the idea. Too many context switches.

I wanted something like M-| (shell-command-on-region) but with predefined commands I could invoke by name. CLI2ELI made this trivial.

The Problem

When debugging data pipelines, I often encounter JSON-escaped SQL like this:

"SELECT date_trunc('month', at_timezone(kpis.\"time\",'UTC')) AS time\nFROM \"prod_analytics\".\"public\".\"machines_kpis_30_min\" kpis\nWHERE kpis.\"machine_id\" IN (UUID '3ee28f49-6792-48f9-9ca9-ba6f86d73753')"

I need to:

  1. Unescape the JSON string
  2. Format the SQL for readability

With M-|, I’d have to type jq -r '.' | sqlfmt - every time. Not hard, but tedious when you do it dozens of times a day.

The Solution: CLI2ELI with stdin Support

CLI2ELI wraps CLI tools as named Emacs commands. With the new stdin property, I can pipe buffer or region content directly to commands.

Here’s my configuration in cli-transform.json:

{
  "tool": "cli-transform",
  "cwd": "default",
  "commands": [
    {
      "name": "unescape SQL",
      "description": "Unescape JSON-escaped SQL string",
      "command": "jq -r '.'",
      "stdin": "region"
    },
    {
      "name": "format SQL",
      "description": "Format SQL using sqlfmt",
      "command": "sqlfmt -",
      "stdin": "region"
    },
    {
      "name": "unescape and format SQL",
      "description": "Unescape and format in one step",
      "command": "jq -r '.' | sqlfmt -",
      "stdin": "region"
    }
  ]
}

That’s it. Three lines per command.

Usage

  1. Select the escaped SQL string
  2. M-x cli-transform-unescape-and-format-sql
  3. Formatted SQL appears in the output buffer

The output buffer shows the command in the header line, making it clear what ran. Copy the result and move on.

The stdin Property

The stdin field accepts two values:

  • "region": Selected text, or entire buffer if no selection
  • "buffer": Always uses entire buffer content

This covers most text transformation use cases.

More Examples

Once you have the pattern, adding more transforms is trivial:

{
  "name": "format JSON",
  "command": "jq '.'",
  "stdin": "region"
},
{
  "name": "minify JSON",
  "command": "jq -c '.'",
  "stdin": "region"
},
{
  "name": "base64 decode",
  "command": "base64 -d",
  "stdin": "region"
},
{
  "name": "url decode",
  "command": "python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read()))'",
  "stdin": "region"
}

Any CLI tool that reads from stdin works.

Why CLI2ELI?

Named commands: M-x cli-transform-format-json is discoverable and memorable. No need to recall exact command syntax.

JSON configuration: No Elisp required. Adding a new transform takes 30 seconds. More importantly, JSON is trivial for AI coding agents to generate. Ask Claude or Copilot to “add a command that converts CSV to JSON” and it can produce the correct JSON config immediately. Try asking it to write the equivalent Elisp—much harder to get right.

Composable: Pipe multiple tools together in the command field. Unix philosophy meets Emacs.

Consistent interface: All transforms work the same way—select text, run command, get output.

Getting Started

  1. Install CLI2ELI from GitHub
  2. Create a JSON config file with your transforms
  3. Load it: (cli2eli-load-tool "~/path/to/config.json")
  4. Start transforming

The barrier to entry is low. Define a command in JSON, reload, use it. When you find yourself typing the same shell pipeline repeatedly, wrap it in CLI2ELI.

┌─
ARTICLE
─┐

└─
─┘

Markdown files deserve the same format-on-save treatment we give to code. I recently integrated https://github.com/rvben/rumdl , a Rust-based markdown linter, into my Emacs setup using Apheleia. Here’s what I learned.

Why rumdl?

rumdl is fast—benchmarks show it processing 478 markdown files in under a second. It implements 54 lint rules, supports automatic fixing, and provides stdin/stdout support for editor integration. That last feature is key for Apheleia.

Apheleia Configuration

Apheleia expects formatters to read stdin and write to stdout. Configuration follows a two-step pattern:

;; 1. Define the formatter command
(setf (alist-get 'rumdl apheleia-formatters)
      '("rumdl" "fmt" "--stdin"))

;; 2. Associate with major modes
(setf (alist-get 'markdown-mode apheleia-mode-alist) 'rumdl)
(setf (alist-get 'gfm-mode apheleia-mode-alist) 'rumdl)

With apheleia-global-mode enabled, markdown files now format automatically on save.

Format-on-save for markdown eliminates the mental overhead of consistent formatting. rumdl handles it fast enough that you won’t notice it’s running.

┌─
ARTICLE
─┐

└─
─┘

I recently explored an interesting architecture pattern: using Claude Code to invoke Gemini CLI for large codebase analysis. The idea was compelling—combine Claude’s superior instruction-following with Gemini’s massive context window. Gemini reads everything, Claude thinks and acts. reddit post

After building it out, I deleted it. Here’s what I learned.

The Pitch

The setup is straightforward. Gemini CLI supports a non-interactive mode (gemini -p) that accepts a prompt and returns a response. You can include files with @ syntax:

gemini -p "@src/ @lib/ Find all authentication patterns. Return file:line for each."

The theory: Claude’s context fills up fast when exploring large codebases. Gemini can ingest everything at once. Let Gemini do the bulk reading, get structured results back, then let Claude reason about what to do.

The Critical Design Insight

If you’re orchestrating one AI to call another, output format is everything.

Gemini’s natural response looks like this:

The authentication system appears to be implemented across several files, primarily in the src directory, where we can observe patterns suggesting a JWT-based approach combined with session management…

Useless. You need this:

src/middleware/auth.ts:15 - JWT validation middleware src/services/user.ts:42 - user lookup by token src/db/sessions.ts:8 - session storage interface

The fix is explicit format instructions in every query:

gemini -p "@src/ @lib/ <QUESTION>

Return findings as:
- file:line - description
- Include relevant code snippets (brief)
- Direct answers, no preamble"

This transforms vague prose into actionable data Claude can immediately use with its Read tool.

Why I Killed It

For my actual workflow, the gains didn’t materialize. Here’s the honest breakdown:

TaskNative approachDoes Gemini help?
Find specific patternast-grep or GrepNo—these are precise
Read known filesRead toolNo
Trace end-to-end flowExplore agentMarginal at best
“Does X exist anywhere?”GrepMaybe, if pattern is fuzzy
First pass on unfamiliar massive codebaseMultiple searchesYes—genuine win

The problem: my codebase is well-structured and familiar. Targeted search followed by reading specific files already works well. The Explore agent (a subagent that investigates across files and reports back) already handles the “understand how X works” case.

The deeper issue: Gemini “seeing everything at once” sounds powerful, but understanding code flow is inherently sequential. A request hits middleware, then a handler, then a service, then a database. I need to trace that chain. Dumping all files into context doesn’t shortcut the reasoning.

And there’s the output problem—even with structured results, I still need to Read the files Gemini identified before I can act. I’ve added a step, not removed one.

When It Actually Helps

The pattern works when:

  • The subordinate has a capability the primary lacks (Gemini’s context window genuinely is larger)
  • The task requires bulk access (onboarding to a 500-file unfamiliar codebase)
  • You’ve solved the output problem with structured format enforcement

If you build it, bake format instructions into every query template and always verify by reading the files the subordinate identifies before acting.

The Takeaway

Before adding orchestration complexity, ask: “What’s actually the bottleneck?” If it’s reasoning, more data access won’t help. For most daily work on a familiar codebase, targeted search plus following the import graph wins.

┌─
ARTICLE
─┐

└─
─┘

Rendering

After years of using WezTerm, I decided to try Ghostty—the new GPU-accelerated terminal that’s been generating buzz. The installation was simple enough, but getting it to look like my carefully tuned WezTerm setup turned into a journey through terminal rendering differences.

Here’s what I learned, and the configuration that finally got Ghostty looking right (almost).

The Problem: Everything Looks Wrong

Opening Ghostty for the first time on my Macbook, something felt off. The colors appeared washed out, the fonts looked thin, and the text seemed more spread out than in WezTerm. Same font (IosevkaTerm Nerd Font Mono), same size—completely different appearance.

This wasn’t just my imagination. These are documented issues in the Ghostty community.

Fix #1: Washed Out Colors

The most jarring difference was color saturation. My Gruvbox Light theme looked faded, like viewing it through a fog.

The fix:

window-colorspace = display-p3

That’s it. One line. Ghostty defaults to sRGB, but on macOS displays, display-p3 provides the color saturation you expect. This is a https://github.com/ghostty-org/ghostty/discussions/3470 that trips up many new users.

Fix #2: Thin Font Rendering

With colors fixed, the fonts still looked anemic. In WezTerm, I use:

config.font = wezterm.font({ family = "IosevkaTerm Nerd Font Mono", weight = "Bold" })

This loads the actual Bold variant of the font. Ghostty doesn’t support specifying font weight this way. Instead, it offers synthetic bolding:

font-thicken = true
font-thicken-strength = 150

The font-thicken option (macOS only) artificially adds stroke weight. The font-thicken-strength parameter (0-255) lets you dial in exactly how much—a feature https://github.com/ghostty-org/ghostty/discussions/3492 .

The catch: Synthetic bold isn’t true bold. A properly designed Bold font variant has intentionally adjusted proportions. Synthetic bold just makes everything uniformly thicker. You’ll notice subtle differences—the bowl of a “d” becomes rounder, letterforms feel slightly different. Whether this matters depends on your sensitivity to typography.

Fix #3: Wide Letter Spacing

Even after the above fixes, text in Ghostty appeared more spread out horizontally. This is another https://github.com/ghostty-org/ghostty/discussions/3842 roughly 1 pixel difference in letter spacing.

adjust-cell-width = -5%

This tightens the horizontal spacing. Some users go as far as -10%, but I found -5% matched WezTerm closely enough.

The Complete Configuration

Here’s my final Ghostty config, matching my WezTerm setup as closely as possible:

# Font
font-family = IosevkaTerm Nerd Font Mono
font-size = 16
font-thicken = true
font-thicken-strength = 150
adjust-cell-width = -5%

# Fix washed out colors on macOS
window-colorspace = display-p3

# Gruvbox Light Soft (base16) - matching WezTerm
background = f2e5bc
foreground = 504945
cursor-color = 504945
selection-background = d5c4a1
selection-foreground = 504945

palette = 0=#f2e5bc
palette = 1=#9d0006
palette = 2=#79740e
palette = 3=#b57614
palette = 4=#076678
palette = 5=#8f3f71
palette = 6=#427b58
palette = 7=#504945
palette = 8=#bdae93
palette = 9=#9d0006
palette = 10=#79740e
palette = 11=#b57614
palette = 12=#076678
palette = 13=#8f3f71
palette = 14=#427b58
palette = 15=#282828

# Window
background-opacity = 0.96
window-padding-x = 8
window-padding-y = 0
macos-titlebar-style = hidden
confirm-close-surface = false

Ghostty vs WezTerm: Honest Comparison

AspectWezTermGhostty
Font weight controlTrue bold via weight = “Bold”Synthetic via font-thicken
Color saturation“Correct” by defaultRequires window-colorspace = display-p3
Letter spacingTighterWider (fix with adjust-cell-width)
Config formatLua (powerful, verbose)INI-style (simple, limited)
Hot reloadAutomaticManual (Cmd+Shift+,)

Should You Switch?

Ghostty is fast, lightweight, and under active development—it has real potential. But after all the tweaking needed to match WezTerm’s appearance (and still not quite getting there), I’m sticking with WezTerm for now. It just works out of the box.

I’ll revisit Ghostty in a few months when it’s more mature. For now, the config above gets it 90% of the way there. Whether that last 10% matters is up to you.


Resources:

┌─
ARTICLE
─┐

└─
─┘

Teaching Claude Code to Use ast-grep

I wanted Claude Code to understand the difference between searching for code structure and searching for plain text. That meant teaching it when to reach for ast-grep instead of ripgrep—and to make that decision automatically.

This is how I taught it to do that.

Figuring Out the Right Tools

Claude Code can be extended in several ways—skills, commands, hooks, sub-agents, MCP servers, and plugins.

Each serves a different purpose, but in this case, I needed two working together:

  • MCP (Model Context Protocol) to connect the actual ast-grep binary as an external tool.
  • Skill to teach Claude when and why to use that tool.

Think of it like this:

  • MCP gives Claude new abilities.
  • Skills give it judgment.

I didn’t want to type /ast-grep every time. I wanted Claude to decide on its own.

Step 1: Adding the ast-grep MCP Server

The first step was to register the ast-grep MCP server with Claude Code.

Once connected, it exposes several tools that Claude can call directly:

  • mcp__ast-grep__find_code — search code using structural patterns
  • mcp__ast-grep__find_code_by_rule — advanced YAML-based rule matching
  • mcp__ast-grep__dump_syntax_tree — inspect syntax trees
  • mcp__ast-grep__test_match_code_rule — test custom rule

After this, Claude had full access to ast-grep—but it still didn’t know when to use it.

That’s where the Skill came in.

Step 2: Teaching Strategy with a Skill

I created a new skill file at:

~/.claude/skills/ast-grep/SKILL.md

The goal was simple: teach Claude how to decide when to use ast-grep versus ripgrep.

Here’s the essence of what I wrote:

---
name: ast-grep
description: Use ast-grep for structural code search. Fall back to ripgrep for plain-text searches.
---

# ast-grep: Strategic Code Search Guidance

## Core Principle

**ast-grep = Code structure** (syntax-aware, AST-based)  
**ripgrep = Plain text** (fast, content-based)

## Decision Tree

Is this about CODE STRUCTURE?
├─ YES → Use ast-grep MCP tools
│   Examples:
│   ✓ Find function or method definitions
│   ✓ Locate class declarations
│   ✓ Search for loops or conditional patterns
│   ✓ Refactor code using syntax patterns
└─ NO → Use ripgrep
    Examples:
    ✓ Search comments or docs
    ✓ Find TODO or FIXME markers
    ✓ Scan config files or logs

This gave Claude a clear rule of thumb:

  • ast-grep for anything syntax-aware
  • ripgrep for everything else

I also added a few anti-patterns—things Claude should avoid:

  • ❌ Don’t use ast-grep for plain text
  • ❌ Don’t use ripgrep for structured code
  • ✅ Use the right tool based on intent, not habit

That’s it. The skill didn’t try to re-document every ast-grep parameter.

It just provided strategic guidance—the kind of context a human developer would know instinctively.

Step 3: Telling Claude Code to Use the Skill

Add this line to your project’s CLAUDE.md:

Prefer ast-grep over Grep for structural code searches.

Or use the quick memory shortcut—type # Prefer ast-grep over Grep for structural code searches. and Claude Code will prompt you to save it.

What I Learned

The key takeaway was separation of responsibility:

  • MCP handles what tools exist and how they work.
  • Skills handle when and why to use them.

Keeping those layers distinct made everything easier to maintain:

  • MCP updates don’t break the skill.
  • Skill logic evolves independently.
  • Claude only loads the skill when it’s relevant.

It also keeps context light—since Skills use progressive disclosure, they load only when Claude detects the topic applies.

Final Thoughts

Teaching Claude to use ast-grep wasn’t just about wiring up another tool.

It was about teaching judgment.

By combining an MCP server (for capability) with a Skill (for reasoning), I gave Claude the intuition to pick the right search tool for the job—without me telling it what to do.

That’s the essence of extending Claude Code effectively:

tools give power, skills give intelligence.

References