Skip to content
KONIGI Patterns

AI Assistants / Answer formats / Code block actions

‹ 2 of 4 ›

Code block actions

People need to copy or use generated code without losing its formatting.

Updated September 12, 2026

Problem

People need to copy code from an answer without missing characters or including line numbers and labels.

Solution

Put a Copy button and a language label on each code block. Copy the source exactly, keeping its indentation and excluding display labels or line numbers.

Offer Run only when the app can execute the code. Explain where it will run and what it can access. For Apply, show the destination file and proposed changes before asking people to confirm.

Wait until the code is complete before enabling these actions. If generation stops early, label the block as incomplete.

Use when

Answers contain code, configuration, or commands that people will use elsewhere.

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

Controls on every block can crowd an answer. Run and Apply require more safeguards than Copy because they can affect files or systems. Their presence should not imply that generated code has been tested.

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 provides space for working on larger code files. Response actions apply to the whole answer. Suggestion diff shows proposed changes before applying them.

Structured output handles formats such as tables. Streaming response affects when a code block is ready to use.

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 screenshots yet for this pattern. Browse Products to see other examples.