[ANN] Mosaic - A Modern Terminal User Interface Framework for OCaml (Early Preview)

I’m excited to share an early preview of Mosaic, a modern terminal user interface framework for OCaml.

What is Mosaic?

Mosaic is a high-level framework for building terminal user interfaces in OCaml. It provides a TEA (The Elm Architecture) runtime with a CSS-compatible flexbox layout engine and a rich set of composable components. It handles text shaping (styling, wrapping, selection), focus and event bubbling, z-ordering, and responsive layout.

Under the hood, it builds on two libraries that can also be used independently:

  • Matrix: a terminal runtime focused on performance and modern protocols. Highlights: near-zero-allocation diffed rendering, immediate- mode API, Kitty keyboard, SGR/URXVT/X10 mouse, bracketed paste, focus tracking, inline/alt/split display modes, and built-in debug overlay/frame dumps. It also provides a virtual terminal emulator (VTE) and pseudo-terminal (PTY) management subsystems.
  • Toffee: a CSS-compatible layout engine. It’s a port of Rust’s Taffy with Flexbox, CSS Grid, and Block layout; gap/padding/borders/titles; and layout caching for efficiency.

Why Mosaic?

Terminal UIs are seeing a renaissance. Tools like Claude Code and OpenCode have gotten people excited about what can be built in the terminal and the TUI community is gaining momentum in other ecosystems.

OCaml has had LambdaTerm and Notty for terminal graphics for years, but there’s been a gap when it comes to performance and high-level abstractions for building complex UIs.

Mosaic aims to fill that gap by providing Matrix as a solid terminal foundation, and building a high-level TEA framework with layout and components on top.

On a personal side, I’m building Mosaic to power the two projects I’m currently working on:

  • It will be the basis for a TUI dashboard for monitoring model training in Raven. We’re starting an Outreachy internship to build this out this Monday.
  • It powers Spice, the upcoming OCaml coding agent I announced at FunOCaml 2025.

Try It Now

The libraries aren’t on opam yet, but you can try them today:

Option 1: Pin from GitHub

opam pin add https://github.com/tmattio/mosaic.git

Option 2: Build from source

git clone https://github.com/tmattio/mosaic
cd mosaic
opam install . --deps-only
dune build

Then run some examples:

# Interactive Game of Life (ported from Notty examples)
dune exec ./matrix/examples/02-life/main.exe

# Particles simulation with multiple display modes
dune exec ./matrix/examples/14-particles/main.exe

# High-level TEA counter
dune exec ./mosaic/examples/01-counter/main.exe

Have a look at the examples directories (Matrix and Mosaic) for more demos to explore!

As a bonus, we also have more complete demos for both projects:

Quick Examples

Mosaic: The Elm Architecture

Mosaic follows TEA for building declarative UIs:

open Mosaic_tea

type msg = Increment | Decrement | Quit

let init () = (0, Cmd.none)

let update msg model =
  match msg with
  | Increment -> (model + 1, Cmd.none)
  | Decrement -> (model - 1, Cmd.none)
  | Quit -> (model, Cmd.quit)

let view model =
  box ~align_items:Center ~justify_content:Center
    ~size:{ width = pct 100; height = pct 100 }
    [
      box ~flex_direction:Column ~align_items:Center ~gap:(gap 1)
        ~border:true ~padding:(padding 2) ~title:"Counter"
        [
          text ~content:(Printf.sprintf "Count: %d" model) ();
          text ~content:"Press + or - to change, q to quit" ();
        ];
    ]

let subscriptions _model =
  Sub.on_key (fun ev ->
      match (Mosaic_ui.Event.Key.data ev).key with
      | Char c when Uchar.equal c (Uchar.of_char '+') -> Some Increment
      | Char c when Uchar.equal c (Uchar.of_char '-') -> Some Decrement
      | Char c when Uchar.equal c (Uchar.of_char 'q') -> Some Quit
      | Escape -> Some Quit
      | _ -> None)

let () = run { init; update; view; subscriptions }

Matrix: Low-Level Power

For direct terminal control, Matrix provides an immediate-mode API:

open Matrix

let () =
  let app = Matrix.create () in
  let count = ref 0 in
  Matrix.run app
    ~on_render:(fun app ->
      let grid = Matrix.grid app in
      Grid.clear grid;
      let cols, rows = Matrix.size app in
      let text = Printf.sprintf "Count: %d" !count in
      let hint = "Press + or - to change, q to quit" in
      Grid.draw_text grid ~x:(cols / 2 - 5) ~y:(rows / 2) ~text;
      Grid.draw_text grid ~x:(cols / 2 - 18) ~y:(rows / 2 + 1) ~text:hint)
    ~on_input:(fun app -> function
      | Input.Key { key = Char c; _ } when Uchar.equal c (Uchar.of_char '+') ->
          incr count
      | Input.Key { key = Char c; _ } when Uchar.equal c (Uchar.of_char '-') ->
          decr count
      | Input.Key { key = Char c; _ } when Uchar.equal c (Uchar.of_char 'q') ->
          Matrix.stop app
      | Input.Key { key = Escape; _ } -> Matrix.stop app
      | _ -> ())

Coming Soon: TUI for ML Training

We’re starting an Outreachy internship to build a TUI for monitoring model training with Raven, the scientific computing ecosystem for OCaml. It will provide a TensorBoard experience in the terminal, built entirely with Mosaic.

A good example of what we’re aiming to build is Wandb’s newly released TUI:

Mosaic vs Notty

Notty is the current go-to terminal UI library for OCaml, with a well-designed declarative image API. Mosaic sits a level above: it’s a TEA runtime with flexbox layout, rich components, focus/event bubbling, and diffed rendering via Matrix. In scope, Notty is closer to Matrix (the terminal infrastructure under Mosaic) than to Mosaic itself.

Matrix covers the low-level rendering, modern terminal protocols, and immediate-mode API that Notty doesn’t. For a detailed Matrix vs Notty comparison, see our comparison table.

Acknowledgements

Mosaic stands on the shoulders of great work:

  • Bubble Tea - inspiration for the high-level TEA runtime and app structure.
  • Notty - Matrix’s declarative Image API is directly copied from Notty’s to provide a familiar interface for OCaml users.
  • OpenTUI - the biggest influence on Mosaic UI internals (render tree, text buffer, events, selection). Mosaic’s UI internals have been rewritten to mirror OpenTUI’s following its release, if you’re working in TypeScript, I can’t recommend it enough, it’s a fantastic piece of engineering.
  • Rich and Textual - for ideas on rich text, diagnostics, and polished terminal UX.

Feedback Welcome

This is an early preview. APIs are stabilizing but may change. I’d love your feedback on:

  • API ergonomics
  • Missing components or features you need
  • Performance on your terminal
  • Bugs (please open issues!)

Give it a try and let me know what you think!

34 Likes

Hello, thank you for sharing this!

One issue I have with notty is the lack of support for windows. Do you plan to support this os with mosaic?

Also, there is a typo in the readme:

opam install [m]mosaic

I’m definitely willing to, but I don’t have a Windows machine. Some help here would be immensely appreciated, if only for logging issues.

Thanks for the typo report! :slight_smile:

1 Like

@tmattio I don’t want to sound too negative but when I saw that your last Complete rewrite commit had an addition count over 1’000’000 loc, I got a bit curious.

So running cloc on the repo gives the following summary:

> cloc .
    2566 text files.
    2458 unique files.                                          
     110 files ignored.

github.com/AlDanial/cloc v 2.06  T=2.71 s (907.9 files/s, 556729.7 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
C                               28           1846           1038        1159606
OCaml                         1316          25965          28618         257229
HTML                          1030           2059              7          18176
C/C++ Header                    29            817           2375           4696
JavaScript                       2            253            140           1332
Text                             2              0              0           1239
Markdown                        48            445              0           1166
JSON                             2              0              0             97
CSS                              1             17              6             64
-------------------------------------------------------------------------------
SUM:                          2458          31402          32184        1443605
-------------------------------------------------------------------------------

I will let aside that 1 million lines of C which seems to be mainly (but not only) a tree-sitter grammar for OCaml. I’m not sure I exactly understand why a “modern terminal user interface framework” needs that but let’s suppose it makes sense.

It still remains 250k of OCaml code which is not on the light side. Did you personally review all these OCaml lines spread in 1316 files?

Seeing at least two UTF-8 decoders in the same folder with exactly the same interface and other nonsense code like this, entices me to think that the answer is no. Also I wish good luck to the person who will have one day to understand what you are exactly doing in grapheme_cluster.ml :–)

There are also quite a few claims in your material and comments about performance. For example in your introductory message:

While I personally never used these libraries, I also never specifically heard about performance issues about these. Do you have any benchmarks that backs your claims here? Anything that shows the problem with these libraries or that what you propose here is actually better?

I wouldn’t have asked the question if you had not claimed that, but I’m asking this because by looking at just some sample code I see absolutely ridiculous allocation patterns which somehow makes me a little bit doubt about the whole “high-performance” tag line for these more than 250k lines of OCaml code which I’m not sure you really have reviewed yourself.

10 Likes

Regarding the LOC: a large portion of the OCaml count comes from programmatically-generated Toffee tests (about 190k lines across 1000 files), which of course I did not review line by line. Without tests, examples, and docs, the core libraries are roughly: 11k LOC for Matrix, 16k for Mosaic, 12k for Toffee.

This spans several subsystems: ANSI sequence parsing, Unicode/grapheme handling, terminal grid diffing, virtual terminal emulation, PTY handling, the rendering tree, reconciler, widgets, charting, and the TEA runtime. So the overall footprint is naturally fairly large.

Since some of your comments seem to imply that the code was generated and pushed without oversight: that’s not the case. I do use AI extensively in my workflow, and I’ve spoken about that openly in the context of Raven. AI writes a significant amount of the initial code, and I review, revise, and iterate on a large portion of it. That’s how I work these days. But the architecture, design, and core logic are very much the result of deliberate iteration and manual refinement. I’ve been working full-time on this version of Mosaic for the past couple of months (and longer before that as one can see on the commit history), going through multiple iterations of the design and implementation and spending a significant amount of time reviewing and refining the code.

As with any project of this scale, parts will continue to evolve, and I’m always open to concrete, actionable feedback.
I’d prefer to focus on specific technical issues rather than debating assumptions about intent or workflow, so if you or others have particular concerns, feel free to open focused issues and we can address them constructively.

On the performance side: you can see the benchmarks at mosaic/matrix/bench at main · tmattio/mosaic · GitHub. I’ll publish comparative benchmarks with Notty and other libraries when I have time to set them up properly.

4 Likes

If that’s not the case then I have to say that I won’t give much trust in the code you are producing.

For example among other non-sensical functions and other obviously badly written functions (see my previous message), I see bold “zero-allocation” claims in some grapheme_cluster.mli file and when I peek under the hood I see an UTF-8 decoder actually allocating one pair of integer per decoded Unicode scalar value… If you want to see a zero-allocation UTF-8 decoder you only need to look at the stdlib I wrote one for it there :–)

I know truth no longer matters much these days in our societies but I have to say that I’m a bit uncomfortable with claims first and proofs afterwards (especially when I look at the code :–)

Note that you decided to paint yourself in that corner, that’s the angle you decided to take. Personally I usually have no problem with people scratching their own itch, if only because for many (myself included) that’s the only way to properly learn about a topic and possibly provide new insights on problems. So in my book there’s no need to compare yourself to other existing libraries.

Of course programmers always prefer to focus on technical issues. But it would be nice if they also act responsibly from time to time.

Bear in mind that I’m not debating assumptions about intent or workflow, I’m debating baseless or wrong claims you and your code are making – those may be a direct side effect of your intent or workflow though.

6 Likes

I see that the main loop is run in an Eio switch. Is it possible to integrate this with other interactive parts of the program in Mosaic?

I’ve been looking into using Nottui, which is another high-level TUI framework based on Notty, together with a window rendered through raylib. There a pattern of manually driving the main loop appears to work. Roughly:


  (* Main loop *)
  let last_ui_update = ref 0.0 in
  let ui_update_interval = 1.0 /. 30.0 in (* 30 FPS for TUI *)

  let rec loop () =
    if not (AppState.should_quit state) then begin
      let now = Unix.gettimeofday () in
      let should_update_uis = now -. !last_ui_update >= ui_update_interval in

      if should_update_uis then begin
        (* Step 1: Process Nottui (terminal) *)
        Nottui_unix.step
          ~timeout:0.0  (* No blocking - check for input immediately *)
          ~process_event:true
          ~renderer
          term
          root;
        last_ui_update := now;

        (* Step 2: Process Raylib events (mouse, window) *)
        process_raylib_events state;

        (* Step 3: Render Raylib graphics at 60 FPS *)
        render_raylib state;

      end;

      (* Continue loop *)
      loop ()
    end
  in

  loop ();

where state is a manually handled global state, term is a Notty terminal, root is the observed root node of the Nottui reactive graph, and renderer is a Nottui.Renderer.t.

Can I accomplish coexistence of interactive loops in Mosaic?

2 Likes

This is so cool :smiling_face_with_sunglasses:

I don’t even know it exist in Ocaml ecosystem !

Have been using a lot of TUI recently on Arch/Omarchy.

1 Like

I’ve been trying to figure out how to get a handle on agentic programming, so I’ve flipped the switch on my private notes and made them public: I’m trying to build a new OCaml library every day to solve a practical problem in my own workflows, but using Claude Code. I’ve been really enjoying wandering through my TODO list, and today I managed to get a Mosaic CLI interface to my contacts manager working.

I’m reserving judgement on this whole thing until I run through my full set of planned attempts in December, but it’s already been quite interesting. @dbuenzli’s libraries really stand out as being by far the easier to dip toes into agentic coding, since they form a composable foundation of well-designed interfaces that plug in well together without being monolithic…

9 Likes

It should be possible yes, the primary API in Matrix is callback based, so Matrix owns the rendering loop, but it should be possible to use the lower level APIs the Matrix runtime uses to build your own runtime (and to run both raylib and Matrix in the same loop).

It wasn’t the main use case I had in mind, but if you see friction points trying to do this, don’t hesitate to open an issue, I’ll be happy to refine the API to make this easier to do.

For what it’s worth, the main performance claim here is coming from Matrix’s design vs Notty, not the necessarily implementation.

Notty can run at 1K+ FPS. In most cases, the limiting performance factor in TUI frameworks is not the rendering loop, but the terminal IO and rendering. Notty sends a full frame on every render. You can try running Notty applications (e.g. their rain example) at high FPS and you’ll start seeing flickering and the terminal dropping frames.

Matrix (and modern TUIs in general) solve this by diffing the previous frame with the current one, and only sending the necessary changes to the terminal. This drastically reduces the amount of data sent to the terminal, which is often the bottleneck.

Matrix aligns with what’s been standard practice in TUI frameworks, which is to optimize for minimizing terminal IO rather than raw rendering speed (although of course we want rendering to be fast too, and we aim to minimize allocation as much as possible!).

One can refer to other TUI frameworks that are built for high performance, like Rust’s Ratatui, C++'s ncurses, Python’s rich, etc. Matrix is OCaml’s implementation of the same principles.

Matrix (and modern TUIs in general) solve this by diffing the previous frame with the current one

(n)curses is ancient, so it’s more that ancient TUIs solve it this way, but modern conditions (faster hardware, people rarely running anything new over ssh) have allowed people to forget how slow terminals can be without it. I made a similar point a while back and the Gleam dev didn’t even believe me.

That thread was just on my mind because I’d just finished duplicating the Erlang game of Sokoban there in OCaml, inspired by the question in this thread. Maybe I misunderstand the context of driving the mainloop, but with user-defined effects the gameplay loop is just this:

let rec play g =
  let open Grid in
  if unfinished g = 0 then
    g
  else (
    perform (Draw g);
    match perform (GetMove ()) with
    | Quit -> g
    | Harder -> play (copy levels.(1))
    | Easier -> play (copy levels.(0))
    | Move dir ->
      let at = where g in
      look g ~at ~dir |> move |> place g ~at ~dir;
      play g)

With the different frontends providing different effect handlers.

2 Likes

AFAIK, Notty does not send a full frame on every render. It finds the minimum set of changes to render the new image. It may not do it efficiently enough (for whatever reason), but the essential mechanism is the same as what you’re using in Matrix.

Unless I’m missing something from their code, that’s not the case. Where did you see that it does frame diffing?

You’re right, my bad.