From 7a681a40f5976d300ef5e417602620cc240e24d3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 13:23:45 -0700 Subject: [PATCH 1/4] Copy editor: rewrap inside quotes, writing each level as Markdown's > Claude Code draws a Markdown quote with a quote bar (U+258E) on every row, nested quotes as one bar per level. The bar sits in Block Elements, so the copy editor read it as box-drawing decoration: a single level was stripped, pasting the quote as unquoted prose, and the inner bar of a nested quote was text that landed mid-sentence once Auto joined its rows. A hard-wrapped Markdown `>` quote had the same flaw from the other side: nothing marked the prefix, so joining its rows left `a > b` behind. A quote prefix is now its own fact of a logical line: one marker per level, `>` or a quote bar, before text. Auto keeps a break where depth changes and judges the rest of a quoted line as if unquoted; every format writes a bar as `>` and drops a prefix with the break deleted before it, so Spaces and per-break overrides follow the same rule. An empty bar line is a blank line inside the quote, not a frame; a lone `>` stays text (an empty prompt). Paragraph scope starts a quoted paragraph at its prefix. The issue's sample also kept two breaks for a second reason: a wrapper that keeps the space it broke at (wrap-ansi with trim: false) carries it onto the row after a full one, which then sits one column deeper than its hanging indent, and the row after that one column shallower. Auto now joins both. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mouse-and-clipboard.md | 4 +- docs/specs/mouse-and-clipboard.rationale.md | 2 + lib/src/lib/copy-text.test.ts | 47 ++++++++ lib/src/lib/copy-text.ts | 125 +++++++++++++++----- scripts/spec-word-budgets.json | 2 +- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 240c60b0e..a07b81f09 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -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 quote bar (`▏ ▎ ▍`) per level. **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`. diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index eeaa43a93..5463b3032 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -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. A lone `>` stays text because it 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 diff --git a/lib/src/lib/copy-text.test.ts b/lib/src/lib/copy-text.test.ts index 350481ab3..05d3251df 100644 --- a/lib/src/lib/copy-text.test.ts +++ b/lib/src/lib/copy-text.test.ts @@ -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) => { @@ -179,6 +196,36 @@ 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 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 a block slab exactly under Auto', () => { const block = span(2, 2, 4, 20, true); expect(text(claude, block, 'auto')).toBe(text(claude, block, 'exact')); diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts index 663d9ba06..5215448e9 100644 --- a/lib/src/lib/copy-text.ts +++ b/lib/src/lib/copy-text.ts @@ -121,19 +121,42 @@ const FRAME_ONLY = /^[─-▟\s]+$/; const LEAD_MARKER = /^(\s*)(⏺ |⎿ |● |[─-▟]+ ?)/; const TRAIL_BOX = / ?[─-▟]+$/; const LIST_MARKER = /^(?:[-*•]|\d+[.)]) /; +/** A quote's prefix, one marker per level: Markdown's `>` or a TUI's quote + * bar (Claude Code's `▎`), the last followed by text or the line's end. */ +const QUOTE = /^(\s*)((?:[>▏▎▍] ?)*[>▏▎▍])(?= +\S| *$)/; +const QUOTE_BAR = /^[▏▎▍]$/; function isFrameOnly(text: string): boolean { return text.length > 0 && FRAME_ONLY.test(text) && BOX.test(text); } -/** Where `text`'s decoration sits, in UTF-16 units: `[blankFrom, blankTo)` is a - * leading marker to blank to spaces, and nothing from `keepTo` on is kept. */ -function decoration(text: string): { blankFrom: number; blankTo: number; keepTo: number } { - const lead = LEAD_MARKER.exec(text); +interface Decoration { + /** `[blankFrom, blankTo)` is a leading marker to blank to spaces. */ + blankFrom: number; + blankTo: number; + /** `[from, to)` is a quote prefix, its bars read as `>`. */ + quote: { from: number; to: number } | null; + /** Nothing from here on is kept. */ + keepTo: number; +} + +/** Where `text`'s decoration sits, in UTF-16 units. */ +function decoration(text: string): Decoration { + const trail = TRAIL_BOX.exec(text); + const trailAt = trail ? trail.index : text.length; + const quoteAt = (at: number) => { + const q = QUOTE.exec(text.slice(at)); + if (!q) return null; + const to = at + q[0].length; + // A lone `>` is a prompt, not an empty quote line; a lone bar is one. + return q[2].includes('>') && !text.slice(to, trailAt).trim() ? null : { from: at + q[1].length, to }; + }; + let quote = quoteAt(0); + const lead = quote ? null : LEAD_MARKER.exec(text); const blankFrom = lead ? lead[1].length : 0; const blankTo = lead ? lead[0].length : 0; - const trail = TRAIL_BOX.exec(text); - return { blankFrom, blankTo, keepTo: Math.max(blankTo, trail ? trail.index : text.length) }; + if (lead) quote = quoteAt(blankTo); + return { blankFrom, blankTo, quote, keepTo: Math.max(quote?.to ?? blankTo, trailAt) }; } /** A logical line's facts every judgement reads, computed once per line: a @@ -143,9 +166,13 @@ interface LineFacts { top: number; bottom: number; /** The rows' text joined as a deleted soft wrap joins them, with its - * decoration blanked (spaces at the marker, so indents still line up with - * the columns under them) and trimmed. */ + * decoration and quote prefix blanked (spaces at the marker, so indents + * still line up with the columns under them) and trimmed. */ plain: string; + /** How many quotes it is nested in. */ + depth: number; + /** Where its quote prefix starts, or else its text. */ + start: number; /** Blank, a frame, or a box's side: what bounds a paragraph. */ boundary: boolean; } @@ -162,8 +189,17 @@ function line(buf: CopyBuffer, r: number): LineFacts { let text = ''; for (let i = top; i <= bottom; i++) text += buf.row(i).cells.join(''); const d = decoration(text); - const plain = (text.slice(0, d.blankFrom) + ' '.repeat(d.blankTo - d.blankFrom) + text.slice(d.blankTo, d.keepTo)).trimEnd(); - const f = { top, bottom, plain, boundary: !text.trim() || isFrameOnly(text) || /^[│┃║]/.test(text) }; + const blank = (s: string, from: number, to: number) => s.slice(0, from) + ' '.repeat(to - from) + s.slice(to); + let plain = blank(text.slice(0, d.keepTo), d.blankFrom, d.blankTo); + if (d.quote) plain = blank(plain, d.quote.from, d.quote.to); + plain = plain.trimEnd(); + const f = { + top, bottom, plain, + depth: d.quote ? text.slice(d.quote.from, d.quote.to).replace(/ /g, '').length : 0, + start: d.quote ? d.quote.from : indentOf(plain), + // A bar-only quote line is blank between its paragraphs. + boundary: !plain.trim() || isFrameOnly(text) || /^[│┃║]/.test(text), + }; for (let i = top; i <= bottom; i++) lineFacts.set(buf.row(i), f); return f; } @@ -221,12 +257,21 @@ function wrapWidth(buf: CopyBuffer, r: number): number { * 40 columns, or 60% of a narrower terminal (rationale). */ const minWrapWidth = (buf: CopyBuffer) => Math.min(40, Math.floor(buf.cols * 0.6)); +/** Whether the line above `l`, in its paragraph and quote, fills the width. */ +function fillsAbove(buf: CopyBuffer, l: LineFacts): boolean { + if (l.top === 0) return false; + const above = line(buf, l.top - 1); + return !above.boundary && above.depth === l.depth && above.plain.length >= wrapWidth(buf, l.top - 1); +} + export type BreakKind = 'keep' | 'space' | 'none'; /** Auto judges the break between rows `r` and `r + 1` in order: * 1. Delete a true soft wrap before inspecting the joined logical lines. - * 2. Keep blanks, a new list item, code punctuation (`; { }` / `) } ]`), or - * indentation that differs from the current line's hanging indent. + * 2. Keep blanks, a change of quote depth, a new list item, code punctuation + * (`; { }` / `) } ]`), or indentation that differs from the current line's + * hanging indent — but for the one column a wrapper carries the space it + * broke at onto the row after a full one. * 3. Keep a break when the local longest line is under `minWrapWidth`, or * when the next first word would have fit within that width. * 4. Delete a margin split whose last word is token-shaped; otherwise space. @@ -237,22 +282,30 @@ export type BreakKind = 'keep' | 'space' | 'none'; export function autoBreak(buf: CopyBuffer, r: number): BreakKind { if (r + 1 >= buf.length) return 'keep'; if (buf.row(r + 1).wrapped) return 'none'; - // The line row `r` ends and the line row `r + 1` starts. - const cur = line(buf, r).plain; - const next = line(buf, r + 1).plain; - if (!cur.trim() || !next.trim()) return 'keep'; + // The line row `r` ends and the line row `r + 1` starts, past any quote prefix. + const above = line(buf, r); + const below = line(buf, r + 1); + const cur = above.plain; + const next = below.plain; + if (!cur.trim() || !next.trim() || above.depth !== below.depth) return 'keep'; const nextTrim = next.trimStart(); if (LIST_MARKER.test(nextTrim)) return 'keep'; if (/[;{}]$/.test(cur) || /^[)}\]]/.test(nextTrim)) return 'keep'; - if (indentOf(next) !== hangIndent(cur)) return 'keep'; - const firstWord = nextTrim.split(' ')[0]; + const hang = hangIndent(cur); + const indent = indentOf(next); + if (Math.abs(indent - hang) > 1) return 'keep'; const width = wrapWidth(buf, r); + // A wrapper that keeps the space it broke at carries it onto the row after a + // full one: one column deeper than the line before, and the line after. + const carried = indent > hang && cur.length >= width; + if (indent !== hang && !carried && !(indent < hang && fillsAbove(buf, above))) return 'keep'; + const firstWord = nextTrim.split(' ')[0]; // A break the next word would have fit before was typed, not wrapped. if (width < minWrapWidth(buf) || cur.length + 1 + firstWord.length <= width) return 'keep'; const lastWord = cur.slice(cur.lastIndexOf(' ') + 1); const tokenish = /[/\\#?=&]/.test(lastWord) || /^[A-Za-z0-9+_\-.]{16,}$/.test(lastWord); // A token wider than the room left is split mid-token at the margin. - return cur.length >= width && tokenish ? 'none' : 'space'; + return cur.length >= width && tokenish && !carried ? 'none' : 'space'; } // --------------------------------------------------------------------------- @@ -292,6 +345,8 @@ interface Line { /** A row a soft wrap continues, whose start is wherever the wrap fell; * never in a block slab. */ continues: boolean; + /** Cells through its quote prefix, which a deleted break drops. */ + quote: number; } /** One logical line's rows in a scope: a row and the rows soft wraps join * to it. */ @@ -308,7 +363,7 @@ function extract(buf: CopyBuffer, scope: Span, original: Span): Line[][] { for (let c = a; c < Math.min(b, row.cells.length); c++) { if (row.cells[c] !== '') out.push({ ch: row.cells[c], added: !contains(original, r, c) }); } - const line = { ...splitTrail(out), row: r, startCol: a, continues: !scope.block && row.wrapped }; + const line = { ...splitTrail(out), row: r, startCol: a, continues: !scope.block && row.wrapped, quote: 0 }; if (line.continues && lines.length) lines[lines.length - 1].push(line); else lines.push([line]); } @@ -335,20 +390,25 @@ function cellsForChars(cells: readonly Cell[], chars: number): number { return n; } -/** A logical line's rows with its decoration blanked, judged on the rows - * joined as a deleted soft wrap joins them, as `line` judges; null for a - * frame-only line. */ +/** A logical line's rows with its decoration blanked and its quote bars read + * as Markdown's `>`, judged on the rows joined as a deleted soft wrap joins + * them, as `line` judges; null for a frame-only line. */ function stripDecoration(rows: LineRows): Line[] | null { // Every row but the last keeps the blanks the join puts back. const own = rows.map((l, k) => (k < rows.length - 1 ? [...l.cells, ...l.trail] : l.cells)); const joined = own.flat(); const text = joined.map((c) => c.ch).join(''); - if (isFrameOnly(text)) return null; const d = decoration(text); + if (!d.quote && isFrameOnly(text)) return null; const from = cellsForChars(joined, d.blankFrom); const to = cellsForChars(joined, d.blankTo); + const qFrom = d.quote ? cellsForChars(joined, d.quote.from) : 0; + const qTo = d.quote ? cellsForChars(joined, d.quote.to) : 0; const keepTo = cellsForChars(joined, d.keepTo); - let kept = joined.slice(0, keepTo).map((c, j) => (j >= from && j < to ? { ...c, ch: ' ' } : c)); + let kept = joined.slice(0, keepTo).map((c, j) => { + if (j >= from && j < to) return { ...c, ch: ' ' }; + return j >= qFrom && j < qTo && QUOTE_BAR.test(c.ch) ? { ...c, ch: '>' } : c; + }); const cut = keepTo < joined.length; // A cut ends the line at its last kept text, and drops the rows past it. if (cut) kept = splitTrail(kept).cells; @@ -359,7 +419,8 @@ function stripDecoration(rows: LineRows): Line[] | null { at += own[k].length; if (cut && k > 0 && first >= kept.length) break; // A row the decoration misses stays as read. - out.push(at <= kept.length && (first >= to || at <= from) ? l : { ...l, ...splitTrail(kept.slice(first, at)) }); + const touched = at > kept.length || (first < to && at > from) || (first < qTo && at > qFrom); + out.push(touched ? { ...l, ...splitTrail(kept.slice(first, at)), quote: k ? 0 : Math.min(qTo, at) } : l); } return out; } @@ -400,9 +461,9 @@ function layout(scopeLines: ScopeLines, format: CopyFormat): Layout { function computeLayout({ buf, raw, stripped }: ScopeLines, format: CopyFormat): Layout { const logical = format === 'exact' ? raw : stripped; - // A blank line is a logical line with no text in the scope: a blank row a - // soft wrap joins to text is part of that text's line. - const blanks = new Set(logical.filter((rows) => rows.every((l) => l.cells.length === 0)).flat()); + // A blank line is a logical line with no text in the scope past its quote + // prefix: a blank row a soft wrap joins to text is part of that text's line. + const blanks = new Set(logical.filter((rows) => rows.every((l) => l.cells.length <= l.quote)).flat()); const blank = (l: Line) => blanks.has(l); let lines = logical.flat(); if (format !== 'exact') { @@ -438,7 +499,8 @@ export function renderLines(scopeLines: ScopeLines, chosen: CopyFormat, override const next = lines[i + 1]; const kind = overrides[i] ?? auto[i]; const rejoins = kind === 'none' && !!next?.continues; - const lead = leading(line.cells); + // A joined line sheds its quote prefix with its indent. + const lead = joined && line.quote ? line.quote + leading(line.cells.slice(line.quote)) : leading(line.cells); let strip: number; if (rejoined) strip = 0; else if (format === 'exact') strip = joined ? lead : 0; @@ -555,7 +617,8 @@ function toParagraph(buf: CopyBuffer, sel: Span): Span { let er = sel.end.row; if (!line(buf, er).boundary) while (er + 1 < buf.length && !line(buf, er + 1).boundary) er++; return { - start: { row: sr, col: contentStart(buf, sr) }, + // A quoted paragraph starts at its prefix, which its later kept lines show. + start: { row: sr, col: buf.row(sr).wrapped ? 0 : line(buf, sr).start }, end: { row: er, col: Math.max(0, buf.row(er).cells.length - 1) }, block: false, }; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index adf6b82b5..cb592500f 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,7 +15,7 @@ "docs/specs/hosted.md": 6500, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, - "docs/specs/mouse-and-clipboard.md": 4200, + "docs/specs/mouse-and-clipboard.md": 4250, "docs/specs/one-time.md": 2650, "docs/specs/pocket-app.md": 3650, "docs/specs/pricing.md": 2750, From 16e10140d43482ad102497b1e08fb107ec231744 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 16:10:21 -0700 Subject: [PATCH 2/4] Copy editor: read an editor's indent guides as indentation, not quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neovim's indent-blankline draws `▎`, the bar Claude Code quotes with, at every indent stop and on blank lines inside a block. Any bar before text was a quote, so a guided code line pasted as `> > print(x)` with its indent gone, and a guide alone on a blank line became an empty `>` line. The two differ in spacing: Claude Code writes its bar one space from its text (two where a wrap carried a space), and a guide at a shiftwidth of 3 or more sits further. A quote bar is now at most two spaces from text that is no other marker, and a prefix needs text after it, so a lone bar is decoration again. A leading run of wider guides is now blanked whole, where before only the first was, leaving the inner guides of nested code in the paste. At shiftwidth 2 guides still read as quote bars; the rationale records the trade. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mouse-and-clipboard.md | 2 +- docs/specs/mouse-and-clipboard.rationale.md | 2 +- lib/src/lib/copy-text.test.ts | 7 ++++++- lib/src/lib/copy-text.ts | 20 ++++++++++---------- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index a07b81f09..36512b738 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -129,7 +129,7 @@ Mouse-up over a terminal-handled drag opens the **copy editor**, as does the cop | **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 (`⏺`, `⎿`, `●`). A **quote prefix** is a `>` or quote bar (`▏ ▎ ▍`) per level. **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). +**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. **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`. diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 5463b3032..457e0ad9f 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -20,7 +20,7 @@ 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. A lone `>` stays text because it is more often an empty prompt (Claude Code's input box, a shell's continuation) than an empty quote line. +**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, and a guide alone on a line is indistinguishable from an empty quote line, so a prefix needs text after it. 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. diff --git a/lib/src/lib/copy-text.test.ts b/lib/src/lib/copy-text.test.ts index 05d3251df..b2532768b 100644 --- a/lib/src/lib/copy-text.test.ts +++ b/lib/src/lib/copy-text.test.ts @@ -203,7 +203,6 @@ describe('copy-text formats', () => { '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.', @@ -226,6 +225,12 @@ describe('copy-text formats', () => { 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 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')); diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts index 5215448e9..575b6d72c 100644 --- a/lib/src/lib/copy-text.ts +++ b/lib/src/lib/copy-text.ts @@ -117,13 +117,14 @@ const isBlankCell = (ch: string | undefined) => ch === undefined || ch === ' '; const BOX = /[─-▟]/; const FRAME_ONLY = /^[─-▟\s]+$/; /** A TUI's line-leading glyph that is decoration, not text: Claude Code's reply - * and tool bullets, or a run of box drawing. */ -const LEAD_MARKER = /^(\s*)(⏺ |⎿ |● |[─-▟]+ ?)/; + * and tool bullets, an editor's indent guides, or a run of box drawing. */ +const LEAD_MARKER = /^(\s*)(⏺ |⎿ |● |(?:[▏▎▍] {2,})+|[─-▟]+ ?)/; const TRAIL_BOX = / ?[─-▟]+$/; const LIST_MARKER = /^(?:[-*•]|\d+[.)]) /; -/** A quote's prefix, one marker per level: Markdown's `>` or a TUI's quote - * bar (Claude Code's `▎`), the last followed by text or the line's end. */ -const QUOTE = /^(\s*)((?:[>▏▎▍] ?)*[>▏▎▍])(?= +\S| *$)/; +/** A quote's prefix, one marker per level and text after: Markdown's `>`, or a + * TUI's quote bar (Claude Code's `▎`) one or two spaces before its text — + * wider, it is an editor's indent guide, one per indent stop (rationale). */ +const QUOTE = /^(\s*)((?:[>▏▎▍] ?)*[>▏▎▍])(?=(?<=>) +[^\s>▏▎▍]| {1,2}[^\s>▏▎▍])/; const QUOTE_BAR = /^[▏▎▍]$/; function isFrameOnly(text: string): boolean { @@ -148,8 +149,8 @@ function decoration(text: string): Decoration { const q = QUOTE.exec(text.slice(at)); if (!q) return null; const to = at + q[0].length; - // A lone `>` is a prompt, not an empty quote line; a lone bar is one. - return q[2].includes('>') && !text.slice(to, trailAt).trim() ? null : { from: at + q[1].length, to }; + // Text a trailing box side ends: Claude Code's empty `│ > │` prompt is no quote. + return text.slice(to, trailAt).trim() ? { from: at + q[1].length, to } : null; }; let quote = quoteAt(0); const lead = quote ? null : LEAD_MARKER.exec(text); @@ -197,8 +198,7 @@ function line(buf: CopyBuffer, r: number): LineFacts { top, bottom, plain, depth: d.quote ? text.slice(d.quote.from, d.quote.to).replace(/ /g, '').length : 0, start: d.quote ? d.quote.from : indentOf(plain), - // A bar-only quote line is blank between its paragraphs. - boundary: !plain.trim() || isFrameOnly(text) || /^[│┃║]/.test(text), + boundary: !text.trim() || isFrameOnly(text) || /^[│┃║]/.test(text), }; for (let i = top; i <= bottom; i++) lineFacts.set(buf.row(i), f); return f; @@ -398,8 +398,8 @@ function stripDecoration(rows: LineRows): Line[] | null { const own = rows.map((l, k) => (k < rows.length - 1 ? [...l.cells, ...l.trail] : l.cells)); const joined = own.flat(); const text = joined.map((c) => c.ch).join(''); + if (isFrameOnly(text)) return null; const d = decoration(text); - if (!d.quote && isFrameOnly(text)) return null; const from = cellsForChars(joined, d.blankFrom); const to = cellsForChars(joined, d.blankTo); const qFrom = d.quote ? cellsForChars(joined, d.quote.from) : 0; From 253a447293783e74950660a2bf1c34c1b5bb39e4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 16:43:37 -0700 Subject: [PATCH 3/4] Copy editor: keep a bar-only line as a blank line, inside a quote as > Round 1 made a line of bars alone frame-only, so it was dropped: a Claude Code quote of several paragraphs pasted as one Markdown paragraph, and code under Neovim's indent guides lost its blank lines. Such a line is either a quote's paragraph break or a guide on a blank code line; its neighbors tell which. It is now blank (and a paragraph boundary), and between two quoted lines it is written with the shallower one's prefix, so the quote goes on past it. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mouse-and-clipboard.md | 2 +- docs/specs/mouse-and-clipboard.rationale.md | 2 +- lib/src/lib/copy-text.test.ts | 6 +++++- lib/src/lib/copy-text.ts | 17 +++++++++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 36512b738..9cc228930 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -129,7 +129,7 @@ Mouse-up over a terminal-handled drag opens the **copy editor**, as does the cop | **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 (`⏺`, `⎿`, `●`). A **quote prefix** is a `>`, or a quote bar (`▏ ▎ ▍`) at most two spaces from its text, per level, with text after it. **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). +**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`. diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 457e0ad9f..fcdc54080 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -20,7 +20,7 @@ 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, and a guide alone on a line is indistinguishable from an empty quote line, so a prefix needs text after it. 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 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. diff --git a/lib/src/lib/copy-text.test.ts b/lib/src/lib/copy-text.test.ts index b2532768b..0a4655434 100644 --- a/lib/src/lib/copy-text.test.ts +++ b/lib/src/lib/copy-text.test.ts @@ -203,6 +203,7 @@ describe('copy-text formats', () => { '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.', @@ -210,6 +211,9 @@ describe('copy-text formats', () => { // 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.'); // 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)); }); @@ -228,7 +232,7 @@ describe('copy-text formats', () => { 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 return width * height'); + 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', () => { diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts index 575b6d72c..8ff0c274e 100644 --- a/lib/src/lib/copy-text.ts +++ b/lib/src/lib/copy-text.ts @@ -127,8 +127,11 @@ const LIST_MARKER = /^(?:[-*•]|\d+[.)]) /; const QUOTE = /^(\s*)((?:[>▏▎▍] ?)*[>▏▎▍])(?=(?<=>) +[^\s>▏▎▍]| {1,2}[^\s>▏▎▍])/; const QUOTE_BAR = /^[▏▎▍]$/; +/** A line of quote bars or indent guides alone: blank, not a frame. */ +const BARS_ONLY = /^[\s▏▎▍]+$/; + function isFrameOnly(text: string): boolean { - return text.length > 0 && FRAME_ONLY.test(text) && BOX.test(text); + return text.length > 0 && FRAME_ONLY.test(text) && BOX.test(text) && !BARS_ONLY.test(text); } interface Decoration { @@ -198,7 +201,7 @@ function line(buf: CopyBuffer, r: number): LineFacts { top, bottom, plain, depth: d.quote ? text.slice(d.quote.from, d.quote.to).replace(/ /g, '').length : 0, start: d.quote ? d.quote.from : indentOf(plain), - boundary: !text.trim() || isFrameOnly(text) || /^[│┃║]/.test(text), + boundary: !text.trim() || BARS_ONLY.test(text) || isFrameOnly(text) || /^[│┃║]/.test(text), }; for (let i = top; i <= bottom; i++) lineFacts.set(buf.row(i), f); return f; @@ -483,9 +486,19 @@ function computeLayout({ buf, raw, stripped }: ScopeLines, format: CopyFormat): const end = next.continues ? line.row : buf.row(line.row).bottom; return FIXED_BREAK[format] ?? (blank(line) || blank(next) || next.row !== end + 1 ? 'keep' : autoBreak(buf, end)); }); + lines = lines.map((l, i) => (blank(l) && i > 0 && i + 1 < lines.length ? quoteBlank(buf, lines[i - 1], l, lines[i + 1]) : l)); return { lines, base: indents.length ? Math.min(...indents) : 0, auto }; } +/** A blank line between quoted lines, written with the shallower one's + * prefix, so the quote goes on past it. */ +function quoteBlank(buf: CopyBuffer, before: Line, blank: Line, after: Line): Line { + let depth = Math.min(line(buf, before.row).depth, line(buf, after.row).depth); + let end = 0; + while (depth && end < after.quote) if (after.cells[end++].ch !== ' ') depth--; + return end ? { ...blank, cells: after.cells.slice(0, end) } : blank; +} + export function renderLines(scopeLines: ScopeLines, chosen: CopyFormat, overrides: Readonly> = {}): Rendering { // Never rewrap a block slab (spec §4.1). const format = scopeLines.block && chosen === 'auto' ? 'exact' : chosen; From 4686a1cf9776e34c1945198451a50651f201b8bc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 16:52:55 -0700 Subject: [PATCH 4/4] Copy editor: let an empty row still end a quote Round 2 wrote the shallower quote prefix onto every blank line between two quoted lines, but a truly empty row is blank too, so two quotes an empty row separates (a Markdown file or email shown with `cat`, or two Claude Code quotes) pasted as one. Only a row of bars alone is now written as a quote line, as the spec already says. Co-Authored-By: Claude Opus 5.5 --- lib/src/lib/copy-text.test.ts | 5 +++++ lib/src/lib/copy-text.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/copy-text.test.ts b/lib/src/lib/copy-text.test.ts index 0a4655434..450093e0a 100644 --- a/lib/src/lib/copy-text.test.ts +++ b/lib/src/lib/copy-text.test.ts @@ -214,6 +214,11 @@ describe('copy-text formats', () => { // 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)); }); diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts index 8ff0c274e..51d2efd06 100644 --- a/lib/src/lib/copy-text.ts +++ b/lib/src/lib/copy-text.ts @@ -490,9 +490,10 @@ function computeLayout({ buf, raw, stripped }: ScopeLines, format: CopyFormat): return { lines, base: indents.length ? Math.min(...indents) : 0, auto }; } -/** A blank line between quoted lines, written with the shallower one's - * prefix, so the quote goes on past it. */ +/** A line of bars alone between quoted lines, written with the shallower + * one's prefix, so the quote goes on past it. An empty row still ends it. */ function quoteBlank(buf: CopyBuffer, before: Line, blank: Line, after: Line): Line { + if (!BARS_ONLY.test(buf.row(blank.row).cells.join(''))) return blank; let depth = Math.min(line(buf, before.row).depth, line(buf, after.row).depth); let end = 0; while (depth && end < after.quote) if (after.cells[end++].ch !== ' ') depth--;