Drafter - Build rich terminal UIs in Elixir, locally or over SSH

I’ve been working on a TUI framework for Elixir called Drafter, inspired by Python’s Textual but built around Elixir idioms — declarative rendering, pattern-matched event handling, and OTP-native architecture.

elixir run_examples.exs

The basic idea:

Mix.install([{:drafter, "~> 0.1.3"}])
defmodule CounterApp do
  use Drafter.App

  def mount(_props), do: %{count: 0}

  def render(state) do
    vertical([
      header("Counter"),
      label("Count: #{state.count}", flex: 1),
      horizontal([
        button("−", on_click: :dec),
        button("+", on_click: :inc)
      ], gap: 2),
      footer(bindings: [{"q", "Quit"}])
    ])
  end

  def handle_event(:inc, _data, state), do: {:ok, %{state | count: state.count + 1}}
  def handle_event(:dec, _data, state), do: {:ok, %{state | count: state.count - 1}}
  def handle_event({:key, :q}, _state), do: {:stop, :normal}
  def handle_event(_event, state), do: {:noreply, state}
end

Drafter.run(CounterApp)

What’s in the box:

  • 30+ widgets — DataTable, Tree, Charts, TextInput, TextArea, Checkbox, RadioSet, Markdown, CodeView, and more
  • Flexible layouts — vertical, horizontal, grid, scrollable, sidebar
  • Multi-screen navigation — push/pop screens, modals, popovers, panels, toasts
  • Theming system with HSL/RGB/hex color support
  • Declarative widget event handling with automatic focus management
  • DOM-like three-phase event system (capture → target → bubble)
  • Animation engine with 30+ easing functions
  • Syntax highlighting via tree-sitter (optional)
  • Headless testing harness for ExUnit
  • Many examples

TUI over SSH:

Drafter.Server.start_ssh(ChatApp,
  port: 2222,
  mode: :shared,
  auth: [{"alice", "pass"}, {"bob", "pass"}]
)

Any standard SSH client connects and gets a full interactive TUI session. :shared mode means all connected clients see the same state — input from any client updates everyone’s view in real time. The included ssh_chat.exs example is a working multi-user chat app you can try in a couple of minutes.

Built on OTP 28 — raw terminal mode, proper Unicode, and lazy input reading. Earlier OTP versions won’t work correctly.

Feedback and contributions welcome. Still early, but it’s been running real apps reliably.

drafter | Hex
Drafter — drafter v0.2.0

23 Likes

it’s a cool project bro! Love it

Thanks!

I’m hoping we see some nice Elixir/beam TUI apps, as well as any feedback for improvements, or any additions.

i will, hopefully soon, drop my heavily WIP project that rides on top of it. Grafana TUI client.
Just need to generate some sharable pics.

1 Like

It looks interesting. I’m wondering how it differs from GitHub - pcharbon70/term_ui: A framework for writing terminal user interfaces in Elixir · GitHub ?

2 Likes

Thanks For pointing that out. I had gone through all of the TUI libraries I could find for Elixir (and checked other ecosystems) before starting on my own library.

I’ve taken a look at term_ui and really like a number of the widgets, and the clean simplicity. I would ask that you try out all of the examples from both projeccts. I know that Drafter is still very much a WIP, but it’s getting there.
There are a number of things we do differently, including colors, themes, animations, mouse support, etc.
I may look to borrow some of the themes/ideas of term_ui as I continue to work on Drafter.

Please let me know what you think after trying both.

Thanks.

1 Like

I made an example runner and recorded a quick dash through almost all of the examples so people can see what’s available without having to pull down the repo.

1 Like

Hi, good library.

I am still reading the code (theres 30k lines of code since March 12th) and I have these comments:

  1. TreeSitterDaemon is not a daemon
  2. Nif compilation is manual. I’d suggest using existing approaches like elixir_make, which handle most of this “find a compiler, detect flags for OS, etc.” for you in a traditional way.
  3. Drafter.PubSub module is unused. Name is used, functions are not
  4. This whole Phoenix.PubSub usage is strange. I suspect that it is not necessary in the first place. How its used: you have a “SharedState” PubSub server running, and it has a single topic named after that server’s pid. Then app loops subscribe to this single topic. First of all, it could be just a genserver which handles subscriptions in it’s state. Second, you can use :pg to not handle the subscriptions manually. Third, I suspect that sharing state with subscription is an error-prone approach because it may result in data races.
    My suggestion: just keep the SharedState server with two calls: subscribe and update_state. Subscribe will monitor the caller and add the pid of the caller in state. update_state will synchronously call every saved pid to propagate the state. It will not receive other update until all callers replied that they have reacted to update. You already have an EventManager which does exactly that. Perhaps it would make sense to reuse this process.
  5. I’d suggest using credo. It pointed out a lot of very easy-to-fix problems with performance, alongside some readability issues. I dont think that you need to fix all the issues found by credo, but fixing most of them would definitely improve the library.
  6. Lots of code like props = %{text: text} |> Map.merge(Map.new(opts)) which is just Map.put_new(Map.new(opts), :text, text)
  7. It is using pdict to store session state. It is fine until somebody calls Drafter.run (for example to start an SSH session in handler) inside some callback, which would generate a very very unusual bug.
  8. CSS stylesheet loader stores the cache[{:app, app_module}] value which is never accessed
  9. You have a custom CSS parser. It is incorrect and will fail if any class name or media rule contains special characters like { or }, or if any string contains wide characters, because it doesnt check for escaping and has a mix of regular expression and binary matching parser
  10. Drafter.Event.CustomRegistry is a bottleneck, never used and mostly undocumented. If you want to let user specify their own events, I think that users can perform their own validations with defstruct or any other validations library of their choice. This is a strange design decision
  11. ActionRegistry is a bottleneck and I’d suggest to not use a mutable state for this. User can just provide a list of action handlers on app start which can be just stored in session state
  12. FocusRegistry is storing keybindings, and storing them globally. Which would create strange bugs when for example two SSH sessions focus different widgets with different keybindings. But this is never used, since it requires widget module to export function keybindings/0, but this callback is listed in App module, not in Widget, which is strange. Oh, and its used only to check if app focuses anything, data is not accessed in any way.
  13. In Widget, you can use defoverridable Drafter.Widget to make all callbacks overridable, instead of listing them all. See this doc
  14. SkinManager handles the character sets globally. Again, this will be a problem in case of two SSH sessions having two different character sets. Then, it uses persistent_term which causes global GC each time it is changed. Not a big deal given that character sets change infrequently, but it could be just stored in the session state, avoiding any of these problems.
  15. Session.start_shared is never called. SSH and telnet connections bypass it and start the SharedState serves directly.
  16. ScreenManager, ThemeManager, EventHandler and Event.Manager are started globally and then one more time per each session. Essentially you have two session states per one session: one in pdict and one in workers, and these sessions. :collision:
  17. Event.Manager is strange. It receives messages, batches them in a queue, but dispatches them out one-by-one. You could’ve just dispatched messages in handle_cast, because inbox is already a queue, so there’s no need to implement another queue on top of it.

I can see that you name many of your GenServers as Registry, but that is kinda misleading, because Registry is usually an ets which tracks some state which is linked to some process, and is usually using the Registry Elixir module.

I dont have time to read the rendering code now, but I will try to read it next weekend. Please dont just send these comments to your coding agent LLM :smiley:, use my review as an opportunity to level up your Elixir.

Anyways, Drafter is a lot of code, a lot of work, and I think that this is a good library and it is has a lot of room for improvement. Congratulations on initial release! :tada:

Thanks for the feedback.

Just for a bit of background: I’ve been working on a number of projects at the same time, focusing on getting the result, and then returning to refactor, as Claude does make some stupid choices. My day job keeps me busy with actual hand-written code, which is not open source/public, so I’ve been delegating the many side-projects to Claude to get those results, which I’ll go back to. With an eye on the results, I haven’t looked at the quality, except in those instances where I see really silly things flying by in the console, in which case I redirect and have Claude do things a bit more sanely.

I really do appreciate the time you’ve taken to do a review. I tink it’s almost in a place where I have most of the functionality, and can now focus on the code quality (though I think I want to get one more chart type so that I can get one of my other projects polished up).

I have a crammed schedule until the second week of April or so. May be pushing out some Claude generated things, but after that, I can clean it up properly, starting with your detailed list here.

A funny thing, just scanning your list, I see #13 about widgets. Claude’s md file has this as a critical point:

  • Create widget processes with OTP behaviours
    It’s really interesting to see it ignore important directives.

In any case, thanks for the suggestions/comments. I will polish this up, especially as time pressures ease.

Does it support double-width characters? CJK, emojis

Great question… and with me sitting here in Asia, should have been top on my list of things to ensure..

And, now, yes…. working. Please let me know how it works for you.

Thank you for the feedback.

I’ll give it a try as soon as I upgrade to OTP 28.