Articles > Building a terminal-recording player for the modern web

Building a terminal-recording player for the modern web

05.11.2026 by Matic Utsumi Gačar

Recording terminal sessions as video is tedious and doesn't let us copy the text. Let's build a modern terminal playback solution for the web, custom colors supported!

I'm a developer using Linux and living inside the terminal like many others are. This mostly means no fancy animations and following the latest design trends as everything is just text, but think about it, everything is just text!

Recording terminals

Let's say we wanted to record a terminal session to use in a blog.
Picking up OBS and getting a recording is easy enough, however we're now left with a 10MB video file. No problem, just render it into a lossy format and reduce the quality a bit, that should do it! Oh, the text looks... like it's been through a microwave, not readable anymore.

We could just settle for a slightly higher filesize and call it a day, but the path to good UX on the web is never easy, so let's find a better solution.

Thinking about it, text in the terminal doesn't actually change every frame, but regular recordings require a constant amount of space per frame. Can't we just remove all the static content somehow? Converting it into a GIF could work... but now we need to manually cut out the static content and control the playback timing using JS... not quite good enough, but the approach seems promising.

It's just text after all

What if we just try to record the actual text as it appears on the terminal along with timestamps so we can recreate frame timings and handle the playback logic with JS? It's just text, sounds easy right?

Alright, now we just need a way to get the text, maybe even listen to changes to avoid checking multiple times per second. My terminal emulator of choice is Alacritty, which had some discussions about the above, but at the time it hasn't been implemented. So... time to look for a new terminal emulator? If only there was a way to make it work for everyone...

Tmux saves the day

Another tool I'm using all the time is Tmux, the terminal multiplexer. It's particularly useful for developers who work with remote servers or need to maintain persistent sessions that survive network disconnections. Beyond its session management capabilities, turns out it also provides a way to capture a snapshot the screen contents.

[me@local:~]# tmux capture-pane -e
[me@local:~]# tmux save-buffer ~/output
[me@local:~]# cat ~/output
[me@local:~]# tmux capture-pane -e
[me@local:~]# tmux save-buffer ~/output
[me@local:~]# cat ~/output

This gives us the raw terminal output including escape sequences. ANSI escape sequences are special character codes that terminals interpret as formatting commands - they start with an escape character (ESC, or \033) followed by specific codes that control text appearance, cursor movement, and screen manipulation. We'll worry about parsing colors later, let's try to get notified about changes rather than running the save command all the time. Turns out that's also doable.

pipe-pane [-IOo] [-t target-pane] [shell-command]
Pipe output sent by the program in target-pane to a shell command or vice versa. A pane may only be connected to one command at a time, any existing pipe is closed before shell-command is executed. The shell-command string may contain the special character sequences supported by the status-left option. If no shell-command is given, the current pipe (if any) is closed.

As is often the case, the answer is buried deep inside the man-page. By using pipe-pane we should be able to get live output, which means we no longer have to manually snapshot the terminal on a timer and risk missing rapid updates right?

[me@local:~]# tmux pipe-pane 'cat > ~/live-output'
[me@local:~]# cat ~/live-output
[me@local:~]# tmux pipe-pane 'cat > ~/live-output'
[me@local:~]# cat ~/live-output

It seems to work on first glance, unfortunately there's a catch. Remember the previous paragraph that mentions ANSI sequences being able to also do a myriad of other things? This includes changing and removing text that has already been printed, which is nice for showing interactive terminal applications without having to redraw everything all the time, but not so good for taking snapshots of the text that's on screen.

Does that mean no update notifications then? Not quite, let's combine the 2 approaches: watch the live output file for filesystem change events and trigger a snapshot using save-buffer!

To make it easier to work with, I quickly implemented a small terminal application using rust (which is a great choice for small terminal utilities like that).

[me@local:~]# rust-record start ~/live-output
[me@local:~]# echo hi
hi
[me@local:~]# rust-record end
[me@local:~]# cat ~/live-output
[me@local:~]# e
---frame--- {"width": 80, "height": 40}
[me@local:~]# ec
---frame--- {"width": 80, "height": 40}
[me@local:~]# ech
---frame--- {"width": 80, "height": 40}
[me@local:~]# echo
---frame--- {"width": 80, "height": 40}
[me@local:~]# echo 
---frame--- {"width": 80, "height": 40}
[me@local:~]# echo h
---frame--- {"width": 80, "height": 40}
[me@local:~]# echo hi
---frame--- {"width": 80, "height": 40}
[me@local:~]# echo hi
hi

Where's the cursor?

Now we're getting somewhere, but it seems like something's missing... The snapshots don't include the current cursor position, color and shape, which objectively makes terminal recordings less awesome. Let's fix that.

Since tmux is nice enough to give us the entire terminal output, I figured it might even give us the cursor info if we ask nicely, the data needs to be stored somewhere inside tmux after all. The good news is I found where the data is stored, bad news is there's currently no way to access it from the outside, but that won't stop us of course.

An easy way to get information from a tmux session is using variables that one can use inside format strings for various UI elements as well as using a terminal command. Turns out all we had to do was get a fundamental understanding of the entire codebase written in C and make a pull request that adds variables for cursor information.

tmux/tmux/pull/4414Expose additional formatting variables

Luckily there was a lot of existing code that made it simple even for non-C developers. The awesome maintainer was very helpful and merged it quickly. After updating to the latest git version of tmux, we can now get all the cursor info we need!

[me@local:~]# tmux display-message -p "shape: #{cursor_shape}, blinking: #{cursor_blinking}, color: #{cursor_colour}"
shape: block, blinking: 1, color: #ffffff

This uses tmux's string formatting feature and allows us to print special variables that get replaced with actual values from the current session.

Building the player component

Now that we can capture frame data along with the cursor position, it's time to build a component that can play back the frames with the correct timings. I decided to build a SolidJS component, for peak performance and minimal bundle size, easy to integrate into my blog, vanilla HTML sites and even React apps (with some adapter magic).

Some core features I had in mind from the beginning were:

  • Dynamic font size that scales to fit the parent HTML element it's in, just like an image
  • Smooth play, pause and seek functionality, maybe some transitions
  • Keybindings, including jumping to next/previous frames in the capture
  • Full-screen button
  • Text diffing between frames, so only parts that change get replaced in the HTML tree, preserving text selections

The implementation is quite involved, but feel free to check it out on github - first published version. And here's what it looks like:

​​​​​​​​​​​​​​​

It even allows for color-remapping, so that we can approximate each color from the terminal's colors scheme to the website's theme. There's probably a couple of things we can tweak here, as well as potential additions like markers, but it's definitely not bad for a first version!

The next step was to write a simple vite plugin that evaluates the frames at compile-time so my static blog website can use it without the need for a backend. In the future we can also move all the logic out into a library that can be called with dynamic content from a backend too.

import { PluginOption } from "vite";
import { compileFrames } from "../src/terminalcap/compiler.server";

export const viteTermcapPlugin = (): PluginOption => {
  return {
    name: "viteTermcapPlugin",
    enforce: "pre",
    async load(id: string) {
      const [filepath] = id.split("?");
      if (!filepath.endsWith(".term-capture")) return undefined;

      const meta = await compileFrames(filepath);

      return {
        code: `
import PlayerComponent from "~/terminalcap/PlayerComponent.vite";
export default PlayerComponent(${JSON.stringify(meta)});
          `,
        moduleSideEffects: true,
      };
    },
  };
};

We basically define a handler for the .term-capture filetype so we can import it from any typescript file. The plugin then reads the file contents, computes the static capture frames from the ANSI sequences and passes them to a parameterized solid component we can instantiate like any other one.

Since we have the recording backend, as well as the frontend component, we're pretty much done here right?

It already exists

There's a few things in programming that make you stop what you're doing and re-evaluate your life choices, one of them being the realization that you've spent a weekend programming something that already exists, you just didn't know it.

I remember seeing Asciinema recordings before, but didn't pay it too much attention as I just assumed it was a regular video recording at that time. It turns out that it's essentially the same thing as my terminal capture program, but better.

So is it time to archive all the code and move on? Not yet!

Looking at the Asciinema recording format - asciicast, it seems well designed and documented, also handles use-cases like recording input sequences and even streaming. Re-inventing it wouldn't make much sense, it's already great.

Next up is the recording CLI available on many platforms, written in rust and does all the stuff we used tmux for earlier, just without the need to run tmux.

The last part of the puzzle is the player component, which is similar to the one we implemented, and also written in solid! But looking at the implementation, there's a crucial difference: it doesn't pre-process frames, but rather parses the ANSI sequences in-browser using a web-assembly module. That's great for streaming, but for a static blog it means every visitor downloads a terminal emulator just to replay the exact same frames each time. Couldn't we just do that once, at build time?

Best of both worlds

Turns out we can! The wasm parser runs just fine in node, so instead of re-implementing the recorder, I swapped my tmux-based format for asciicast and let the vite plugin feed the recording through asciinema's own parser at compile-time. The output is the same thing my player already understood: a list of frames with only the lines that changed, plus the cursor.

The browser now gets plain data and a small solid component. No wasm, no worker, no second copy of solid, and the first frame renders on the server, so you can read it before any JS loads.

Patching asciinema

Well, almost. The parser is only bundled inside the player, so there's no way to import it from node. And remember the cursor info we went through all that trouble to get out of tmux? The terminal emulator powering asciinema, avt, only keeps track of the cursor position and visibility, ignoring the ESC [ n q sequence programs use to set the cursor shape. Looks like it's time for some forks!

Teaching avt about cursor shapes was mostly a matter of parsing the sequence and storing the shape (block, underscore, line, box) along with a blinking flag on the cursor. Then the player fork builds against it, passes the extra cursor info through getCursor() and exposes a separate terminal.js entry point with just the parser, so the vite plugin can run it at compile-time. A couple dozen lines of rust and some rollup config later, we get blinking line cursors in neovim's insert mode, just like the real thing.

Maintaining forks is no fun though, so the next step is to get these changes upstreamed. If it goes anything like the tmux pull request, everyone gets nice cursors soon!

Final solution

​​​​​​​​​​​​​​​​​​​​​

So was the weekend wasted? Not really. Asciinema does the hard parts better than I ever would, but the player is where things get personal. Asciinema's themes swap out the 16 base colors, but anything printed in true-color (looking at you, neovim colorschemes) stays exactly as recorded. My player snaps every color to the closest one in the website's palette, so recordings look like they belong on the page instead of being pasted in from someone else's terminal.

On top of that, all the heavy lifting happens at build time. Visitors get pre-computed frames, a tiny component that shares solid with the rest of the site, and a readable first frame before JS even loads.

Use asciinema to record, then let the browser do as little as possible. Perfection.

Check the code on Github