Taking Rich Screenshots with Wezterm

Taking the screenshot

I’ll admit it, this whole idea started out as a solution in search of a problem!

I’ve been using WezTerm as my terminal emulator for a while now, but I wasn’t taking advantage of the fact that it is configured using Lua and so I was looking for an excuse to try something non-trivial.

Before long I found it - pane:get_lines_as_escapes().

The above function returns the current contents of the terminal pane as a string, including all the escape codes that control the formatting! By mashing together a couple of examples from the documentation I soon had a custom command I could invoke through the command palette which would dump the contents of the active pane to a text file.

wezterm.on('augment-command-palette', function(window, pane)
  return {
    {
      brief = 'Take screenshot',
      icon = 'cod_device_camera',

      action = act.PromptInputLine {
        description = 'Screenshot name',
        action = wezterm.action_callback(function(window, pane, line)
          if line then
            local text = pane:get_lines_as_escapes()
            local f = io.open(line .. '.txt', 'w+')

            f:write(text)
            f:flush()
            f:close()
          end
        end),
      },
    },
  }
end)

Flatpak permissions…

I have the Flatpak version of WezTerm installed.

This led to a bizarre issue where the call to io.open() kept failing silently by returning nil. Apparently, in order to get an actual error message you need to wrap the call in an assert().

Eventually, I was able to spot that WezTerm was complaining that the filesystem was read-only. Thankfully, the KDE System Settings app allowed me to grant WezTerm write access to my filesystem without too much hassle.

After running the command and giving it a filename I was left with a plain-ish text file that looked something like the following

\x1b[38:2::255:255:255m\x1b[48:2::49:49:49m|\x1b(B\x1b[0;1m\x1b[38:2::255:255:255m\x1b[40m*gud-test*        x\x1b(B\x1b[0m\x1b['z...

Rendering the Screenshot

Now that we have the raw data, there are lots of ways we could approach rendering it, for example you could dump it into xterm.js.

However, while I have nothing against JavaScript it’s always nice not to rely on it unnecessarily. Since this is a static screenshot anyway it makes sense to render it offline and embed the result in the page.

Using the rich Python library, it only takes a few lines of code to convert the screenshot from plain-ish text to an SVG.

import io
import pathlib

from rich.console import Console
from rich.text import Text

content = pathlib.Path('<filepath>').read_text()
text = Text.from_ansi(content)

console = Console(
    file=io.StringIO(),
    force_terminal=True,
    record=True,
    width=None,
    height=None,
)
console.print(text, end="")
svg = console.export_svg(title="Example screenshot")

There is one small issue… passing None for the width and height of the Console object means that it will use the dimensions of the current terminal executing the code rather than the dimensions of the terminal the screenshot was captured from!

Width and Height

In order for the captured data to be rendered correctly, rich’s Console object needs to use the same width and height as the original terminal.

This means that when we take the screenshot, we also need to capture the dimensions of the terminal pane. Easy enough with WezTerm’s Lua API, but where to store the values?

Rather than try and invent a file format, why not reuse the asciicast format from asciinema? After all a screenshot is just a single frame screencast!

We don’t need to worry too much about the details of the format, the following snippet should be enough for this use case.

{"version": 3, "term": {"cols": "<width>", "rows": "<height>"}}
[0, "o", "<captured_output>"]

Which we can modify our custom WezTerm command to produce.

wezterm.on('augment-command-palette', function(window, pane)
  return {
    {
      brief = 'Take screenshot',
      icon = 'cod_device_camera',

      action = act.PromptInputLine {
        description = 'Screenshot name',
        action = wezterm.action_callback(function(window, pane, line)
          if line then
            local dimensions = pane:get_dimensions()
            local body = {0, "o", pane:get_lines_as_escapes()}

            local header = {
              version = 3,
              term = {
                cols = dimensions.cols,
                rows = dimensions.viewport_rows,
              },
            }

            local f = io.open(line .. '.cast', 'w+')
            f:write(wezterm.json_encode(header) .. '\n')
            f:write(wezterm.json_encode(body))
            f:flush()
            f:close()
          end
        end),
      },
    },
  }
end)

And update our Python code accordingly

fpath = pathlib.Path('<filepath>')
with fpath.open() as f:
   header = json.loads(f.readline())
   body = json.loads(f.readline())

text = Text.from_ansi(body[-1])

console = Console(
    file=io.StringIO(),
    force_terminal=True,
    record=True,
    width=header["term"]["cols"],
    height=header["term"]["rows"],
)
console.print(text, end="")
svg = console.export_svg(title=self.options.get("title", ""))

At this point you’d think it would be job done, but there is one more detail to consider…

|*gud-test*        x                                 [89%] 21:17 18/06/25 ☰ (Pdb) n > /var/home/alex/Projects/swyddfa/lsp-devtools/develop/lib/lsp-devtools/ls\ p_devtools/agent/server.py(166)feed()                                       -> if (idx := state.buffer.find(SEP)) == -1:                                (Pdb) n > /var/home/alex/Projects/swyddfa/lsp-devtools/develop/lib/lsp-devtools/ls\ p_devtools/agent/server.py(169)feed()                                       -> line, state.buffer = state.buffer[:idx], state.buffer[idx + len(SEP) :]  (Pdb) idx 30                                                                          (Pdb)  -  *gud-test* 60:6      (Debugger:run Apheleia)                               160 # So we will keep running that parser as long as the buffe\ r continues toshrink   161 previous_length = len(state.buffer) + 1                       162 whilelen(state.buffer) < previous_length:                    163 previous_length = len(state.buffer)                       164 breakpoint()                                              165 ifnot state.headers_complete:                            166 if (idx := state.buffer.find(SEP)) == -1:             167 return   168    =>169 line, state.buffer = state.buffer[:idx], state.buf\ -🖿 lsp-devtools server.py 169:0      (Python Flymake[001] ws Apheleia© 

My terminal colours are not being preserved!

SGR Codes

I’m not the right person to try and explain how terminal rendering works. I only know enough to say that the story is long and built up using many layers of escape codes…

The codes that matter for this tale are something called SGR codes and they control various formatting properties including colour. Most documentation I’ve found will say that to set the text colour to… red for example, you need to use the following escape sequence:

\x1b[38;2;255;0;0m

However, there are some places that specify an alternative representation:

\x1b[38:2::255:0:0m

It turns out that WezTerm uses this second style, which rich does not recognise, resulting in the colourless image you saw above. To fix this, I tweaked the way rich parsed escape codes to allow for both styles, resulting in the image you see below.

|*gud-test*        x                                 [89%] 21:17 18/06/25 ☰ (Pdb) n > /var/home/alex/Projects/swyddfa/lsp-devtools/develop/lib/lsp-devtools/ls\ p_devtools/agent/server.py(166)feed()                                       -> if (idx := state.buffer.find(SEP)) == -1:                                (Pdb) n > /var/home/alex/Projects/swyddfa/lsp-devtools/develop/lib/lsp-devtools/ls\ p_devtools/agent/server.py(169)feed()                                       -> line, state.buffer = state.buffer[:idx], state.buffer[idx + len(SEP) :]  (Pdb) idx 30                                                                          (Pdb)  -  *gud-test* 60:6      (Debugger:run Apheleia)                               160 # So we will keep running that parser as long as the buffe\ r continues toshrink   161 previous_length = len(state.buffer) + 1                       162 whilelen(state.buffer) < previous_length:                    163 previous_length = len(state.buffer)                       164 breakpoint()                                              165 ifnot state.headers_complete:                            166 if (idx := state.buffer.find(SEP)) == -1:             167 return   168    =>169 line, state.buffer = state.buffer[:idx], state.buf\ -🖿 lsp-devtools server.py 169:0      (Python Flymake[001] ws Apheleia© 

Which is better, but it still isn’t an accurate representation of what I see in my terminal.

Colour Palette

Aside from providing specific RGB values, SGR codes also allow for applications to choose from a palette of 16 colours - 8 “normal” and 8 “bright” colours.

The specific colours in this palette are of course dependent on whatever theme you have configured in your terminal emulator, so the final step is to ensure that rich uses the same colour palette as my terminal.

Thankfully the asciicast format supports recording this information alongside the width and height, all we need to do is extend the screenshot command in WezTerm to populate it.

local dimensions = pane:get_dimensions()
local theme = wezterm.get_builtin_color_schemes()[window:effective_config().color_scheme]
local body = {0, "o", pane:get_lines_as_escapes()}

local header = {
  version = 3,
  term = {
    cols = dimensions.cols,
    rows = dimensions.viewport_rows,
    theme = {
      fg = theme.foreground,
      bg = theme.background,
      palette = table.concat(append_tables(theme.ansi, theme.brights), ':'),
    },
  },
}
Expand to see the complete command implementation
wezterm/commands/screenshot.lua
local wezterm = require 'wezterm'
local io = require 'io'
local os = require 'os'
local act = wezterm.action

local M = {}

local function append_tables(...)
 local result = {}
 for _, tbl in ipairs({...}) do
     for i = 1, #tbl do
         result[#result + 1] = tbl[i]
     end
 end
 return result
end

function M.setup()

  wezterm.on('augment-command-palette', function(window, pane)
    return {
      {
        brief = 'Take screenshot',
        icon = 'cod_device_camera',

        action = act.PromptInputLine {
          description = 'Screenshot name',
          -- initial_value = os.date('%Y%m%dT%H%M%S--terminal-screenshot'), -- apparently this requires a nightly build
          action = wezterm.action_callback(function(window, pane, line)
            if line then
              local dimensions = pane:get_dimensions()
              local theme = wezterm.get_builtin_color_schemes()[window:effective_config().color_scheme]
              local body = {0, "o", pane:get_lines_as_escapes()}

              local header = {
                version = 3,
                term = {
                  cols = dimensions.cols,
                  rows = dimensions.viewport_rows,
                  theme = {
                    fg = theme.foreground,
                    bg = theme.background,
                    palette = table.concat(append_tables(theme.ansi, theme.brights), ':'),
                  },
                },
              }

              local f = io.open(line .. '.cast', 'w+')
              f:write(wezterm.json_encode(header) .. '\n')
              f:write(wezterm.json_encode(body))
              f:flush()
              f:close()
            end
          end),
        },
      },
    }
  end)
end

return M

Which of course requires a similar update to the rendering code

term = header["term"]
ansi_colors = term["theme"]["palette"].split(":")
theme = TerminalTheme(
    background=Color.parse(term["theme"]["bg"]).triplet,
    foreground=Color.parse(term["theme"]["fg"]).triplet,
    normal=[Color.parse(col).triplet for col in ansi_colors[:8]],
    bright=[Color.parse(col).triplet for col in ansi_colors[8:]],
)

...

svg = console.export_svg(
    title=self.options.get("title", ""),
    theme=theme,
)

Which, finally, results in the SVG you saw at the start of this blog post!

Sphinx Integration

When working on a post for this site, I want to be able to just include the *.cast file produced by WezTerm as I would with any other image

.. termshot:: /images/example.cast
   :title: Example

and have Sphinx perform the conversion to SVG automatically during the build. This can be done by creating a custom directive.

The following class defintion defines the interface for the directive, we want it accept a single argument (/images/example.cast) and have the option to specify a :title:.

class Termshot(SphinxDirective):
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True

    option_spec = {
        "title": directives.unchanged,
    }

Each directive has a run() method, which should return a list of nodes which are inserted into the document at the location where the directive was used.

Other Sphinx directives like .. figure:: or .. include:: interpret absolute paths as paths relative to the project root. I want the .. termshot:: directive to behave in the same way, so I pass the given filepath through the self.env.relfn2path function which handles that for me

def run(self):
    _, fpath = self.env.relfn2path(self.arguments[0])

Once the svg has been generated, it needs to be included in the final HTML somehow, there are many way to approach this but for simplicity I’ve opted to inline the SVG code using a raw docutils node.

svg = console.export_svg(
    title=self.options.get("title", ""),
    theme=theme,
)

node = nodes.raw("", svg, format="html")
return [node]
Expand to see the complete implementation
from __future__ import annotations

import io
import json
import pathlib
import typing
from os.path import abspath, relpath

from docutils import nodes
from docutils.parsers.rst import directives
from rich.color import Color
from rich.console import Console
from rich.terminal_theme import TerminalTheme
from rich.text import Text
from sphinx.util.docutils import SphinxDirective

if typing.TYPE_CHECKING:
    from sphinx.application import Sphinx


class Termshot(SphinxDirective):
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True

    option_spec = {
        "title": directives.unchanged,
    }

    def run(self):
        _, fpath = self.env.relfn2path(self.arguments[0])
        with pathlib.Path(fpath).open() as f:
            header = json.loads(f.readline())
            body = json.loads(f.readline())

        term = header["term"]
        ansi_colors = term["theme"]["palette"].split(":")

        text = Text.from_ansi(body[-1])
        theme = TerminalTheme(
            background=Color.parse(term["theme"]["bg"]).triplet,
            foreground=Color.parse(term["theme"]["fg"]).triplet,
            normal=[Color.parse(col).triplet for col in ansi_colors[:8]],
            bright=[Color.parse(col).triplet for col in ansi_colors[8:]],
        )

        console = Console(
            file=io.StringIO(),
            force_terminal=True,
            record=True,
            width=term["cols"],
            height=term["rows"],
        )
        console.print(text, end="")
        svg = console.export_svg(
            title=self.options.get("title", ""),
            theme=theme,
        )

        node = nodes.raw("", svg, format="html")
        return [node]


def setup(app: Sphinx):
    app.add_directive("termshot", Termshot)
    return {"version": "1.0", "parallel_read_safe": True}