tldr
I wrote a @clack/prompts-inspired single header library in plain C99 for making beautiful CLIs. It compiles instantly, works on every OS and the browser, and has zero dependencies – not even libc1.
The source code is here; just drop it into your project and you’re done. If you just want screenshots, jump down to the gallery.
demo
Or, even better, try it! The below demo is compiled to WASM and running inside a true blue, real terminal thanks to libghostty and restty. Just tap or click the terminal to try it. (And yes, it works great on your phone – use Vim keys!)
sp_prompt.h
loading sp_prompt
Your browser can't run this demo.
The demo needs WebAssembly and WebGL2, but something on your Safari doesn't want to cooperate. Try any other browser, or just keep reading; there are tons of screenshots!
opencode has nice things
I’m a big fan of OpenCode. It is, ironically, powerful anti-slop. Every part of it feels intentionally designed. Their TUI uses little half-width Unicode box characters to squeeze finer padding from the paltry framebuffer that is your terminal. And internally, all of their UIs are just consumers of the same SSE stream that you yourself subscribe to.
But the most striking, I thought, was their CLI. I mean, come on. Just look at this:

Taste! Even the god damn login CLI is stuffed to overflowing with taste:2
- When you type, it filters
- The box outline on the active widget is a pretty cyan
- Extra entries get truncated, like a scroll region
- It understands Vim keys!
Who in the hell put so much love into something so uninteresting? And why does it make my blood so good and hot?
clack puts C to shame
It turns out that this CLI uses a library, @clack/prompts, written in TypeScript.
To my eye, it’s well written. The builtin widgets delighted me with their usefulness and simplicity instead of overwhelming me with choices. Which but for a C programmer, one obvious question:
Why can’t you do this in C?
Seriously. This comes virtually out of the box in TypeScript. The ecosystem of software doesn’t just rely on C code; it is C code. Unless you want to go full stage0 and matryoshka your way to self-hosted, there’s no escape. You want to build a kernel? A database? Sure, no problem.
But you want people to have a pleasant time using your CLI? No chance. I think this is mostly because systems programmers for whom ergonomics is important prefer languages built by other folks for whom ergonomics is important.
sp_prompt.h
Instead of complaining, let’s fix it. I wrote sp_prompt.h. It’s a single header library3 that clocks in at 2,000 lines of code. Right now, it gives you:
selectan item from a listmultiselectby toggling items from a listpasswordinput with censored text- Fuzzy filtering across items
- Titles, boxed notes, and warning/error/info/etc. indicators
- Vim keybindings
It compiles and runs on Linux, macOS, Windows, or WASM, and it has zero dependencies. It doesn’t need libc. Here’s a 40KB statically linked binary which contains the demo and runs on any Linux machine4 (although I highly recommend against running random executables from strangers). Where some would call C’s complete lack of quality-of-life flawed, others might call it portable.
usage
When I look at libraries, I just want to see what my code would look like, so let’s go through a simple example.
multiselect (input driven)

sp_prompt_ctx_t* ctx = sp_prompt_begin();
sp_prompt_intro(ctx, "Demo: Multiselect");
sp_prompt_select_option_t options [] = {
{ .label = "mimi", .hint = "the original" },
{ .label = "ohno", .hint = "mountain rat", .selected = true },
{ .label = "pigpen", .hint = "peeper", .selected = true },
};
u32 num_options = sp_carr_len(options);
sp_prompt_multiselect(ctx, (sp_prompt_multiselect_t) {
.prompt = "Pick your cats",
.options = options,
.num_options = num_options,
.filter = true
});
if (sp_prompt_submitted(ctx)) {
const c8* selected = sp_prompt_join_selection(options, num_options);
sp_prompt_note(ctx, selected, "Cats");
}
else if (sp_prompt_cancelled(ctx)) {
sp_prompt_cancel(ctx, "You picked no cats");
}
sp_prompt_outro(ctx, "Bye!");
sp_prompt_end(ctx);
If it looks simple, it’s because it is simple. sp_prompt_begin() saves the terminal state, configures it for rendering, and hands you a context to the library. Then, you call widgets, one after another. Widgets return whether they were submitted, so if you prefer the ImGui style you can write:
if (!sp_prompt_multiselect(ctx, ...)) {
// ...handle the cancellation
}
When you’re done, sp_prompt_end() puts everything back how it was. That’s it!
progress bar (event driven)

// Some worker that you'd like to show progress for
s32 demo_progress_worker(void* userdata) {
sp_prompt_ctx_t* ctx = (sp_prompt_ctx_t*)userdata;
sp_for(it, 233) { // Arbitrary
f32 progress = (f32)it / 233;
sp_prompt_send_progress_f32(ctx, progress);
sp_prompt_send_status(ctx, progress_to_status(progress)); // e.g. "Loading", "Compiling", etc.
do_work();
// You can cancel work in response to C-c or any other abort on the prompt
if (sp_prompt_is_aborted(ctx)) {
return 0;
}
// You can invert control and abort the prompt from the worker like this:
//
// sp_prompt_abort(ctx);
}
sp_prompt_complete(ctx);
return 0;
}
s32 main(s32 num_args, const c8** args) {
sp_prompt_ctx_t* ctx = sp_prompt_begin();
sp_prompt_intro(ctx, "Demo: Progress");
sp_thread_t worker = sp_zero();
sp_thread_init(&worker, demo_progress_worker, ctx);
sp_prompt_progress(ctx, (sp_prompt_progress_t) {
.prompt = "Working...",
.color.ansi = SP_ANSI_FG_CYAN_U8
});
sp_thread_join(&worker);
if (sp_prompt_submitted(ctx)) {
sp_prompt_success(ctx, "Installed!");
}
else {
sp_prompt_cancel(ctx, "Cancelled");
}
sp_prompt_end(ctx);
return 0;
}
The second kind of widget that’s commonly used is one that reacts to events generated elsewhere; think of monitoring a download. Here, we’re showing the builtin progress bar.
The setup is the same. We spawn a thread to do the work. sp_prompt.h of course does not require you to use sp_thread; it’s just for an example.
In our worker, we have a few APIs for reporting back to the widget.
sp_prompt_send_progress_f32()lets you send back a float. This API can be used by any widget, and there’s a union of primitives + a user_data pointer for if you want to report arbitrary information as progress.sp_prompt_send_status()lets you send back a string, which is copied into the context’s memory arena to keep lifetimes simple
Widgets have bidirectional cancellation with abort() and is_aborted(), and when the worker is done it sends sp_prompt_complete() to tell the widget to submit.
spinner (tick driven)

s32 spinner_thread(void* userdata) {
sp_prompt_ctx_t* ctx = (sp_prompt_ctx_t*)userdata;
do_work();
sp_prompt_complete(ctx);
return 0;
}
s32 demo_spinner(sp_prompt_ctx_t* ctx) {
sp_prompt_intro(ctx, "Demo: Spinner");
sp_thread_t worker = sp_zero();
sp_thread_init(&worker, spinner_thread, ctx);
sp_prompt_spinner(ctx, (sp_prompt_spinner_t) {
.prompt = "Spinning for 2 seconds...",
.fps = 12,
.frames = SP_PROMPT_SPINNER_PACMAN_MUNCHER,
.color.rgb = { .r = 150, .g = 180, .b = 255 },
});
if (sp_prompt_submitted(ctx)) {
sp_prompt_success(ctx, "You did it!");
}
else if (sp_prompt_cancelled(ctx)) {
sp_prompt_cancel(ctx, "You got dizzy and decided against spinning");
}
return 0;
}
The final kind of widget doesn’t use events to update. Spinners are the classic example. You want your spinner to update at some fixed frame rate, no matter what.
At the user level, this looks identical to every other widget. The difference is internal; a regular widget only needs to define on_event to react to inputs, cancellation, etc. A spinner additionally defines on_update, which runs unconditionally at a fixed frame rate.
sp_prompt.h has ~70 neat spinners built in, mostly pulled from Spinners.txt, plus a very fancy Knight Rider spinner jacked from OpenCode.
under the hood
Nothing beats an example, and there’s an excellent one in the source tree, plus documentation at the top of the header. But let’s walk through the basics of how it works.
A widget is just a user_data pointer + three functions:
on_event, which lets it handle inputs, cancellation, progress, stuff like thaton_update, which lets you do realtime widgets, like spinnerson_render
The widgets in the public API are just wrappers around sp_prompt_run(), which takes the widget data and functions and runs them in a video game style loop:
while (true) {
events = poll();
if (!events && widget.can_block) {
events = yield_to_kernel_until_event();
}
widget.handle(events)
widget.render()
if (accumulated > ns_per_frame) {
widget.update();
widget.render();
}
}
gallery
In case you missed it, the only thing better than a gallery is a demo compiled to WASM and running in your browser. Any way.
cancellation

confirm


indicators

knight rider

notes

progress
