An Expressive Code plugin that labels command output — the gutter API and the copy payload

Building an Expressive Code plugin for Astro that marks command output: the gutter element API, plugin ordering, and rewriting the copy button payload.

A dark code window with a violet titlebar on white, its lines of code drawn as grey and green bars with one band highlighted, and a paintbrush with rainbow bristles sweeping across the lower half
On this page

Introduction

Claude Code labels a command IN and its result OUT in its own transcript, and I wanted the same in this blog’s code blocks. Bash output here was written as a # comment in the same block. It reads fine but brings two problems:

  1. A reader cannot tell an instruction from printed output. Both are spelled #.
  2. Neither can the copy button. It was shipping the printed line inside the command you paste.

Claude Code's transcript: a shell command on one line labelled IN, and below a horizontal rule, its two lines of output labelled OUT, with both labels in a narrow left-hand gutter

Claude Code’s own transcript, not this site. The labels sit in a gutter beside the code rather than in the code, which is the part worth stealing.

The fix is an Expressive Code plugin that adds an out={…} option to a fence:

Terminal window
IN
gh api repos/oharu121/<repo-name>/automated-security-fixes
OUT
{"enabled":true,"paused":false}

It uses three extension points:

  1. A gutter element carries the IN/OUT labels.
  2. A post-render rewrite takes the output back out of the copy button.
  3. A specificity override drops Shiki’s colouring on output lines.

This article walks through those three, what the marker refuses, and the two obstacles I faced.

Why Expressive Code’s comment-stripping is off here

Expressive Code already handles problem 2. Its frames plugin strips whole-line # comments from a copied terminal frame, on by default:

@expressive-code/plugin-frames/dist/index.js
if (options.removeCommentsWhenCopyingTerminalFrames && isTerminal) {
codeToCopy = codeToCopy.replace(/(?<=^|\n)\s*#.*($|\n+)/g, "").trim();
}

This repo turns it off, deliberately: most # lines here are the instructions, not noise. With the option off, those instructions stay on the clipboard. The trade-off is that output lines stay too.

One character carrying two meaningsTwo terminal snippets side by side. Both contain a line beginning with a hash. On the left the hash line is an instruction the reader wants to paste along with the command; on the right it is the JSON the command printed. Underneath each, a box shows what the copy button places on the clipboard, and both boxes contain the hash line. The right-hand one is marked as wrong, because a printed result has been pasted in as though it were part of the command.# as an instruction# enable the API firstgcloud services enable aiplatformCopy button puts on the clipboard# enable the API firstgcloud services enable aiplatform✓Wanted# as outputgh api repos/OWNER/REPO/automated-security-fixes# {"enabled":true,"paused":false}Copy button puts on the clipboardgh api repos/OWNER/REPO/automated-security-fixes# {"enabled":true,"paused":false}✗Not a commandSame character, same handling, opposite intent
The same character in both blocks. With comment-stripping off, the copy button treats them identically, so the reader pastes a command with a JSON response glued to the end of it.

In the end, neither setting is right:

  • Off copies the instructions, and the output with them.
  • On strips the output, and the instructions with it.

Input and output have to be marked apart, the way Claude Code does it. A divider between input and output lets the copy button take the input only, and the instructions live in the input. Expressive Code has the extension points for exactly that.

The three extension points I made

Three parts of Expressive Code’s API carry the feature, and they fire at different points in the render.

A gutter element for the labels

Expressive Code exposes the gutter its line-numbers plugin uses, and any plugin can add to it. addGutterElement takes three things:

  1. renderLine builds the cell for each line: the IN label, the OUT label, or an empty one.
  2. renderPlaceholder builds it for lines the engine inserts itself, and here it is always empty.
  3. renderPhase orders the cells when several plugins add to the gutter, and here nothing else does.
src/lib/expressive-code-io.ts
context.addGutterElement({
renderPhase: "earlier",
renderLine: ({ lineIndex }) => {
const run =
lineIndex === 0 ? firstOut : lineIndex === firstOut ? lines.length - firstOut : 0;
if (!run) return h("div.io-label", "");
const label = lineIndex === 0 ? texts.inputLabel : texts.outputLabel;
return h("div.io-label", { style: `--io-run:${run}` }, label);
},
renderPlaceholder: () => h("div.io-label", ""),
});
Which function builds which part of the gutterA rendered code block on the right, with its gutter column down the left edge of it. The gutter holds an IN cell beside the command, an OUT cell centred beside the two output lines, and empty cells elsewhere. Three annotations on the left point into it. renderLine points at the run of real lines and is described as returning the IN label, the OUT label or an empty cell. renderPlaceholder points at a fourth row, drawn dashed, which is a line the engine inserts rather than one from the source. renderPhase points at the whole gutter column and is described as deciding where the column sits when other plugins add cells too.INgit count-objects -vHOUTcount: 2081size: 16.46 MiBinserted linerenderPhaseWhere this column sits whenother plugins add cells too.renderLineCalled for every real line.Returns the IN label, the OUTlabel, or an empty cell.renderPlaceholderCalled only for a line theengine inserts itself.
One call per line, one cell back. renderLine does all the work here; renderPlaceholder only fires for a line no source file has, and renderPhase has nothing to be ordered against.

One hard constraint shapes the CSS later: every line’s gutter must be the same width, or the code stops lining up. A line with no label still renders a cell, sized rather than left to its content.

Labels go through PluginTexts, the class frames uses for its own strings, so three locales register once in astro.config.mjs. getBlockLocale already resolves the locale from the filename, so a Japanese page renders 入力 and 出力 with no extra work.

Plugin order, and the copy button rewrite

Registered plugins run after the built-in ones, because the built-ins are prepended rather than appended:

expressive-code/dist/index.js
const pluginsWithDefaults = [...pluginsToPrepend, ...baseConfig.plugins || []];

pluginsToPrepend is Shiki, then text markers, then frames. Hooks run in plugin order, so by the time a hook in plugins: [pluginIo()] fires, all three have already had their turn on the same block.

Built-in plugins run first, so the copy button already existsA left-to-right pipeline of four plugin boxes. The first three, Shiki, text markers and frames, are grouped as prepended by the engine. The fourth, pluginIo, is the one registered in the Astro config and sits after them. Two callouts hang below the fourth box, naming the hooks it uses: annotateCode, where the block lines and metadata are settled, and postprocessRenderedBlock, where the frame and its copy button have already been rendered as a tree the plugin can edit.Prepended by the engineShikicolours as inline varsText markersins / del / highlightsFramesframe, title, copy buttonplugins: [pluginIo()]pluginIogutter, payload, coloursannotateCodeLines and metadata settled.Validate out={…}, add the gutter element.postprocessRenderedBlockFrame and copy button already rendered.Edit the HAST tree in place.
Two hooks matter here. In annotateCode the block’s lines and metadata are settled; in postprocessRenderedBlock the frame and its copy button already exist as a HAST tree that a later plugin can edit.

That ordering is what the copy rewrite rests on. This plugin never builds the copy button; frames already did, and postprocessRenderedBlock reaches into the rendered tree to change what it holds.

Validation lives in annotateCode. Before that hook, a plugin can still add or remove lines in the block; annotateCode is the first one to run after all of them, so the line numbers it checks cannot be shifted afterwards.

A specificity override on Shiki’s colours

Output lines lose their syntax colouring, and an ordinary class selector does it without !important. Shiki never writes a colour on the span: it writes an inline custom property, and a separate rule turns that variable into the colour you see:

.ec-line :where(span[style^='--']:not([class])) {
color: var(--0, inherit);
}

:where() contributes nothing to specificity, so that rule scores as a single class. The override here counts three classes, an attribute and a type, so it takes precedence:

.ec-line.is-out .code span[style^="--"] {
color: inherit;
background-color: transparent;
font-style: inherit;
font-weight: inherit;
}

This is worth the trouble because a # comment rendered uniformly, and it is the one place the old convention beat the new marker. Bash tokenisation colours a JSON response’s punctuation at random, inventing structure the output does not have. Targeting span[style^="--"] leaves the text-markers plugin’s own spans alone.

Validation, and the plain-text twin

Neither of these is a rendering concern, and both are the kind of thing that only shows up once the marker is in an article rather than in a test.

Why the marker only accepts trailing lines

Six shapes fail the build, and the first is the one you hit by habit: several command and result pairs in one block. An earlier version allowed it, and the frame read IN, OUT, IN, OUT, IN, OUT. I rejected that, so the marked lines now have to be the block’s trailing run and each pair gets its own block.

The rule is enforced rather than documented. An interleaved frame is plainly wrong to look at, and an author can still write one by habit:

src/lib/expressive-code-io.ts
if (!outLines.has(lines.length - 1) || outLines.size !== lines.length - firstOut) {
throw new Error(
"`out=` marks one command and its result, so the output must be the " +
"block's trailing lines. Split a block that pairs several commands " +
"with their results into one block per pair. …",
);
}

On a three-line block, five more shapes fail the build:

MarkerWhy it fails
out={x}Not a number.
out={4-2}Runs backwards.
out={9}Past the end of the block.
out={1-3}Every line is output, so nothing is a command.
out=2No braces, so getRanges never returns it.

The last one is the subtle one. An unbraced marker parses as a different kind of meta option and comes back as an empty list, which looks exactly like a fence that never asked for a marker. metaOptions.list("out") is kind-agnostic and tells the two apart.

Failing loudly matters because a marker that silently matches nothing puts output back in the clipboard, the exact bug the plugin removes, and it would ship looking correct.

Keeping the plain-text twin readable

A fence marker leaks into every other output you have. Every article here has a .md twin, and out={…} means nothing in plain Markdown. An unmarked output line there reads as a second command, so the twin generator converts the marker back into #-prefixed lines. That is the form these articles always used, and the twin is what answer engines read.

The conversion runs on the raw source, before the existing code-masking pass, which makes a fence quoted inside another fence safe. An article documenting this syntax puts its example in a four-backtick block, and the outer fence matches first with no out= in its info string:

```bash out={2}
gh api repos/oharu121/<repo-name>/automated-security-fixes
{"enabled":true,"paused":false}
```

The check that mattered compared every fence body in the twin against the pre-change source, byte for byte, with no whitespace normalising. A leading space in 26M is not decoration, because du -sh right-aligns its size field.

The two obstacles I faced

One produced a page that looked right and copied the wrong text. The other looked wrong on sight and took a measurement to explain.

Why writing data-code added a second attribute

The copy-button rewrite carried a bug. Writing the new payload to properties["data-code"] added a second data-code attribute rather than replacing the first, and HTML resolves a duplicate attribute to the one that appears first. The button silently went on copying the output.

hastscript normalises data-* names to camelCase, so frames’ h("button", { "data-code": … }) lands in the tree as dataCode. A literal "data-code" key is a different key, and both serialise:

<button data-code="gh api …&#x7f;{&quot;enabled&quot;:true}" data-code="gh api …">

Nothing failed. The page rendered, the button worked, and it copied the wrong thing. What gave it away was counting data-code in the serialised HTML and finding two, in a harness that rendered a block through the engine directly.

The fix overwrites whichever key is actually present, rather than assuming either:

src/lib/expressive-code-io.ts
const key = "data-code" in button.properties ? "data-code" : "dataCode";
button.properties[key] = inputCode.replace(/\n/g, "\x7F");

The payload is rebuilt from the block’s own lines, never parsed back out of the attribute. What frames put there depends on removeCommentsWhenCopyingTerminalFrames, so reading it would tie this plugin’s correctness to that setting staying off. \x7F is frames’ own newline placeholder, mapped back by its client script before the write.

Sizing the gutter against the block

The spacing around the divider comes from Expressive Code’s own codePaddingBlock, not from this site’s spacing scale. The first attempt used --space-3, a 12px design token, and read wrong on sight: both rows sat closer to the rule than to the edges of the block.

The numbers said the same thing. codePaddingBlock is 1rem, so each row had 16px at the block edge and 6px at the divider. Twice the block’s own padding, rule centred in it, makes all four gaps equal:

.ec-line.is-io-boundary {
padding-block-start: calc(2 * var(--ec-codePaddingBlock));
}
.ec-line.is-io-boundary::before {
content: "";
position: absolute;
inset-inline: 0;
top: var(--ec-codePaddingBlock);
height: 1px;
}
What the gutter has to get rightOne code block with a gutter column on the left. The first line is the command, labelled IN. Below a horizontal rule, four lines of output share a single OUT label. Three annotations point into the drawing: every gutter cell is the same width whether or not it carries a label; the gap around the rule is twice the block’s own vertical padding, measured as sixteen pixels above and sixteen below; and the OUT label sits level with the middle of its four-line run rather than beside the first line of it.INgit count-objects -vH16px16pxOUTcount: 2081size: 16.46 MiBin-pack: 240size-pack: 437.45 KiBGap = 2 × codePaddingBlock,rule centred in itLabel at the run’s centre,not on its first lineEvery cell the same width,labelled or not
The three constraints the gutter has to satisfy at once. Every cell is the same width, the divider’s two gaps match the block’s own padding, and a label sits at the centre of its run rather than on the run’s first line.

Centring a label over a multi-line run is the other half. A gutter element renders per line, so the label goes on the run’s first line and shifts down by half the run:

.io-label {
height: 100%;
display: flex;
align-items: center;
transform: translateY(calc((var(--io-run, 1) - 1) * 50%));
}

height: 100% is load-bearing there. A translateY percentage resolves against the element’s own height, and .gutter is a stretched grid item exactly one code line tall. Left to its own content the label would be one --font-size-1 line box, and every multi-line run would land short. Measured offset from each run’s centre: 0px for runs of one, two, three and four lines.

Summary

An Expressive Code plugin has more reach than the styling options in astro.config.mjs suggest. Three extension points carried this feature:

  1. addGutterElement puts content in the line-numbers plugin’s gutter, under one rule: every cell the same width.
  2. Plugin order puts your postprocessRenderedBlock after the built-ins’, so the frame and copy button are already rendered and yours to edit.
  3. Zero-specificity :where() in the core stylesheet means an ordinary class selector overrides Shiki’s colours, no !important needed.

Two things cost more than they should have:

  1. A duplicate data-code attribute. The page looked right and copied the wrong text; the only tell was counting the attribute in the serialised HTML.
  2. A 6px gap against a 16px one. That is what a component gets when its internal rhythm comes from a design scale outside it rather than the padding it already has.

References

Share this article