SLOPSHOPPER

copy-back

Type /cc after copying text from the terminal to put Claude's own text for it back on the clipboard, without the margin and the hard line breaks (experimental…

newcommandtoastprocess
v0.1.1MITupdated 2026-10-25sairam0424/claude-copy-back
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-back
› fix the failing auth test and add an audit log call ╭─────────────────────────────────────╮ │ copy-back │ ⏺ Read(src/auth.ts) │ clipboard is empty, nothing changed │ ⎿ Read 6 lines ╰─────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cc ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

copy-back

A Claude Code mod for copying text out of the terminal. Select part of a reply, copy it as usual, type /cc on an empty prompt, and the clipboard holds Claude's own text for that selection: no two-space margin, no trailing padding, no hard line breaks at the terminal width.

Status: v0.1.1, experimental. Checked by unit tests, by tests on the Claude Code engine, and by sealed tests of the real Claude Code (classic display) against synthetic sessions: a counted live pass of 0.1.0 on 2.1.309, and, for 0.1.1, a screen-matching holdout and a counted live pass of the 0.1.1 build, both on 2.1.310, drawn the way Warp draws a session and the default way. In those tests the copies were built by a model of a terminal selection from real Claude Code screens, not by a real terminal, so they show what Claude Code draws and that the mod runs, but not what Warp or any other terminal puts on the clipboard. Not yet checked: a real Warp session (how it selects and copies), the real pbpaste and pbcopy, Claude Code's fullscreen display, other Claude Code versions in a live pass, and an organisation policy that refuses a mod starting a program. macOS only, because it reads the clipboard with pbpaste. See Limits.

Text copied from the Claude Code terminal typically arrives as the screen drew it: every row starts with the margin, rows are padded and cut at the terminal width, and a paragraph becomes five short lines (what a given terminal really copies is the part not yet checked). Pasted into an editor, a ticket or a chat, it has to be cleaned by hand. /cc does not clean the text; it looks it up. It finds the words you copied in the newest replies of the current session and puts the original text of that selection on the clipboard.

Because it copies from the session transcript, it can only ever write text that was in this session's last 40 replies, and every character it writes is a character Claude wrote. When it is not sure (no match, or the same words in places where Claude's original text differs) it says so and leaves your clipboard as it was.

For example, the terminal copies this (margin, rows cut at 60 columns):

  The cache is rebuilt on every deploy, so the first request
  after a release is slower than the rest. If that matters,
  warm it from the release script instead of waiting for a
  visitor.

After /cc, the clipboard holds this (one line, as Claude wrote it), and a toast says copy-back: copied 182 chars from reply 1 (1 = newest):

The cache is rebuilt on every deploy, so the first request after a release is slower than the rest. If that matters, warm it from the release script instead of waiting for a visitor.

copy-back is an independent project. It is not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Install

/plugin install copy-back --marketplace sairam0424/claude-copy-back

From a shell: claude plugin marketplace add sairam0424/claude-copy-back && claude plugin install copy-back@claude-copy-back.

You need macOS and Claude Code 2.1.299 or newer (the oldest version tested; 2.1.292 and 2.1.296 refused it when it loaded). Node is only for developing the mod; using it needs nothing else installed.

To try a clone without installing, load a copy of three folders:

mkdir -p ~/claude-plugins/copy-back
cp -R .claude-plugin hooks src ~/claude-plugins/copy-back/
claude --plugin-dir ~/claude-plugins/copy-back

Do not load the clone itself: the engine can write about 840 KB of type files and a tsconfig.json into a plugin folder it loads.

First run

Run /reload-plugins (or restart Claude Code). Select a region of a reply with the mouse, press Cmd+C, leave the prompt empty, and type /cc. There is nothing to configure.

Using it

  1. Copy a region of a Claude Code reply as you always do. Claude Code has a classic display and a fullscreen display, chosen by its tui setting (default is the classic one). The live pass used the classic one. In fullscreen Claude Code turns on mouse tracking (seen in a clean profile on 2.1.309), so a normal drag may not select text and your terminal may need a modifier key to do it; that, and what then lands on the clipboard, was not tested.
  2. On an empty prompt, type /cc and press Enter. It is declared as an immediate command and starts no turn: nothing is sent to the model (the sealed live pass saw zero model requests).
  3. A toast tells you what happened. On success: copy-back: copied 182 chars from reply 1 (1 = newest). The number is the reply the text came from, counting the newest reply with text as 1. If the same original text is in several replies, it names the newest of them, which may not be the one you copied from; the text written is the same. Paste as usual.
  4. On a refusal the toast says why. It ends with nothing changed for every refusal that happens before a write: nothing was written, so your clipboard is exactly as it was (the exception is listed under Troubleshooting). A refusal is always safer than a guess.

Claude Code puts the mod's name (copy-back: ) in front of every toast, so the mod's own line does not repeat it and is at most 45 characters, which keeps the reply number visible at 60 columns. The 45 was inferred from earlier sealed screens of Claude Code 2.1.309 and 2.1.310; in the counted live pass of the 0.1.1 build on 2.1.310 all 283 toasts were shown whole, the longest row (56 characters) at 60 columns (reported). Other versions were not looked at.

Data handling

The mod sends nothing anywhere: it calls no network API, makes no model call and starts no program except pbpaste. The source guard checks that as a tripwire, not a sandbox, and nothing here shows that the engine would stop a mod from starting another program (see SECURITY.md).

The engine's access report (claude plugin validate . prints it) lists exactly five engine calls, and scripts/check-footprint.sh fails if the list changes:

  • $.command.register: declares /cc when a session starts.
  • $.process.run, via pbpaste: reads the clipboard once per /cc. The program is pbpaste by name, with no arguments, a 2 second timeout and a UTF-8 locale. A clipboard that is empty, over 200,000 characters, cut short or unreadable is refused, never used in part.
  • $.session.messages: reads the session's rows. The mod looks only at assistant rows that have text, takes the newest 40, and keeps nothing: what the matcher cached is dropped at the end of every run.
  • $.ui.copy: writes the clipboard, once, and only after a match, with text taken from one of those replies.
  • $.ui.toast: shows one line made of counts and fixed words. Clipboard and message text are never put in a toast, a log, a thrown error or the command's answer (the command answers {}).

It uses no files, no state or store, no timers and no telemetry. Hooks: session.start and command.run{cc}.

Things the access report cannot see (printing, timers, reflection, imports) are guarded in layers described in SECURITY.md: a source scan of everything the hooks load, a type check that has no console, process or fetch, and tests that spy on the console, the standard streams and fetch. That scan is a tripwire, not a sandbox.

Installing from this repository installs the whole folder, not only the 25 files the hooks load. That includes src/captures, a tool for the maintainer's own testing that can read the clipboard and write files when someone runs it by hand. The plugin never loads it.

The tools in this repository that touch real data, and only when you run them yourself:

  • node scripts/replay.mjs (and npm run k1; not --selftest, which uses synthetic data) reads your Claude Code transcripts under ~/.claude/projects (set another folder with --root DIR) and prints one JSON document of counts.
  • node scripts/captures.mjs add reads the clipboard once and saves it to ~/copy-back-captures (mode 0700; it refuses a folder inside a git repository). run and tally read that folder and the transcripts and print counts. run --show prints your own text, so it refuses unless its output is a terminal. Never run it inside Claude Code and never type it with a leading !.
  • node realrender/live.mjs clip reads the real clipboard only to store its SHA-256, at most twice.

How it works

  1. A model of the screen. Claude Code draws a reply by fixed rules: a two-column margin, a marker on the reply's first row, - for bullets, list text hanging under its first word, rows cut at spaces at the terminal width, code cut by character. The matcher (src/core) implements those rules, so for any reply it can compute the text a terminal would show. It was calibrated against real screens of Claude Code 2.1.309 and 2.1.310. Links and struck text are drawn two ways depending on the terminal Claude Code detects: a link as label (url) with the ~~ kept (what the rig draws with no terminal name set; Terminal.app is expected to behave this way), or a link as its label alone with struck text shown without tildes (what the rig draws with TERM_PROGRAM=WarpTerminal, and once with TERM_PROGRAM=iTerm.app). A static read of Claude Code 2.1.310 by a review (reported) lists many more terminals on the second path, such as kitty, Ghostty, Alacritty, WezTerm, VS Code and Hyper, and a setting or an environment variable can force either drawing. /cc cannot know which applies to you, so it searches both drawings.
  2. Compare like with like. The copy is stripped of its decoration, and runs of spaces and line breaks are folded to one space on both sides, because the copy arrives hard-wrapped.
  3. Find the source. Each reply is searched, newest first. A hit is mapped back to offsets in the reply's markdown, so the answer is the exact slice of what Claude wrote: message.slice(start, end), never edited.
  4. Ambiguity refuses. If the same words sit in several places and Claude's original text differs between them (for example one has markup the other lacks), the copy's own indentation is tried first (it can tell two copies of the same code apart); if that cannot decide, /cc refuses and does not take the newest. If the original text is identical in every place, it is one answer: /cc writes it and the toast names the newest reply that holds it. A copy found in too many places to tell apart is refused as ambiguous too. The search does a capped amount of work, but its cost still grows with the size of the replies; docs/DEVELOPMENT.md has the caps and the measured worst case.
  5. Source field and paste field. The matcher returns source (the exact slice) and paste (what /cc writes). paste has the same characters, minus the list or quote prefix on every line after the first, with the first line's indentation put back, so code copied out of a list item pastes as code.

Troubleshooting: why was my copy refused?

Each refusal is one toast. Nothing is written before a match, so the clipboard is exactly as it was and the line ends with nothing changed, with one exception: the two clipboard-write lines below, which come after the engine was asked to write.

  • could not read the clipboard - pbpaste failed or took longer than 2 seconds. Run /cc again. This happens on a machine under heavy load, and it cannot work off macOS.
  • clipboard is empty - copy something first.
  • clipboard over 200000 chars - select less. A read that was cut off is reported the same way.
  • copied text under 12 chars - too short to find with confidence. Select more.
  • no match in last N replies (N is up to 40; no match in the last reply when there is one) - the text is not in any of the newest replies as drawn. Usual causes: a table, a diff or tool output (not matched), text older than 40 replies, a drawing the matcher does not model (see Limits), or a selection that runs from one reply into the next.
  • matches more than one place - the same words are in more than one place and Claude's original text differs between them (for example one has markup such as code formatting or different indentation where the other does not, or a link that one drawing hides), so /cc cannot tell which one you meant. Select a longer or more distinctive stretch. Words that sit in several replies with identical original text are not a refusal: there is nothing to decide, so /cc copies that text and the toast names the newest reply that holds it.
  • could not read the session - the engine did not hand over the session's rows.
  • no replies to search - the session has no assistant reply with text yet.
  • clipboard write was refused (...) or clipboard write failed - the engine's own write call did not succeed. These two lines do not say nothing changed: check what is on the clipboard before you paste.
  • unexpected error - a bug. Please report it without pasting your text.
  • could not register /cc - the engine refused the command name, for example because a built-in command uses it.

Configuration

None. The plugin declares no options.

Updating

  • Installed from GitHub: claude plugin update copy-back@claude-copy-back, then restart Claude Code. An update only happens when the version in .claude-plugin/plugin.json changes.
  • Loaded from a copy: copy the three folders again.
  • To remove it: claude plugin uninstall copy-back@claude-copy-back.

Limits

  • macOS only. It starts pbpaste. On another system /cc is expected to say it could not read the clipboard and change nothing; that was not tested. Linux and Windows are not supported.
  • What was verified. 1,556 unit tests, 87 tests on the engine, 398 tests of the test rig, and three kinds of sealed run of the real Claude Code (classic display) on synthetic sessions, all reported rather than re-runnable from this repository. For 0.1.0, a counted live pass on Claude Code 2.1.309: 160 of 160 copies came back exact, 32 of 32 refusals left the clipboard untouched, and no model request or outside network connection was seen; the copies went to /cc through stand-ins for pbpaste and pbcopy. For 0.1.1, a holdout on 2.1.310 in which the rig drew the session the way Warp does and, separately, the default way: 240 of 240 copies exact in each, 0 wrong slices and 0 false positives, scored directly against the matcher (not through /cc) with a rule that was hashed before the first of its screens existed; the 0.1.0 matcher scored 178 of 240 on the Warp-drawn window. And for 0.1.1 a counted live pass of /cc itself on 2.1.310, 12 of 12 conditions of its rule: in the Warp drawing 160 of 160 copies exact (links 11 of 11, struck text 11 of 11) and 32 of 32 refusals untouched, in the default drawing 40 of 40 and 8 of 8, no wrong payload and no wrong toast in 283 cases, all 283 toasts shown whole, no model request, no non-local socket in 8,658 samples, 13 of 13 sandbox controls under each profile; the plugin folders that passed (.claude-plugin, hooks, src) are unchanged in this release, checked on 2026-10-25 against the pass's freeze record, which is kept outside this repository. Its limits: that rule (rule 3, revision 2) is kept outside this repository, so the repository cannot re-run it; the Warp set was rebuilt from the spans the screen shows (link destinations are not drawn, so the stock generator loses 1 to 3 of 40 spans per width) and the exact-rate bar was 95 percent; the default drawing was exercised at one width only (40 copies and 8 refusals); the check of the real clipboard was two hash reads around a stand-in that writes, not around the real plugin, and both hashes were the hash of empty input, so it shows only that no text appeared; and the copies still came from a model of a terminal selection. In every run the copies were built by that model from real Claude Code screens, so the runs check what Claude Code draws and that the mod works. They do not show what Warp or any other terminal puts on the clipboard; that is what the 20-real-copy test in CAPTURES.md is for, and it has not been run yet.
  • Who verified it. The author, on one Mac, working with Claude Code. The reviews, the re-score of the live pass and the 0.1.1 rig work were done by separate Claude sessions that the author started (the model was not recorded), and the raw evidence is not published here, so treat these numbers as reported, not as something you can re-run from this repository.
  • Not verified: how Warp selects and copies (its drawing is checked in the rig; the text it puts on the clipboard is not), the real pbpaste and pbcopy (locale, large clipboards), Claude Code's fullscreen display, an organisation policy that refuses a mod starting a program, other Claude Code versions (the gates also pass on 2.1.299, 2.1.309 and 2.1.310, but counted live passes ran only on 2.1.309 and 2.1.310), typing /cc while a reply is still streaming, and that 20-real-copy test.
  • Tables, diffs and tool output are not matched. They are refused.
  • Classic and fullscreen draw identical rows on 2.1.309 for the window that was tested, and, checked once, on 2.1.310 in the Warp drawing; but the live passes ran only the classic display. In fullscreen the engine takes over the mouse, so selecting text may need a modifier key in your terminal; how selecting and copying work there was not tested.
  • Links and struck-through text. Claude Code draws these differently depending on the terminal it detects (see How it works): label (url) with the kept, or the label alone with no tildes. /cc cannot know which applies, so since 0.1.1 it searches both drawings. Only two profiles were scored: the rig's default (no terminal name set) and Warp (TERM_PROGRAM=WarpTerminal); an iTerm run was a screen comparison at one width that matched Warp's. In the Warp-drawn holdout of the rig, all 44 copies that included an inline link and all 36 that included struck text came back exact. The price of not knowing the terminal: a copy that also fits another message under the other drawing is refused as ambiguous. Some drawings are not modelled and are expected to be refused: [](url) and , <mailto:a@b>, a bare www. address in the drawing that shows label (url), &nbsp;, a zero-width space inside a word, a link inside a link label, a line or list item that starts with ~ or ~~ and text, x~~~ next to a link, a nested ordered list that starts at a number other than 1 directly under its parent line, and an ordered list that starts at 0 (or a depth-2 list that starts at 3999). Other constructs may also be refused. In a sweep of the real engine (reported) each of these was a refusal and none gave a wrong slice, but it has not been shown that none can. All of this is about what Claude Code draws; what Warp puts on the clipboard when you select that text is still untested.
  • At least 12 characters after folding; only the newest 40 replies with text are searched.
  • One reply at a time. A selection that runs from one reply into the next is expected to be refused (one example was checked against the matcher, none in a live session).
  • Unicode forms. A copy in a different normalization form than the reply (NFD against NFC) is refused. Warp is expected to copy what Claude drew, but that was not tested.
  • A busy machine can make /cc refuse. The clipboard read has a 2 second limit. In two sanity launches on a loaded machine, 3 of 80 copies were refused with could not read the clipboard. It never copied wrong text; type /cc again.
  • Terminal only. No desktop app, no VS Code panel.

Development

Gates, the engine rules, the sealed live-test rig, the real-copy test and the release steps are in docs/DEVELOPMENT.md. To contribute, read CONTRIBUTING.md; contributor notes for agents are in AGENTS.md. The main gates are npm test, bash scripts/kit-test.sh, claude plugin validate . --strict and bash scripts/check-footprint.sh. There is nothing to install; development needs Node 22.19 or newer. CI runs the suite on Node 22.19.0, 24.x and 26.x on Linux (every pull request) and on 22.19.0 and 24.x on macOS (on every pull request); its first run, on the 0.1.0 tree, passed all of them, and the suite also passes locally on Node 22.23.2 and 26.9.0. A weekly job runs the engine gates against the newest published Claude Code, because the plugin API is early access.

realrender/probe/ is a scratch plugin the test rig loads. Do not install it.

Acknowledgements

  • src/replay/baseline.ts, a comparator used only by the offline replay harness, follows the approach of claude-clean-copy by Brian Pravato (MIT, Copyright (c) 2026 Brian Pravato): the same wrap bounds (60 and 240 characters) and a block-start pattern of the same shape. It is a re-implementation, not a copy of that project's code, and the plugin never loads it.
  • The inline and block rules in src/core follow the behaviour of the CommonMark specification.
  • The test generators use mulberry32 (a small public random-number generator by Tommy Ettinger) and FNV-1a, a public hash.

License

MIT, see LICENSE. Copyright (c) 2026 Sairam Ugge.

Source 25 files
hooks/register.tsx 33 lines
1import type { Register } from "claude-code";
2import { notify } from "./api.ts";
3import { runCommand } from "./run.ts";
4
5// /cc: copy text out of the terminal, leave the prompt empty, type /cc. The
6// command is `immediate` (it runs at once even while a turn streams, and never
7// starts a turn of its own), and its hook answers `{}`: nothing is printed and
8// nothing reaches the model. There are no timers, no state, no store and no
9// network; the only program it starts is pbpaste (see hooks/api.ts).
10
11export const register: Register = (on) => {
12  on("session.start", async ($, e, next) => {
13    try {
14      await $.command.register({
15        name: "cc",
16        description:
17          "Put the text you copied from the terminal back on the clipboard as Claude wrote it",
18        immediate: true,
19      });
20    } catch {
21      // A name the engine refuses must not break the session's start. The
22      // engine shows the plugin's name in front of the line.
23      notify($, "could not register /cc");
24    }
25    return next(e);
26  });
27
28  // The hook answers its own command and never reads `next`, so the validator
29  // lists it as "answers its own command", not as a gate. The `.catch` answers
30  // `{}` too: a crash prints nothing and tells the model nothing.
31  on("command.run", { command: "cc" }, ($) => runCommand($)).catch(() => ({}));
32};
33
hooks/api.ts 62 lines
1import type { EngineInterface } from "claude-code";
2
3// The engine seam: every call /cc makes to the engine's `$.<noun>.<fn>` goes
4// through one of these four wrappers, except the one-time command declaration
5// that register.tsx makes itself. `claude plugin validate` lists each API it
6// finds with the functions that call it directly, and cuts a line near 1,000
7// characters; one wrapper per API, each with a short unique name, keeps the
8// whole access report short, complete and readable. Nothing is hidden: every
9// API is still named in the report, and a wrapper only forwards.
10//
11// Each wrapper asks only for the slice of `$` it calls, so a caller holding
12// less than the whole engine still type-checks. Each is a function
13// declaration: the engine follows `$` past an import only into a function
14// declaration (an arrow bound to a const fails the load), and reads the API
15// off the call spelled in the wrapper's body. Names are at most seven
16// characters and unique across hooks/ (tests/cc-seam.test.mts pins all three;
17// tests/kit/api.test.tsx pins the length again, on the built wrappers).
18//
19// `pbpaste` is the one wrapper that fixes its arguments: it can start one
20// program, pbpaste, with a short timeout and a UTF-8 locale, and nothing else,
21// whoever calls it. pbpaste prints the clipboard in the encoding of the
22// locale (its manual page), and a bare environment would give ASCII.
23
24/** The slice of `$` a wrapper calls: member `M` of noun `N`. */
25type Slice<
26  N extends keyof EngineInterface,
27  M extends keyof EngineInterface[N],
28> = Record<N, Pick<EngineInterface[N], M>>;
29
30/** The arguments the engine's own `N.M` takes: a wrapper takes them as they are. */
31type Args<
32  N extends keyof EngineInterface,
33  M extends keyof EngineInterface[N],
34> = EngineInterface[N][M] extends (...args: infer A) => unknown ? A : never;
35
36/** The whole of `$` that a /cc run needs, for the code that wires it up. */
37export type Io = Slice<"process", "run"> &
38  Slice<"session", "messages"> &
39  Slice<"ui", "copy"> &
40  Slice<"ui", "toast">;
41
42// process
43export function pbpaste($: Slice<"process", "run">) {
44  return $.process.run(["pbpaste"], {
45    timeoutMs: 2000,
46    env: { LC_ALL: "en_US.UTF-8" },
47  });
48}
49
50// session (the no-argument form: the main conversation's rows)
51export function history($: Slice<"session", "messages">) {
52  return $.session.messages();
53}
54
55// ui
56export function toClip($: Slice<"ui", "copy">, ...a: Args<"ui", "copy">) {
57  return $.ui.copy(...a);
58}
59export function notify($: Slice<"ui", "toast">, ...a: Args<"ui", "toast">) {
60  return $.ui.toast(...a);
61}
62
hooks/run.ts 21 lines
1// The body of /cc against the live engine: hands hooks/cc.ts the four engine
2// calls (through the seam in api.ts) and answers the command with `{}`.
3//
4// `{}` matters: a command's returned `text` is printed in the transcript and
5// Claude reads it, so anything returned here would put a line in front of the
6// model. The person's feedback is the toast, which the model never sees.
7
8import { history, notify, pbpaste, toClip } from "./api.ts";
9import type { Io } from "./api.ts";
10import { runCc } from "./cc.ts";
11
12export async function runCommand($: Io): Promise<Record<string, never>> {
13  await runCc({
14    readClipboard: () => pbpaste($),
15    getMessages: () => history($),
16    copy: (text) => toClip($, { text }),
17    toast: (text) => notify($, text),
18  });
19  return {};
20}
21
hooks/cc.ts 153 lines
1// The /cc run, with the engine injected: no `$` in this file, so it is tested
2// with plain functions (tests/cc-run.test.mts). hooks/run.ts wires the real
3// engine calls in; this file only decides.
4//
5// One run: read the clipboard once, read the session, ask the verified core
6// (src/core/match.ts) where in a recent reply the copied text came from, and,
7// only on a match, write that reply's own text with the copy dependency.
8// Every other end leaves the clipboard untouched. A refusal is a normal end,
9// not an error: it is a Decision with a reason, shown as one counts-only line.
10//
11// PRIVACY: the clipboard text, the message text and every error a dependency
12// raises stay inside this file's functions. A raised error is caught where it
13// happens and becomes a reason word; its message is never read.
14
15import { findSource, resetMatchCache } from "../src/core/match.ts";
16import type { FindResult } from "../src/core/match.ts";
17import { classifyClip, selectReplies } from "./input.ts";
18import type { ClipRead, RunAnswer } from "./input.ts";
19import { COPY_REASONS, toastText } from "./outcome.ts";
20import type { CcOutcome, CopyReason, Decision, Refusal } from "./outcome.ts";
21
22export type { RunAnswer };
23export type { CcOutcome, Decision, Refusal };
24
25/** What the engine's copy answers (src: UiCopyResult), read defensively. */
26export type CopyAnswer =
27  { isCopied: true } | { isCopied: false; reason: string };
28
29export type FindFn = (
30  copied: string,
31  messages: readonly string[],
32) => FindResult;
33
34export interface CcDeps {
35  /** One read of the clipboard (pbpaste), answered as the engine's process answer. */
36  readClipboard(): Promise<RunAnswer>;
37  /** The session's rows, oldest first, as the engine returns them. */
38  getMessages(): Promise<readonly unknown[]>;
39  /** Write the clipboard. Called only with a matched slice. */
40  copy(text: string): Promise<CopyAnswer>;
41  /** Show one line. May be synchronous; a returned promise is awaited. */
42  toast(text: string): void | Promise<void>;
43  /** Drop what the matcher cached about the messages. Called last, on every path. */
44  forget?(): void;
45  /** The matcher; the real one unless a test swaps it. */
46  find?: FindFn;
47}
48
49function refuse(reason: Refusal, searched = 0): Decision {
50  return { copied: false, reason, searched };
51}
52
53type Replies = { ok: true; replies: string[] } | { ok: false };
54
55async function readReplies(deps: CcDeps): Promise<Replies> {
56  try {
57    const rows = await deps.getMessages();
58    if (!Array.isArray(rows)) return { ok: false };
59    return { ok: true, replies: selectReplies(rows) };
60  } catch {
61    return { ok: false };
62  }
63}
64
65function copyReasonOf(answer: Record<string, unknown>): CopyReason {
66  const word = COPY_REASONS.find((known) => known === answer.reason);
67  return word ?? "refused";
68}
69
70/** Read the engine's copy answer: only its yes/no and a word from a fixed list count. */
71function readCopyAnswer(
72  answer: unknown,
73  done: Decision,
74  searched: number,
75): Decision {
76  if (typeof answer !== "object" || answer === null) {
77    return refuse("copy-failed", searched);
78  }
79  const record = answer as Record<string, unknown>;
80  if (record.isCopied === true) return done;
81  if (record.isCopied !== false) return refuse("copy-failed", searched);
82  const copyReason = copyReasonOf(record);
83  return { copied: false, reason: "copy-refused", searched, copyReason };
84}
85
86async function writeSlice(
87  deps: CcDeps,
88  found: Extract<FindResult, { ok: true }>,
89  searched: number,
90): Promise<Decision> {
91  if (typeof found.paste !== "string" || found.paste === "") {
92    return refuse("no-match", searched);
93  }
94  const done: Decision = {
95    copied: true,
96    chars: Array.from(found.paste).length,
97    rank: found.messageIndex + 1,
98  };
99  try {
100    return readCopyAnswer(await deps.copy(found.paste), done, searched);
101  } catch {
102    return refuse("copy-failed", searched);
103  }
104}
105
106async function decide(deps: CcDeps): Promise<Decision> {
107  let clip: ClipRead;
108  try {
109    clip = classifyClip(await deps.readClipboard());
110  } catch {
111    return refuse("unreadable");
112  }
113  if (!clip.ok) return refuse(clip.reason);
114
115  const session = await readReplies(deps);
116  if (!session.ok) return refuse("session-unreadable");
117  const { replies } = session;
118
119  const found = (deps.find ?? findSource)(clip.text, replies);
120  if (!found.ok) return refuse(found.reason, replies.length);
121  return writeSlice(deps, found, replies.length);
122}
123
124async function announce(deps: CcDeps, decision: Decision): Promise<CcOutcome> {
125  try {
126    await deps.toast(toastText(decision));
127    return { ...decision, toasted: true };
128  } catch {
129    // A toast that cannot be shown changes nothing about the clipboard; the
130    // outcome records it so the caller can tell.
131    return { ...decision, toasted: false };
132  }
133}
134
135/**
136 * Run /cc once. Resolves with what happened; never rejects, so the command
137 * that calls it can always answer `{}`.
138 */
139export async function runCc(deps: CcDeps): Promise<CcOutcome> {
140  try {
141    let decision: Decision;
142    try {
143      decision = await decide(deps);
144    } catch {
145      decision = refuse("failed");
146    }
147    return await announce(deps, decision);
148  } finally {
149    if (deps.forget) deps.forget();
150    else resetMatchCache();
151  }
152}
153
src/core/match.ts 399 lines
1// The lossless source match: given text copied out of the terminal and the
2// session's assistant messages (NEWEST FIRST), find the exact slice of the
3// source markdown the user selected.
4//
5//   1. strip the renderer's decoration from the copy (strip.ts)
6//   2. fold whitespace runs (spaces, newlines, tabs) to one space on BOTH
7//      sides, because the copy arrives hard-wrapped
8//   3. project each message to what the renderer draws (project.ts) under each
9//      way it can draw links and struck text (drawing.ts), search every message,
10//      and map each hit back to source offsets
11//
12// The searches, in order (patterns.ts): literal, then marker-agnostic, each in
13// fold then skeleton form, each with the copy kept as typed before it is
14// stripped. Every message is searched by one search before the next starts.
15//
16// LOSSLESS: `source` is message.slice(start, end) and nothing else; characters
17// are never edited or invented. `paste` is a derived, paste-ready form of the
18// same slice (container prefixes dropped, first-line indentation restored; see
19// paste.ts) and also contains only source characters.
20//
21// NEVER GUESS. A wrong slice is worse than no answer, so these all refuse:
22//   - the text sits at several places with DIFFERENT slices (one message or
23//     several): "ambiguous". Identical slices are not ambiguous.
24//   - when messages disagree, the row indentation of the copy decides first
25//     (fit.ts): code that only differs in indentation is told apart. If that
26//     cannot decide, the answer is "ambiguous" rather than "the newest".
27//     `newestWinsOnConflict` turns that last step back into newest-first.
28//   - the text sits in too many places to tell apart (search.ts: more than
29//     MAX_HITS_PER_MESSAGE in one message, MAX_HITS_TOTAL in all, or more
30//     candidate work than MAX_CANDIDATE_WORK): also "ambiguous". The search is
31//     bounded in memory and in time whatever the messages hold.
32
33import { breakConflict, compareIndentation } from "./fit.ts";
34import type { IndentFit } from "./fit.ts";
35import { pasteText } from "./paste.ts";
36import { buildPatterns, charCount } from "./patterns.ts";
37import type { Pattern } from "./patterns.ts";
38import { isSpace } from "./fold.ts";
39import {
40  foldedOf,
41  inProjected,
42  isLead,
43  leadSources,
44  locator,
45  markerlessView,
46  prepareAll,
47} from "./views.ts";
48import type { Prepared, View } from "./views.ts";
49import { findAll, newMeter, newNeedle } from "./search.ts";
50import type { Meter, Needle } from "./search.ts";
51import { lowerBound } from "./sorted.ts";
52
53export { projectedOf, resetMatchCache, setMatchCacheLimit } from "./views.ts";
54
55export interface FindOptions {
56  /** Shortest copy (in characters, after folding) worth searching for. Default 12. */
57  minProjectedChars?: number;
58  /** How many messages, newest first, to search. Default 40. */
59  maxMessages?: number;
60  /**
61   * Row widths the real renderer may have used. Reserved for the fidelity
62   * report; the match itself is width-free and ignores it.
63   */
64  widths?: number[];
65  /**
66   * When messages hold the same words with different slices and nothing in the
67   * copy tells them apart, take the newest message instead of refusing as
68   * ambiguous. Does not apply to a conflict inside the newest message itself.
69   * Default false.
70   */
71  newestWinsOnConflict?: boolean;
72  /**
73   * Limits on the search (search.ts); the defaults are the ones to use. A test
74   * lowers `native` to 0 to force the linear-time search on small inputs.
75   */
76  budget?: Partial<Meter>;
77}
78
79export type FindResult =
80  | {
81      ok: true;
82      /** The exact slice of the message: message.slice(start, end). */
83      source: string;
84      /** The same slice made paste-ready (paste.ts). Equals `source` outside containers. */
85      paste: string;
86      messageIndex: number;
87      start: number;
88      end: number;
89      projectedChars: number;
90      /** Occurrences found in the winning message (all map to this slice). */
91      candidates: number;
92    }
93  | { ok: false; reason: "too-short" | "no-match" | "ambiguous" | "empty" };
94
95const DEFAULT_MIN_CHARS = 12;
96const DEFAULT_MAX_MESSAGES = 40;
97
98/** One occurrence of the copy in a message, as source offsets plus where it sits in the view. */
99interface Hit {
100  start: number;
101  end: number;
102  view: View;
103  lo: number;
104  hi: number;
105}
106
107interface Candidate {
108  mi: number;
109  hit: Hit;
110  slice: string;
111  /** The projection (one drawing of the message) the hit was found in. */
112  p: Prepared;
113}
114
115/** Index in `projected` of the list marker just before `first` (skipping blanks), or null. */
116function reachBack(p: Prepared, first: number): number | null {
117  let j = first - 1;
118  while (j >= 0 && isSpace(p.projected.charCodeAt(j))) j--;
119  if (j < 0 || !isLead(p, j)) return null;
120  while (j > 0 && isLead(p, j - 1)) j--;
121  return j;
122}
123
124/** The mirror image for a copy that ended on a lone marker: index of its last unit, or null. */
125function reachForward(p: Prepared, last: number): number | null {
126  const n = p.projected.length;
127  let j = last + 1;
128  while (j < n && isSpace(p.projected.charCodeAt(j))) j++;
129  if (j >= n || !isLead(p, j)) return null;
130  while (j + 1 < n && isLead(p, j + 1)) j++;
131  return j;
132}
133
134/** The list markers as drawn (glyph or number, without the space) in projected[from..to]. */
135function leadGlyphs(p: Prepared, from: number, to: number): string[] {
136  const out: string[] = [];
137  const { leadAt } = p;
138  let k = lowerBound(leadAt, from);
139  if (k < 0) return out;
140  while (k < leadAt.length && (leadAt[k] as number) <= to) {
141    let e = k;
142    while (
143      e + 1 < leadAt.length &&
144      (leadAt[e + 1] as number) === (leadAt[e] as number) + 1 &&
145      (leadAt[e + 1] as number) <= to
146    )
147      e++;
148    out.push(p.projected.slice(leadAt[k] as number, (leadAt[e] as number) + 1));
149    k = e + 1;
150  }
151  return out;
152}
153
154const DIGITS = /^\d+/;
155const LETTERS = /^[a-zA-Z]/;
156
157/**
158 * The marker-agnostic search ignores the CONTENT of list markers, which is
159 * only safe when what it ignored is compatible with what is there:
160 *   - the copy's markers are as many as the message draws in the hit;
161 *   - a numbered marker in the copy is the same number in the message;
162 *   - a lettered or roman marker (another numbering style) needs an ordered
163 *     item, a bullet-like glyph needs a bullet item.
164 * A copy with no markers at all is a renderer that draws none.
165 */
166function markersAgree(
167  p: Prepared,
168  copy: readonly string[],
169  from: number,
170  to: number,
171): boolean {
172  if (copy.length === 0) return true;
173  const drawn = leadGlyphs(p, from, to);
174  if (drawn.length !== copy.length) return false;
175  return copy.every((marker, i) => {
176    const theirs = DIGITS.exec(drawn[i] as string);
177    const mine = DIGITS.exec(marker);
178    if (mine !== null) return theirs !== null && Number(theirs[0]) === Number(mine[0]);
179    return LETTERS.test(marker) ? theirs !== null : theirs === null;
180  });
181}
182
183/** The hit for view indices [lo, hi], or null when the marker rules refuse it. */
184function hitAt(
185  p: Prepared,
186  view: View,
187  pattern: Pattern,
188  lo: number,
189  hi: number,
190): Hit | null {
191  const first = inProjected(view, lo);
192  const last = inProjected(view, hi);
193  // The copy began / ended on a list marker the search skipped: the hit is only
194  // the whole selection if a real marker sits right there. Refuse otherwise.
195  let from = pattern.startedWithMarker ? reachBack(p, first) : first;
196  let to = pattern.endedWithMarker ? reachForward(p, last) : last;
197  if (from === null || to === null) return null;
198  if (pattern.markerless && !markersAgree(p, pattern.markers, from, to))
199    return null;
200  // A glyph the strip step took off an edge may have been a list marker drawn
201  // with the reply-bullet glyph: if a real marker sits right there, it was.
202  // (After the marker check: the copy's marker list does not count this one.)
203  if (pattern.glyphHead && !pattern.startedWithMarker)
204    from = reachBack(p, first) ?? from;
205  if (pattern.glyphTail && !pattern.endedWithMarker)
206    to = reachForward(p, last) ?? to;
207  const start = Math.min(p.map[from] as number, p.map[first] as number);
208  return { start, end: (p.map[to] as number) + 1, view, lo, hi };
209}
210
211/** The hits of the pattern in one message, or null when it sits in too many places to keep. */
212function occurrences(
213  p: Prepared,
214  pattern: Pattern,
215  needle: Needle,
216  meter: Meter,
217): Hit[] | null {
218  const view = pattern.markerless ? markerlessView(p) : p.whole;
219  const ats = findAll(foldedOf(view, pattern.mode), needle, meter);
220  if (ats === null) return null;
221  if (ats.length === 0) return [];
222  const where = locator(view, pattern.mode, ats.length);
223  const hits: Hit[] = [];
224  for (const at of ats) {
225    const hit = hitAt(
226      p,
227      view,
228      pattern,
229      where(at),
230      where(at + pattern.text.length - 1),
231    );
232    if (hit !== null) hits.push(hit);
233  }
234  return hits;
235}
236
237/**
238 * Every hit of the pattern in every searched message, newest message first; or
239 * null when the pattern sits in more places than the budget keeps.
240 */
241function collect(
242  pattern: Pattern,
243  searched: readonly string[],
244  meter: Meter,
245): Candidate[] | null {
246  const out: Candidate[] = [];
247  const needle = newNeedle(pattern.text);
248  for (let mi = 0; mi < searched.length; mi++) {
249    const message = searched[mi];
250    if (typeof message !== "string" || message === "") continue;
251    const seen = new Set<string>(); // the same slice found under two drawings is one hit
252    for (const prepared of prepareAll(message)) {
253      if (pattern.sameAsLiteral && prepared.leadAt.length === 0) continue;
254      const hits = occurrences(prepared, pattern, needle, meter);
255      if (hits === null) return null;
256      for (const hit of hits) {
257        const key = `${hit.start}:${hit.end}`;
258        if (seen.has(key)) continue;
259        seen.add(key);
260        out.push({ mi, hit, slice: message.slice(hit.start, hit.end), p: prepared });
261      }
262    }
263  }
264  return out;
265}
266
267function allSame(candidates: readonly Candidate[]): boolean {
268  const first = (candidates[0] as Candidate).slice;
269  return candidates.every((c) => c.slice === first);
270}
271
272interface Judged {
273  c: Candidate;
274  fit: IndentFit;
275}
276
277/**
278 * Rows that carry exactly the message's own indentation beat rows that would
279 * need an unexplained shift (a container the message does not have there),
280 * but only when every other candidate is proven shifted.
281 */
282function exactIndentation(pool: readonly Judged[]): Judged[] {
283  const exact = pool.filter((j) => j.fit.shift === 0);
284  const shifted = pool.filter((j) => j.fit.shift !== null && j.fit.shift > 0);
285  return exact.length > 0 && exact.length + shifted.length === pool.length
286    ? exact
287    : [...pool];
288}
289
290/** The candidates that draw no line break the copy lacks (fit.ts); if none is left, all stay. */
291function withTheCopysBreaks(candidates: readonly Candidate[], pattern: Pattern): Candidate[] {
292  const kept = candidates.filter(
293    (c) => !breakConflict(pattern.raw, c.hit.view.text.slice(c.hit.lo, c.hit.hi + 1)),
294  );
295  return kept.length > 0 ? kept : [...candidates];
296}
297
298function judge(candidates: readonly Candidate[], copy: string): Judged[] {
299  return candidates.map((c) => ({
300    c,
301    fit: compareIndentation(copy, c.hit.view.text.slice(c.hit.lo, c.hit.hi + 1)),
302  }));
303}
304
305/**
306 * The candidates the copy's row indentation leaves standing. First with the
307 * renderer's margin removed from the copy (then rows that carry exactly the
308 * message's own indentation win). If that rules out every candidate, the copy
309 * may carry no margin at all, so try once more with the margin left alone: a
310 * container only ever ADDS indentation, so the true message still never needs a
311 * negative shift. If nothing is ruled out either way, nothing is decided.
312 */
313function indentationPool(candidates: readonly Candidate[], pattern: Pattern): Judged[] {
314  const assumed = judge(candidates, pattern.raw).filter((j) => !j.fit.conflict);
315  if (assumed.length > 0) return exactIndentation(assumed);
316  const flat = judge(candidates, pattern.flat).filter((j) => !j.fit.conflict);
317  return flat.length > 0 ? flat : judge(candidates, pattern.raw);
318}
319
320/**
321 * The candidate to answer with, or null when the answer must be "ambiguous".
322 * Identical slices are one answer (the newest message's). Differing slices: the
323 * copy's row indentation rules out candidates that cannot be what was copied
324 * and prefers the one that fits exactly; if the rest still differ, refuse (or
325 * take the newest if the caller allows it and the newest message itself is
326 * unambiguous).
327 */
328function choose(
329  candidates: readonly Candidate[],
330  pattern: Pattern,
331  newestWins: boolean,
332): Candidate | null {
333  if (allSame(candidates)) return candidates[0] as Candidate;
334  const drawn = withTheCopysBreaks(candidates, pattern);
335  if (allSame(drawn)) return drawn[0] as Candidate;
336  const pool = indentationPool(drawn, pattern).map((j) => j.c);
337  const head = pool[0] as Candidate;
338  if (allSame(pool)) return head;
339  if (newestWins && allSame(pool.filter((c) => c.mi === head.mi))) return head;
340  return null;
341}
342
343function answer(
344  winner: Candidate,
345  candidates: readonly Candidate[],
346  pattern: Pattern,
347  message: string,
348): FindResult {
349  const { hit, p: prepared } = winner;
350  const paste = pasteText(
351    { message, contentAt: prepared.contentAt, leadSrc: leadSources(prepared) },
352    hit.start,
353    hit.end,
354    pattern.indent,
355  );
356  return {
357    ok: true,
358    source: winner.slice,
359    paste,
360    messageIndex: winner.mi,
361    start: hit.start,
362    end: hit.end,
363    projectedChars: charCount(pattern.text),
364    candidates: candidates.filter(
365      (c) => c.mi === winner.mi && c.slice === winner.slice,
366    ).length,
367  };
368}
369
370export function findSource(
371  copied: string,
372  messages: readonly string[],
373  opts: FindOptions = {},
374): FindResult {
375  const minChars = opts.minProjectedChars ?? DEFAULT_MIN_CHARS;
376  const searched = messages.slice(0, opts.maxMessages ?? DEFAULT_MAX_MESSAGES);
377  const built = buildPatterns(
378    typeof copied === "string" ? copied : "",
379    minChars,
380  );
381  if ("refuse" in built) return { ok: false, reason: built.refuse };
382
383  const meter = newMeter(opts.budget);
384  for (const pattern of built.tries) {
385    if (charCount(pattern.text) < minChars) continue;
386    const candidates = collect(pattern, searched, meter);
387    if (candidates === null) return { ok: false, reason: "ambiguous" };
388    if (candidates.length === 0) continue;
389    const winner = choose(
390      candidates,
391      pattern,
392      opts.newestWinsOnConflict === true,
393    );
394    if (winner === null) return { ok: false, reason: "ambiguous" };
395    return answer(winner, candidates, pattern, searched[winner.mi] as string);
396  }
397  return { ok: false, reason: "no-match" };
398}
399
hooks/input.ts 78 lines
1// Reading what /cc works from: the clipboard answer and the session rows.
2// Both functions are pure and total: whatever the engine hands them, they
3// return a value; they never throw and never keep what they were given.
4
5import { MAX_CLIPBOARD_CHARS, MAX_REPLIES } from "./outcome.ts";
6
7/** The part of the engine's process answer that /cc reads (the rest is ignored). */
8export interface RunAnswer {
9  exitCode: number;
10  stdout: string;
11  stderr?: string;
12  isStdoutTruncated: boolean;
13  isStderrTruncated?: boolean;
14}
15
16export type ClipRead =
17  | { ok: true; text: string }
18  | { ok: false; reason: "empty" | "unreadable" | "too-large" };
19
20function isRecord(value: unknown): value is Record<string, unknown> {
21  return typeof value === "object" && value !== null;
22}
23
24/**
25 * Whether `text` holds more than `max` characters (code points, as the toast
26 * counts them), without building an array. A character is one or two UTF-16
27 * units, so a text of up to `max` units is within the bound and one of more
28 * than twice that is over it; only the band between is counted.
29 */
30function hasMoreThan(text: string, max: number): boolean {
31  if (text.length <= max) return false;
32  if (text.length > 2 * max) return true;
33  let seen = 0;
34  for (const _ of text) {
35    seen += 1;
36    if (seen > max) return true;
37  }
38  return false;
39}
40
41/**
42 * Turn the answer of the clipboard read into the copied text or a refusal.
43 * A read that failed, was cut short, or is over the bound is never used in
44 * part: a half-read clipboard could match the wrong thing.
45 */
46export function classifyClip(run: unknown): ClipRead {
47  if (!isRecord(run)) return { ok: false, reason: "unreadable" };
48  if (run.exitCode !== 0 || typeof run.stdout !== "string") {
49    return { ok: false, reason: "unreadable" };
50  }
51  if (run.isStdoutTruncated === true) return { ok: false, reason: "too-large" };
52  if (hasMoreThan(run.stdout, MAX_CLIPBOARD_CHARS)) {
53    return { ok: false, reason: "too-large" };
54  }
55  if (run.stdout.trim() === "") return { ok: false, reason: "empty" };
56  return { ok: true, text: run.stdout };
57}
58
59/** The text of one row if it is an assistant reply with something in it. */
60function replyText(row: unknown): string[] {
61  if (!isRecord(row) || row.role !== "assistant") return [];
62  if (typeof row.text !== "string" || row.text.trim() === "") return [];
63  return [row.text];
64}
65
66/**
67 * The newest assistant replies that have text, newest first, at most `max`.
68 * Rows are in conversation order; user rows, tool-only turns and anything
69 * malformed are skipped.
70 */
71export function selectReplies(
72  rows: readonly unknown[],
73  max: number = MAX_REPLIES,
74): string[] {
75  if (max <= 0) return [];
76  return rows.flatMap(replyText).slice(-max).toReversed();
77}
78
hooks/outcome.ts 107 lines
1// What a /cc run decided, and the one line that tells the person.
2//
3// PRIVACY BY CONSTRUCTION: a Decision holds numbers and words from closed
4// lists, never a piece of the clipboard or of a message. toastText can only
5// print what a Decision holds, so no text can reach the toast. Keep it so: add
6// a field here only if it is a count or a member of a fixed list.
7
8/** Longest clipboard (in characters) a run will look at. */
9export const MAX_CLIPBOARD_CHARS = 200_000;
10/** How many of the newest assistant replies a run searches. */
11export const MAX_REPLIES = 40;
12/** The matcher's own floor (src/core/match.ts), repeated for the toast only. */
13export const MIN_COPY_CHARS = 12;
14
15/** The words ui.copy gives when it did not copy; anything else reads "refused". */
16export const COPY_REASONS = ["no-surface", "no-clipboard", "refused"] as const;
17export type CopyReason = (typeof COPY_REASONS)[number];
18
19/** Every way a run can end without writing the clipboard. */
20export const REFUSALS = [
21  "empty",
22  "unreadable",
23  "too-large",
24  "session-unreadable",
25  "too-short",
26  "no-match",
27  "ambiguous",
28  "copy-refused",
29  "copy-failed",
30  "failed",
31] as const;
32export type Refusal = (typeof REFUSALS)[number];
33
34export type Decision =
35  | { copied: true; chars: number; rank: number }
36  | {
37      copied: false;
38      reason: Refusal;
39      /** How many replies the matcher looked through (0 if it never ran). */
40      searched: number;
41      copyReason?: CopyReason;
42    };
43
44/** A Decision plus whether the toast could be shown. */
45export type CcOutcome = Decision & { toasted: boolean };
46
47/**
48 * The most characters a toast line should hold. In sealed live runs on Claude
49 * Code 2.1.309 and 2.1.310 the engine put the plugin's name in front of the
50 * line ("copy-back: ", so the line must not repeat it) and, at 60 columns,
51 * drew the whole row in 58 columns, cutting the rest with an ellipsis: 58 less
52 * 2 spaces of indent less 11 of name leaves 45. Not checked on other versions.
53 * Every line below fits; the success line fits up to 99,999 characters and
54 * keeps its rank in view beyond that (tests/cc-outcome.test.mts checks both).
55 */
56export const TOAST_MAX_CHARS = 45;
57
58const UNCHANGED = ", nothing changed";
59
60function noMatchLine(searched: number): string {
61  if (searched <= 0) return `no replies to search${UNCHANGED}`;
62  const what = searched === 1 ? "the last reply" : `last ${searched} replies`;
63  return `no match in ${what}${UNCHANGED}`;
64}
65
66function refusedLine(copyReason: CopyReason | undefined): string {
67  // Re-checked here, not only where the engine's answer is read: this is the
68  // last place a word reaches the screen, so only a member of the list can.
69  const word = COPY_REASONS.find((known) => known === copyReason) ?? "refused";
70  return `clipboard write was refused (${word})`;
71}
72
73/** The refusal lines that need nothing but the reason. */
74const FIXED_LINES: Record<
75  Exclude<Refusal, "no-match" | "copy-refused">,
76  string
77> = {
78  empty: `clipboard is empty${UNCHANGED}`,
79  unreadable: `could not read the clipboard${UNCHANGED}`,
80  "too-large": `clipboard over ${MAX_CLIPBOARD_CHARS} chars${UNCHANGED}`,
81  "session-unreadable": `could not read the session${UNCHANGED}`,
82  "too-short": `copied text under ${MIN_COPY_CHARS} chars${UNCHANGED}`,
83  ambiguous: `matches more than one place${UNCHANGED}`,
84  "copy-failed": "clipboard write failed",
85  failed: `unexpected error${UNCHANGED}`,
86};
87
88/**
89 * The one line that tells the person what happened: counts and fixed words
90 * only. It carries no plugin name; Claude Code shows that in front of it.
91 */
92export function toastText(decision: Decision): string {
93  if (decision.copied) {
94    const { chars, rank } = decision;
95    return `copied ${chars} chars from reply ${rank} (1 = newest)`;
96  }
97  if (decision.reason === "no-match") return noMatchLine(decision.searched);
98  if (decision.reason === "copy-refused") {
99    return refusedLine(decision.copyReason);
100  }
101  // A reason outside the list (only a hand-built Decision can have one) reads
102  // as the catch-all line, never as `undefined` or a property of the table.
103  return Object.hasOwn(FIXED_LINES, decision.reason)
104    ? FIXED_LINES[decision.reason]
105    : FIXED_LINES.failed;
106}
107
src/core/fit.ts 85 lines
1// Does a message show the copied text with the SAME line structure?
2//
3// The search folds whitespace, so two messages with the same words but
4// different indentation both match. That is the right thing for prose (a hard
5// wrap is just a space) and the wrong thing for code, where indentation is
6// the meaning. `indentationConflicts` is the tie-breaker the matcher applies
7// only when two candidate slices differ: it compares, for every place where
8// BOTH the copy and the message break the line, the indentation that follows.
9//
10//   copy row indent  =  message line indent  +  d          (d is the same everywhere)
11//
12// d is the container prefix the copy carries and the message projection does
13// not (list hanging indent, an unknown renderer margin), so it is the same
14// for every row of one block and never negative: a container only ADDS
15// indentation. A candidate whose rows would need a negative or a changing d
16// cannot be what was copied.
17//
18// A shift of exactly 0 (the rows carry the message's own indentation, which is
19// what top-level code gives) beats a positive shift (the rows would need an
20// unexplained container) when candidates still differ. Used by the matcher
21// only; see match.ts.
22//
23// Deliberately blind to everything else: gaps where either side has no line
24// break (a prose wrap), gaps that contain tabs, and rows that wrap inside a
25// token. Unknown renderer behaviour should never demote the true message.
26
27/** Whitespace runs between tokens, in order (the text must already be trimmed). */
28function gapsOf(text: string): string[] {
29  return text.match(/\s+/g) ?? [];
30}
31
32/** Blanks after the last newline of a gap that contains one. */
33function indentAfterBreak(gap: string): number {
34  return gap.length - gap.lastIndexOf("\n") - 1;
35}
36
37export interface IndentFit {
38  /** The rows' indentation cannot be what `shown` draws. */
39  conflict: boolean;
40  /**
41   * The constant d (copy indent minus message indent) when there was at least
42   * one comparable row break, else null. 0 means the rows carry exactly the
43   * message's own indentation.
44   */
45  shift: number | null;
46}
47
48/**
49 * Compare `copy` (the stripped, trimmed copy) with `shown` (the message's
50 * visible text for the hit). Both must be equal once whitespace is folded.
51 */
52export function compareIndentation(copy: string, shown: string): IndentFit {
53  const mine = gapsOf(copy);
54  const theirs = gapsOf(shown);
55  if (mine.length !== theirs.length) return { conflict: false, shift: null };
56  let shift: number | null = null;
57  for (let i = 0; i < mine.length; i++) {
58    const a = mine[i] as string;
59    const b = theirs[i] as string;
60    if (!a.includes("\n") || !b.includes("\n")) continue;
61    if (a.includes("\t") || b.includes("\t")) continue;
62    const d = indentAfterBreak(a) - indentAfterBreak(b);
63    if (d < 0) return { conflict: true, shift };
64    if (shift === null) shift = d;
65    else if (d !== shift) return { conflict: true, shift };
66  }
67  return { conflict: false, shift };
68}
69
70/**
71 * Does the message draw a line break where the copy has none? Claude Code ends every
72 * row itself and draws a source line break as a row break, so a copy that spans rows has
73 * a newline wherever the message it came from has one; the copy may have MORE (a wrap at
74 * the screen width). A copy with no newline at all says nothing about breaks, and texts
75 * that do not split into the same words (a row split inside a token) cannot be compared.
76 * Used by the matcher to tell apart candidates with the same words and different breaks.
77 */
78export function breakConflict(copy: string, shown: string): boolean {
79  if (!copy.includes("\n")) return false;
80  const mine = gapsOf(copy);
81  const theirs = gapsOf(shown);
82  if (mine.length !== theirs.length) return false;
83  return theirs.some((gap, i) => gap.includes("\n") && !(mine[i] as string).includes("\n"));
84}
85
src/core/paste.ts 112 lines
1// The paste-ready form of a source slice.
2//
3// findSource returns the EXACT slice of the source (lossless, by contract). A
4// slice that comes out of a container carries syntax the user never saw as
5// text: code inside a numbered list item has the list indentation on every
6// line after the first, code inside a quote has "> " on every line, and a
7// selection that started at the beginning of a code row lost the row's own
8// indentation on the first line. Pasting that slice as-is breaks the code.
9//
10//   pasteText(...)  the slice with
11//     1. the container prefix (list indentation, "> ", fence indentation)
12//        removed from every line after the first, using the parser's own
13//        per-line offsets (`contentAt`), and
14//     2. the first line's leading blanks put back, as many as the copy carried
15//        and the source line really has, never reaching into the container
16//        prefix.
17//   Lines that start a list item (they hold the marker) are left alone, so a
18//   slice that spans items keeps its markers and its nesting. Lines the parser
19//   did not consume as text and that hold nothing but blanks and quote markers
20//   become empty. Every character of the result is a character of the message
21//   from the start of the slice's first line to its end; nothing is invented.
22
23import { firstWithin } from "./sorted.ts";
24
25export interface PasteSource {
26  /** The whole message. */
27  message: string;
28  /** Ascending source offsets where each content line's own text starts. */
29  contentAt: Int32Array;
30  /** Ascending source offsets of list-marker units (lines holding one are left alone). */
31  leadSrc: Int32Array;
32}
33
34function lineBounds(message: string, at: number): { from: number; to: number } {
35  const from = at <= 0 ? 0 : message.lastIndexOf("\n", at - 1) + 1;
36  const nl = message.indexOf("\n", at);
37  return { from, to: nl < 0 ? message.length : nl };
38}
39
40/** How many blanks to put back before `start`, never past `floor` and never more than the copy carried. */
41function restoredBlanks(
42  message: string,
43  start: number,
44  floor: number,
45  carried: number,
46): number {
47  let blanks = 0;
48  while (blanks < carried && start - blanks - 1 >= floor) {
49    const ch = message[start - blanks - 1];
50    if (ch !== " " && ch !== "\t") break;
51    blanks++;
52  }
53  return blanks;
54}
55
56function firstLine(
57  src: PasteSource,
58  start: number,
59  end: number,
60  carried: number,
61): string {
62  const { message } = src;
63  const line = lineBounds(message, start);
64  const content = firstWithin(src.contentAt, line.from, line.to);
65  const floor = content !== null && content <= start ? content : line.from;
66  const back = restoredBlanks(message, start, floor, carried);
67  return message.slice(start - back, Math.min(line.to, end));
68}
69
70/** A later line of the slice: [from, to) is its part inside the slice, `full` its whole source line. */
71function laterLine(
72  src: PasteSource,
73  from: number,
74  to: number,
75  full: { from: number; to: number },
76): string {
77  const { message } = src;
78  if (firstWithin(src.leadSrc, full.from, full.to) !== null)
79    return message.slice(from, to);
80  const content = firstWithin(src.contentAt, full.from, full.to);
81  if (content !== null)
82    return content <= to ? message.slice(content, to) : message.slice(from, to);
83  const text = message.slice(from, to);
84  return /^[ \t>]*(?:\r)?$/.test(text) ? text.replace(/[ \t>]/g, "") : text;
85}
86
87/**
88 * Paste-ready text for the slice [start, end) of `src.message`.
89 * `carried` is how many leading blanks the copy's first row had.
90 */
91export function pasteText(
92  src: PasteSource,
93  start: number,
94  end: number,
95  carried: number,
96): string {
97  const { message } = src;
98  const head = firstLine(src, start, end, carried);
99  const firstTo = lineBounds(message, start).to;
100  if (firstTo >= end) return head;
101  const out = [head];
102  let from = firstTo + 1;
103  while (from <= end) {
104    const full = lineBounds(message, from);
105    const to = Math.min(full.to, end);
106    out.push(laterLine(src, from, to, full));
107    if (full.to >= end) break;
108    from = full.to + 1;
109  }
110  return out.join("\n");
111}
112
src/core/patterns.ts 156 lines
1// The searches findSource tries for one copy, in order.
2//
3//   literal    fold, then skeleton   (skeleton drops whitespace entirely, for
4//                                     rows split inside a token: long URLs,
5//                                     long code lines, CJK)
6//   markerless the same two, with list markers ignored on BOTH sides: the
7//              bullet glyph and numbering style are the simulator's least
8//              certain guess, and a renderer that draws no marker still works
9// each with the copy first kept as typed, then with the renderer's reply
10// bullet and quote bars stripped.
11//
12// Kept-as-typed comes FIRST on purpose: a first row that starts with a
13// character that is real source text but also looks like decoration ("│" of a
14// model-drawn tree, "●" in a status line) would otherwise be stripped, match as
15// a suffix of the selection and win, and the answer would lose its head. The
16// stripped form only gets a say when the kept form finds nothing.
17
18import { foldText } from "./fold.ts";
19import type { Mode } from "./fold.ts";
20import {
21  endsWithLoneMarker,
22  firstRowIndent,
23  glyphEdges,
24  listMarkersOf,
25  startsWithMarker,
26  stripDecoration,
27} from "./strip.ts";
28
29export interface Pattern {
30  mode: Mode;
31  markerless: boolean;
32  /** The copy, folded for this mode and trimmed. */
33  text: string;
34  /** The copy as stripped (not folded), trimmed: its row structure, for the indentation check. */
35  raw: string;
36  /** Same, but with NO margin removed: for a copy that carries none. */
37  flat: string;
38  /** Blanks the copy's first row carried before its first character (paste step). */
39  indent: number;
40  /** List markers the copy carried, in order (only meaningful when markerless). */
41  markers: readonly string[];
42  /** The copy's first row began with a list marker (only meaningful when markerless). */
43  startedWithMarker: boolean;
44  /** The copy's last row was nothing but a list marker (only meaningful when markerless). */
45  endedWithMarker: boolean;
46  /** Same text as the literal search of this mode: pointless on a message without markers. */
47  sameAsLiteral: boolean;
48  /** The glyph step took a glyph off the first row: it may have been a list marker (match.ts takes it back). */
49  glyphHead: boolean;
50  /** The glyph step removed a last row that was nothing but a glyph. */
51  glyphTail: boolean;
52}
53
54export type Built = { tries: Pattern[] } | { refuse: "empty" | "too-short" };
55
56export function charCount(text: string): number {
57  let n = 0;
58  for (const _ of text) n++;
59  return n;
60}
61
62interface Variants {
63  /** [kept as typed, decoration stripped] */
64  plain: [string, string];
65  /** Same, with list markers removed too. */
66  bare: [string, string];
67  /** [kept as typed, stripped] with the margin left alone, markers as in `plain` and `bare`. */
68  flatPlain: [string, string];
69  flatBare: [string, string];
70}
71
72function variantsOf(copied: string): Variants {
73  return {
74    plain: [
75      stripDecoration(copied, { glyphs: false }),
76      stripDecoration(copied),
77    ],
78    bare: [
79      stripDecoration(copied, { glyphs: false, markers: true }),
80      stripDecoration(copied, { markers: true }),
81    ],
82    flatPlain: [
83      stripDecoration(copied, { glyphs: false, margin: 0 }),
84      stripDecoration(copied, { margin: 0 }),
85    ],
86    flatBare: [
87      stripDecoration(copied, { glyphs: false, markers: true, margin: 0 }),
88      stripDecoration(copied, { markers: true, margin: 0 }),
89    ],
90  };
91}
92
93function patternFor(
94  v: Variants,
95  which: 0 | 1,
96  mode: Mode,
97  markerless: boolean,
98  text: string,
99): Pattern {
100  const typed = v.plain[which];
101  const raw = (markerless ? v.bare : v.plain)[which].trim();
102  const edges = which === 1 ? glyphEdges(v.plain[0]) : { head: false, tail: false };
103  return {
104    mode,
105    markerless,
106    text,
107    raw,
108    flat: (markerless ? v.flatBare : v.flatPlain)[which].trim(),
109    indent: firstRowIndent(typed),
110    markers: markerless ? listMarkersOf(typed) : [],
111    startedWithMarker: markerless && startsWithMarker(typed),
112    endedWithMarker: markerless && endsWithLoneMarker(typed),
113    sameAsLiteral: markerless && text === foldText(typed, mode).trim(),
114    glyphHead: edges.head,
115    glyphTail: edges.tail,
116  };
117}
118
119function compile(v: Variants): Pattern[] {
120  const out: Pattern[] = [];
121  for (const markerless of [false, true]) {
122    for (const mode of ["fold", "skeleton"] as const) {
123      const seen = new Set<string>();
124      for (const which of [0, 1] as const) {
125        const text = foldText(
126          (markerless ? v.bare : v.plain)[which],
127          mode,
128        ).trim();
129        if (text === "" || seen.has(text)) continue;
130        seen.add(text);
131        out.push(patternFor(v, which, mode, markerless, text));
132      }
133    }
134  }
135  return out;
136}
137
138/**
139 * The searches for a copy, or the reason there is nothing to search.
140 * "empty": nothing visible once decoration is stripped (a lone reply bullet or
141 * quote bar is nothing). "too-short": fewer than `minChars` folded characters
142 * in every form worth searching.
143 */
144export function buildPatterns(copied: string, minChars: number): Built {
145  const v = variantsOf(copied);
146  const typed = foldText(v.plain[0], "fold").trim();
147  const stripped = foldText(v.plain[1], "fold").trim();
148  if (typed === "" || (stripped === "" && charCount(typed) < minChars))
149    return { refuse: "empty" };
150  const tries = compile(v);
151  const searchable = tries.some(
152    (t) => !t.markerless && t.mode === "fold" && charCount(t.text) >= minChars,
153  );
154  return searchable ? { tries } : { refuse: "too-short" };
155}
156
src/core/fold.ts 55 lines
1// Whitespace folding for the search. Both the copied text and the projected
2// message are folded the same way, so hard wraps, padding and indentation
3// differences cannot matter.
4//   "fold"      runs of whitespace become one space (word boundaries survive)
5//   "skeleton"  whitespace is removed (survives a row split inside a token)
6
7export type Mode = "fold" | "skeleton";
8
9export function isSpace(c: number): boolean {
10  if (c > 32 && c < 0xa0) return false;
11  return (
12    c === 32 ||
13    (c >= 9 && c <= 13) ||
14    c === 0xa0 ||
15    c === 0x1680 ||
16    (c >= 0x2000 && c <= 0x200a) ||
17    c === 0x2028 ||
18    c === 0x2029 ||
19    c === 0x202f ||
20    c === 0x205f ||
21    c === 0x3000 ||
22    c === 0xfeff
23  );
24}
25
26// JavaScript's \s is exactly isSpace's set (ASCII whitespace, NBSP, the Unicode
27// space separators, line and paragraph separators, BOM), and the regex engine
28// scans it natively, several times faster than a character loop.
29const SPACE_RUN = /\s+/g;
30
31export function foldText(source: string, mode: Mode): string {
32  return source.replace(SPACE_RUN, mode === "fold" ? " " : "");
33}
34
35/** Index in `text` of the character at index `target` of its folded form. */
36export function unfold(text: string, mode: Mode, target: number): number {
37  const n = text.length;
38  let folded = 0;
39  let i = 0;
40  while (i < n) {
41    if (isSpace(text.charCodeAt(i))) {
42      if (mode === "fold") {
43        if (folded === target) return i;
44        folded++;
45      }
46      while (i < n && isSpace(text.charCodeAt(i))) i++;
47    } else {
48      if (folded === target) return i;
49      folded++;
50      i++;
51    }
52  }
53  return n - 1;
54}
55
src/core/views.ts 181 lines
1// What the matcher knows about one message, cached: its projection (project.ts)
2// and the searchable views of it. A "view" is the projected text, optionally
3// with the list-marker units left out (the marker-agnostic search), plus the
4// whitespace-folded forms the search runs over.
5
6import { DEFAULT_DRAWING, drawingsFor } from "./drawing.ts";
7import type { Drawing } from "./drawing.ts";
8import { foldText, isSpace, unfold } from "./fold.ts";
9import type { Mode } from "./fold.ts";
10import { projectText } from "./project.ts";
11import { lowerBound } from "./sorted.ts";
12
13/** A searchable text plus, when units were left out, which ones (ascending indices of `projected`). */
14export interface View {
15  text: string;
16  removed: Int32Array | null;
17  fold?: string;
18  skeleton?: string;
19  /** Folded index -> index in `text`; built only for a message with very many hits. */
20  foldIndex?: Int32Array;
21  skeletonIndex?: Int32Array;
22}
23
24export interface Prepared {
25  projected: string;
26  map: Int32Array;
27  /** Ascending indices of list-marker units. */
28  leadAt: Int32Array;
29  /** Ascending source offsets where each content line's own text starts. */
30  contentAt: Int32Array;
31  whole: View;
32  markerless?: View;
33  leadSrc?: Int32Array;
34}
35
36const cache = new Map<string, Prepared>();
37let cacheLimit = 300;
38
39/** Hits above this many get an O(1) folded-index lookup instead of a linear scan each. */
40const LINEAR_LOCATES = 8;
41
42/** The cache key of a message under a drawing: the default one keeps the bare text. */
43const keyOf = (text: string, drawing: Drawing): string =>
44  drawing === DEFAULT_DRAWING ? text : `${drawing.links}/${drawing.strike}\u0000${text}`;
45
46export function prepare(text: string, drawing: Drawing = DEFAULT_DRAWING): Prepared {
47  const key = keyOf(text, drawing);
48  const hit = cache.get(key);
49  if (hit !== undefined) return hit;
50  const { projected, map, leadAt, contentAt } = projectText(text, undefined, drawing);
51  const made: Prepared = {
52    projected,
53    map,
54    leadAt,
55    contentAt,
56    whole: { text: projected, removed: null },
57  };
58  if (cache.size >= cacheLimit) {
59    const oldest = cache.keys().next().value;
60    if (oldest !== undefined) cache.delete(oldest);
61  }
62  cache.set(key, made);
63  return made;
64}
65
66/**
67 * Every way the message can be drawn that looks different: its projection under each drawing
68 * (drawing.ts), the default first, those that draw the same text as an earlier one left out.
69 * A message with no link and no double tilde has one.
70 */
71export function prepareAll(text: string): Prepared[] {
72  const out: Prepared[] = [];
73  for (const drawing of drawingsFor(text)) {
74    const p = prepare(text, drawing);
75    if (!out.some((q) => q.projected === p.projected)) out.push(p);
76  }
77  return out;
78}
79
80/** How many message projections to keep (default 300). The replay harness raises it. */
81export function setMatchCacheLimit(limit: number): void {
82  cacheLimit = Math.max(1, Math.floor(limit));
83}
84
85/** The cached visible text of a message, shared with the search. */
86export function projectedOf(message: string): string {
87  return prepare(message).projected;
88}
89
90/** Forget every cached projection. Only tests that time a cold search need this. */
91export function resetMatchCache(): void {
92  cache.clear();
93}
94
95export function isLead(p: Prepared, index: number): boolean {
96  const at = lowerBound(p.leadAt, index);
97  return at >= 0 && p.leadAt[at] === index;
98}
99
100/** Source offsets of the list-marker units (for the paste step). */
101export function leadSources(p: Prepared): Int32Array {
102  if (p.leadSrc !== undefined) return p.leadSrc;
103  const out = Int32Array.from(p.leadAt, (at) => p.map[at] as number);
104  return (p.leadSrc = out.sort());
105}
106
107/** The projection with list-marker units removed (the markerless search's haystack). */
108export function markerlessView(p: Prepared): View {
109  if (p.markerless !== undefined) return p.markerless;
110  if (p.leadAt.length === 0) return (p.markerless = p.whole);
111  const parts: string[] = [];
112  let from = 0;
113  for (const at of p.leadAt) {
114    if (at > from) parts.push(p.projected.slice(from, at));
115    from = at + 1;
116  }
117  if (from < p.projected.length) parts.push(p.projected.slice(from));
118  return (p.markerless = { text: parts.join(""), removed: p.leadAt });
119}
120
121/**
122 * Index in `projected` of unit `index` of a view's text: the index plus the
123 * number of removed units before it. `removed[k] - k` never decreases, so the
124 * count is a binary search.
125 */
126export function inProjected(view: View, index: number): number {
127  const gone = view.removed;
128  if (gone === null) return index;
129  let lo = 0;
130  let hi = gone.length;
131  while (lo < hi) {
132    const mid = (lo + hi) >> 1;
133    if ((gone[mid] as number) - mid <= index) lo = mid + 1;
134    else hi = mid;
135  }
136  return index + lo;
137}
138
139export function foldedOf(view: View, mode: Mode): string {
140  if (mode === "fold") return (view.fold ??= foldText(view.text, "fold"));
141  return (view.skeleton ??= foldText(view.text, "skeleton"));
142}
143
144function buildIndex(view: View, mode: Mode): Int32Array {
145  const { text } = view;
146  const n = text.length;
147  const out = new Int32Array(n);
148  let k = 0;
149  let i = 0;
150  while (i < n) {
151    if (isSpace(text.charCodeAt(i))) {
152      if (mode === "fold") out[k++] = i;
153      while (i < n && isSpace(text.charCodeAt(i))) i++;
154    } else {
155      out[k++] = i;
156      i++;
157    }
158  }
159  return out.subarray(0, k);
160}
161
162/**
163 * A function from a folded index to the index in the view's text. A few hits
164 * use the linear `unfold`; many hits share one precomputed index, so scanning
165 * every occurrence of a repeated phrase stays linear.
166 */
167export function locator(
168  view: View,
169  mode: Mode,
170  hits: number,
171): (folded: number) => number {
172  if (hits <= LINEAR_LOCATES)
173    return (folded) => unfold(view.text, mode, folded);
174  const index =
175    mode === "fold"
176      ? (view.foldIndex ??= buildIndex(view, mode))
177      : (view.skeletonIndex ??= buildIndex(view, mode));
178  const last = index.length - 1;
179  return (folded) => index[Math.min(folded, last)] as number;
180}
181