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…

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
pbpasteandpbcopy, 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 withpbpaste. 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.
/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.
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.
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./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).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.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.
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.- 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.message.slice(start, end), never edited./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.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.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.None. The plugin declares no options.
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.claude plugin uninstall copy-back@claude-copy-back.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./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.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.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), , 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./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.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.
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.src/core follow the behaviour of the CommonMark specification.MIT, see LICENSE. Copyright (c) 2026 Sairam Ugge.
hooks/register.tsx 33 lines1import 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};
33hooks/api.ts 62 lines1import 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}
62hooks/run.ts 21 lines1// 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}
21hooks/cc.ts 153 lines1// 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}
153src/core/match.ts 399 lines1// 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}
399hooks/input.ts 78 lines1// 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}
78hooks/outcome.ts 107 lines1// 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}
107src/core/fit.ts 85 lines1// 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}
85src/core/paste.ts 112 lines1// 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}
112src/core/patterns.ts 156 lines1// 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}
156src/core/fold.ts 55 lines1// 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}
55src/core/views.ts 181 lines1// 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