gfx-replacer

Runs a program inside a pseudo-terminal and changes the pictures it prints. It recognises images sent with the Sixel, kitty and iTerm2 graphics protocols, checks them against rules in a TOML file, and swaps matches for other images, or replaces a chosen line of text with an image, before the output reaches your terminal.

Where it sits

Between a program and its terminal How gfx-replacer sits in the output path. It creates a pseudo-terminal pair and runs the program in the inner one, so the program behaves exactly as it would in a terminal. It reads everything the program writes, finds graphics escape sequences (Sixel, kitty and iTerm2) and lines of text, checks them against the rules, swaps matches for other images re-encoded in the right protocol, and writes the result to the real terminal. The rules file can be reloaded while it runs. Keyboard input is not passed to the program yet. the programinner pseudo-terminal gfx-replacerfind · match · replaceSixel · kitty · iTerm2 your terminalouter pseudo-terminal rules.tomlhash · size · regex · text output changed output hot reload

It is about 5,200 lines of Rust, with end-to-end tests for each protocol, mixed output, passthrough, dry runs and reloading the rules.

A real run

The page's build script generates two small images, prints the red one as a kitty graphics sequence between two lines of text, and runs that through gfx-replacer with this rule, which matches the sequence by its SHA-256:

[[rule]]
id = "swap-square"
priority = 10

[rule.matcher]
kind = "hash"
sha256 = "b8501136716cefb1d66ece01f86b8f7d3d8aa5dfd86b809b486efb9a3b9965e8"

[rule.replacement]
kind = "file"
path = "after.png"
The input image: a red square with a cream centre.
sent by the program
The image gfx-replacer emitted instead: a blue square with a cream centre, decoded from its real output.
emitted by gfx-replacer, decoded from its output
Graphics seen1
Replaced1
Bytes original299
Bytes replaced303

The right-hand image is decoded from what gfx-replacer actually wrote. The text before and after it came through unchanged. With --dry-run the same rule still matches and is counted, but the original image is passed through and the output is byte-for-byte the input:

Graphics seen1
Replaced0
Passthrough1
swap-square1

Rules

Matching an image

  • hash: the SHA-256 of the whole escape sequence;
  • dimensions: an exact size or a range;
  • regex: a pattern over the escape sequence;
  • protocol: Sixel, kitty or iTerm2;
  • composite: and, or and not over the others.

Replacing it

  • file: an image on disk, re-encoded for the protocol;
  • inline: base64 image data in the rule;
  • passthrough: leave it alone.

Rules have priorities, the file can be watched and reloaded while the program runs, and --stats prints counts per protocol and per rule on exit.

Replacing text with an image

A text rule matches an exact line, a regular expression, or a block between start and end markers, such as a program's ASCII-art banner. The text is printed as usual; then gfx-replacer moves the cursor back up onto it, clears those lines and draws the image in their place.

[[text_rule]]
id = "banner"
priority = 10

[text_rule.matcher]
kind = "text_exact"
text = "=== build passed ==="

[text_rule.replacement]
kind = "file"
path = "after.png"

What does not work yet

  • Keyboard input is not passed to the program. A line sent on standard input never reaches it, so for now it suits programs that only print, such as image viewers and logs, not interactive ones.
  • A text rule moves the cursor up by the height of the match, so if more output arrives in the same burst after the matched text, the image lands on the wrong line.
  • The hash covers the whole escape sequence, so the same picture sent with different parameters hashes differently.
  • The dimensions matcher is limited in this version, and replacements fetched from a URL are not implemented.