diff --git a/datalad/interface/utils.py b/datalad/interface/utils.py
index 3ad582302..db03e38dc 100644
--- a/datalad/interface/utils.py
+++ b/datalad/interface/utils.py
@@ -42,8 +42,10 @@ from datalad.utils import (
 from datalad.support.gitrepo import GitRepo
 from datalad.support.exceptions import IncompleteResultsError
 from datalad import cfg as dlcfg
-from datalad.dochelpers import exc_str
-
+from datalad.dochelpers import (
+    exc_str,
+    single_or_plural,
+)
 
 from datalad.ui import ui
 import datalad.support.ansi_colors as ac
@@ -512,6 +514,17 @@ def default_result_renderer(res):
                 if res.get('message', None) else ''))
 
 
+def _display_suppressed_message(nsimilar, ndisplayed):
+    # +1 because there was the original result + nsimilar displayed.
+    n_suppressed = nsimilar - ndisplayed + 1
+    if n_suppressed > 0:
+        ui.message('  [{} similar {} been suppressed]'
+                   .format(n_suppressed,
+                           single_or_plural("message has",
+                                            "messages have",
+                                            n_suppressed, False)))
+
+
 def _process_results(
         results,
         cmd_class,
@@ -534,7 +547,6 @@ def _process_results(
     result_repetitions = 0
     # how many repetitions to show, before suppression kicks in
     render_n_repetitions = 10
-    result_suppression_msg = '  [{} similar messages have been suppressed]'
 
     for res in results:
         if not res or 'action' not in res:
@@ -583,15 +595,14 @@ def _process_results(
             if res.get('status', None) != 'notneeded' \
                     and trimmed_result == last_result:
                 # this is a similar report, suppress if too many, but count it
+                result_repetitions += 1
                 if result_repetitions < render_n_repetitions:
                     default_result_renderer(res)
-                result_repetitions += 1
             else:
                 # this one is new, first report on any prev. suppressed results
                 # by number, and then render this fresh one
-                if result_repetitions:
-                    ui.message(result_suppression_msg.format(
-                        result_repetitions - render_n_repetitions))
+                _display_suppressed_message(
+                    result_repetitions, render_n_repetitions)
                 default_result_renderer(res)
                 result_repetitions = 0
             last_result = trimmed_result
@@ -626,9 +637,8 @@ def _process_results(
                 break
         yield res
     # make sure to report on any issues that we had suppressed
-    if result_repetitions:
-        ui.message(result_suppression_msg.format(
-            result_repetitions - render_n_repetitions))
+    _display_suppressed_message(
+        result_repetitions, render_n_repetitions)
 
 
 def keep_result(res, rfilter, **kwargs):

Result suppression issues
I've noticed a couple of rough edges with the recent "suppress similar results" feature.

For demonstration purposes, here's a script saves a number of files equal to the number passed as the first argument.

<details>
<summary>script</summary>

```sh
#!/bin/sh

set -eu

cd "$(mktemp -d --tmpdir dl-XXXXXXX)"
datalad create >/dev/null 2>&1

ntimes=${1:-20}
for i in $(seq 1 "$ntimes")
do
    echo "$i content" >foo-$(printf "%03d" $i)
done

echo "There are $(find . -name "foo-*" | wc -l) files to save\n"
datalad save
```

</details>

All the output below is with 0.12.4-574-gcedcb9e69f.

For ten files, I expected to see all those files.  That's what happens, but there is also a message about -1 suppressed messages.

```
There are 10 files to save

add(ok): foo-001 (file)
add(ok): foo-002 (file)
add(ok): foo-003 (file)
add(ok): foo-004 (file)
add(ok): foo-005 (file)
add(ok): foo-006 (file)
add(ok): foo-007 (file)
add(ok): foo-008 (file)
add(ok): foo-009 (file)
add(ok): foo-010 (file)
  [-1 similar messages have been suppressed]
save(ok): . (dataset)
action summary:
  add (ok: 10)
  save (ok: 1)
```

There is a similar negative number count below the suppression threshold of 10:

```
There are 5 files to save

add(ok): foo-001 (file)
add(ok): foo-002 (file)
add(ok): foo-003 (file)
add(ok): foo-004 (file)
add(ok): foo-005 (file)
  [-6 similar messages have been suppressed]
save(ok): . (dataset)
action summary:
  add (ok: 5)
  save (ok: 1)
```

Right above the limit, there is an odd 0 count:

```
There are 11 files to save

add(ok): foo-001 (file)
add(ok): foo-002 (file)
add(ok): foo-003 (file)
add(ok): foo-004 (file)
add(ok): foo-005 (file)
add(ok): foo-006 (file)
add(ok): foo-007 (file)
add(ok): foo-008 (file)
add(ok): foo-009 (file)
add(ok): foo-010 (file)
add(ok): foo-011 (file)
  [0 similar messages have been suppressed]
save(ok): . (dataset)
action summary:
  add (ok: 11)
  save (ok: 1)
```

Once we get to 12, things look pretty good, though I think the intent was for the results to start being suppress at 10 rather 11.

```
There are 12 files to save

add(ok): foo-001 (file)
add(ok): foo-002 (file)
add(ok): foo-003 (file)
add(ok): foo-004 (file)
add(ok): foo-005 (file)
add(ok): foo-006 (file)
add(ok): foo-007 (file)
add(ok): foo-008 (file)
add(ok): foo-009 (file)
add(ok): foo-010 (file)
add(ok): foo-011 (file)
  [1 similar messages have been suppressed]
save(ok): . (dataset)
action summary:
  add (ok: 12)
  save (ok: 1)
```
diff --git a/datalad/interface/tests/test_utils.py b/datalad/interface/tests/test_utils.py
index a754dc1a5..c2a43ea1c 100644
--- a/datalad/interface/tests/test_utils.py
+++ b/datalad/interface/tests/test_utils.py
@@ -23,6 +23,7 @@ from datalad.tests.utils import (
     assert_not_equal,
     assert_not_in,
     assert_raises,
+    assert_re_in,
     assert_repo_status,
     assert_true,
     ok_,
@@ -31,6 +32,7 @@ from datalad.tests.utils import (
     with_tree,
 )
 from datalad.utils import swallow_logs
+from datalad.utils import swallow_outputs
 from datalad.distribution.dataset import (
     Dataset,
     datasetmethod,
@@ -214,17 +216,25 @@ class TestUtils(Interface):
             doc=""""specify the dataset to update.  If
             no dataset is given, an attempt is made to identify the dataset
             based on the input and/or the current working directory""",
-            constraints=EnsureDataset() | EnsureNone()),)
+            constraints=EnsureDataset() | EnsureNone()),
+        result_fn=Parameter(
+            args=tuple(),   # Hide this from the cmdline parser.
+            doc="""Generate the result records with this function
+            rather than using the default logic. `number` will be
+            passed as an argument."""),)
 
     @staticmethod
     @datasetmethod(name='fake_command')
     @eval_results
-    def __call__(number, dataset=None):
-
-        for i in range(number):
-            # this dict will need to have the minimum info required by
-            # eval_results
-            yield {'path': 'some', 'status': 'ok', 'somekey': i, 'action': 'off'}
+    def __call__(number, dataset=None, result_fn=None):
+        if result_fn:
+            yield from result_fn(number)
+        else:
+            for i in range(number):
+                # this dict will need to have the minimum info
+                # required by eval_results
+                yield {'path': 'some', 'status': 'ok', 'somekey': i,
+                       'action': 'off'}
 
 
 def test_eval_results_plus_build_doc():
@@ -273,8 +283,10 @@ def test_eval_results_plus_build_doc():
 
     # test signature:
     from datalad.utils import getargspec
-    assert_equal(getargspec(Dataset.fake_command)[0], ['number', 'dataset'])
-    assert_equal(getargspec(TestUtils.__call__)[0], ['number', 'dataset'])
+    assert_equal(getargspec(Dataset.fake_command)[0],
+                 ['number', 'dataset', 'result_fn'])
+    assert_equal(getargspec(TestUtils.__call__)[0],
+                 ['number', 'dataset', 'result_fn'])
 
 
 def test_result_filter():
@@ -367,3 +379,56 @@ def test_discover_ds_trace(path, otherdir):
         spec = {}
         discover_dataset_trace_to_targets(ds.path, input, [], spec, includeds=eds)
         assert_dict_equal(spec, goal)
+
+
+def test_utils_suppress_similar():
+    tu = TestUtils()
+
+    # Check suppression boundary for straight chain of similar
+    # messages.
+
+    def n_foo(number):
+        for i in range(number):
+            yield dict(action="foo",
+                       status="ok",
+                       path="path{}".format(i))
+
+    with swallow_outputs() as cmo:
+        list(tu(9, result_fn=n_foo, result_renderer="default"))
+        assert_in("path8", cmo.out)
+        assert_not_in("suppressed", cmo.out)
+
+    with swallow_outputs() as cmo:
+        list(tu(10, result_fn=n_foo, result_renderer="default"))
+        assert_in("path9", cmo.out)
+        assert_not_in("suppressed", cmo.out)
+
+    with swallow_outputs() as cmo:
+        list(tu(11, result_fn=n_foo, result_renderer="default"))
+        assert_not_in("path10", cmo.out)
+        assert_re_in(r"[^-0-9]1 .* suppressed", cmo.out, match=False)
+
+    # Check a chain of similar messages, split in half by a distinct one.
+
+    def n_foo_split_by_a_bar(number):
+        half = number // 2 - 1
+        for i in range(number):
+            yield dict(action="foo",
+                       status="ok",
+                       path="path{}".format(i))
+            if i == half:
+                yield dict(action="bar",
+                           status="ok",
+                           path="path")
+
+    with swallow_outputs() as cmo:
+        list(tu(20, result_fn=n_foo_split_by_a_bar, result_renderer="default"))
+        assert_in("path10", cmo.out)
+        assert_in("path19", cmo.out)
+        assert_not_in("suppressed", cmo.out)
+
+    with swallow_outputs() as cmo:
+        list(tu(21, result_fn=n_foo_split_by_a_bar, result_renderer="default"))
+        assert_in("path10", cmo.out)
+        assert_not_in("path20", cmo.out)
+        assert_re_in("[^-0-9]1 .* suppressed", cmo.out, match=False)

pip install -e .[full]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
apt-get update apt-get install -y gcc
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
annexremote==1.6.6
appdirs==1.4.4
backports.tarfile==1.2.0
beautifulsoup4==4.13.3
boto==2.49.0
certifi==2025.1.31
cffi==1.17.1
chardet==5.2.0
charset-normalizer==3.4.1
citeproc-py==0.8.2
coverage==7.8.0
cryptography==44.0.2
-e git+https://github.com/datalad/datalad.git@7a4abb204ca6eed31f55ab276088fbf73cdf188d#egg=datalad
Deprecated==1.2.18
distro==1.9.0
duecredit==0.10.2
exceptiongroup==1.2.2
execnet==2.1.1
ExifRead==3.0.0
fasteners==0.19
httpretty==1.1.4
humanize==4.12.2
idna==3.10
importlib_metadata==8.6.1
iniconfig==2.1.0
iso8601==2.1.0
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.9.0
jsmin==3.0.1
keyring==25.6.0
keyrings.alt==5.0.2
looseversion==1.3.0
lxml==5.3.1
more-itertools==10.6.0
msgpack==1.1.0
multidict==6.2.0
mutagen==1.47.0
nose==1.3.7
packaging==24.2
patool==1.15.0
pillow==11.1.0
pluggy==1.5.0
propcache==0.3.1
pycparser==2.22
PyGithub==2.6.1
PyJWT==2.10.1
PyNaCl==1.5.0
pyperclip==1.9.0
pytest==8.3.5
pytest-asyncio==0.26.0
pytest-cov==6.0.0
pytest-mock==3.14.0
pytest-xdist==3.6.1
python-dateutil==2.9.0.post0
python-xmp-toolkit==2.0.2
pytz==2025.2
PyYAML==6.0.2
requests==2.32.3
requests-ftp==0.3.1
SecretStorage==3.3.3
simplejson==3.20.1
six==1.17.0
soupsieve==2.6
tomli==2.2.1
tqdm==4.67.1
typing_extensions==4.13.0
urllib3==1.26.20
vcrpy==7.0.0
Whoosh==2.7.4
wrapt==1.17.2
yarl==1.18.3
zipp==3.21.0

name: datalad
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - annexremote==1.6.6
      - appdirs==1.4.4
      - backports-tarfile==1.2.0
      - beautifulsoup4==4.13.3
      - boto==2.49.0
      - certifi==2025.1.31
      - cffi==1.17.1
      - chardet==5.2.0
      - charset-normalizer==3.4.1
      - citeproc-py==0.8.2
      - coverage==7.8.0
      - cryptography==44.0.2
      - deprecated==1.2.18
      - distro==1.9.0
      - duecredit==0.10.2
      - exceptiongroup==1.2.2
      - execnet==2.1.1
      - exifread==3.0.0
      - fasteners==0.19
      - httpretty==1.1.4
      - humanize==4.12.2
      - idna==3.10
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - iso8601==2.1.0
      - jaraco-classes==3.4.0
      - jaraco-context==6.0.1
      - jaraco-functools==4.1.0
      - jeepney==0.9.0
      - jsmin==3.0.1
      - keyring==25.6.0
      - keyrings-alt==5.0.2
      - looseversion==1.3.0
      - lxml==5.3.1
      - more-itertools==10.6.0
      - msgpack==1.1.0
      - multidict==6.2.0
      - mutagen==1.47.0
      - nose==1.3.7
      - packaging==24.2
      - patool==1.15.0
      - pillow==11.1.0
      - pluggy==1.5.0
      - propcache==0.3.1
      - pycparser==2.22
      - pygithub==2.6.1
      - pyjwt==2.10.1
      - pynacl==1.5.0
      - pyperclip==1.9.0
      - pytest==8.3.5
      - pytest-asyncio==0.26.0
      - pytest-cov==6.0.0
      - pytest-mock==3.14.0
      - pytest-xdist==3.6.1
      - python-dateutil==2.9.0.post0
      - python-xmp-toolkit==2.0.2
      - pytz==2025.2
      - pyyaml==6.0.2
      - requests==2.32.3
      - requests-ftp==0.3.1
      - secretstorage==3.3.3
      - simplejson==3.20.1
      - six==1.17.0
      - soupsieve==2.6
      - tomli==2.2.1
      - tqdm==4.67.1
      - typing-extensions==4.13.0
      - urllib3==1.26.20
      - vcrpy==7.0.0
      - whoosh==2.7.4
      - wrapt==1.17.2
      - yarl==1.18.3
      - zipp==3.21.0
prefix: /opt/conda/envs/datalad

datalad/interface/tests/test_utils.py::test_utils_suppress_similar
datalad/interface/tests/test_utils.py::test_eval_results_plus_build_doc datalad/interface/tests/test_utils.py::test_result_filter
datalad/interface/utils.py
datalad/interface/utils.py