[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!

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

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

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

2 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. Here 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 and renderer is a Nottui.Renderer.t

Can I accomplish coexistence of interactive functionality in Mosaic?