2025-07-05 08:00:00
“Like fingers pointing to the moon, other diverse disciplines from anthropology to education, behavioral economics to family counseling, similarly suggest that the skillful management of attention is the sine qua non of the good life and the key to improving virtually every aspect of your experience.” — Winifred Gallagher
Our attention is being stolen.
We’re slowly losing the ability to concentrate, not only because of TikTok, but
also because we constantly have access to easy consumption.
It’s digital fast food.
When I started to notice this on myself, I started with the naïve approach and deleted all time sink1 apps like YouTube or Instagram. This worked for a day or two until I either got bored or found another reason to re-install.
Then I moved to Apple Screen Time. This works for a bit, but that “15 minutes more” button starts to become muscle memory very quickly. Even apps like one sec got deleted because they’re annoying (that’s the idea, I know).
In January 2024 I went 7 days with only my Apple Watch, leaving my iPhone at my desk at all times. It was doable, but very impractical.
In early 2025 I sold my Apple Watch and bought a Casio DW-5600BB-1. This had a large impact as I would no longer get buzzed on my wrist for every notification2.
Then I deleted my Instagram account and the YouTube, Mastodon and Bluesky apps. Yes, a lot of my friends are on Instagram—but I mostly watched Reels anyway! I do miss Mastodon. I now use YouTube in Safari with shorts blocked. This creates enough friction for me to watch videos intentionally (most of the time). It also lets me use cool extensions like SponsorBlock and DeArrow.
And when Apple Intelligence (if you can call it that) got to Europe, I turned on the new Reduce Interruptions focus and never turned it off. This works great because it randomly lets things through, but blocks most of it.
I tried a dumb phone3, but this was way too much friction—I need a usable phone for renting a bike, getting my parcels, etc.
In March I bought a Bullet Journal and started tracking my tasks there instead of my phone. I also started journalling and tracking my habits and I love the analog lifestyle—even my running plan is analog!
The combination of all of this is working out pretty well so far: My phone is mostly boring. It doesn’t have any exciting apps. It doesn’t spark joy. Because of this, I’m spending my time a lot more intentional.
That being said, sometimes I spend a little too much time on YouTube (at least that’s longform content) and I should probably delete the Slack app to not check the work chat when I’m not at my desk.
I’m happy to see the first smartphone vendors start to add physical switches to their phones to disable connectivity or limit apps4 and maybe I’ll switch to one of them one day.
This is not a political post, but if it was, it would talk about the obscene power of big tech and the necessity to regulate and break up. It would appeal on you to rethink your investment into these services, services that are actively spying on you, services that exploit your mental health for the sake of raising shareholder value.
Now go, touch some grass5. 🌱
Knapp, J. & Zeratsky, J. (2018). Make Time: How to focus on what matters every day. ↩
Yes, you can turn all notifications off. I didn’t. ↩
Apparently there’s an app for that ↩
2024-12-22 08:00:00
Here are my favorite things, digital and physical, of 2024!
These links go to my reviews (no spoilers):
What were your favorites?
I even wrote a blogpost about it. ↩
2024-10-21 08:00:00
This post is not about the Japanese martial arts Jiu-jitsu, it’s about a new
VCS, or version control system.
There are some great tutorials and introductions for
Jujutsu, short jj
, so I want to give some
insight in how I use it day to day for this website.
You can initialize jj
in an existing git
repository like this:
This will create a .jj
directory next to .git
.
You can now use both git
and jj
, although I wouldn’t recommend it.
There’s a work-in-progress native storage backend, but since all my projects
are git
repositories anyway, this works great for me.
Note that this is non-destructive; if you want to go back to git
, all it takes
is a rm -r .jj
.
Running jj log
in the repository for this very website gives this output:
This already shows one of the biggest differences, compared to git
:
There’s no branches, other than main
.
You can create branches, which are called bookmarks in jj
, but you don’t
need to.
Instead, you work mostly with changes1.
The terminal above shows the change w
(you can use the first letter to
reference changes; on your terminal it’ll be highlighted as well) as a parent
to x
, t
, y
and q
.
All these child-revisions don’t have a branch/bookmark, but they don’t need one.
You can see what’s in-flight at this repository better than with any git
repo,
especially if branches haven’t been cleaned up in a while.
My usual flow with git
is to leave changes in the staging area until I’m
ready to commit.
Sometimes, if I have to switch branches or want to save my work, I’ll stash
or create a WIP commit.
In jj
, there is no staging area—everything is a revision.
Let’s create a new revision on top of my revisions where I add atoms
functionality:
Running git log
again:
()
You’ll notice that our active revisions are now left-aligned, and the one to
add this very blog post has moved to the right.
There’s no hierarchy, they’re all descendants of Add weekly/166
.
After doing some work, e.g. adding a new atom, I can describe that revision
with jj describe
.
This is comparable to git commit
, but it doesn’t actually create a commit or
a revision, it only describes the current one.
Sometimes I want to update a previous revision, in this case Add atoms/1
.
I can run jj squash
to merge the current one with its parent.
To fetch new revisions, I run jj git fetch
; to push branches/bookmarks, I run
jj git push
.
This uses the same git
server it was using before.
Before pushing, I need to move my bookmark to the revision I want to push.
I push the main
branch to deploy my website, so if I wanted to publish my
atoms functionality (should I?), I would run jj bookmark set main -r p
before
pushing.
Sometimes I need to rebase. Fortunately that’s a lot simpler than it is in
git
:
I can run jj rebase -s <source> -d <destination>
to move revisions around.
If I wanted support for atoms for this blog post, I would run
jj rebase -s q -d p
and it would move the revision for this blog post on top
of “Add atoms/1”.
jj
also does automatic rebasing, e.g. if you squash changes into a revision
that has descendants.
And if I have a revision that I’d like to be two, I run jj split
and
interactively select what belongs to which revision.
Undoing an (interactive) rebase in git
is not fun.
jj undo
undoes the last jj
operation, doesn’t matter if it’s abandoning
(deleting) a revision or doing a rebase.
This is a life saver!
You can also run jj op log
to display your last jj
operations.
I’ve been using
git
for a long, long time.
My brain assumes that after a commit
, I’m working on the next one.
It also assumes that jj describe
does the same as git commit
(it does not).
I often describe a revision and continue editing files, which then erroneously
get added to the current revision.
I’m not saying this is wrong, it makes sense in the jj
world, but I keep
tripping over that and have to run jj split
to pull changes out again.
alterae on Lobste.rs pointed out
that you can describe and immediately create a new revision on top of it with
jj commit
. Thanks!
One other thing is that you cannot check out a revision directly (or maybe I
just don’t know how to), so when I’ve moved to a different revision and want
to move back, I run
jj new <revision>
, which creates an empty revision on top
of it.
This means that if I’m not done with the revision, I have to keep running
jj squash
to move changes into it.
gecko on Lobste.rs pointed out
that you can check out a revision directly with jj edit <revision>
. Thanks!
A week ago, I removed jj
from my website’s repository, to see if I’d miss it.
I added it back the same day.
Jujutsu feels lighter than git
, while at the same time giving you a full
overview of what’s in-flight right now2.
Having no staging area means I only need to worry about revisions (see caveat
above).
If trying new things sounds fun to you, give Jujutsu a spin!
What’s cool about a jj
change, is that updating it doesn’t change its ID. ↩
If you work in large projects with many contributors, you can
tune your jj log
to only your revisions. ↩
2024-10-01 08:00:00
It just works. — Steve Jobs
If you follow this blog, you’ll know that I’m doing a series called “Emacs
Config From Scratch”1.
Emacs is an editor operating system, where you can configure and customize
literally everything.
I like that you can truly make it yours, but it’s a lot of work to get there.
Recently, I’ve become fond of (command line) tools that just work, out of the box2. This blogpost is an ode to them.
Julia Evans recently posted Reasons I still love the fish shell, and the first point she makes is “no configuration”.
Things that require plugins and lots of code in shells like ZSH, like autosuggestions, are included and configured by default in fish. At the time of writing this, my fish config has less than 31 loc, most of which are abbreviations.
I have two fish plugins configured: z for jumping to a directory, and hydro as my shell prompt. Neither need configuration.
My Neovim config had 21 external plugins. Making LSP, tree-sitter and formatting work took a while (LSP alone needs 3 plugins) and in the end there were still things that didn’t work.
I’ve switched to Helix, which can do so much out of the box, here’s a non-exhaustive list:
The config for the code editor I use all day is 5 loc. Here it is:
theme = "kanagawa"
[editor]
line-number = "relative"
cursorline = true
rulers = [80]
I will say that it takes some getting used to as it folows the selection -> action
model, i.e. you need to run wd
instead of dw
to delete the next
word.
After raving about Magit in London, my team showed me Lazygit and I’ve been using it ever since—it’s really good and it does exactly what you want it to do, without configuration3.
You can toggle different screen modes, panes adjust in size when active and pretty much everything you want to do is only a few keystrokes away.
A batteries-included Tmux alternative, Zellij doesn’t need any configuration to work well. You can set up Layouts without additional plugins (although there is a plugin system) and I’m generally not missing anything from my Tmux configuration.
My favorite feature is the floating panes. Press Ctrl + p, w
to toggle a
pane floating on top of everything else—I often use this for Lazygit.
Here are some tools you have sent me that are zero/minimal config:
Do you have a tool that requires no (or minimal) configuration? Send me an email and I’ll add it to the list!
And if you’re building something, please strive to make the default experience work really well for most people.
2024-05-11 08:00:00
This is Part 3 of my series, Emacs Config From Scratch1, where I create my perfect editor using Emacs. In this post, I’ll do some housekeeping, set up LSP2, language modes for Rust, Go, TypeScript and Zig, and add search.
The first thing I want to do is make the UI titlebar match the Emacs theme and hide the file icon:
The next thing is automatically loading $PATH
from my shell using
exec-path-from-shell—this
is important so Emacs can find our installed binaries, e.g. for language servers:
Another problem I ran into was writing square brackets when on the MacBook, as it
would interpret the keys as Meta-5
/Meta-6
.
I fixed that by updating the keybindings from
Part 1:
I like to keep most of my code at 80 characters, so let’s add a ruler:
Finally, we want to store backup files in ~/.saves
instead of next to the file
we’re saving:
Let’s install company-mode first, for auto-completion:
Now we configure the built-in LSP package eglot:
This runs eglot-ensure
in languages we have language servers installed for.
It also sets up SPC l r
to rename a symbol and SPC l a
to prompt for code
actions.
We’ll use treesit-auto to automatically install and use tree-sitter major modes:
This is handy because it doesn’t require us to think about using e.g.
zig-ts-mode
instead of zig-mode
, it handles everything for us.
Next, we install all language modes we need:
I’m using SPC m
to change based on major-mode, e.g. means SPC m t
means test
in most programming modes, but won’t exist in markdown-mode
.
Sometimes we don’t know what file we’re looking for, so let’s add rg.el to help us find it:
This opens a Magit-like menu and allows you to search in various modes (dwim, regex, literal, etc.).
Opening a Zig project now looks like this3; see also the final
init.el
:
I’m going to switch to Emacs as my primary editor and tune it further in the coming weeks. In the next part, I want to add support for Org-mode, show a dashboard on startup, enable font ligatures and fix all the small things that I’ll find.
Subscribe to the RSS feed so you don’t miss Part 4, and let me know what you think!
2024-05-09 08:00:00
The first project I used Zig for was a rewrite of a custom static site generator for the Fire Chicken Webring, you can read that post here: Thoughts on Zig.
Writing a small application is a lot easier than writing a library, especially if you’re hacking it together like I was. Let’s do something harder.
And because I work at Axiom, we’re going to write an SDK for
the public API.
In this first part I’ll set up the library and add a single getDatasets
fn
to fetch all datasets the token has access to.
We’re using Zig 0.12. It might not work with a different version.
First, we create a directory called axiom-zig
and run zig init
:
We also want to create a .gitignore
to ignore the following folders:
/zig-cache
/zig-out
Perfect. Next step: Create a client struct in root.zig
.
We’ll need an Axiom API token to authenticate requests, a std.http.Client
to
make requests and an std.mem.Allocator
to allocate and free resources:
const std = @import("std");
const Allocator = std.mem.Allocator;
const http = std.http;
// We'll need these later:
const fmt = std.fmt;
const json = std.json;
/// SDK provides methods to interact with the Axiom API.
pub const SDK = struct {
allocator: Allocator,
api_token: []const u8,
http_client: http.Client,
/// Create a new SDK.
fn new(allocator: Allocator, api_token: []const u8) SDK {
return SDK{ .allocator = allocator, .api_token = api_token, .http_client = http.Client{ .allocator = allocator } };
}
/// Deinitialize the SDK.
fn deinit(self: *SDK) void {
self.http_client.deinit();
}
}
test "SDK.init/deinit" {
var sdk = SDK.new(std.testing.allocator, "token");
defer sdk.deinit();
try std.testing.expectEqual(sdk.api_token, "token");
}
Initially I had deinit(self: SDK)
(without the pointer). Zig didn’t like this
at all and led me down a rabbit hole of storing the http.Client
as a pointer
too—once I found my way out and remembered I need a pointer everything worked.
I like that Zig encourages writing tests not only next to the source code (like Go), not only in the same file (like Rust), but next to the code it’s testing.
Our first method will be getDatasets
, which returns a list of Axiom datasets
(see the api documentation).
First we need a model:
pub const Dataset = struct {
id: []const u8,
name: []const u8,
description: []const u8,
who: []const u8,
created: []const u8,
};
Don’t worry about created
being a datetime, we’ll deal with that later.
getDatasets
fnLet’s add a function to get the datasets to our SDK
struct:
/// Get all datasets the token has access to.
/// Caller owns the memory.
fn getDatasets(self: *SDK) ![]Dataset {
// TODO: Store base URL in global const or struct
const url = comptime std.Uri.parse("https://api.axiom.co/v2/datasets") catch unreachable;
// TODO: Draw the rest of the owl
}
We’re taking a pointer to SDK
called self
again, this means that this is a
method you call on a created SDK
. The !
means it can return an error.
In a later post I want to go deeper into error handling, for now it can return
any error.
Because there is no dynamic part of the URL, we can parse it at compile time
using comptime
.
I like this explicit keyword, in Rust you need to rely on macros to achieve
something similar, or use
lazy_static.
Let’s open a connection to the server:
var server_header_buffer: [4096]u8 = undefined; // Is 4kb enough?
var request = try self.http_client.open(.GET, url, .{
.server_header_buffer = &server_header_buffer,
});
defer request.deinit();
I wonder if 4kb is always enough for server headers. Especially in a library I don’t want it to fail because the server is suddenly sending more headers.
Axiom uses Bearer auth, so let’s set the Authorization
header:
var authorization_header_buf: [64]u8 = undefined;
// TODO: Store this on the SDK for better re-use.
const authorization_header = try fmt.bufPrint(&authorization_header_buf, "Bearer {s}", .{self.api_token});
request.headers.authorization = .{ .override = authorization_header };
An Axiom API is 41 characters, plus Bearer
‘s 7 characters equals 48 characters.
We’re allocating 64 to be safe if it ever changes (it really shouldn’t).
Also note that I’m calling try fmt.BufPrint
; this will return the error
to the caller of our function (that’s what the !
indicating).
Finally, we can send the headers to the server and wait for a response:
try request.send();
try request.wait();
First, we need to read the body into a buffer:
var body: [1024 * 1024]u8 = undefined; // 1mb should be enough?
const content_length = try request.reader().readAll(&body);
Same issue as with the server headers: What is a good size for a fixed buffer here?
I’ve tried doing this dynamically with
request.reader().readAllAlloc(...)
, but parsing the JSON with this allocated
memory relied on the allocated []const u8
for string values.
This means as soon as I deallocated the body, all string values in the returned
JSON were invalid (use-after-free). Yikes.
Next, we call json.parseFromSlice
with our body:
const parsed_datasets = try json.parseFromSlice([]Dataset, self.allocator, body[0..content_length], .{});
defer parsed_datasets.deinit();
Now we need to copy the memory out of the parsed_datasets.value
to prevent it
from being freed on the parsed_datasets.deinit()
above and return it:
const datasets = try self.allocator.dupe(Dataset, parsed_datasets.value);
return datasets;
Edit: Matthew let me know on Mastodon
that this implementation is still illegal, and it’s only working because the
stack memory is not getting clobbered.
You can set .{ .allocate = .alloc_always }
in json.parseFromSlice
,
which will dupe the strings, but not actually solve the problem (where do the
strings live?).
What I ended up doing is creating a Value(T)
struct, which embeds both the
value and an arena allocator which I pass to json.parseFromSliceLeaky
.
This means the value you get back from getDatasets
will have a deinit()
method and you need to do .value
to get the actual value.
You can read the updated source code on GitHub.
And finally we’ll write a test where we initialize the SDK, get the datasets
and ensure _traces
is the first one returned.
Once I set up CI, I’ll create an Axiom org just for testing so we can be sure
which datasets are returned.
test "getDatasets" {
const allocator = std.testing.allocator;
const api_token = try std.process.getEnvVarOwned(allocator, "AXIOM_TOKEN");
defer allocator.free(api_token);
var sdk = SDK.new(allocator, api_token);
defer sdk.deinit();
const datasets = try sdk.getDatasets();
defer allocator.free(datasets);
try std.testing.expect(datasets.len > 0);
try std.testing.expectEqualStrings("_traces", datasets[0].name);
}
If you want to see everything together, check it out on GitHub.
In the next part I’ll add createDataset
, updateDataset
and deleteDataset
,
initial error handling and show how you can import the library in a Zig project.
Any thoughts? Let me know!