VHS Language Cheat Sheet

Many developers who are constantly writing blog posts or giving a presentation at conferences may want a tool to record their terminal screenshots easily like me. In addition, you may wish to be free from the burden of re-recording or editing the demo video many times to adjust the content and format.

I found VHS allows us to do that work in a programmable manner. Moreover, it provides a simple programming language to write the scenario shown in the terminal.

For instance, this program can quickly generate the following footage recording the terminal.

Output hello.gif

Set FontSize 32 
Set Width 1200
Set Height 600

Type "echo 'Hello, World'"
Sleep 500ms
Sleep 5s


This tool is so easy to use that I want to write here the basic usage of VHS as a sort of cheat sheet.


First, you write the program in an ordinal text file with the extension .tape. After that, you can use any text editor you like. Then vhs executes the program and outputs the final artifact in the specified format.

$ vim hello.tape
$ vhs < hello.tape # You will get `hello.gif` file

Available Commands


Specify file output file. Usable formats are .gif, .mp4, .webm, or a collection of .png images.


Set recording configuration to control the appearance of the terminal. Available subcommands are:

  • Shell
  • FontSize
  • FontFamily
  • Width
  • Height
  • LetterSpacing
  • LineHeight
  • TypingSpeed
  • Theme
  • Padding
  • Framerate

TypingSpeed controls the global configuration to set the typing speed. A command-based configuration can overwrite it with @time.

Set TypingSpeed 0.1
Type "100ms delay per character"
[email protected] "500ms delay per character"


You can choose any theme available here.


Emulate typing. You can launch any other program available in the $PATH in the terminal you are running the vhs. This feature makes me even happier because there is no need to remember other special commands or syntax. Just typing like I usually do is sufficient. For example, the following tape writes the executable python program in the REPL.

Output python_add.gif

Set FontSize 32 
Set Width 1200
Set Height 600

Type "python3"
Sleep 500ms

Type "def add(x, y):"
Type "return x + y"
Enter 2
Sleep 500ms
Type "result = add(1, 3)"
Type "print(result)"

Sleep 2s

python add

Cursor Movement

Move cursor Left, Right, Up, and Down respectively. You can set the number of types of these cursor movements following the command. The same rule applies to the following special keys as well.

Output cursor.gif

Set FontSize 32 
Set Width 1200
Set Height 600

Type "echo 'Hello, World'"
Sleep 500ms
Left 10
Right 8
Left 5
Sleep 5s


Special Keys

Backspace, Enter, Tab, and Space special keys are also available. They are especially critical to write the program in the VHS environment.

Control Key

Ctrl+ provides us a way to type control + key stuff.


Wait for a certain amount of time.

Showing/Hiding Type Movement

Hide is helpful to keep the typing command from being recorded. This type of command may include some prerequisites or cleanup commands in this category. Note that the final thing we type is shown with Hide but not capturing frames. Exiting from the non-capturing mode, we can use Show.

python execute


VHS is just a programming language, so we can build up some valuable tools running on top of it. That will accelerate our blog writing or demo preparation significantly.

Chinese Pinyin in macOS keyboard

macOS is a powerful operating system to support multiple languages. Not only can you switch the display language, but also we can write any language possible by changing the input source. But the Chinese pinyin input system is a little bit tricky. So we need to get used to it when we get a chance.

Input Source

It’s easy to type kanjis once you learn the pinyin of characters. But Chinese is a tone language which means the tone of the voice has non-negligible semantics. So you may also need to study the tone of each character. For instance, when you say 马(mǎ), it means a horse, but 妈(mā) is mother. We do not talk about the detail of the tone here. Instead, we are interested in how to type tones in macOS like mā or mǎ.

There are four types of tones.

  • first tone (ā)
  • second tone (á)
  • third tone (ǎ)
  • fourth tone (ǎ)

We have two options to write these four types of tones in pinyin.

Switching the tone by tab

Select the “Pinyin - Simplified” input source.

We can use a tab to switch the tone. For example, after we write a vowel character, change the tone before pushing the enter key. It switches the tone one by one in order.

Using option key with ABC-extended

After we select the “ABC-Extended” input source,

  • Option + a + vowel -> First tone
  • Option + e + vowel -> Second tone
  • Option + v + vowel -> Third tone
  • Option + ~ + vowel -> Fourth tone

The second option requires us to type more. It’s necessary to switch the input source and press the option key. So I recommend you use the first option.

Caveat using all? and any? in Ruby

There are always several pitfalls in writing a code, regardless of its difficulty. Developers are likely to get into trouble for several hours or more. This time I will briefly describe the situation where I’ve got stuck due to a lack of recognition of the short-circuit evaluation semantics provided by all? and any? in Ruby.

What is short-circuit evaluation?

The short answer is here. It is semantic for a kind of optimization. We only evaluate the second argument of the boolean expression only if the first argument does not enough to provide the total value of the expression. For example, let’s say we have the following boolean expression.

a && b

We do not need to evaluate the variable b if a' is false because we can know the overall final value is false without b`.

But what if a and b have side effects, respectively? We want to make sure to execute the result of side effects. That happened when I used the all? method in Ruby.

my_models = [...]

if my_models.all?(&:valid?)
  puts "All okay."
  puts "Someone is not okay."

I wanted to collect all models’ errors in my_models so that users can see all possible errors at once. valid? allows us to accumulate validation errors in the model. But all? stops executing valid? if some models are already invalid. So we need to rewrite the code like this.

my_models = [...]
all_validity = my_models.map(&:valid?)

if all_validity.all?
  puts "All okay."
  puts "Someone is not okay."

This is a simple problem, and well experienced Ruby developer may not have made such a mistake. I hope this caveat may help someone who accidentally forgets the short-circuit semantics when writing the code.

Ruby Build Failure with OpenSSL3

There may be no developer who has not encountered installation errors when using Ruby and rbenv. As a piece of evidence that Ruby and OpenSSL have bad chemistry with each other, you may be able to find a lot of questions line about the error like this.

This time, I tried to upgrade my Ruby environment to Ruby3 and encountered the following issue.

OpenSSL 3 - symbol not found in flat namespace '_SSL_get1_peer_certificate'

This is because OpenSSL 3 has SSL_get1_peer_certificate but Open SSL 1.1 does not, which has SSL_get_peer_certificate instead. When you build Ruby with OpenSSL 1.1 but puma running for Rails using Open SSL 3, the problem shows up.

This solution targets the developer using macOS and Homebrew to manage their system packages.

Short Answer

The quick answer to this problem is deleting OpenSSL 3 from your environment. If you use macOS and Homebrew, you will find which version is installed.

$ brew --prefix openssl
/usr/local/opt/[email protected]

It indicates that the system, including puma, can refer to OpenSSL 3. It might be better to uninstall this version completely. You may imagine changing the build option to compile puma with OpenSSL 1 can work.

$ bundle config build.puma \
    --with-opt-include=/usr/local/opt/[email protected]/include

But it did not work in my case. Eradicating the package from the system was the sole solution.

Loop summation with MLIR

MLIR is not a programming language in a broad sense. As the name suggests, it’s an intermediate representation to express the middle-level structure of the program. This framework is so versatile and flexible by employing the plugin architecture inside. It might be possible (and even natural) to write our program with MLIR by hand. MLIR is powerful in representing the high-level structure we recognize when writing algorithms. I tried to run a simple program adding up all values from 0 to 10 (inclusive).

Affine Dialect

It is straightforward to use Affine Dialect to implement the nested loop in MLIR. The syntax is very similar to what we see with the higher-level programming language like C/C++ and Java. affine.for is an operation representing a loop containing a region in its body. It gets three operands, lower bound, upper bound, and step value.

affine.for $i = 0 to 11 step 1 {
  // Body

This code iterates the SSA value $i from 0 to 10. Step operand is optional. The block in affine.for should have one terminator operation affine.yield. This operation yields zero or more SSA values from an affine op region. In this case, we will use this operation to return the final summation value. iter_args is helpful to retain the loop-carryed variables, which are in the scope of the body region of affine.for. This value holds what is returned by the termination operation affile.yield. We will use %sum_iter to keep the current accumulated value.

In addition to the affine dialect, we need to use Arith Dialect, which holds basic integer and floating-point mathematical operations. We utilize this dialect to initialize the constant and add operations.

As a whole, the program will look as follows.

func.func @main() -> i32 {
  %sum_0 = arith.constant 0 : i32
  %sum = affine.for %i = 0 to 11 step 1 iter_args(%sum_iter = %sum_0) -> (i32) {
    %t = arith.index_cast %i : index to i32
    %sum_next = arith.addi %sum_iter, %t : i32
    affine.yield %sum_next : i32
  return %sum : i32

Lowering to LLVM

To run the program in MLIR, we need to lower it to the lowest level in the executable format. That means converting one dialect to another dialect in the MLIR sense. We will convert affine and arithmetic dialect to LLVM dialect first. mlir-opt is a handy tool to achieve that type of conversion.

$ mlir-opt \
    --lower-affine \
    --convert-arith-to-llvm \
    --convert-scf-to-cf \
    --convert-func-to-llvm \
    --reconcile-unrealized-casts sum.mlir

module attributes {llvm.data_layout = ""} {
  llvm.func @main() -> i32 {
    %0 = llvm.mlir.constant(0 : i32) : i32
    %1 = llvm.mlir.constant(0 : index) : i64
    %2 = llvm.mlir.constant(11 : index) : i64
    %3 = llvm.mlir.constant(1 : index) : i64
    llvm.br ^bb1(%1, %0 : i64, i32)
  ^bb1(%4: i64, %5: i32):  // 2 preds: ^bb0, ^bb2
    %6 = llvm.icmp "slt" %4, %2 : i64
    llvm.cond_br %6, ^bb2, ^bb3
  ^bb2:  // pred: ^bb1
    %7 = llvm.trunc %4 : i64 to i32
    %8 = llvm.add %5, %7  : i32
    %9 = llvm.add %4, %3  : i64
    llvm.br ^bb1(%9, %8 : i64, i32)
  ^bb3:  // pred: ^bb1
    llvm.return %5 : i32

As you can see, there are several options to complete this conversion.

  • --lower-affine : Lowering affine dialect to standard dialect.
  • --convert-arith-to-llvm : Convert arithmetic dialect to LLVM dialect.
  • --convert-scf-to-cf : Convert structured control flow dialect to the primitive control flow dialect.
  • --convert-func-to-llvm : Convert func dialect to LLVM dialect.

We do not talk about them in detail here, but the final code in MLIR only contains operations from the LLVM dialect. (Note that they start with the llvm prefix). Finally, it’s ready to go down to LLVM IR!

Translate MLIR to LLVM IR

mlir-translate is another handy tool to convert the MLIR program into LLVM IR format. For example, put --mlir-to-llvmir option as follows.

$ mlir-opt \
    --lower-affine \
    --convert-arith-to-llvm \
    --convert-scf-to-cf \
    --convert-func-to-llvm \
    --reconcile-unrealized-casts sum.mlir | \
    mlir-translate --mlir-to-llvmir

; ModuleID = 'LLVMDialectModule'
source_filename = "LLVMDialectModule"

declare ptr @malloc(i64)

declare void @free(ptr)

define i32 @main() {
  br label %1

1:                                                ; preds = %5, %0
  %2 = phi i64 [ %8, %5 ], [ 0, %0 ]
  %3 = phi i32 [ %7, %5 ], [ 0, %0 ]
  %4 = icmp slt i64 %2, 11
  br i1 %4, label %5, label %9

5:                                                ; preds = %1
  %6 = trunc i64 %2 to i32
  %7 = add i32 %3, %6
  %8 = add i64 %2, 1
  br label %1

9:                                                ; preds = %1
  ret i32 %3

You may find several additional directives for debugging purposes. But the central part of the program should be identical. Now it should be able to execute.

$ mlir-opt \
    --lower-affine \
    --convert-arith-to-llvm \
    --convert-scf-to-cf \
    --convert-func-to-llvm \
    --reconcile-unrealized-casts sum.mlir | \
    mlir-translate --mlir-to-llvmir | lli

$ echo $?

The program returns the summation value as an exit code correctly! If you enjoy the writing program at MLIR, please visit the MLIR website for more detail. You may find excellent examples or hint to implementing the algorithm in MLIR directly.