Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ Mouse-up over a terminal-handled drag opens the **copy editor**, as does the cop
|---|---|
| **Auto** (opens here) | Decoration stripped, each break judged on its own (§4.1.1). |
| **Exact** | As displayed: selected rows joined by `\n`, each trimmed of trailing whitespace — soft-wrapped rows included. |
| **Spaces** | Decoration stripped, blank lines dropped, every break one space, continuation indents removed. |
| **Spaces** | Decoration stripped, blank lines dropped, every break one space, continuation indents and quote prefixes removed. |
| **No breaks** | As Spaces, every break deleted. |

**Decoration** is a frame-only line (dropped), a leading or trailing run of box drawing (`U+2500–U+259F`, Box Drawing and Block Elements), and a TUI's leading bullet (`⏺`, `⎿`, `●`). **Must read cells through xterm's wide-character continuation cells.** **A deleted soft wrap joins its rows exactly**, keeping a blank on either side of it. **Every judgement reads a soft wrap's rows as one line**, indented as its first row (rationale). **Never rewrap a block-shape selection**: it is a rectangular slab, so Auto reads it as Exact, and it has no wider scope (§4.2) and no edge keys (§4.3).
**Decoration** is a frame-only line (dropped), a leading or trailing run of box drawing (`U+2500–U+259F`, Box Drawing and Block Elements), and a TUI's leading bullet (`⏺`, `⎿`, `●`). A **quote prefix** is a `>`, or a quote bar (`▏ ▎ ▍`) at most two spaces from its text, per level, with text after it; a line of bars alone is blank, written between quoted lines with the shallower one's prefix. **Must write a quote bar as `>`, drop a quote prefix with the break deleted before it, and keep a break where quote depth changes** (rationale). **Must read cells through xterm's wide-character continuation cells.** **A deleted soft wrap joins its rows exactly**, keeping a blank on either side of it. **Every judgement reads a soft wrap's rows as one line**, indented as its first row (rationale). **Never rewrap a block-shape selection**: it is a rectangular slab, so Auto reads it as Exact, and it has no wider scope (§4.2) and no edge keys (§4.3).

Source of truth: `render` in `lib/src/lib/copy-text.ts`, pinned by `lib/src/lib/copy-text.test.ts`.

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/mouse-and-clipboard.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ The editor replaced a two-button Copy Raw / Copy Rewrapped popup. Three shapes w

## 4.1 Formats

**Why quote prefixes.** Claude Code draws a Markdown quote with `▎` on every row, nested quotes as `▎ ▎`. Read as box drawing, the bar was stripped, so a quote pasted as unquoted prose, and a nested quote's inner bar landed mid-sentence once its rows joined (issue #22, 2026-10). Markdown's `>` keeps the nesting in a form chat inputs and editors render. The same bar is the default indent guide of Neovim's indent-blankline, drawn at every indent stop and on blank lines: Claude Code's bar sits one space from its text (two where a wrap carried a space), a guide at a shiftwidth of 3 or more sits further. A bar alone on a line is either a quote's paragraph break or a guide on a blank code line, so it is a blank line, and only its quoted neighbors make it a quote line: dropped as a frame (16e10140d), it merged a quote's paragraphs into one and cost guided code its blank lines. The trade: at shiftwidth 2, guides read as quote bars. A lone `>` is more often an empty prompt (Claude Code's input box, a shell's continuation) than an empty quote line.

**Why a soft wrap's rows are one line.** Narrowing a pane, as a split does, makes xterm reflow every row wider than the new width onto soft-wrapped continuation rows, and §3.4 keeps an open editor through the resize. Judged by rows, the Claude reply fixture narrowed from 80 to 60 columns kept every break of its first paragraph: each continuation row (` the final flush`) brought its own indent, which missed the next row's hanging indent, and its own length, which always left room for the next word. The same rows put the shared indent at 0, keeping the code block indented, because a continuation row starts wherever the wrap fell. A blank continuation row, a run of spaces as wide as the pane, read as a paragraph break and split Paragraph and Auto there. Judged by lines, the narrowed reply reads as the original does (`reads a reply the terminal narrowed` in `copy-text.test.ts`, 2026-10). The trade: a program's single long line that soft-wraps is judged as a pane wide enough to show it would judge it, as its paragraph's widest line and so wrapped, where by rows its short continuation row usually kept the break after it.

## 4.5 Placement and Dismissal
Expand Down
61 changes: 61 additions & 0 deletions lib/src/lib/copy-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ function reflowed(rows: readonly string[], cols: number): CopyBuffer {
const narrowed = reflowed(CLAUDE, 60);
/** A line whose wrap leaves a row of only blanks between its text. */
const blankWrap = lines(['intro', '', 'aaaa bbbb cccc dddd ', ' '.repeat(20), 'eeee ffff', '', 'outro'], { cols: 20, wrapped: [3, 4] });
/** Claude Code's rendering of Markdown quotes, hard-wrapped at 55 columns:
* rows 1 and 6 start with the space the full row before them broke at. */
const QUOTED = [
' This section lost its selling power. Previous version',
' sold the use case:',
'',
' ▎ "Run builds, agents, servers, and scripts side by',
' ▎ side. Minimize the ones you’re not watching to a',
' ▎ compact status indicator — every pane keeps running',
' ▎ and every alert still fires whether minimized or',
' ▎ not."',
' ▎',
' ▎ ▎ A quote of a quote, wrapped at the margin so that',
' ▎ ▎ it reaches the next row.',
'',
' Current version (lines 35–39) sells the mechanics.',
];

describe('copy-text formats', () => {
it.each(['', ' '])('keeps relative Auto indent when the first selected row starts mid-line (%j)', (prefix) => {
Expand Down Expand Up @@ -179,6 +196,50 @@ describe('copy-text formats', () => {
expect(text(buf, span(0, 0, 2, 16), 'auto')).toBe('- the first item wraps because it is long enough to reach past the margin of the pane\n- the second item');
});

it('rewraps a Claude Code quote inside its bars, written as Markdown’s >', () => {
const buf = lines(QUOTED, { cols: 58 });
const all = span(0, 2, QUOTED.length - 1, QUOTED[QUOTED.length - 1].length - 1);
expect(text(buf, all, 'auto')).toBe([
'This section lost its selling power. Previous version sold the use case:',
'',
'> "Run builds, agents, servers, and scripts side by side. Minimize the ones you’re not watching to a compact status indicator — every pane keeps running and every alert still fires whether minimized or not."',
'>',
'> > A quote of a quote, wrapped at the margin so that it reaches the next row.',
'',
'Current version (lines 35–39) sells the mechanics.',
].join('\n'));
// Every later quote prefix goes with the break before it.
expect(text(buf, span(3, 2, 7, 8), 'spaces')).toBe('> "Run builds, agents, servers, and scripts side by side. Minimize the ones you’re not watching to a compact status indicator — every pane keeps running and every alert still fires whether minimized or not."');
expect(text(buf, all, 'exact').split('\n')[3]).toBe(QUOTED[3]);
// A bar alone is a blank line inside the quote, so its paragraphs stay apart.
const paragraphs = lines([' ▎ First.', ' ▎', ' ▎ Second.'], { cols: 58 });
expect(text(paragraphs, span(0, 2, 2, 10), 'auto')).toBe('> First.\n>\n> Second.');
// An empty row ends a quote, whichever marker it uses.
for (const bar of ['▎', '>']) {
const quotes = lines([` ${bar} First.`, '', ` ${bar} Second.`], { cols: 58 });
expect(text(quotes, span(0, 2, 2, 10), 'auto')).toBe('> First.\n\n> Second.');
}
// A quoted paragraph starts at its prefix, which its later kept lines show.
expect(computeScopes(buf, span(4, 10, 4, 12)).find((s) => s.label === 'Paragraph')?.span).toEqual(span(3, 2, 7, 8));
});

it('rewraps Markdown quotes by depth, a lone > staying text', () => {
const buf = lines([
'> > The nested quote of an email is wrapped at the margin of its pane',
'> > by the mail client that sent it.',
'> The reply to it.',
'>',
'The reply.',
], { cols: 80 });
expect(text(buf, span(0, 0, 4, 9), 'auto')).toBe('> > The nested quote of an email is wrapped at the margin of its pane by the mail client that sent it.\n> The reply to it.\n>\nThe reply.');
});

it('reads an editor’s indent guides as indentation, not quotes', () => {
// Neovim's indent-blankline draws `▎` at each indent stop, here every four columns.
const buf = lines(['def area(width, height):', '▎ if width < 0:', '▎ ▎ raise ValueError(width)', '▎', '▎ return width * height'], { cols: 80 });
expect(text(buf, span(0, 0, 4, 24), 'auto')).toBe('def area(width, height):\n if width < 0:\n raise ValueError(width)\n\n return width * height');
});

it('reads a block slab exactly under Auto', () => {
const block = span(2, 2, 4, 20, true);
expect(text(claude, block, 'auto')).toBe(text(claude, block, 'exact'));
Expand Down
Loading
Loading