Steve's Real Blog

all words typed by human fingers

I‘ve been working on autowt, my git worktree helper, on and off for almost a year now. Most of that time, I was at Descript and getting live feedback from a pretty wide spread of engineers: habits, preferences, which terminal and shell they used, what they were willing to put up with.

Nearing the end of my time at Descript, a few things stood out as missed opportunities.

Performance

autowt 0.5.x takes at least 200ms to do anything, because it has to import the Textual TUI library. Not only that, but what it was doing with Textual was just OK.

Meanwhile, Go has the Charm family of libraries to help build great TUIs. Go, being a compiled language, doesn‘t pay a cost to import code on every program invocation. And conveniently, my upcoming gig uses a lot of Go. I had a good reason to look closer.

So as I was winding down at Descript, I took a couple of days to have a coding agent rewrite the whole project in Go. Now it launches in 30ms. Hooray! It’s a little scary to do this kind of port, but I did a lot of manual regression testing.

The Python version is dead code now. Uninstall autowt from uv/pip, and install it with Homebrew or Mise instead.

Shell integration

I originally built autowt to control your terminal program, i.e. iTerm2, Ghostty, Terminal.app, etc. This is an unusual thing to do! The more traditional approach is to integrate with your shell (zsh, bash, fish) to cd you to the right place. But terminal automation adds value because it‘s legitimately fewer keystrokes, and you can do extra “background” work after spawning the new tab.

Some people just could not get into the workflow of having autowt open tabs on their behalf. It‘s not my job to change their habits, and it is my job to make their lives easier. So I finally figured out a clean way to add a shell integration to autowt, so any autowt command can magically cd you somewhere. Instead of autowt go opening a new tab for you, you can open your own new tab and autowt go inside it.

Invoking hooks via the command line

autowt‘s most important feature is hooks: commands that run at various points in the git worktree lifecycle, like after creating a new worktree. This is the main value proposition of autowt: it installs dependencies and runs configuration code in new worktrees automatically.

Every freaking coding agent GUI tool wants to manage its own worktrees, which means a proliferation of configuration and setup code. autowt now exposes autowt hook to let you reuse autowt’s configuration across tools.

I was tired of waiting on GitHub Actions to see if I broke anything in my last commit. Why did I need network access and a fleet of containers just to run a few linters and tests? I felt like a chump!

The nature of validation during development

Layers of checks are necessary

Every software development process uses a set of automated checks which reduce the likelihood of end users experiencing a harmful change. Some of these checks are cheap, like making sure changed files are correctly formatted, or typechecking small programs. But many are expensive, like UI tests.

These checks are run with frequency inversely proportional to their wall clock execution time. In other words, fast checks are run very often, usually in blocking precommit git hooks. Slow checks are run less often, perhaps only on cloud CI servers triggered by commits to branches. And sometimes checks are so slow or flaky they are only run on the main branch after changes are landed, to be looked at later.

Layers make feedback loops worse

It’s very easy to break something you aren’t looking at. If you’re working on, for example, a design system library, you might introduce a change that breaks a UI test in your main application. If you don’t run your application’s UI tests until you open a pull request, you’re at risk of breaking something without noticing until you’re out of flow state.

Another problem is unique to coding agents: they often want to run an expensive check themselves which has already run elsewhere, because they have no concept of time. Claude is happy to run a UI test suite that takes ten minutes just to “reproduce an issue from CI.”

Remote checks are a tradeoff

Running all your checks remotely can give you parallelism and better laptop battery life. But it has major downsides. For one thing, you need to be on a network. For another, the service you rely on needs to be up, which in the era of not-even-one-nine-of-GitHub-uptime is iffy.

Assuming you’re on a network, and the service is up, now you need to use the service to view results. There is cognitive load to clicking around a CI provider’s UI, or needing to go through extra steps to download a raw log or a build artifact.

LocalCI is a different way of doing continuous validation

Wrestling with these problems in a personal project led me to ask, what if I could get all the best benefits of remote CI without needing an external service? Many of us are sitting here with 16+ core CPUs and tens of gigabytes of RAM, using that power to mostly compile JavaScript programs and serve web pages. The checks we care about most are often IO-bound and need a tiny fraction of that power.

Suppose you could run every check after every commit automatically and check on the results later. What would be the impact?

  1. Memory and diligence of a human or agent are no longer a factor in which checks run.
  2. Results are just files on your disk. No mandatory over-built web UI.
  3. You can use it on an airplane without paying for wifi.
  4. It is easier to commit code that fails fast checks. Arguably not a problem since you can amend.

I built LocalCI to see how far I could take this idea. At a high level, it adds a postcommit hook which enqueues tasks in a background process, and provides multiple ways of interacting with tasks and results.

Here’s how it works once you install the postcommit hook:

> git commit -am "test commit"
[//:postcommit] $ ~/dev/cli/localci/mise-tasks/postcommit --repo /Users/<reacted>/dev/…
cwd: /Users/<reacted>/dev/cli/localci
Enqueued 1 task for /Users/<reacted>/dev/cli/localci at 4dacf08719844468d1965830ea5307adec10571c
Status: localci status --repo /Users/<reacted>/dev/cli/localci --commit 4dacf08719844468d1965830ea5307adec10571c
Results: http://127.0.0.1:61924/repo/Users/<reacted>/dev/cli/localci/commit/4dacf08719844468d1965830ea5307adec10571c

Wait: localci wait --repo /Users/<reacted>/dev/cli/localci --commit 4dacf08719844468d1965830ea5307adec10571c
[tmp 4dacf08] test commit
 1 file changed, 2 insertions(+)

Once you see this text, the daemon is running and you can look at the results via the cli, an interactive terminal UI, or a web browser.

With the CLI, you'd usually use localci wait to await results and then print them.

> localci wait
Completed
repo     /Users/steve/dev/cli/localci
commit   4dacf08719844468d1965830ea5307adec10571c
summary  18 passed, 1 failed, 0 timed out, 0 not run
message  test commit
branch   tmp

Failed Tasks
status  task        attempt  duration  failure
failed  noisy-fail  1        181ms     exit
  Output: /Users/steve/Library/Caches/localci/7fcbc2dda3b75a7eb817158c05616922/4dacf08719844468d1965830ea5307adec10571c/out/___localci_noisy-fail/attempt-001
  Results: http://127.0.0.1:61924/repo/Users/steve/dev/cli/localci/commit/4dacf08719844468d1965830ea5307adec10571c/task/%2F%2F:localci:noisy-fail
  Primary artifact: combined.log
  Primary log path: /Users/steve/Library/Caches/localci/7fcbc2dda3b75a7eb817158c05616922/4dacf08719844468d1965830ea5307adec10571c/out/___localci_noisy-fail/attempt-001/combined.log
localci: localci run failed

With the TUI (localci dash), you can browse results interactively, and it live updates.

Terminal-based UI, interactively showing recent runs by git repository

Another terminal-based UI, this one showing scrollable log output for a build task

And if you prefer a traditional SaaS-like web interface, you can run locali web to open it.

A web page listing tasks under a commit: build, fmt, setup

A web page showing the log output of the 'build' task, with a downloadable artifact

Getting fancy

With all built artifacts available locally, and a web server running, LocalCI can give you more options than cloud-based CI. For example, if you build documentation, it can serve the HTML. Or if you produce a non-text artifact, you can give yourself multiple options for what to do with it. The options are configurable.

A right-click menu showing options for a build artifact: show in finder, download, copy path, open in browser

This has been subtly transformative to how I do something as basic as writing documentation for my projects. Usually for HTML project docs, you either run a self-live-updating dev server in a dedicated terminal, or run a build-the-docs command whenever you feel like it. Both have downsides. I dislike dedicating more terminals than necessary to development, so I like to avoid dev servers, but I also hate manually typing my build commands.

With LocalCI, I just commit, and when I want to double check the docs render, I open the web UI and it simply serves me the built docs.

Mise owns the hard parts

CI systems have sophisticated configuration languages to define which tasks run in response to events, and in which order. You are expected to create containers and install all dependencies. Reproducing this logic myself would have been a big API surface and an implementation with many edge cases and possible bugs.

Rather than requiring a special config file for LocalCI, I decided to use Mise. It’s a dev tool manager and task runner which can handle dependencies, parallelism, secrets, environment variables, and more. It has its own config file and more than enough features to support LocalCI’s use case.

The core idea is that LocalCI will run every task with a localci: prefix, starting with localci:setup if present. As an example, here’s part of LocalCI’s own mise.toml file:

[tools]
node = "24.11.0"
pnpm = "11.2.2"

[tasks."localci:setup"]
description = "Install dependencies for cloned localci runs"
run = [
  "mise trust",
  "mise install",
  "pnpm --dir web install --frozen-lockfile",
]

[tasks."localci:web-build"]
description = "Make sure the web bundle builds"
run = "pnpm --dir web exec vite build"

I’m not sure how well this scales past individuals

LocalCI has been a dream workflow for my hobby projects. Being 100% self-sufficient on my laptop means I can work on anything, any time, anywhere, without adding drudgery.

But when I think about bringing it to a job, I imagine it living alongside an existing CI system, requiring people to duplicate validation commands as Mise tasks, or move the source of truth to Mise and have the CI system call the Mise task, or something else. I’m really curious if anyone has thoughts on how to make this work well. The localci: Mise task convention was a choice I made quickly, but it might not be the best or only option.

In July 2025, I started working on autowt to make git worktree management simpler. It was partly a response to seeing people over-building “agentic multitasking” tools, heavyweight opinionated GUIs or multiple-git-clone-agent-queue managers or Claude Code menu bars. When I heard git worktrees were a thing, I thought, why don’t we just automate that a little bit and then we can add agent stuff on top of it as needed?

(If you just want to know what autowt can do for you, read the docs. This post is more about the process than the result!)

Fundamentally, git worktrees are git clones of one repo, but sharing one .git/ directory. You only pay the cost of checkout and avoid the cost of cloning over a network. They’ve been a feature of git for years, but not a popular one, until the rise of coding agents raised the perceived value of multitasking. It was exotic to need a second clone before you found yourself waiting for Claude Code to do something.

The UX of git worktrees is minimalistic:

git worktree add ~/worktrees/new-feature -b new-feature
# (Open a new terminal tab)
cd ~/worktrees/new-feature
uv sync               # install dependencies
cp <repo_dir>/.env .  # copy secrets

But I wanted it to look more like this:

awt go new-feature
# there is no step 2; you're already on the new-feature branch
# in a fresh worktree

If you make creating and switching to worktrees seamless, you don’t need “agent integration” on top of it. Just start claude if you want claude. Every project has slightly different needs. Straightforward customization can be more important than magic. (Integration can help, which is why autowt can auto-start coding agents and integrate with issue trackers.)

With this vision in mind, I started coding. I leaned heavily on agents at first, which meant the results were just OK. As I got my head around the edge cases and the shape of a good API, I refactored and rewrote, until I was finally comfortable sharing it out as a usable project. As the months went by, I picked up a few dozen users, some of whom filed tickets or sent PRs to help me sand off the rough edges.

Earlier I said that customization is more important than magic. Well, a little magic is good too. One thing autowt does that I don’t see anywhere else is terminal automation. I multitask by flipping between tabs in iTerm2. “Go to a worktree” to me means ”go to the iTerm2 tab associated with a worktree.” So autowt uses AppleScript—or dbus on Linux!—to automate your terminal program, opening or switching to tabs. I broke this part of autowt out into its own library, automate-terminal, with support for 8 terminal emulators across macOS and Linux, including tmux.

Today, autowt is rolled out internally at Descript, and there are real people daily-driving it out in the wild. It’s very fulfilling to have written a tool I invoke many times per day. Most of my side projects are of the “wouldn’t it be cool if…” variety, but autowt has changed my actual day-to-day process.

Six years ago, I released Oscillator Drum Jams on the iOS App Store. It’s a companion to this book by my former drum teacher. You can play any exercise out of the book to a chill instrumental loop, with the sheet music up, at any tempo.

iOS 26 killed it. That's a shame, because students had been using the app to practice! It’s never had organic growth, but for the people who need it, it’s a valuable tool.

Faced with the choice between figuring out how to the update the app (I haven’t written iOS code since 2024), letting it die, or rewriting it from the ground up as a web app using coding agents while I watched TV, I chose the web rewrite.

Oscillator Drum Jams on the web

Audio

The central problem of a drum practice app based on stretching audio samples is: if you're letting the user change the tempo, how do you sync up the metronome with the audio samples without losing timing fidelity? The metronome plays spaced-out audio samples that aren't stretched, and the transients of those samples needs to match the transients of the instrument tracks, which are stretched. The timing must be accurate enough for drum practice at all tempos.

The blog series for the iOS app goes into detail about this problem. The theory was correct. But there were persistent issues I had trouble reproducing or fixing, probably because I was letting the audio engine call some Swift code on a MIDI timer, and that Swift code was then calling the audio engine back to play samples. There was room for error.

The web rewrite does it differently. It has to—web audio can’t be triggered from the background as reliably as iOS native audio. Rather than playing metronome samples live in time with the music, the metronome gets rendered to a buffer the same length as the instrumental tracks, and they all loop together. All playback stays in the audio engine.

When you change the tempo in the web version of Oscillator, the Play button will get a little progress bar around it while the rendering happens. The progress bar feels like an imperfection, but the resulting audio is rock solid. An appropriate feeling when you’re practicing rock drumming!

Besides the metronome, the other problem I had to solve in a new way was time stretching. Apple's audio frameworks include time stretching, but web audio doesn’t. Fortunately, someone packaged the Rubber Band C++ library as wasm, and it sounds even better than Apple’s.

Safari's web audio implementation is antagonistic toward developers, resulting in obvious UX deficiencies. You need to turn off your phone’s silent mode to hear anything. The lock screen playback controls don’t work. The regressions are a bummer, but it's accessible to more people and easier to maintain, so this is the tradeoff I'm living with.

UI

The original app interface was very, very red. The web version is more like a classic dark mode. And because the range of viewports is no longer limited to iPhone and iPad hardware devices, it tortures CSS Grid and media queries to adapt to any size.

Yeah, I used coding agents

If I hadn’t been able to turn my brain off and let a coding agent do the typing, I probably would have just let this app die. I have a toddler, and I’m past the point where little portfolio pieces have much impact on my professional life. I’m glad I was able to continue helping drum students.

One last link in case you missed it: Oscillator Drum Jams on the web

If you play the drums, you can check out Oscillator-the-book, as well as Jake’s other books.

A thing I used to do was install new vim plugins.

I was an intern at Yelp who was mostly coding in TextMate through college. TextMate wasn’t going to fly at Yelp because most development happened over ssh. My Real Hacker coworkers mostly used vim and emacs. I wanted to grow up to be a Real Hacker, and so, during work hours, I forced myself to use vim.

At first I slowed down. A lot. But a week into it, I was just as fast as I had been with TextMate, and by the end of the internship, I was much faster.

In 2011, one did not simply “use vim.” vim needed customization. You could optimize all kinds of things! How integrated the programming language was with the editor. How to browse the directory tree. Even how to move the cursor. While it was annoying to need to add plugins to build a good experience, it also meant I was learning each piece at a time, and making a considered decision based on how it would affect my workflow. I was constantly introspecting and “watching myself code.”

My goal as I added plugins to my config was to minimize time and effort between having an intention and fulfilling that intention in code. If I edited a function and wanted to update the corresponding test, I wanted to blink and find my cursor in the right place. If I was deep into a complex problem with ten interrelated files, I wanted to be able to flip between them instantly. (Naturally, I installed too many plugins and had to pare back when things got unwieldy.)

The point of all this isn’t that I eventually reached the perfect state. I didn’t—the point is the process of improvement. My efforts paid off over time, even now in the VSCode era when enough people find vim’s model valuable that its key commands are available in most places that matter.

The outcome you are looking for is productivity

Good engineers care whether they are productive or not. Productivity is hard to measure as an outsider, but as an individual, you know whether you’re doing your best and getting good outcomes.

The metrics you can move when optimizing your coding workflow are roughly:

  • How fast you can type
  • How fast you can navigate to a specific place so you can type
  • Whether you know the right answer to a question without having to ask or look it up
  • How fast you can discover the right answer to a question
  • How fast you can get confirmation of a hypothesis
  • How quickly you enter and how long you stay in flow state

People quibble about how important each one is. Personally, I think raw typing speed is underrated. But when you add large language models using tools in a loop—OK fine, “AI agents”—you introduce a whole new set of metrics you can move:

  • How much correct code you can produce by typing and thinking as little as possible
  • How much time you spend unwinding garbage code
  • How often you are kicked out of the flow state due to waiting for agents
  • How often you fail to apply your own skills because you are distracted by agents

Moving these metrics for yourself is, to use a BigCo phrase, extremely leveraged.

It’s roughly as hard as learning vim, but the potential gains are much greater. And the state of the art with language models doesn’t need to advance for this to change the industry forever. It’s not only that you can potentially do your existing work faster; embracing these tools can change the nature of the work you choose to take on.

I want to talk about how you can become faster in practice by using language models.

The rest of this post assumes you are using Claude Code because it’s currently the best monetary value, gets the best results in practice, and is the most extensible, but the framing applies to all coding agents.

How to move the metrics

Practice a lot and build a mental model

A coding agent is not a magic “do what I want” machine. It is a tool with tradeoffs, rough edges, and best practices.

Learn when to cut bait

There are times when agents simply cannot make the leap to find the root cause of an issue or the solution to a problem. The only way to know when this is happening is to experience it and develop a taste for it.

Develop a taste for models

Gemini leaves too many useless comments. o3 is a great problem solver but its context window feels short. Opus isn’t that much better than Sonnet as a coding agent. These are beliefs I’ve developed after switching between models a lot during the day in the Cursor IDE.

I don’t want you to adopt my beliefs. I want you to develop your own beliefs through experimentation. If you only ever code with GPT4.1, you’ll think that LLMs are terrible coders and insufferable communicators.

It’s a rough time to be doing this here in July 2025 because Cursor just nerfed its paid plans and all the first-party coding tools (Codex, Gemini CLI, Claude Code) don’t have feature parity with each other, but do what you can.

Get good at prompting

“Prompting” can sound like a silly skill to work on. But there are specific techniques that I’ve found can greatly improve the output quality in practice. The techniques will probably change over time, and this isn’t a how-to-prompt article, so I won’t get deep into it.

However, I want to give a special mention to one technique: having agents write down plans in Markdown files. Having an agent write down a plan forces the agent to gather enough information to make well-informed decisions, and then gives you an opportunity to review its future actions before they happen, reducing the need for you to step in as it works.

Also, agents are just OK at finding content related to a topic. If you mention a few related files in your prompt, you’re likely to save seconds or minutes of the agent grepping pointlessly. It took me some time to start doing this habitually.

Get the agent to do the right thing more often

Document things and help agents find the docs

Because LLMs are “just” spicy autocomplete, what gets into the context window is important.

Suppose you hire an intern and ask them to write an API endpoint. If there are a dozen gotchas with this and you don’t warn them, the intern is going to hit at least four. On the other hand, if you give them a document about best practices for writing API endpoints, you’re a little more likely to get a good result.

The same principle applies to agents. If you write the doc, and then mention its existence in all the API endpoint files, then agents will probably go read it and follow best practices. And then you can say “hey agent, reread the best practices doc and audit the new code carefully” to catch problems.

A related strategy is to use #hashtags in your code. If an agent sees “#auth-edge-case” in a comment above a function, it will probably search your codebase for the string “#auth-edge-case” and find the large comment explaining exactly what the auth edge case is. A lot of orgs do this already for humans, and it’s just as effective for LLMs. Probably more effective, since LLMs don’t get bored.

If you adopt good documentation practices, they benefit humans and agents alike!

Garden your prompts

Every tool has a special set of Markdown files that get injected into the LLM’s context window. With Claude Code, this is CLAUDE.md. This file can make or break your productivity. If it contains the right set of relevant information, you have to do so much less prompting in each session to get an agent to do the right thing.

My personal project prompt files usually contain:

  • A very brief summary of how the project is structured
  • A list of encouraged and forbidden commands (for example, “always use git --no-pager diff instead of git diff to avoid pagination”)
  • Workflow notes such as universal acceptance criteria (tests, typechecking, and lint must pass) and when to commit or push (which may be never)
  • Style guide rules that can’t be enforced automatically

Using bleeding-edge or hipster tech has always been risky. Now it can also prevent agents from being able to code effectively. If something is too new or too unusual to have much written about it around the internet, LLMs are likely to hallucinate. I found this to be true when trying to write a Tauri app, and eventually stopped trying to get my agent to write Tauri code. (At the start of the project, I knew zero Rust and zero Tauri. By the time I dropped the agent, I had learned enough to fly solo. The mediocre agent code got me far enough to start learning organically—I never read a tutorial.)

Get the agent to do the wrong thing less often

Use automated typechecking, linting, and testing

One of the great things about the software engineering industry is that we’re mostly bought into automated checks for fallible human changes. LLMs are either more or less fallible than humans, depending on the task, and so these automated checks remain critical.

I am most productive working with an agent when it can check its own output and notice mistakes based on evidence. If they can typecheck, lint, and unit test changes locally, agents can do more on their own and self-correct.

Write consistent code, or make “the right way” obvious

LLMs, being stochastic parrots, love repeating what they’ve recently heard. If there’s mostly one way to accomplish a common task in your codebase, and all features mostly do it that way, then an LLM will be able to pattern match well. Without consistency, you’re likely to get the LLM adding yet another way to do the same thing.

Those of you working in mature codebases might be giving a heavy sigh right now. One thing I’ve found helpful in these situations is to mark some specific files as “the right way” and then provide a way for agents to discover these files.

When I worked at Asana, there were three different ways a feature could be written: oldest, old, and current. We automatically annotated every single file with which pattern it used. We did this in the service of humans, before the age of LLMs, but I think it would work well for LLMs as well.

Treat bad agent behavior like a developer experience problem

If an agent does something obviously wrong, try to figure out if you can prevent that behavior automatically in the future. You have many tools available to you. You can write a hook to catch it, or write docs that agents are prompted in CLAUDE.md to read, or add a lint rule, or an automated test. If you’re lacking inspiration, you can ask the agent itself. Its idea might be bad, or it might not.

Sometimes the answer is to just get better at prompting, or to recognize that a class of problems is a bad fit for coding with agents.

Use the full power of the tools

Claude Code has a feature called Hooks which lets you run scripts when things happen. One way we use this at Descript is to force agents to call pnpm instead of npm. pnpm is popular, but npm will always be the 500lb gorilla of the JavaScript packaging world, and it doesn’t bother me that LLMs can’t help but try to use it. Human engineers make the same mistake sometimes, but I can’t add automated hooks into their brains like I can do with Claude!

Keep momentum

Give agents the tools to see and solve problems without your involvement

If you need to talk to your agent a lot to accomplish simple tasks, you’re probably not moving faster. When agents can test changes and iterate, they can work autonomously. As long as agents are behaving well, autonomy is a very good thing!

Here’s an example. Suppose you have a language model that is able to fix basic test failures. You, a human being, put up a PR that fails a test after a five-minute CI run. You click into the log for the failure and scroll down until you see failure traceback. You paste it into your agent and say “fix this.” It fixes a bug in your code to get the test to pass, but five minutes later, the next CI run fails because the fixed code reveals another issue. You repeat the process, clicking into the logs, pasting the message, having the agent fix the bug, committing and pushing. You check on the build and see that it passed. Congratulations, you used AI to solve a problem! It only took you fifteen minutes of lost focus.

Now instead of manually pasting the CI output to your agent, suppose that you asked Claude Code to run cimonitor watch after every push and fix problems that have obvious solutions. So when the first run fails, the agent noticed the error, pushed the first fix, noticed the second error, and pushed the second fix. You fired it off one time and started working on something else. With enough tools to observe the outcome and the ability to run those tools, you fired it off once and went to work on something else. You still had to check on it at the end, but you had two fewer interruptions.

A good general rule is, any time you copy and paste to or from an agent, find a way to automate it, probably using a command line tool or MCP server. Investigate log forwarding or use persistproc to make sure your agents can read your logs.

Multitask

Let’s continue with the example above. Now you’ve got Claude Code waiting on a CI job for your branch, so you’re free to do whatever. But your git clone is tied up with this CI thing! What do you do?

Developers are used to working out of “their clone” of a git repo. Dev tooling in organizations is mostly set up based on this assumption. In a world of agent-assisted coding, I don’t think this is reasonable anymore because agents encourage you to multitask.

I believe this is one of the frontiers of tooling as far as developer productivity goes. Engineers who embrace coding with agents will have multiple worktrees or containers or cloud VMs at the same time, dedicated to different ongoing coding tasks. During the workday I’ve started using a small script that wraps git worktrees and it’s been going well, but I suspect more will happen in this space, especially open source tools.

Prompt boldly, commit conservatively

One way in which I differ from a lot of AI hypefolk is that I don’t let agents commit my changes. I manage the staging area using Tower and make sure I understand every change that goes into a commit.

This workflow allows me to ask an agent to do something really ambitious, and just back out of the whole thing if it goes badly. It’s one area where my “classical engineering productivity” skill of being good at Git has served me well in the coding-with-agents era.

This is how I operate at work. But at home, on side projects, I do let Claude commit on my behalf because the stakes are lower and the problems are simpler.

It is worth finding out whether it works for you

This might sound like a lot of work. Maybe you’re skeptical that this can ever work. Maybe this isn’t why you got into coding and agent-assisted coding seems like a black hole for joy. Maybe you don’t like the vibes you get from AI companies. Or maybe your coworkers are submitting low-quality AI-generated pull requests without checking the results and you don’t want to be like that.

I’ve heard all of these things from friends, coworkers, and social media posts. I agree with at least one.

But despite all that, I find myself seeing a step change in my output without a drop in quality. It energizes me because I love building, and it scares me because I believe the bar will raise for engineering productivity across the industry and I need to stay employed.

So, seeing an opportunity to become more productive without spending more time, I take it. It seems like the rational thing to do. This entire article is a distillation of what I’ve learned just by practicing and experimenting. I still think I’m half as productive as I could be with the right tooling, and there are so many new plugins to install.

No part of this post was generated by an LLM.

MDN is great for a reference, but I haven't found a source of truth for modern CSS best practices. Once in a while I run across an article that captures a small piece of it. Here's a list of those articles.

Hit me up on Mastodon if I should add anything to the list.

There are three patterns I use in most of my UIKit projects that I've never seen anyone else talk about. I think they help readability a lot, so I'm sharing them here:

  1. An addSubviews method to define your view hierarchy all at once
  2. An @AssignedOnce property wrapper
  3. A pattern for keeping view creation at the bottom of a file to keep the top clean

addSubviews

I've seen a lot of view and view controller code that looks like this:

override func loadView() {
  view = UIView()
  let scrollView = UIScrollView()
  view.addSubview(scrollView)
  let contentView = MyContentView()
  scrollView.addSubview(contentView)
  let topLabel = UILabel()
  let button = UIButton()
  contentView.addSubview(topLabel)
  contentView.addSubview(button)
}

This style of setup is straightforward, but I usually have a difficult time understanding the view hierarchy without spending more time than I'd like.

In most of my projects, I use this simple extension to help with this problem:

extension UIView {
  func addSubviews(_ subviews: UIView...) -> UIView {
    subviews.forEach { self.addSubview($0) }
    return self
  }
}

Now, it's possible for the calls to addSubviews() to visually resemble the view hierarchy!

override func loadView() {
  view = UIView()
  let scrollView = UIScrollView()
  let contentView = MyContentView()
  let topLabel = UILabel()
  let button = UIButton()
  
  view.addSubviews(
    scrollView.addSubviews(
      contentView.addSubviews(
        topLabel,
        bottomLabel)))
}

You can also use this pattern in UIView initializers.

@AssignedOnce

When using storyboards, you commonly need to use force-unwrapped optional vars to keep references to views and other things. fine, but there is no compile-time guarantee that the property can't be overwritten. Kotlin solves this problem with the lateinit keyword, but Swift has no equivalent.

You can at least prevent multiple writes to vars at runtime by using this simple property wrapper, which throws an assertion failure in debug builds if you write to the property more than once. It's not as good as a compile-time guarantee, but it does double as inline documentation.

@propertyWrapper
public struct AssignedOnce<T> {
  #if DEBUG
    private var hasBeenAssignedNotNil = false
  #endif

  public private(set) var value: T!

  public var wrappedValue: T! {
    get { value }
    
    // Normally you don't want to be running a bunch of extra code when storing values, but
    // since you should only be doing it one time, it's not so bad.
    set {
      #if DEBUG
        assert(!hasBeenAssignedNotNil)
        if newValue != nil {
          hasBeenAssignedNotNil = true
        }
      #endif

      value = newValue
    }
  }

  public init(wrappedValue initialValue: T?) {
    wrappedValue = initialValue
  }
}

In practice, you can just add @AssignedOnce in front of any properties you want to prevent multiple assignment to:

class MyViewController: UIViewController {
  @AssignedOnce var button: UIButton! // assigned by storyboard
  @AssignedOnce var label: UILabel! // assigned by storyboard
}

Looks pretty nice, right?

View Factories

The most critical part of any source code file is the first hundred lines. If you're browsing through code, it really helps to not have to scroll very much to see what's going on.

Unfortunately, it's very easy to gum up the top of a view view controller file by creating subviews over multiple lines, especially if (like me) you don't use storyboards at all. Here's what I mean:

class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
  // I'm declaring all these as FUO `var`s instead of `let`s
  // so I can instantiate them in loadView().
  @AssignedOnce private var headerLabel: UILabel!
  @AssignedOnce private var tableView: UITableView!
  @AssignedOnce private var continueButton: UIButton!
  
  override func loadView() {
    view = UIView()
    
    headerLabel = UILabel()
    headerLabel.text = NSLocalizedString("List of things:", comment: "")
    headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
    headerLabel.textAlignment = .center
    headerLabel.textColor = UIColor.systemBlue
    
    continueButton = UIButton()
    continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
    continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
    
    tableView = UITableView()
    tableView.dataSource = self
    tableView.delegate = self
    // more semi-arbitrary table view configuration
    tableView.separatorStyle = .none
    tableView.showsVerticalScrollIndicator = false
    
    view.am_addSubviews(
      headerLabel,
      tableView,
      continueButton)
      
    // ... add constraints ...
  }
  
  // MARK: Actions
  
  @objc private func continueAction() {
    dismiss(animated: true, completion: nil)
  }
  
  // MARK: UITableViewDataSource
  
  /* ... */
  
  // MARK: UITableViewDelegate
  
  /* ... */
}

This is OK, but do you really need to know the implementation details of all the views so near the top of the file? In my experience, those parts of the code are written once and then never touched again.

Additionally, it's not great to use force-unwrapped optionals to store anything. But if we use let instead, then all views will be created at init time instead of in loadView().

View factories

We can solve a lot of problems by moving all view creation to the bottom of the file and using lazy var.

class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
  private lazy var headerLabel = makeHeaderLabel()
  private lazy var tableView = makeTableView()
  private lazy var continueButton = makeContinueButton()
  
  override func loadView() {
    view = UIView()
    
    view.am_addSubviews(
      headerLabel,
      tableView,
      continueButton)
      
    // ... add constraints ...
  }
  
  // MARK: Actions
  
  @objc private func continueAction() {
    dismiss(animated: true, completion: nil)
  }
  
  // MARK: UITableViewDataSource
  
  /* ... */
  
  // MARK: UITableViewDelegate
  
  /* ... */
  
  // MARK: View factories
  
  private func makeHeaderLabel() -> UILabel {
    let headerLabel = UILabel()
    headerLabel.text = NSLocalizedString("List of things:", comment: "")
    headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
    headerLabel.textAlignment = .center
    headerLabel.textColor = UIColor.systemBlue
    return headerLabel
  }
  
  private func makeTableView() -> UITableView {
    let tableView = UITableView()
    tableView.dataSource = self
    tableView.delegate = self
    // more semi-arbitrary table view configuration
    tableView.separatorStyle = .none
    tableView.showsVerticalScrollIndicator = false
    return tableView
  }
  
  private func makeContinueButton() -> UIButton {
    let continueButton = UIButton()
    continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
    // `self` is available inside `lazy var` method calls!
    continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
    return continueBUtton
  }
}

The main advantage of this approach is that rarely-touched view creation code is both in a predictable place, and completely out of the way if you're browsing lots of files quickly. A bonus is that FUOs are not necessary due to the use of lazy var. And the factory method return types enable you to remove the explicit types from the property declarations.

That's all, thanks for reading!

In the middle of 2020, I was inspired to take everything I had learned from working on Literally Canvas and Buildy, and make a multi-user online whiteboard. The result is Browserboard, and I've been improving it regularly for the past year and a half.

screenshot

If you want to read more about it, check out the Browserboard Blog.

Yesterday, I added PNG export to Browserboard, my multiplayer whiteboard web app. About half the effort was spent getting text to render correctly. 90% of what I found about this topic on Google was garbage, especially on Stack Overflow, so here's my attempt at clearing things up for people who want to do this without relying on somebody's half-baked code snippet.

HTML Canvas has not changed in 18 years

Apple created the <canvas> element to enable Mac developers to create widgets for Dashboard, a feature that died in 2019. Canvas replicates a subset of the Core Graphics C API in JavaScript. Naturally, this hack intended for a proprietary OS feature became the foundation of thousands of browser games and the only browser-native way to generate PNG images from JavaScript.

Because <canvas> is based primarily on a macOS graphics API and not the web, it was not designed with great web support in mind. In particular, its text rendering capabilities are extremely poor. Some issues include:

  1. Line breaks are ignored.
  2. Only one font and style can be used. Multiple styles are not possible.
  3. Text will not wrap automatically. There is a “max width” argument, but it stretches the text instead of wrapping.
  4. Only pixel-based font and line sizes are supported.

So the best we can hope for in “multi-line text support in <canvas>” is to support line breaks, text wrapping, and a single font style. Supporting right-to-left languages is an exercise for the reader.

There is a good JavaScript library for drawing multi-line text in <canvas>

There's a library called Canvas-Txt that is as good as it gets, but for some reason doesn't rise above the blog trash on Google.

If you got here just trying to figure out how to accomplish this one task, there is your answer. You can stop here.

Deriving <canvas> text styles from the HTML DOM

For Browserboard's PNG export, I needed a way to configure Canvas-Txt to match my quill.js contenteditable text editor. The key to doing that is CSSStyleDeclaration.getPropertyValue(), which you can use to find out any CSS property value from its computed style.

The TypeScript code snippet below finds the first leaf node in a DOM element and applies its styles to Canvas-Txt. (If you're using JavaScript, you can just delete all the type declarations and it should work.)

import canvasTxt from "canvas-txt";

function findLeafNodeStyle(ancestor: Element): CSSStyleDeclaration {
  let nextAncestor = ancestor;
  while (nextAncestor.children.length) {
    nextAncestor = nextAncestor.children[0];
  }
  return window.getComputedStyle(nextAncestor, null);
}

function renderText(
  element: HTMLElement,
  canvas: HTMLCanvasElement,
  x: number,
  y: number,
  maxWidth: number = Number.MAX_SAFE_INTEGER,
  maxHeight: number = Number.MAX_SAFE_INTEGER
) {
  const ctx = canvas.getContext("2d");
  if (!ctx) return canvas;

  // const format = this.quill.getFormat(0, 1);
  const style = findLeafNodeStyle(element);

  ctx.font = style.getPropertyValue("font");
  ctx.fillStyle = style.getPropertyValue("color");

  canvasTxt.vAlign = "top";
  canvasTxt.fontStyle = style.getPropertyValue("font-style");
  canvasTxt.fontVariant = style.getPropertyValue("font-variant");
  canvasTxt.fontWeight = style.getPropertyValue("font-weight");
  canvasTxt.font = style.getPropertyValue("font-family");
  // This is a hack that assumes you use pixel-based line heights.
  // If you're rendering at something besides 1x, you'll need to multiply this.
  canvasTxt.lineHeight = parseFloat(style.getPropertyValue("line-height"));
  // This is a hack that assumes you use pixel-based font sizes.
  // If you're rendering at something besides 1x, you'll need to multiply this.
  canvasTxt.fontSize = parseFloat(style.getPropertyValue("font-size"));

  // you could probably just assign the value directly, but in TypeScript
  // we try to explicitly handle every possible case.
  switch (style.getPropertyValue("text-align")) {
    case "left":
      canvasTxt.align = "left";
      break;
    case "right":
      canvasTxt.align = "right";
      break;
    case "center":
      canvasTxt.align = "center";
      break;
    case "start":
      canvasTxt.align = "left";
      break;
    case "end":
      canvasTxt.align = "right";
      break;
    default:
      canvasTxt.align = "left";
      break;
  }

  canvasTxt.drawText(
    ctx,
    this.quill.getText(),
    x,
    y,
    maxWidth,
    maxHeight
  );
}

So there you go. Good luck.

This is the fifth post in a series about my new app Oscillator Drum Jams. Start here in Part 1.

You can download Oscillator Drum Jams at oscillatordrums.com.

Earlier this year I learned that Garageband on my iPhone can do multitrack recording when I plug it into my 16-channel USB audio interface. This is an object less than 6 inches long handling tasks that would have required thousands of dollars of equipment twenty years ago, accessible to most teenagers today.

The audio system running on iPhones was designed in 2003 for desktop Macs running at around 800 MHz, slightly slower than the original iPhone’s processor. It’s a complex system, but the high level APIs are consistent and well-documented. As a result, there are many fantastic audio-related apps on the store: synthesizers, metronomes, music players, and toys that make music creation accessible to anyone. And because there’s a USB audio standard shared between iOS and macOS, there’s no need to install drivers.

I’m really grateful that I’m able to build on the work of past engineers to make Oscillator Drum Jams. It wasn’t easy, but I was ultimately able to ship it because the pieces already exist and can be plugged together by a single person working on occasional nights and weekends.

I’m also grateful that I got the opportunity to work on this project with Jake, whose passion and dedication to his music meant that we had over a hundred loops to share with the drum students of the world.