All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH b4 0/3] Improve email quote trimming
@ 2026-08-25 18:35 Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 1/3] review: trim trailing quoted when adding a tag Jason Gunthorpe
                   ` (2 more replies)
  0 siblings, 3 replies; 7+ messages in thread
From: Jason Gunthorpe @ 2026-08-25 18:35 UTC (permalink / raw)
  To: tools

Three patches to make the automatic trimming more versatile and work in
more cases.

I would, broadly, like to edit the messages without editing or removing
any of the existing quoting. This is because I tend to make a few passes
at things and once quoted text is deleted it is a huge PITA to bring it
back.

Introduce a >8< marker which tells b4 to cut the quoting and then b4 can
reassemble the message similarly to if the editor macros had been used.

Along the way fix a few corner cases in the email construction that
resulted in sub optimal email generation.

It took a while to get this generating emails the way I think it should,
I've included several of the cases that didn't obviously work as permanent
tests.

I've opted to leave the editor changes alone, but I think it would be much
better if it could editor fold the text the cut would select instead of
deleting it. That's far beyond me though :)

Jason Gunthorpe (3):
  review: trim trailing quoted when adding a tag
  review: discard blank lines between | and > quotes when trimming
  review: add >8< inline marker to snip quoted reply context

 docs/maintainer/review.rst       |  24 +++
 docs/releases.rst                |   9 +-
 src/b4/review/_review.py         | 144 +++++++++++++--
 src/b4/review_tui/_review_app.py |  10 +-
 src/tests/test_review.py         | 296 +++++++++++++++++++++++++++++++
 src/tests/test_tui_review.py     |  69 +++++++
 6 files changed, 532 insertions(+), 20 deletions(-)


base-commit: 0c2c3c276e0447e37f2e90da60505ec6d765ded9
-- 
2.43.0


^ permalink raw reply	[flat|nested] 7+ messages in thread

* [PATCH b4 1/3] review: trim trailing quoted when adding a tag
  2026-08-25 18:35 [PATCH b4 0/3] Improve email quote trimming Jason Gunthorpe
@ 2026-08-25 18:35 ` Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 2/3] review: discard blank lines between | and > quotes when trimming Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context Jason Gunthorpe
  2 siblings, 0 replies; 7+ messages in thread
From: Jason Gunthorpe @ 2026-08-25 18:35 UTC (permalink / raw)
  To: tools

If some comments are added and then a review tag is placed the trailing
quote that is normally trimmed is instead retained with the trailer
landing at the end of the message. Instead place it right after the
last user comment.

Assisted-by: LLM
Signed-off-by: Jason Gunthorpe <jgg@nvidia.com>
---
 src/b4/review/_review.py | 17 ++++++++++++++---
 src/tests/test_review.py | 13 +++++++++++++
 2 files changed, 27 insertions(+), 3 deletions(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 7fe746bb5441c6..c303f9a1c34f8a 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -3283,13 +3283,24 @@ def _insert_trailer_in_reply(reply_text: str, trailer: str) -> str:
     """Append *trailer* as its own bare line in the reply buffer.
 
     The line is added at the end of the maintainer's content, before any
-    trailing ``-- `` signature, grouped with any trailers already there.  The
-    maintainer can move it afterwards — placement is just a sensible default.
+    trailing quoted context or ``-- `` signature, grouped with any trailers
+    already there.  Keeping a trailing quote below the trailer lets the
+    ordinary send-time trimming discard it.  The maintainer can move the
+    trailer afterwards — placement is just a sensible default.
     """
     lines = _normalize_line_endings(reply_text).split('\n')
     sig_idx = next((i for i, ln in enumerate(lines) if ln == '-- '), len(lines))
     head = lines[:sig_idx]
     tail = lines[sig_idx:]
+    quoted_tail: List[str] = []
+    quote_start = len(head)
+    while quote_start and (
+        head[quote_start - 1].startswith('>') or not head[quote_start - 1].strip()
+    ):
+        quote_start -= 1
+    if any(line.startswith('>') for line in head[quote_start:]):
+        quoted_tail = head[quote_start:]
+        head = head[:quote_start]
     while head and not head[-1].strip():
         head.pop()
     # Separate from non-trailer content with a blank line so the trailers
@@ -3297,7 +3308,7 @@ def _insert_trailer_in_reply(reply_text: str, trailer: str) -> str:
     if head and not _BARE_TRAILER_RE.match(head[-1]):
         head.append('')
     head.append(trailer)
-    return '\n'.join(head + tail)
+    return '\n'.join(head + quoted_tail + tail)
 
 
 def _remove_trailer_from_reply(reply_text: str, name: str) -> str:
diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index 6bc2aa3edad9f2..325edccedfb784 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -856,6 +856,19 @@ class TestReplyTrailerEditing:
         # No blank line inserted between the two trailers.
         assert 'Reviewed-by: Me <me@x.com>\nTested-by: Me <me@x.com>' in out
 
+    def test_insert_before_trailing_quote(self) -> None:
+        buf = 'Please fix this.\n\n> context left below my message\n> +old code\n'
+        trailer = 'Reviewed-by: Me <me@x.com>'
+        out = review._insert_trailer_in_reply(buf, trailer)
+        assert out == (
+            'Please fix this.\n\n'
+            'Reviewed-by: Me <me@x.com>\n'
+            '\n'
+            '> context left below my message\n'
+            '> +old code\n'
+        )
+        assert review._trim_quoted_reply(out) == 'Please fix this.\n\n' + trailer
+
     def test_remove_drops_matching_bare_line(self) -> None:
         buf = 'Thanks!\n\nReviewed-by: Me <me@x.com>\nAcked-by: Me <me@x.com>'
         out = review._remove_trailer_from_reply(buf, 'reviewed-by')
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 7+ messages in thread

* [PATCH b4 2/3] review: discard blank lines between | and > quotes when trimming
  2026-08-25 18:35 [PATCH b4 0/3] Improve email quote trimming Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 1/3] review: trim trailing quoted when adding a tag Jason Gunthorpe
@ 2026-08-25 18:35 ` Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context Jason Gunthorpe
  2 siblings, 0 replies; 7+ messages in thread
From: Jason Gunthorpe @ 2026-08-25 18:35 UTC (permalink / raw)
  To: tools

When b4 constructs the email to respond to it inserts the | quoted (eg
sashiko) text in the > flow with blank lines between sections. When it
generates the final email it strips the | quoted text but retains the
blank lines it added. Thus the email ends up with random blank lines
inside the quoted text blocks.

Remove the blank lines along with the | blocks, being careful not to
disturb user test.

Assisted-by: LLM
Signed-off-by: Jason Gunthorpe <jgg@nvidia.com>
---
 src/b4/review/_review.py | 51 +++++++++++++++++++++++++++++++++++++++-
 src/tests/test_review.py | 26 ++++++++++++++++++++
 2 files changed, 76 insertions(+), 1 deletion(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index c303f9a1c34f8a..5a03e1667dc1b2 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -3368,6 +3368,54 @@ def _sync_reply_trailers(reply_text: str, selected: List[str], identity: str) ->
     return reply_text
 
 
+def _strip_external_reviewer_lines(lines: List[str]) -> List[str]:
+    """Remove read-only external-review blocks without leaving quote gaps.
+
+    Rendering puts bare blank separators around ``|``-prefixed external
+    comments.  When both sides of a separator are quoted diff, it belongs to
+    that presentation block and must leave with the comment.  A separator
+    after maintainer text is retained exactly, since it is part of their reply
+    layout rather than the external review's quote padding.
+    """
+    result: List[str] = []
+    index = 0
+    while index < len(lines):
+        if not lines[index].startswith('|'):
+            result.append(lines[index])
+            index += 1
+            continue
+
+        # A leading separator belongs to an external block only when it
+        # separates that block from quoted diff.  Do not alter the same blank
+        # after the maintainer's own text.
+        previous = len(result) - 1
+        while previous >= 0 and not result[previous].strip():
+            previous -= 1
+        if previous >= 0 and result[previous].startswith('>'):
+            del result[previous + 1 :]
+
+        # Treat adjacent external blocks as one block.  Their intervening
+        # blanks are presentation padding too.
+        while index < len(lines) and lines[index].startswith('|'):
+            index += 1
+            blank_end = index
+            while blank_end < len(lines) and not lines[blank_end].strip():
+                blank_end += 1
+            if blank_end < len(lines) and lines[blank_end].startswith('|'):
+                index = blank_end
+                continue
+            if blank_end < len(lines) and lines[blank_end].startswith('>'):
+                # The following quoted context is already separated by its
+                # quote prefix; do not leave the external block's gap behind.
+                index = blank_end
+            else:
+                result.extend(lines[index:blank_end])
+                index = blank_end
+            break
+
+    return result
+
+
 def _trim_quoted_reply(buffer: str) -> str:
     """Prepare a hand-edited reply buffer for sending.
 
@@ -3379,7 +3427,8 @@ def _trim_quoted_reply(buffer: str) -> str:
     maintainer left in place anywhere above their final comment is kept
     exactly as written; nothing is collapsed, reordered, or relocated.
     """
-    lines = [line for line in buffer.splitlines() if not line.startswith(('#', '|'))]
+    lines = [line for line in buffer.splitlines() if not line.startswith('#')]
+    lines = _strip_external_reviewer_lines(lines)
     # Drop the trailing quoted/blank run below the maintainer's last comment.
     end = len(lines)
     while end > 0 and (lines[end - 1].startswith('>') or not lines[end - 1].strip()):
diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index 325edccedfb784..d8a10709386c85 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -808,6 +808,32 @@ class TestTrimQuotedReply:
     def test_empty_buffer(self) -> None:
         assert review._trim_quoted_reply('') == ''
 
+    def test_removes_external_gaps_inside_quoted_diff(self) -> None:
+        buffer = (
+            '> first quoted line\n'
+            '\n'
+            '| sashiko.dev <sashiko@sashiko.dev>:\n'
+            '|\n'
+            '| An external finding.\n'
+            '|\n'
+            '| via: https://sashiko.dev/#/message/example\n'
+            '\n'
+            '> second quoted line\n'
+            '\n'
+            '| another reviewer <reviewer@example.com>:\n'
+            '|\n'
+            '| Another external finding.\n'
+            '\n'
+            '> third quoted line\n'
+            'My maintainer comment.\n'
+        )
+        assert review._trim_quoted_reply(buffer) == (
+            '> first quoted line\n'
+            '> second quoted line\n'
+            '> third quoted line\n'
+            'My maintainer comment.'
+        )
+
 
 class TestParseReplyTrailers:
     """Tests for _parse_reply_trailers() — derived trailer display index."""
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 7+ messages in thread

* [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context
  2026-08-25 18:35 [PATCH b4 0/3] Improve email quote trimming Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 1/3] review: trim trailing quoted when adding a tag Jason Gunthorpe
  2026-08-25 18:35 ` [PATCH b4 2/3] review: discard blank lines between | and > quotes when trimming Jason Gunthorpe
@ 2026-08-25 18:35 ` Jason Gunthorpe
  2026-08-25 21:25   ` Konstantin Ryabitsev
  2 siblings, 1 reply; 7+ messages in thread
From: Jason Gunthorpe @ 2026-08-25 18:35 UTC (permalink / raw)
  To: tools

When adding review comments to a message it is very inconvenient to
permanently loose the quotable text. Often I will make several passes over
a series and the nice b4 behavior of keeping everything in draft
encourages coming back and making revisions and further comments.

However, if the quoted text has been erased that isn't possible.

Instead of erasing in the editor provide a full line marker '>8<' for b4
to automagically do the trimming similar to how it trims unused quote at
the end. Trailing text after user lines and till the marker is removed
similar to the trailing text.

This way the message can be sliced and minimally quoted appropriately with
no loss of quoted text. Everything can be selectively undone by the user.

The string is selected because it is similar to 'scissors' in git commit.

Assisted-by: LLM
Signed-off-by: Jason Gunthorpe <jgg@nvidia.com>
---
 docs/maintainer/review.rst       |  24 +++
 docs/releases.rst                |   9 +-
 src/b4/review/_review.py         |  76 +++++++--
 src/b4/review_tui/_review_app.py |  10 +-
 src/tests/test_review.py         | 257 +++++++++++++++++++++++++++++++
 src/tests/test_tui_review.py     |  69 +++++++++
 6 files changed, 429 insertions(+), 16 deletions(-)

diff --git a/docs/maintainer/review.rst b/docs/maintainer/review.rst
index ca0632696b1b0b..985b699b3a8a7b 100644
--- a/docs/maintainer/review.rst
+++ b/docs/maintainer/review.rst
@@ -584,6 +584,30 @@ cyan, external ``|`` comments visually bracketed, and your own comments
 in the default foreground. Spell checking is limited to your own
 comment lines.
 
+**Trimming quoted context (any editor)**
+
+No editor plugin is required to prune a run of quoted context. Put the
+following marker alone on a line after the quoted lines you want to discard::
+
+    >8<
+
+When the reply is sent, b4 removes the contiguous run of quoted lines above
+the marker, stopping at your last unquoted note, and replaces it with the
+same breadcrumb used by the Vim and Emacs trimming commands::
+
+    > [ ... 42 lines skipped ... ]
+
+Quoted context below the marker is left untouched. Only ``>``-quoted lines
+contribute to the count, so your own notes are always boundaries and can
+never be discarded. If you type the marker while the cursor is still in the
+quoted flow, ``> >8<`` is accepted as an equivalent spelling.
+
+Hand-typed markers and the Vim/Emacs commands produce identical output and
+coalesce with each other. If a plugin-generated breadcrumb is immediately
+above ``>8<``, its recorded count is absorbed into the new breadcrumb instead
+of leaving two markers. This works in both the main review reply editor and
+the follow-up quick-reply editor.
+
 *Vim*
 
 Copy or symlink the files into your Vim configuration::
diff --git a/docs/releases.rst b/docs/releases.rst
index 4bff1b05645728..13ff732fb3683f 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -40,6 +40,14 @@ the full reference.
 Several notable improvements to the ``b4 review`` workflow shipped this
 cycle, building on the v0.15 foundation.
 
+**Inline snip markers in review replies**
+
+In any editor, put ``>8<`` alone on a line to discard the run of quoted
+context above it back to your last note. B4 replaces the run with the same
+``> [ ... NN lines skipped ... ]`` breadcrumb produced by the Vim and Emacs
+review helpers. The marker also works in follow-up quick replies, and existing
+breadcrumbs coalesce into its skipped-line count.
+
 **Manual revision linking**
 
 When automatic revision discovery fails — for example, a submitter sent
@@ -628,4 +636,3 @@ reviewing patches, testing, and contributing code:
 - Rob Herring
 - Tamir Duberstein
 - Toke Høiland-Jørgensen
-
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 5a03e1667dc1b2..06e48dbd704dbb 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -2028,6 +2028,9 @@ def _render_quoted_diff_with_comments(
         '# trailer (Reviewed-by:, etc.) you place inline. The only change made',
         '# on send is to drop quoted diff left below your last comment; quoted',
         '# context you keep above a comment is sent as-is. Trim freely.',
+        '# To discard a run of quoted context without deleting it by hand, put',
+        '# ">8<" alone on a line -- everything quoted above it back to your last',
+        '# note is dropped when you send.',
         '#',
     ]
     current_a_file = ''
@@ -2038,7 +2041,7 @@ def _render_quoted_diff_with_comments(
 
     def _insert(key: Tuple[str, int]) -> None:
         for text, attr, prov in comment_map.pop(key, []):
-            if result:
+            if result and result[-1]:
                 result.append('')
             if attr:
                 # External reviewer comment — render with | prefix
@@ -2105,10 +2108,10 @@ def _extract_editor_comments(
 ) -> List[Dict[str, Any]]:
     """Extract comments from the quoted-diff editor format.
 
-    Strips instruction lines (``#`` prefix) and external reviewer
-    comments (``|`` prefix), then delegates to
-    :func:`_extract_comments_from_quoted_reply` which handles the
-    ``> ``-quoted diff with unquoted comment format.
+    Strips instruction lines (``#`` prefix), resolves snip markers, and then
+    removes external reviewer comments (``|`` prefix) before delegating to
+    :func:`_extract_comments_from_quoted_reply`, which handles the ``> ``-
+    quoted diff with unquoted comment format.
 
     When *diff_text* is provided, runs :func:`_resolve_comment_positions`
     to correct diff comment positions when the user has trimmed quoted
@@ -2121,10 +2124,9 @@ def _extract_editor_comments(
         # Strip editor instruction lines
         if line.startswith('#'):
             continue
-        # Strip external reviewer comment blocks (| prefix)
-        if line.startswith('|'):
-            continue
         filtered.append(line)
+    filtered = _apply_snip_markers(filtered)
+    filtered = _strip_external_reviewer_lines(filtered)
     comments = _extract_comments_from_quoted_reply(
         '\n'.join(filtered), capture_preamble=True
     )
@@ -3271,6 +3273,8 @@ def _parse_reply_trailers(buffer: str) -> List[str]:
     return [lt.as_string() for lt in found]
 
 
+_SNIP_MARKER_RE = re.compile(r'^\s*(?:> )?>8<\s*$')
+_SKIP_BREADCRUMB_RE = re.compile(r'^> \[ \.\.\. (\d+) lines skipped \.\.\. \]$')
 _BARE_TRAILER_RE = re.compile(r'^\s*([\w-]+):\s')
 
 # The trailer names the TUI trailer menu offers.  The menu (and
@@ -3416,18 +3420,64 @@ def _strip_external_reviewer_lines(lines: List[str]) -> List[str]:
     return result
 
 
+def _apply_snip_markers(lines: List[str]) -> List[str]:
+    """Resolve standalone ``>8<`` directives in a filtered reply buffer.
+
+    Each marker replaces the contiguous quoted/external-review/blank run
+    immediately above it with the same breadcrumb emitted by the Vim and
+    Emacs review helpers.  The external-review blocks are snippable so their
+    surrounding separators can be removed without touching maintainer
+    spacing.  Unquoted lines are hard boundaries, so maintainer comments can
+    never be consumed.  Existing breadcrumbs contribute their recorded count,
+    allowing editor-generated and hand-typed trims to coalesce.
+    """
+    result: List[str] = []
+    for line in lines:
+        if not _SNIP_MARKER_RE.fullmatch(line):
+            result.append(line)
+            continue
+
+        boundary = len(result) - 1
+        while boundary >= 0 and (
+            not result[boundary].strip() or result[boundary].startswith(('>', '|'))
+        ):
+            boundary -= 1
+
+        snipped = result[boundary + 1 :]
+        skipped = 0
+        for quoted in snipped:
+            if not quoted.startswith('>'):
+                continue
+            breadcrumb = _SKIP_BREADCRUMB_RE.fullmatch(quoted)
+            skipped += int(breadcrumb.group(1)) if breadcrumb else 1
+
+        if skipped:
+            del result[boundary + 1 :]
+            first_snippable = next(
+                index
+                for index, snipped_line in enumerate(snipped)
+                if snipped_line.startswith(('>', '|'))
+            )
+            result.extend(snipped[:first_snippable])
+            result.append(f'> [ ... {skipped} lines skipped ... ]')
+
+    return result
+
+
 def _trim_quoted_reply(buffer: str) -> str:
     """Prepare a hand-edited reply buffer for sending.
 
     The maintainer's text is sent as written.  The only changes are to strip
     b4's own scaffolding — the ``#`` instruction header and ``| `` read-only
-    external-reviewer lines — and to drop the run of quoted diff at the very
-    bottom of the message that has no comment after it, the usual courtesy of
-    trimming quoted material below your last reply.  Quoted context the
-    maintainer left in place anywhere above their final comment is kept
-    exactly as written; nothing is collapsed, reordered, or relocated.
+    external-reviewer lines — resolve any standalone ``>8<`` snip markers —
+    and drop the run of quoted diff at the very bottom of the message that has
+    no comment after it, the usual courtesy of trimming quoted material below
+    your last reply.  Other quoted context the maintainer left in place
+    anywhere above their final comment is kept exactly as written; nothing is
+    collapsed, reordered, or relocated.
     """
     lines = [line for line in buffer.splitlines() if not line.startswith('#')]
+    lines = _apply_snip_markers(lines)
     lines = _strip_external_reviewer_lines(lines)
     # Drop the trailing quoted/blank run below the maintainer's last comment.
     end = len(lines)
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index d73fe057b5d499..2e312ca0e5bb93 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -1837,7 +1837,12 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]):
             orig_author = entry.get('fromname', '') or entry.get('fromemail', '')
             body = entry.get('body', '')
             quoted = '\n'.join(f'> {line}' for line in body.splitlines())
-            editor_text = f'On {orig_date}, {orig_author} wrote:\n{quoted}\n\n'
+            editor_text = (
+                '# Put ">8<" alone on a line to discard quoted context above it\n'
+                '# back to your last note; b4 resolves it when you send.\n'
+                '#\n'
+                f'On {orig_date}, {orig_author} wrote:\n{quoted}\n\n'
+            )
 
         result = suspend_and_edit(
             self, editor_text.encode(), 'reply.eml', topdir=self._topdir
@@ -1859,7 +1864,8 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]):
 
     def _send_followup_reply(self, entry: Dict[str, Any], text: str) -> None:
         """Build and immediately send a quick reply to a follow-up message."""
-        msg = entry['lmsg'].make_reply(text)
+        reply_text = b4.review._trim_quoted_reply(text)
+        msg = entry['lmsg'].make_reply(reply_text)
         try:
             with self.suspend():
                 smtp, fromaddr = b4.get_smtp(dryrun=self._email_dryrun)
diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index d8a10709386c85..ccd6d5d1862e3c 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -136,6 +136,12 @@ class TestRenderQuotedDiffWithComments:
         )
         assert 'My comment' in result
         assert '| Ext comment' in result
+        lines = result.splitlines()
+        own_index = lines.index('My comment')
+        external_index = lines.index('| Ext <ext@example.com>:')
+        # _insert() terminates the own comment with one blank line, which is
+        # already enough to separate it from the external review.
+        assert lines[own_index + 1 : external_index] == ['']
 
     def test_cross_file_comments(self) -> None:
         """Comments in different files render correctly."""
@@ -835,6 +841,257 @@ class TestTrimQuotedReply:
         )
 
 
+class TestSnipMarker:
+    """Tests for resolving hand-typed quoted-context snip markers."""
+
+    def test_discards_quote_run_above_and_keeps_quote_below(self) -> None:
+        lines = [
+            'My first note.',
+            '> first quoted line',
+            '> second quoted line',
+            '',
+            '>8<',
+            '> quoted context below the marker',
+            'My second note.',
+        ]
+        assert _review._apply_snip_markers(lines) == [
+            'My first note.',
+            '> [ ... 2 lines skipped ... ]',
+            '> quoted context below the marker',
+            'My second note.',
+        ]
+
+    def test_snip_removes_external_comment_separators(self) -> None:
+        buffer = (
+            'My first note.\n'
+            '\n'
+            '> quoted context before the external review\n'
+            '\n'
+            '| sashiko.dev <sashiko@sashiko.dev>:\n'
+            '|\n'
+            '| An external finding.\n'
+            '|\n'
+            '| via: https://sashiko.dev/#/message/example\n'
+            '\n'
+            '> quoted context after the external review\n'
+            '>8<\n'
+            '> quoted context below the marker\n'
+            'My second note.\n'
+        )
+        assert review._trim_quoted_reply(buffer) == (
+            'My first note.\n'
+            '\n'
+            '> [ ... 2 lines skipped ... ]\n'
+            '> quoted context below the marker\n'
+            'My second note.'
+        )
+
+    def test_snip_keeps_spacing_before_external_review(self) -> None:
+        buffer = (
+            'My maintainer comment.\n'
+            '\n'
+            '| sashiko.dev <sashiko@sashiko.dev>:\n'
+            '|\n'
+            '| An external finding.\n'
+            '|\n'
+            '| via: https://sashiko.dev/#/message/example\n'
+            '\n'
+            '> quoted context after the external review\n'
+            '>8<\n'
+            '> quoted context below the marker\n'
+            'My second note.\n'
+        )
+        assert review._trim_quoted_reply(buffer) == (
+            'My maintainer comment.\n'
+            '\n'
+            '> [ ... 1 lines skipped ... ]\n'
+            '> quoted context below the marker\n'
+            'My second note.'
+        )
+
+    def test_snip_keeps_maintainer_spacing_before_first_quote(self) -> None:
+        lines = [
+            'My first paragraph.',
+            'My second paragraph.',
+            '',
+            '> first quoted line',
+            '',
+            '> second quoted line',
+            '>8<',
+            '> quoted context below the marker',
+            'My later note.',
+        ]
+        assert _review._apply_snip_markers(lines) == [
+            'My first paragraph.',
+            'My second paragraph.',
+            '',
+            '> [ ... 2 lines skipped ... ]',
+            '> quoted context below the marker',
+            'My later note.',
+        ]
+
+    @pytest.mark.parametrize(
+        ('lines', 'expected'),
+        [
+            (['', '>8<', 'My note.'], ['', 'My note.']),
+            (
+                ['My note.', '', '>8<', '> quote below', 'Another note.'],
+                ['My note.', '', '> quote below', 'Another note.'],
+            ),
+        ],
+    )
+    def test_marker_with_nothing_to_discard(
+        self, lines: List[str], expected: List[str]
+    ) -> None:
+        assert _review._apply_snip_markers(lines) == expected
+
+    def test_coalesces_existing_breadcrumb_count(self) -> None:
+        lines = [
+            'My note.',
+            '> one more quoted line',
+            '> [ ... 42 lines skipped ... ]',
+            '>8<',
+            '> kept below',
+            'Later note.',
+        ]
+        assert _review._apply_snip_markers(lines) == [
+            'My note.',
+            '> [ ... 43 lines skipped ... ]',
+            '> kept below',
+            'Later note.',
+        ]
+
+    def test_multiple_markers_never_cross_comment_boundaries(self) -> None:
+        lines = [
+            'First comment.',
+            '> first run',
+            '>8<',
+            '> kept after first marker',
+            'Adopted reviewer comment.',
+            '> second run one',
+            '> second run two',
+            '> >8<',
+            '> kept after second marker',
+            'Final comment.',
+        ]
+        assert _review._apply_snip_markers(lines) == [
+            'First comment.',
+            '> [ ... 1 lines skipped ... ]',
+            '> kept after first marker',
+            'Adopted reviewer comment.',
+            '> [ ... 2 lines skipped ... ]',
+            '> kept after second marker',
+            'Final comment.',
+        ]
+
+    @pytest.mark.parametrize('marker', ['>8<', '> >8<'])
+    def test_accepted_spellings_resolve_identically(self, marker: str) -> None:
+        lines = ['My note.', '> one', '> two', marker, '> kept', 'Later note.']
+        assert _review._apply_snip_markers(lines) == [
+            'My note.',
+            '> [ ... 2 lines skipped ... ]',
+            '> kept',
+            'Later note.',
+        ]
+
+    def test_marker_substring_is_not_a_directive(self) -> None:
+        lines = ['My note.', '> quoted', 'Do not treat >8< here as a marker.']
+        assert _review._apply_snip_markers(lines) == lines
+
+    def test_trim_composes_scaffolding_snip_and_trailing_quote(self) -> None:
+        buffer = (
+            '# instructions\n'
+            '| External reviewer note\n'
+            'My first note.\n'
+            '> old context one\n'
+            '> old context two\n'
+            '>8<\n'
+            '> context kept below the marker\n'
+            'My second note.\n'
+            '> trailing untouched quote\n'
+        )
+        assert review._trim_quoted_reply(buffer) == (
+            'My first note.\n'
+            '> [ ... 2 lines skipped ... ]\n'
+            '> context kept below the marker\n'
+            'My second note.'
+        )
+
+    def test_diff_comment_after_marker_resolves_against_real_diff(self) -> None:
+        edited = (
+            '# instructions\n'
+            'On today, Author wrote:\n'
+            '> diff --git a/f.c b/f.c\n'
+            '> index 1111111..2222222 100644\n'
+            '> --- a/f.c\n'
+            '> +++ b/f.c\n'
+            '> @@ -1,2 +1,2 @@\n'
+            '> -old first\n'
+            '> +new first\n'
+            '>  keep first\n'
+            '>8<\n'
+            '> @@ -10,2 +10,2 @@\n'
+            '> -old target\n'
+            '> +new target\n'
+            '\n'
+            'Fix the target.\n'
+            '>  keep target\n'
+        )
+        real_diff = (
+            'diff --git a/f.c b/f.c\n'
+            'index 1111111..2222222 100644\n'
+            '--- a/f.c\n'
+            '+++ b/f.c\n'
+            '@@ -1,2 +1,2 @@\n'
+            '-old first\n'
+            '+new first\n'
+            ' keep first\n'
+            '@@ -10,2 +10,2 @@\n'
+            '-old target\n'
+            '+new target\n'
+            ' keep target\n'
+        )
+        comments = review._extract_editor_comments(edited, diff_text=real_diff)
+        assert comments == [
+            {
+                'path': 'b/f.c',
+                'line': 10,
+                'text': 'Fix the target.',
+                'content': '+new target',
+            }
+        ]
+
+    def test_message_comment_after_marker_resolves_against_real_text(self) -> None:
+        edited = (
+            '# instructions\n'
+            'On today, Author wrote:\n'
+            '> First body line.\n'
+            '> Second body line.\n'
+            '>8<\n'
+            '> Target body line.\n'
+            '\n'
+            'Comment on the target.\n'
+            '> Last body line.\n'
+        )
+        message = (
+            'Subject\n'
+            '\n'
+            'First body line.\n'
+            'Second body line.\n'
+            'Target body line.\n'
+            'Last body line.\n'
+        )
+        comments = review._extract_editor_comments(edited, message_text=message)
+        assert comments == [
+            {
+                'path': review.COMMIT_MESSAGE_PATH,
+                'line': 3,
+                'text': 'Comment on the target.',
+                'content': 'Target body line.',
+            }
+        ]
+
+
 class TestParseReplyTrailers:
     """Tests for _parse_reply_trailers() — derived trailer display index."""
 
diff --git a/src/tests/test_tui_review.py b/src/tests/test_tui_review.py
index cd808ec8b7bf63..3787881e7070d5 100644
--- a/src/tests/test_tui_review.py
+++ b/src/tests/test_tui_review.py
@@ -9,6 +9,7 @@ Tests the shell-return reconciliation logic that detects and handles
 cosmetic commit edits (e.g. reworded subjects via git rebase -i).
 """
 
+import email.message
 import json
 from typing import Any, Dict, List, Tuple
 from unittest import mock
@@ -362,6 +363,74 @@ class TestReplyVerbatim:
             )
 
 
+class TestFollowupSnipMarker:
+    """Quick follow-up replies expose and resolve the snip marker."""
+
+    def test_compose_includes_marker_instructions(self, gitdir: str) -> None:
+        branch, _shas = _create_review_branch_with_patches(
+            gitdir, 'followup-snip-instructions', ['patch 1']
+        )
+        app = ReviewApp(_build_session(gitdir, branch))
+        entry = {
+            'date': 'Thu, 1 Jan 2026 12:00:00 +0000',
+            'fromname': 'Reviewer',
+            'fromemail': 'reviewer@example.com',
+            'body': 'First line.\nSecond line.',
+        }
+
+        with mock.patch(
+            'b4.review_tui._review_app.suspend_and_edit', return_value=None
+        ) as edit:
+            app._compose_followup_reply(entry)
+
+        editor_text = edit.call_args.args[1].decode()
+        assert editor_text.startswith('# Put ">8<" alone on a line')
+        assert '# back to your last note; b4 resolves it when you send.' in editor_text
+        assert '> First line.\n> Second line.' in editor_text
+
+    def test_send_resolves_marker_and_drops_trailing_quote(self, gitdir: str) -> None:
+        import contextlib
+
+        branch, _shas = _create_review_branch_with_patches(
+            gitdir, 'followup-snip-send', ['patch 1']
+        )
+        session = _build_session(gitdir, branch)
+        session['email_dryrun'] = True
+        app = ReviewApp(session)
+        lmsg = mock.Mock()
+        outgoing = email.message.EmailMessage()
+        lmsg.make_reply.return_value = outgoing
+        entry = {'lmsg': lmsg, 'fromemail': 'reviewer@example.com'}
+        buffer = (
+            '# Put ">8<" alone on a line to trim quoted context.\n'
+            'On today, Reviewer wrote:\n'
+            '> old context one\n'
+            '> old context two\n'
+            '>8<\n'
+            '> context kept below the marker\n'
+            'My reply.\n'
+            '> trailing untouched quote\n'
+        )
+
+        with (
+            mock.patch.object(
+                app, 'suspend', side_effect=lambda: contextlib.nullcontext()
+            ),
+            mock.patch.object(app, 'notify'),
+            mock.patch('b4.get_smtp', return_value=(None, 'me@example.com')),
+            mock.patch('b4.send_mail', return_value=0) as send_mail,
+        ):
+            app._send_followup_reply(entry, buffer)
+
+        lmsg.make_reply.assert_called_once_with(
+            'On today, Reviewer wrote:\n'
+            '> [ ... 2 lines skipped ... ]\n'
+            '> context kept below the marker\n'
+            'My reply.'
+        )
+        assert send_mail.call_args.args[1] == [outgoing]
+
+
 class TestReconcileAfterShell:
     """Tests for _reconcile_after_shell tracking fixup."""
 
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 7+ messages in thread

* Re: [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context
  2026-08-25 18:35 ` [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context Jason Gunthorpe
@ 2026-08-25 21:25   ` Konstantin Ryabitsev
  2026-08-26 23:12     ` Jason Gunthorpe
  0 siblings, 1 reply; 7+ messages in thread
From: Konstantin Ryabitsev @ 2026-08-25 21:25 UTC (permalink / raw)
  To: Jason Gunthorpe; +Cc: tools

> When adding review comments to a message it is very inconvenient to
> permanently loose the quotable text. Often I will make several passes over
> a series and the nice b4 behavior of keeping everything in draft
> encourages coming back and making revisions and further comments.
> 
> However, if the quoted text has been erased that isn't possible.
> 
> Instead of erasing in the editor provide a full line marker '>8<' for b4
> to automagically do the trimming similar to how it trims unused quote at
> the end. Trailing text after user lines and till the marker is removed
> similar to the trailing text.

My only objection is that >8< doesn't say "scissors" to me, it says
"angry bee". How about we equally recognize:

  >--cut--
  >--8<--

-- 
Konstantin Ryabitsev <konstantin@linuxfoundation.org>

^ permalink raw reply	[flat|nested] 7+ messages in thread

* Re: [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context
  2026-08-25 21:25   ` Konstantin Ryabitsev
@ 2026-08-26 23:12     ` Jason Gunthorpe
  2026-08-27 16:12       ` Konstantin Ryabitsev
  0 siblings, 1 reply; 7+ messages in thread
From: Jason Gunthorpe @ 2026-08-26 23:12 UTC (permalink / raw)
  To: Konstantin Ryabitsev; +Cc: tools

On Tue, Aug 25, 2026 at 09:25:41PM +0000, Konstantin Ryabitsev wrote:
> > When adding review comments to a message it is very inconvenient to
> > permanently loose the quotable text. Often I will make several passes over
> > a series and the nice b4 behavior of keeping everything in draft
> > encourages coming back and making revisions and further comments.
> > 
> > However, if the quoted text has been erased that isn't possible.
> > 
> > Instead of erasing in the editor provide a full line marker '>8<' for b4
> > to automagically do the trimming similar to how it trims unused quote at
> > the end. Trailing text after user lines and till the marker is removed
> > similar to the trailing text.
> 
> My only objection is that >8< doesn't say "scissors" to me, it says
> "angry bee". How about we equally recognize:

Oh, it was supposed to be > to make it a quote followed by the 8<
which kinda looks like sizzors.

In the end I bound it to an emacs macro and it turns out to be pretty
tricky to type anyhow. So.. Any of these are fine for me, lets just do
one?

>   >--cut--

How about this? It is self describing

Thanks,
Jason

^ permalink raw reply	[flat|nested] 7+ messages in thread

* Re: [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context
  2026-08-26 23:12     ` Jason Gunthorpe
@ 2026-08-27 16:12       ` Konstantin Ryabitsev
  0 siblings, 0 replies; 7+ messages in thread
From: Konstantin Ryabitsev @ 2026-08-27 16:12 UTC (permalink / raw)
  To: Jason Gunthorpe; +Cc: tools

On Wed, Aug 26, 2026 at 08:12:07PM -0300, Jason Gunthorpe wrote:
> Oh, it was supposed to be > to make it a quote followed by the 8<
> which kinda looks like sizzors.
> 
> In the end I bound it to an emacs macro and it turns out to be pretty
> tricky to type anyhow. So.. Any of these are fine for me, lets just do
> one?
> 
> >   >--cut--
> 
> How about this? It is self describing

Sounds good. Do you mind sending a v2? I would also consider including your
keybinding into the emacs .el if that makes sense.

-K

^ permalink raw reply	[flat|nested] 7+ messages in thread

end of thread, other threads:[~2026-08-27 16:12 UTC | newest]

Thread overview: 7+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-25 18:35 [PATCH b4 0/3] Improve email quote trimming Jason Gunthorpe
2026-08-25 18:35 ` [PATCH b4 1/3] review: trim trailing quoted when adding a tag Jason Gunthorpe
2026-08-25 18:35 ` [PATCH b4 2/3] review: discard blank lines between | and > quotes when trimming Jason Gunthorpe
2026-08-25 18:35 ` [PATCH b4 3/3] review: add >8< inline marker to snip quoted reply context Jason Gunthorpe
2026-08-25 21:25   ` Konstantin Ryabitsev
2026-08-26 23:12     ` Jason Gunthorpe
2026-08-27 16:12       ` Konstantin Ryabitsev

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.