My daily editor is Helix. I never got deep into Vim, so switching to Helix cost me nothing in terms of adjustment — it actually felt more natural. Helix works great out of the box, and the “select-then-act” editing model clicks with me more intuitively than Vim’s “verb-then-noun” approach.
Helix mainline doesn’t have a plugin system, which has long been one of the biggest pain points in the community. However, mattwparas maintains a fork (the steel-event-system branch) that integrates Steel — an embedded Scheme implementation — as a plugin runtime. Scheme is a dialect of Lisp, and Steel is a dialect of Scheme, so you can probably guess what writing plugins looks like: lots of parentheses.
I’ve packaged this fork on the AUR: helix-steel-git, so Arch users can install it directly.
I’d known about this fork for a while but never actually used it — simply because parentheses genuinely scared me. But recently I fully abandoned VSCode and switched to Helix as my one and only editor, and with that came an increasingly strong need for plugins. The Steel ecosystem has barely any usable plugins right now; if you want something, you basically have to write it yourself. So I finally bit the bullet and started writing Scheme.
Round One: Let the LLM Do It
The first step was copying a template from an existing community plugin, creating the wakatime.hx repo, and then just dumping my requirements into Claude to generate the first version. After all, I knew nothing about Lisp — I had to look up even basic syntax on the fly.
Claude did give me a working version. The problem was — LLM-generated Lisp is somehow even more painful to read than hand-written Lisp. Take this snippet for getting the editor version:
(define (editor-version)
(if *wakatime-editor-version*
*wakatime-editor-version*
(begin
(let ([spawned (spawn-process
(with-stdout-piped (command "hx" (list "--version"))))])
(if (Ok? spawned)
(let ([output (wait->stdout (Ok->value spawned))])
(if (Ok? output)
(let ([version (extract-version (Ok->value output))])
(set! *wakatime-editor-version* version)
version)
"unknown"))
"unknown")))))
Nested if-let-if-let-if, peeling through Ok? results layer by layer — a textbook case of “the LLM doesn’t know the idiomatic way to write this language, so it falls back to the most conservative pile of logic.” It runs, but reading it is a headache.
Learning Some Scheme
After patching up the LLM-generated mess for a while, I realized this wasn’t sustainable — I couldn’t even read my own code, so debugging was hopeless. I decided to stop and spend some time actually learning the fundamentals of Scheme.
I had background in ML and Haskell, and had always heard that Lisp was a functional language too, so I figured it couldn’t be that hard to pick up. The core concepts of Scheme turned out to be genuinely simple: S-expressions, define, lambda, let, cond, map/filter/fold — if you’ve written any FP language, you can grasp these in minutes. No complex type system, no monads, no trait bounds, barely any syntax at all — everything is a list, everything is an expression.
But the parentheses really do mess with your head. In Haskell you use indentation and $ to organize structure; in ML you have let...in and match...with as visual anchors. In Scheme it’s just parentheses, all the way down. By the third level of nesting you lose track of which closing paren goes with which opening one.
Making the Code Readable
I started thinking about how to improve readability.
Step one: break it into functions. If parentheses make your head spin once there are too many, push them out of the way. Take send-heartbeat! from earlier — the original crammed “validate path → throttle check → build args → spawn process → wait for result → error handling” into a single function, six levels deep. After breaking it up:
(define (send-heartbeat-for-path! path is-write lineno cursorpos)
(cond
[(not (trackable-path? path)) (warn-untrackable-document!)]
[(not (should-send-heartbeat? path is-write)) #f]
[else
(record-heartbeat-instant! path)
(log-heartbeat-start! path is-write)
(spawn-heartbeat-thread! path is-write lineno cursorpos)]))
One branch per line, logic at a glance. trackable-path?, should-send-heartbeat?, and spawn-heartbeat-thread! are each their own small function, each simple when read on its own. Scheme has no type signatures to help you understand intent — the function name is the only documentation. The more granularly you split things up and the better you name them, the more readable the code becomes.
Step two: dig through the Steel API. The Steel Book often only lists an API name without real documentation, but you can usually guess the purpose from the name. Digging through it turned up some useful finds — like ~>, a threading macro that works like a pipeline operator, flattening nested calls into a linear data flow:
;; Before: nested spawn-process + wait->stdout + extract-version
(define (detect-version-with command-name)
(~> (command command-name '("--version"))
with-stdout-piped
spawn-process
(ok-and-then wait->stdout)
(ok-and-then extract-version)))
There’s also ok-and-then (similar to Rust’s and_then) for chaining Result handling, unwrap-or for default values, and when/unless as single-branch alternatives to if — small things, but together they take the code from “barely legible” to “actually comfortable.”
That said, Steel has one real gotcha: it has two error-handling mechanisms living side by side. One is the traditional Scheme exception system — caught with call-with-exception-handler, thrown with raise. The other is a Result type imported from the Rust side, with explicit Ok/Err returns. Some APIs return Result (like spawn-process), others throw exceptions directly (like split-whitespace on an empty string). Using them together is deeply awkward — you’re happily chaining ok-and-then, and then one function in the middle doesn’t return a Result but just blows up, and the whole chain falls apart.
To let myself comfortably chain everything with Result, I wrote a macro that converts exception-style errors into Result:
(define-syntax try-result
(syntax-rules ()
[(try-result body ...)
(call/cc (lambda (k)
(call-with-exception-handler (lambda (exn) (k (Err exn)))
(lambda ()
(Ok (begin body ...))))))]))
It captures the current continuation with call/cc, and in the exception handler jumps out and returns Err. Normal execution wraps the result in Ok. Any expression that might throw can be wrapped in try-result and then plugged seamlessly into an ok-and-then chain. For example, extract-version uses both split-whitespace and second, either of which might blow up — wrap them and it’s safe:
(define (extract-version output)
(~> output trim split-whitespace second try-result))
Anyone who writes Rust should recognize this pattern immediately — it’s essentially a hand-rolled poor-man’s ? operator.
With this combination — splitting into functions, using threading macros, unifying error handling — readability improved significantly. More importantly, debugging stopped being a nightmare. Before, when something went wrong, all you could do was stare at a wall of nested logic and guess. Now each small function has a single responsibility, and when a step fails you can pinpoint it immediately. Scheme has no sophisticated debugging tools; the only thing you can rely on is the code itself being clear.
Plugin String: a Debugging Saga Over One Tiny Field
Once the code structure was sorted out, the next problems were on the business logic side.
WakaTime’s --plugin parameter identifies the editor and plugin information, and the backend uses it to categorize statistics. Sounds like it’s just string concatenation — but in practice, WakaTime’s official backend and Wakapi (the self-hosted open-source alternative) parse this field differently, and Wakapi is extremely strict about it.
The version Claude originally generated assembled three segments: helix/24.7 helix-steel-wakatime/0.1.0 helix-wakatime/0.1.0 — and Wakapi parsed the editor name incorrectly. Trimming it to two segments, helix/24.7 wakatime-hx/0.1.0 — Wakapi still rejected it. Its user-agent parser requires the plugin name to strictly match the pattern <editor>-wakatime.
After several commits of back-and-forth, I landed on:
(define *wakatime-plugin-name* "helix-wakatime")
;; Produces: "helix/24.7 helix-wakatime/0.1.0"
Only helix-wakatime satisfies both sides: WakaTime’s official backend accepts it, and Wakapi can correctly parse helix as the editor name from it. One string concatenation, three attempts to get right — this kind of problem has no documentation; you can only figure it out by trying.
Debouncing and Throttling
The core job of a WakaTime plugin is to send heartbeats at the right moment. But “the right moment” isn’t simple — you can’t call wakatime-cli on every keypress; the CPU and network overhead would be unbearable. Many community plugins implement throttling: only one activity heartbeat per file per 2 minutes. That’s fine, but it’s not enough.
Throttling solves the “sending too many” problem, but not the “sending too early” problem. Imagine you open a file and quickly type a line of code — every keypress fires a document-changed event, the first keypress passes the throttle check and sends a heartbeat, and then all the edits in the next 2 minutes get swallowed. The result: the heartbeat’s timestamp is the moment you started typing, not the moment you stopped. For aggregate statistics it doesn’t matter much, but if you care about heartbeat time precision (for instance, viewing a timeline in Wakapi), the offset is noticeable.
So I added debouncing on top: an edit event doesn’t immediately send a heartbeat — instead it starts a 2-second delayed callback. If another edit comes in within those 2 seconds, the old callback is discarded and the timer resets. Only after you’ve actually stopped for 2 seconds does a heartbeat go out.
The implementation uses a generation counter: each edit increments the counter for that file, and when the delayed callback fires, it checks whether the counter still matches the value from when it was scheduled — if not, a new edit came in since then, and the callback silently exits:
(define (schedule-idle-heartbeat-for-path! doc-id path)
(let ([generation (bump-doc-generation! path)])
(enqueue-thread-local-callback-with-delay
*wakatime-idle-delay-ms*
(lambda () (send-idle-heartbeat-if-current! doc-id path generation)))))
(define (idle-heartbeat-current? doc-id path generation)
(and (editor-doc-exists? doc-id) (= (doc-generation path) generation)))
This is cleaner than maintaining a timer ID and canceling it — nothing needs to be canceled; stale callbacks just know to exit quietly on their own. Debounce first, then throttle — two layers of filtering, and heartbeats are neither too frequent nor too early.
Wrap-up
After writing this plugin, my impression of Steel is much better than I expected. The API coverage is quite solid: register-hook lets you listen for document open, save, close, content change, selection change, and more; editor-document->path, editor-document->language, and editor->doc-id give you direct access to editor state; spawn-native-thread lets you push expensive work to a background thread; enqueue-thread-local-callback-with-delay provides native delayed callbacks; instant/now + duration-since for time calculations; even process management (spawn-process, wait, wait->stdout) and Result chaining (ok-and-then, unwrap-or) are all there. For an editor plugin runtime, this API surface is already quite complete — I’d argue it’s even more modernly designed than some established editors’ plugin interfaces (yes, I’m talking about Emacs).
If Steel ever gets merged into Helix mainline, the potential would be enormous. The biggest barrier right now is probably not technical — it’s Scheme itself, which will turn a lot of people away. Honestly, when it comes to FP, I still prefer the style of Haskell or F#: a type system to catch your mistakes, pattern matching that makes code self-documenting, no swimming in a sea of parentheses. But then again, Haskell and F# runtimes are both far too heavy for embedding, and Steel as a pure-Rust Scheme implementation has extremely low embedding overhead — which is probably the most pragmatic reason it was chosen.
The plugin repo is at wakatime.hx, ready to use if you have helix-steel installed.