MoreRSS

site iconArne BahloModify

a developer, podcaster & dad based near Frankfurt, Germany.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Arne Bahlo

Reclaiming my attention

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.

None of this worked

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.

We deserve more humane tech

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. 🌱

  1. Knapp, J. & Zeratsky, J. (2018). Make Time: How to focus on what matters every day.

  2. Yes, you can turn all notifications off. I didn’t.

  3. Punkt MP02

  4. Fairphone 6, Mudita Kompakt

  5. Apparently there’s an app for that

My favorite things of 2024

2024-12-22 08:00:00

Here are my favorite things, digital and physical, of 2024!

Music

Books

These links go to my reviews (no spoilers):

Apps

  • Manet: I’ve started buying my music and hosting it myself this year1, this app makes it a joy to listen to music.
  • CouchTimes: The best TV show tracker in the App Store, period. It doesn’t have feature bloat and it respects your privacy.
  • Ghostty: A super fast, new Terminal emulator for the Mac.
  • Halide: This camera app has been around for a while, but this year they released Process Zero, a mode that applies minimal corrections and makes photos look more like digital camera photos.

Goods

  • aranet4: There are cheaper options, but a CO2 monitor for my home office has helped me keep the air at good levels.
  • heat-it: This tiny tool draws power from your phone to relieve itch & pain from insect bites.
  • Leatherman 2H Wave+: The only big Leatherman that’s legal to carry in Germany, super versatile and handy to have around.

What were your favorites?

  1. I even wrote a blogpost about it.

Jujutsu in practice

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.

Initialize a repository

You can initialize jj in an existing git repository like this:

$ jj git init --git-repo .

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.

Get an overview

Running jj log in the repository for this very website gives this output:

$ jj log
@  qzsvtxpv [email protected] 2024-10-21 09:58:06 e18f7532
  Add blog/jj-in-practice
 ○  yoxxsupn [email protected] 2024-10-20 22:55:03 ae5d9109
├─╯  Add library/calibans-war
 ○  tvkvwslw [email protected] 2024-10-20 22:49:54 5e4dee1f
├─╯  Add library/the-posthumous-memoirs-of-bras-cubas
 ○  pywmtrys [email protected] 2024-10-20 21:20:11 7bda14b7
 │  Add atoms/1
 ○  xnlzypwn [email protected] 2024-10-20 21:20:10 8a004404
├─╯  Add atoms functionality
  wxxtrmqk [email protected] 2024-10-20 16:18:15 main HEAD@git 1eb46c81
  Add weekly/166
~

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.

Create a revision

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:

$ jj new -r p

Running git log again:

$ jj log
@  kxqvnxnw [email protected] 2024-10-21 10:03:20 22c020cf
  (empty) (no description set)
  pywmtrys [email protected] 2024-10-20 21:20:11 HEAD@git 7bda14b7
  Add atoms/1
  xnlzypwn [email protected] 2024-10-20 21:20:10 8a004404
  Add atoms functionality
 ○  qzsvtxpv [email protected] 2024-10-21 10:03:18 27229dca
├─╯  Add blog/jj-in-practice
 ○  yoxxsupn [email protected] 2024-10-20 22:55:03 ae5d9109
├─╯  Add library/calibans-war
 ○  tvkvwslw [email protected] 2024-10-20 22:49:54 5e4dee1f
├─╯  Add library/the-posthumous-memoirs-of-bras-cubas
  wxxtrmqk [email protected] 2024-10-20 16:18:15 main 1eb46c81
  Add weekly/166
~

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.

Push and pull

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.

Rebase and split

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.

Undo

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.

Things I stumble upon

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!

Why it works for me

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!

Further reading

  1. What’s cool about a jj change, is that updating it doesn’t change its ID.

  2. If you work in large projects with many contributors, you can tune your jj log to only your revisions.

We need more zero config tools

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.

Fish

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.

Helix

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:

  • LSP (including autocompletion, show signature, go to definition, show references, etc.) just works
  • Tree-sitter is built in, you can even do selections on tree-sitter objects
  • A file picker and global search
  • Pressing a key in normal mode shows subsequent keys you can press, and what they do
  • You can jump to any visible word, add/remove/replace quotes or other characters
  • … and so much more

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.

Lazygit

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.

Zellij

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.

What else?

Here are some tools you have sent me that are zero/minimal config:

  • Matthew shared Broot, a new way to see and navigate directory trees
  • Ilija shared k9s, a CLI to maange Kubernetes clusters
  • Alexander shared Orbiton, a text editor they built

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.

  1. Check it out

  2. I’m starting to feel the same about programming languages and external dependencies, but that’s a different post.

  3. You can configure almost everything—but you don’t need to.

Emacs Config From Scratch, Part 3: LSP &amp; Tree-sitter

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.

Table Of Contents

Housekeeping

The first thing I want to do is make the UI titlebar match the Emacs theme and hide the file icon:

(use-package emacs
  :init
  (add-to-list 'default-frame-alist '(ns-transparent-titlebar . t))
  (add-to-list 'default-frame-alist '(ns-appearance . light))
  (setq ns-use-proxy-icon  nil)
  (setq frame-title-format nil))

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:

(use-package exec-path-from-shell
  :init
  (exec-path-from-shell-initialize))

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:

(use-package emacs
  :init
  (when (eq system-type 'darwin)
    (setq mac-command-modifier 'super)
    (setq mac-option-modifier nil)
    (setq mac-control-modifier nil)))

I like to keep most of my code at 80 characters, so let’s add a ruler:

(use-package emacs
  :init
  (setq-default fill-column 80)
  (set-face-attribute 'fill-column-indicator nil
                      :foreground "#717C7C" ; katana-gray
                      :background "transparent")
  (global-display-fill-column-indicator-mode 1))

Finally, we want to store backup files in ~/.saves instead of next to the file we’re saving:

(use-package emacs
  :config
  (setq backup-directory-alist `(("." . "~/.saves"))))

LSP

Let’s install company-mode first, for auto-completion:

(use-package company-mode
  :init
  (global-company-mode))

Now we configure the built-in LSP package eglot:

(use-package emacs
  :hook (zig-mode . eglot-ensure)
  :hook (rust-mode . eglot-ensure)
  :hook (go-mode . eglot-ensure)
  :hook (typescript-mode . eglot-ensure)
  :general
  (leader-keys
    "l" '(:ignore t :which-key "lsp")
    "l <escape>" '(keyboard-escape-quit :which-key t)
    "l r" '(eglot-rename :which-key "rename")
    "l a" '(eglot-code-actions :which-key "code actions")))

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.

Tree-sitter

We’ll use treesit-auto to automatically install and use tree-sitter major modes:

(use-package treesit-auto
  :custom
  (treesit-auto-install 'prompt)
  :config
  (treesit-auto-add-to-auto-mode-alist 'all)
  (global-treesit-auto-mode))

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.

Language support

Next, we install all language modes we need:

(use-package markdown-mode
  :config
  (setq markdown-fontify-code-blocks-natively t))
(use-package zig-mode
  :general
  (leader-keys
    "m" '(:ignore t :which-key "mode")
    "m <escape>" '(keyboard-escape-quit :which-key t)
    "m b" '(zig-compile :which-key "build")
    "m r" '(zig-run :which-key "run")
    "m t" '(zig-test :which-key "test")))
(use-package rust-mode
  :general
  (leader-keys
    "m" '(:ignore t :which-key "mode")
    "m <escape>" '(keyboard-escape-quit :which-key t)
    "m b" '(rust-compile :which-key "build")
    "m r" '(rust-run :which-key "run")
    "m t" '(rust-test :which-key "test")
    "m k" '(rust-check :which-key "check")
    "m c" '(rust-run-clippy :which-key "clippy")))
(use-package go-mode)
(use-package gotest
  :general
  (leader-keys
    "m" '(:ignore t :which-key "mode")
    "m <escape>" '(keyboard-escape-quit :which-key t)
    "m t" '(go-test-current-project :which-key "test")
    "m r" '(go-run :which-key "run")))
(use-package typescript-mode)

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.

Search

Sometimes we don’t know what file we’re looking for, so let’s add rg.el to help us find it:

(use-package rg
  :general
  (leader-keys
    "f" '(rg-menu :which-key "find")))

This opens a Magit-like menu and allows you to search in various modes (dwim, regex, literal, etc.).

Wrapping up

Opening a Zig project now looks like this3; see also the final init.el:

A screenshot of Emacs with a dark theme showing Zig code and a context menu for code actions

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!

  1. Check out parts one and two if you haven’t already!

  2. Language Server Protocol

  3. By the way, I switched my theme to Kanagawa.

Writing an SDK in Zig

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.

Bootstrap the SDK

First, we create a directory called axiom-zig and run zig init:

info: created build.zig
info: created build.zig.zon
info: created src/main.zig
info: created src/root.zig
info: see `zig build --help` for a menu of options

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.

Add getDatasets

Our first method will be getDatasets, which returns a list of Axiom datasets (see the api documentation).

Create a model

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.

Add the getDatasets fn

Let’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.

Make the HTTP request

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();

Parse the JSON-response

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.

Write a test

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.

Next steps

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!