Skip to content
KONIGI

AI Assistants / Output shape / Code block actions

2 of 4

Code block actions

Generated code gets read in one place and used in another.

Updated September 12, 2026

Problem

A fenced block of code sits in the middle of an answer. Its whole purpose is to end up somewhere else, and getting it there by selecting text with a mouse picks up line numbers, drops the last character, or misses the top line entirely.

Solution

Attach a small control cluster to the block itself. Copy first, because copy is what nearly everyone does. Then whatever the surrounding product can honestly offer: a language label, a run control where there’s somewhere to run it, and an apply control where there’s a file to apply it to.

Copy has to be exact. The clipboard write should carry the raw source with original indentation and no decoration, which means line numbers, the language label, and any diff markers have to be excluded from the copied text rather than merely styled differently. Selecting rendered code with a pointer is exactly the operation that picks up all three, which is why a copy control is worth having even though the text is right there.

The language label earns its space by doing two jobs: it tells the reader what they’re looking at, and it’s the honest place to admit uncertainty when the model didn’t specify. A block silently rendered as one language and highlighted wrong is a small, constant irritation.

Apply is the action worth the most and the one carrying real consequence. Writing generated code into a file is a change to the viewer’s work, and HAX guideline 16 puts the obligation before the action rather than after: name the file, show the change as a diff, and require an explicit confirmation. An apply control that writes silently is the fastest path to someone losing work they can’t recover.

Run has the same shape with a sharper edge. Executing generated code is the point at which a suggestion becomes an action with effects, and the questions of where it runs, with what access, and what happens on failure belong in the interface rather than in documentation.

Streaming complicates all of this. A block still arriving is incomplete, and a copy control that appears at the opening fence hands over half a function. The controls should wait for the closing fence, and a block whose fence never arrives because generation was stopped needs to say so rather than presenting itself as complete.

Use when

Answers regularly contain code, configuration, or commands that are meant to be used rather than read.

Don’t use when

The snippet is illustrative. A three-word inline example wants inline code styling, and hanging a control cluster off it adds chrome to something nobody will copy.

Trade-offs

A control row on every block adds visual weight to answers that are already dense, and an answer with six blocks carries six of them. Copy is nearly free; run and apply are expensive to build well and dangerous to build carelessly. Line numbers help discussion and actively hurt copying, so a product wanting both has to keep two representations. A run control implies the code was checked. Nothing in the pipeline guarantees that.

Checklist

  • Does copy carry the raw source with no line numbers or markers?
  • Is indentation preserved exactly?
  • Does the control cluster wait until the block has finished streaming?
  • Is the language labelled, and is a guess shown as a guess?
  • Does apply name the target file and show a diff before writing?
  • Is an apply reversible in one step?
  • Where does a run control execute, with what access?
  • Is copy reachable from the keyboard?
  • Is there feedback confirming the copy happened?
  • What does an unterminated block from a stopped generation look like?

Compare

ChatGPT puts copy on every block and adds a canvas path for code meant to be worked on rather than lifted, which separates reading from editing. Claude promotes substantial code into an artifact with its own controls and version history, so the block in the transcript stays a reference rather than the working copy. Cursor makes apply the primary action and writes the change into the open file as a reviewable diff. It’s the highest-consequence version of the pattern here, and the most gated. Perplexity keeps the cluster to copy alone. Code there is evidence in an answer—not work in progress.

Artifact panel is where a block graduates once it becomes the thing being worked on. Response actions is the same idea at turn scale. Suggestion diff is what an apply control should show before writing. Structured output is the sibling problem for tables. Streaming response determines when these controls may appear.

Code block actions anatomy A fenced block with a language label and a control cluster, showing what the clipboard receives against what is rendered, an apply control naming its target file, and a second block still streaming with its controls withheld. What is copied is not what is rendered bash copy run apply 1 2 3 the clipboard gets this, with the indent and no line numbers unknown still streaming, so no controls yet 1 3 2 4 1 COPY IS THE JOB A pointer selection picks up line numbers and drops the last character. 2 TWO REPRESENTATIONS Numbers help discussion and hurt copying, so a product wanting both keeps two. 3 APPLY NAMES ITS TARGET Writing into a file is a change to the viewer's work. Show a diff, confirm, undo. 4 WAIT FOR THE FENCE Controls on an unclosed block hand over half a function as though it were whole. A run control implies the code was checked, which nothing in the pipeline actually guarantees.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

What the clipboard receives is not what the page renders. Line numbers and the syntax wrapper stay behind; the indent comes with it. A block still streaming has no controls, because applying half a file is worse than waiting.

shadcn
npx shadcn@latest add button
npm
lucide-react
Tokens
--foreground--card--accent--accent-foreground--muted--muted-foreground--border
bash
for f in dist/*.js; do
  gzip -k "$f"
done
unknownstill streaming, so no controls yet
npm run build && npm run

CodeBlock.tsxCopy sends the source prop, not the DOM. Line numbers sit outside the selection. A streaming block has no controls.

import { useState } from "react";
import { Check, Copy, Play, FileDiff } from "lucide-react";
import { Button } from "@/components/ui/button";

type Props = {
  /** The source exactly as the model wrote it. This is what copy sends. */
  code: string;
  lang?: string;
  /** True until the closing fence arrives. Controls on an unclosed block
   *  hand over half a function as though it were whole. */
  streaming?: boolean;
  onRun?: (code: string) => void;
  /** Applying writes into the viewer's work, so it names the file and the
   *  caller shows a diff, confirms, and keeps an undo. */
  apply?: { target: string; onApply: (code: string) => void };
};

export function CodeBlock({ code, lang, streaming = false, onRun, apply }: Props) {
  const [copied, setCopied] = useState(false);
  const lines = code.split("\n");

  // The clipboard gets `code`, not the DOM: the indent comes with it, the
  // line numbers and the syntax wrapper stay behind.
  const copy = async () => {
    await navigator.clipboard.writeText(code);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };

  return (
    <div className={`overflow-hidden rounded-lg border ${streaming ? "border-dashed" : "bg-muted"}`}>
      <div className="flex items-center gap-1 border-b px-3 py-1.5">
        <span className="font-mono text-xs text-muted-foreground">{lang ?? "unknown"}</span>
        {streaming ? (
          <span className="ml-auto text-xs text-muted-foreground">still streaming, so no controls yet</span>
        ) : (
          <div className="ml-auto flex gap-1">
            <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={copy}>
              {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} {copied ? "copied" : "copy"}
            </Button>
            {onRun && (
              <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => onRun(code)}>
                <Play className="size-3.5" /> run
              </Button>
            )}
            {apply && (
              <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => apply.onApply(code)}>
                <FileDiff className="size-3.5" /> apply to {apply.target}
              </Button>
            )}
          </div>
        )}
      </div>

      {/* Two representations. Numbers help discussion and hurt copying, so
          they sit outside the selection and outside the accessibility tree. */}
      <pre className="flex overflow-x-auto py-2.5 font-mono text-xs leading-6">
        <span aria-hidden="true" className="select-none border-r px-2.5 text-right tabular-nums text-muted-foreground">
          {lines.map((_, i) => <span key={i} className="block">{i + 1}</span>)}
        </span>
        <code className="pl-3 pr-3">
          {code}
          {streaming && <span aria-hidden="true" className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse bg-foreground align-middle" />}
        </code>
      </pre>
    </div>
  );
}

demo.tsxHow it is called: a closed block with copy, run and apply, and a second one still arriving.

import { useEffect, useState } from "react";
import { CodeBlock } from "./CodeBlock";

const CLOSED = 'for f in dist/*.js; do\n  gzip -k "$f"\ndone';
const OPEN_HEAD = "npm run build && npm run";
const OPEN_TAIL = " deploy -- --prod";

/** A closed block with its controls, and one still arriving without them. */
export default function Demo() {
  const [tail, setTail] = useState(OPEN_HEAD);
  const [streaming, setStreaming] = useState(true);
  const [applied, setApplied] = useState<string | null>(null);

  useEffect(() => {
    const words = OPEN_TAIL.split(/(?<=\s)/);
    let i = 0;
    const id = setInterval(() => {
      if (i >= words.length) { setStreaming(false); clearInterval(id); return; }
      setTail((t) => t + words[i++]);
    }, 700);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="rounded-lg border bg-card p-4">
      <CodeBlock
        code={CLOSED}
        lang="bash"
        onRun={() => {}}
        apply={{ target: "deploy.sh", onApply: (code) => setApplied(code) }}
      />
      {applied && (
        <p className="mt-2 text-xs text-muted-foreground">apply handed {applied.split("\n").length} lines to the diff view for deploy.sh</p>
      )}
      <div className="mt-4">
        <CodeBlock code={tail} lang={streaming ? undefined : "bash"} streaming={streaming} />
      </div>
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.