TUI status bar duplication and rendering bugs

NousResearch/hermes-agent · cluster 3130 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #3291. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #3291. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #3291, #16527. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple issues and fixes addressing status bar duplication, ghost rows, and in-place update failures during CLI/TUI sessions. Also includes a related fix to skip a spinner component when the TUI handles tool progress itself.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3291 fix(tui): status bar duplicates and degrades during long sessions

    merged to main

    files: cli.py, tests/test_cli_status_bar.py

    Show the diff
    • cli.py
      @@ -1329,7 +1329,12 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]:
           def _build_status_bar_text(self, width: Optional[int] = None) -> str:
               try:
                   snapshot = self._get_status_bar_snapshot()
      -            width = width or shutil.get_terminal_size((80, 24)).columns
      +            if width is None:
      +                try:
      +                    from prompt_toolkit.application import get_app
      +                    width = get_app().output.get_size().columns
      +                except Exception:
      +                    width = shutil.get_terminal_size((80, 24)).columns
                   percent = snapshot["context_percent"]
                   percent_label = f"{percent}%" if percent is not None else "--"
                   duration_label = snapshot["duration"]
      @@ -1359,7 +1364,16 @@ def _get_status_bar_fragments(self):
                   return []
               try:
                   snapshot = self._get_status_bar_snapshot()
      -            width = shutil.get_terminal_size((80, 24)).columns
      +            # Use prompt_toolkit's own terminal width when running inside the
      +            # TUI — shutil.get_terminal_size() can return stale or fallback
      +            # values (especially on SSH) that differ from what prompt_toolkit
      +            # actually renders, causing the fragments to overflow to a second
      +            # line and produce duplicated status bar rows over long sessions.
      +            try:
      +                from prompt_toolkit.application import get_app
      +                width = get_app().output.get_size().columns
      +            except Exception:
      +                width = shutil.get_terminal_size((80, 24)).columns
                   duration_label = snapshot["duration"]
       
                   if width < 52:
      @@ -6894,6 +6908,15 @@ def _get_voice_status():
                   Window(
                       content=FormattedTextControl(lambda: cli_ref._get_status_bar_fragments()),
                       height=1,
      +                # Prevent fragments that overflow the terminal width from
      +                # wrapping onto a second line, which causes the status bar to
      +                # appear duplicated (one full + one partial row) during long
      +                # sessions, especially on SSH where shutil.get_terminal_size
      +                # may return stale values.  _get_status_bar_fragments now reads
      +                # width from prompt_toolkit's own output object, so fragments
      +                # will always fit; wrap_lines=False is the belt-and-suspenders
      +                # guard against any future width mismatch.
      +                wrap_lines=False,
                   ),
                   filter=Condition(lambda: cli_ref._status_bar_visible),
               )
    • tests/test_cli_status_bar.py
      @@ -182,3 +182,94 @@ def test_zero_priced_provider_models_stay_unknown(self, capsys):
               assert "Total cost:" in output
               assert "n/a" in output
               assert "Pricing unknown for glm-5" in output
      +
      +
      +class TestStatusBarWidthSource:
      +    """Ensure status bar fragments don't overflow the terminal width."""
      +
      +    def _make_wide_cli(self):
      +        from datetime import datetime, timedelta
      +        cli_obj = _attach_agent(
      +            _make_cli(),
      +            prompt_tokens=[redacted:secret-assignment]
      +            completion_tokens=[redacted:secret-assignment]
      +            total_tokens=[redacted:secret-assignment]
      +            api_calls=20,
      +            context_tokens=[redacted:secret-assignment]
      +            context_length=200_000,
      +        )
      +        cli_obj._status_bar_visible = True
      +        return cli_obj
      +
      +    def test_fragments_fit_within_announced_width(self):
      +        """Total fragment text length must not exceed the width used to build them."""
      +        from unittest.mock import MagicMock, patch
      +        cli_obj = self._make_wide_cli()
      +
      +        for width in (40, 52, 76, 80, 120, 200):
      +            mock_app = MagicMock()
      +            mock_app.output.get_size.return_value = MagicMock(columns=width)
      +
      +            with patch("prompt_toolkit.application.get_app", return_value=mock_app):
      +                frags = cli_obj._get_status_bar_fragments()
      +
      +            total_text = "".join(text for _, text in frags)
      +            assert len(total_text) <= width + 4, (  # +4 for minor padding chars
      +                f"At width={width}, fragment total {len(total_text)} chars overflows "
      +                f"({total_text!r})"
      +            )
      +
      +    def test_fragments_use_pt_width_over_shutil(self):
      +        """When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
      +        from unittest.mock import MagicMock, patch
      +        cli_obj = self._make_wide_cli()
      +
      +        mock_app = MagicMock()
      +        mock_app.output.get_size.return_value = MagicMock(columns=120)
      +
      +        with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \
      +             patch("shutil.get_terminal_size") as mock_shutil:
      +            cli_obj._get_status_bar_fragments()
      +
      +        mock_shutil.assert_not_called()
      +
      +    def test_fragments_fall_back_to_shutil_when_no_app(self):
      +        """Outside a TUI context (no running app), shutil must be used as fallback."""
      +        from unittest.mock import MagicMock, patch
      +        cli_obj = self._make_wide_cli()
      +
      +        with patch("prompt_toolkit.application.get_app", side_effect=Exception("no app")), \
      +             patch("shutil.get_terminal_size", return_value=MagicMock(columns=100)) as mock_shutil:
      +            frags = cli_obj._get_status_bar_fragments()
      +
      +        mock_shutil.assert_called()
      +        assert len(frags) > 0
      +
      +    def test_build_status_bar_text_uses_pt_width(self):
      +        """_build_status_bar_text() must also prefer prompt_toolkit width."""
      +        from unittest.mock import MagicMock, patch
      +        cli_obj = self._make_wide_cli()
      +
      +        mock_app = MagicMock()
      +        mock_app.output.get_size.return_value = MagicMock(columns=80)
      +
      +        with patch("prompt_toolkit.application.get_app", return_value=mock_app), \
      +             patch("shutil.get_terminal_size") as mock_shutil:
      +            text = cli_obj._build_status_bar_text()  # no explicit width
      +
      +        mock_shutil.assert_not_called()
      +        assert isinstance(text, str)
      +        assert len(text) > 0
      +
      +    def test_explicit_width_skips_pt_lookup(self):
      +        """An explicit width= argument must bypass both PT and shutil lookups."""
      +        from unittest.mock import patch
      +        cli_obj = self._make_wide_cli()
      +
      +        with patch("prompt_toolkit.application.get_app") as mock_get_app, \
      +             patch("shutil.get_terminal_size") as mock_shutil:
      +            text = cli_obj._build_status_bar_text(width=100)
      +
      +        mock_get_app.assert_not_called()
      +        mock_shutil.assert_not_called()
      +        assert len(text) > 0
  • #16527 fix(cli): eliminate ghost status-bar + DSR input leaks from terminal drift

    merged to main

    files: cli.py, hermes_cli/commands.py, scripts/release.py, tests/cli/test_cli_force_redraw.py, tests/cli/test_cli_terminal_response_sanitizer.py

    Show the diff
    • cli.py
      @@ -1574,6 +1574,34 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
           return text
       
       
      +# Cursor Position Report (CPR / DSR) response, format ``ESC[<row>;<col>R``.
      +# prompt_toolkit's _on_resize() + renderer send ``ESC[6n`` queries to the
      +# terminal; under resize storms or tab switches the terminal's reply can
      +# race past the input parser and end up in the input buffer as literal
      +# text (see issue #14692). Also matches the visible-form ``^[[<row>;<col>R``
      +# that appears when the ESC byte was stripped by a prior filter.
      +_DSR_CPR_ESC_RE = re.compile(r"\x1b\[\d+;\d+R")
      +_DSR_CPR_VISIBLE_RE = re.compile(r"\^\[\[\d+;\d+R")
      +
      +
      +def _strip_leaked_terminal_responses(text: str) -> str:
      +    """Strip leaked terminal control-response sequences from user input.
      +
      +    Covers Cursor Position Report (CPR / DSR) responses — ``ESC[<row>;<col>R``
      +    and the visible ``^[[<row>;<col>R`` form. These are replies the terminal
      +    sends back to queries prompt_toolkit makes during ``_on_resize`` /
      +    ``_request_absolute_cursor_position``. When the input parser drops one
      +    (resize storms, multiplexer focus changes, slow PTYs) the response
      +    lands in the input buffer as literal text and corrupts what the user
      +    typed.
      +    """
      +    if not text:
      +        return text
      +    text = _DSR_CPR_ESC_RE.sub("", text)
      +    text = _DSR_CPR_VISIBLE_RE.sub("", text)
      +    return text
      +
      +
       def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
           """Collect local image attachments for single-query CLI flows."""
           message = query or ""
      @@ -2182,6 +2210,42 @@ def _invalidate(self, min_interval: float = 0.25) -> None:
                   self._last_invalidate = now
                   self._app.invalidate()
       
      +    def _force_full_redraw(self) -> None:
      +        """Force a clean full-screen repaint of the prompt_toolkit UI.
      +
      +        Used to recover from terminal buffer drift caused by external
      +        redraws we can't detect — e.g. macOS cmux / tmux tab switches,
      +        ``clear`` issued from a subshell, or SSH window restores. These
      +        wipe or repaint the terminal without firing SIGWINCH, so
      +        prompt_toolkit's tracked ``_cursor_pos`` no longer matches reality
      +        and the next incremental redraw stacks on top of stale content
      +        (ghost status bars, duplicated prompts).
      +
      +        Bound to Ctrl+L and exposed as the ``/redraw`` slash command,
      +        matching the standard terminal-UX convention (bash, zsh, fish,
      +        vim, htop).
      +        """
      +        app = getattr(self, "_app", None)
      +        if not app:
      +            return
      +        try:
      +            renderer = app.renderer
      +            out = renderer.output
      +            out.reset_attributes()
      +            out.erase_screen()
      +            out.cursor_goto(0, 0)
      +            out.flush()
      +            # Drop prompt_toolkit's cached screen + cursor state so the
      +            # next _redraw() starts from a known (0, 0) origin and
      +            # re-renders every cell rather than diffing against stale.
      +            renderer.reset(leave_alternate_screen=False)
      +        except Exception:
      +            pass
      +        try:
      +            app.invalidate()
      +        except Exception:
      +            pass
      +
           def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
               if percent_used is None:
                   return "class:status-bar-dim"
      @@ -5998,6 +6062,12 @@ def process_command(self, command: str) -> bool:
                   self.show_toolsets()
               elif canonical == "config":
                   self.show_config()
      +        elif canonical == "redraw":
      +            # Manual recovery for terminal buffer drift from multiplexer
      +            # tab switches, subshell ``clear``, SSH window restores, etc.
      +            # See issue #8688 (cmux). Ctrl+L is bound to the same helper.
      +            self._force_full_redraw()
      +            _cprint(f"  {_DIM}✓ UI redrawn{_RST}")
               elif canonical == "clear":
                   self.new_session(silent=True)
                   # Clear terminal screen.  Inside the TUI, Rich's console.clear()
      @@ -9563,6 +9633,17 @@ def history_down(event):
                   """Down arrow: browse history when on last line, else move cursor down."""
                   event.app.current_buffer.auto_down(count=event.arg)
       
      +        @kb.add('c-l')
      +        def handle_ctrl_l(event):
      +            """Ctrl+L: force a clean full-screen repaint.
      +
      +            Recovers the UI after external terminal buffer drift — tmux /
      +            cmux tab switches, ``clear`` from a subshell, SSH window
      +            restores, etc. — that prompt_toolkit can't detect on its own.
      +            Matches the universal bash/zsh/fish/vim/htop convention.
      +            """
      +            self._force_full_redraw()
      +
               @kb.add('c-c')
               def handle_ctrl_c(event):
                   """Handle Ctrl+C - cancel interactive prompts, interrupt agent, or exit.
      @@ -9795,6 +9876,7 @@ def handle_paste(event):
                   # so the 5-line collapse threshold and display are consistent.
                   pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n')
                   pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
      +            pasted_text = _strip_leaked_terminal_responses(pasted_text)
                   if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image():
                       event.app.invalidate()
                   if pasted_text:
      @@ -9937,6 +10019,7 @@ def _on_text_changed(buf):
                      event so it never triggers this.
                   """
                   text = _strip_leaked_bracketed_paste_wrappers(buf.text)
      +            text = _strip_leaked_terminal_responses(text)
                   if text != buf.text:
                       cursor = min(buf.cursor_position, len(text))
                       _paste_just_collapsed[0] = True
      @@ -10601,36 +10684,30 @@ def _get_voice_status():
               # only cursor_up()s by the stored layout height, missing the extra
               # rows created by reflow — leaving ghost duplicates visible.
               #
      -        # Fix: before the standard erase, inflate _cursor_pos.y so the
      -        # cursor moves up far enough to cover the reflowed ghost content.
      +        # It's not just column-shrink: widening, row-shrinking, and
      +        # multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch)
      +        # all produce the same class of drift, where the renderer's tracked
      +        # _cursor_pos.y no longer matches terminal reality. The only reliable
      +        # recovery is a full screen-clear (\x1b[2J\x1b[H) before the next
      +        # redraw, so we force one on every resize rather than trying to
      +        # compute the exact drift.
               _original_on_resize = app._on_resize
       
               def _resize_clear_ghosts():
      -            from prompt_toolkit.data_structures import Point as _Pt
                   renderer = app.renderer
                   try:
      -                old_size = renderer._last_size
      -                new_size = renderer.output.get_size()
      -                if (
      -                    old_size
      -                    and new_size.columns < old_size.columns
      -                    and new_size.columns > 0
      -                ):
      -                    reflow_factor = (
      -                        (old_size.columns + new_size.columns - 1)
      -                        // new_size.columns
      -                    )
      -                    last_h = (
      -                        renderer._last_screen.height
      -                        if renderer._last_screen
      -                        else 0
      -                    )
      -                    extra = last_h * (reflow_factor - 1)
      -                    if extra > 0:
      -                        renderer._cursor_pos = _Pt(
      -                            x=renderer._cursor_pos.x,
      -                            y=renderer._cursor_pos.y + extra,
      -                        )
      +                out = renderer.output
      +                # Reset attributes, erase the entire screen, and home the
      +                # cursor. This overwrites any reflowed status-bar rows or
      +                # stale content the terminal kept from the prior layout.
      +                out.reset_attributes()
      +                out.erase_screen()
      +                out.cursor_goto(0, 0)
      +                out.flush()
      +                # Tell the renderer its tracked position is fresh so its
      +                # own erase() inside _on_resize doesn't cursor_up() past
      +                # the top of the screen.
      +                renderer.reset(leave_alternate_screen=False)
                   except Exception:
                       pass  # never break resize handling
                   _original_on_resize()
      @@ -10638,7 +10715,6 @@ def _resize_clear_ghosts():
               app._on_resize = _resize_clear_ghosts
       
               def spinner_loop():
      -            last_idle_refresh = 0.0
                   while not self._should_exit:
                       if not self._app:
                           time.sleep(0.1)
      @@ -10647,10 +10723,11 @@ def spinner_loop():
                           self._invalidate(min_interval=0.1)
                           time.sleep(0.1)
                       else:
      -                    now = time.monotonic()
      -                    if now - last_idle_refresh >= 1.0:
      -                        last_idle_refresh = now
      -                        self._invalidate(min_interval=1.0)
      +                    # Do not repaint the idle prompt every second. In non-full-screen
      +                    # prompt_toolkit mode, background redraws can fight tmux/Ghostty/cmux
      +                    # viewport restoration after focus changes and visually move the
      +                    # command input area. Keep idle stable; input/agent events still
      +                    # invalidate explicitly when the UI actually changes.
                           time.sleep(0.2)
       
               spinner_thread = threading.Thread(target=spinner_loop, daemon=True)
      @@ -10695,6 +10772,7 @@ def process_loop():
       
                           if isinstance(user_input, str):
                               user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
      +                        user_input = _strip_leaked_terminal_responses(user_input)
                           
                           # Check for commands — but detect dragged/pasted file paths first.
                           # See _detect_file_drop() for details.
    • hermes_cli/commands.py
      @@ -62,6 +62,8 @@ class CommandDef:
                      aliases=("reset",)),
           CommandDef("clear", "Clear screen and start a new session", "Session",
                      cli_only=True),
      +    CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
      +               cli_only=True),
           CommandDef("history", "Show conversation history", "Session",
                      cli_only=True),
           CommandDef("save", "Save the current conversation", "Session",
    • scripts/release.py
      @@ -61,6 +61,7 @@
           "[redacted:email]": "BadTechBandit",
           "[redacted:email]": "Yoimex",
           "[redacted:email]": "romanornr",
      +    "[redacted:email]": "foxion37",
           # contributors (from noreply pattern)
           "[redacted:email]": "davidvv",
           "[redacted:email]": "xiaoqiang243",
    • tests/cli/test_cli_force_redraw.py
      @@ -0,0 +1,73 @@
      +"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
      +
      +Covers:
      +  - _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
      +  - the resize handler we install over prompt_toolkit's _on_resize (#5474)
      +
      +Both behaviors are exercised against fake prompt_toolkit renderer/output
      +objects — we're asserting the escape sequences the CLI sends, not that
      +the terminal physically repainted.
      +"""
      +
      +from unittest.mock import MagicMock
      +
      +import pytest
      +
      +from cli import HermesCLI
      +
      +
      [redacted:email]
      +def bare_cli():
      +    """A HermesCLI with no __init__ — we only exercise the redraw helper."""
      +    cli = object.__new__(HermesCLI)
      +    return cli
      +
      +
      +class TestForceFullRedraw:
      +    def test_no_app_is_safe(self, bare_cli):
      +        # _force_full_redraw must be a no-op when the TUI isn't running.
      +        bare_cli._app = None
      +        bare_cli._force_full_redraw()  # must not raise
      +
      +    def test_missing_app_attr_is_safe(self, bare_cli):
      +        # Simulate HermesCLI before the TUI has ever been constructed.
      +        bare_cli._force_full_redraw()  # must not raise
      +
      +    def test_sends_full_clear_and_invalidates(self, bare_cli):
      +        app = MagicMock()
      +        out = app.renderer.output
      +        bare_cli._app = app
      +
      +        bare_cli._force_full_redraw()
      +
      +        # Must erase screen, home cursor, and flush — in that order.
      +        out.reset_attributes.assert_called_once()
      +        out.erase_screen.assert_called_once()
      +        out.cursor_goto.assert_called_once_with(0, 0)
      +        out.flush.assert_called_once()
      +
      +        # Must reset prompt_toolkit's tracked screen/cursor state so the
      +        # next incremental redraw starts from a clean (0, 0) origin.
      +        app.renderer.reset.assert_called_once_with(leave_alternate_screen=False)
      +
      +        # Must schedule a repaint.
      +        app.invalidate.assert_called_once()
      +
      +    def test_swallows_renderer_exceptions(self, bare_cli):
      +        # If the renderer blows up for any reason, the helper must not
      +        # propagate — otherwise a stray Ctrl+L would crash the CLI.
      +        app = MagicMock()
      +        app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
      +        bare_cli._app = app
      +
      +        bare_cli._force_full_redraw()  # must not raise
      +
      +        # invalidate() is still attempted after a renderer failure.
      +        app.invalidate.assert_called_once()
      +
      +    def test_swallows_invalidate_exceptions(self, bare_cli):
      +        app = MagicMock()
      +        app.invalidate.side_effect = RuntimeError("boom")
      +        bare_cli._app = app
      +
      +        bare_cli._force_full_redraw()  # must not raise
    • tests/cli/test_cli_terminal_response_sanitizer.py
      @@ -0,0 +1,57 @@
      +"""Tests for defensive terminal control-response stripping in the CLI.
      +
      +Covers Cursor Position Report (CPR / DSR) responses that occasionally
      +leak into the input buffer after terminal resize storms or multiplexer
      +tab switches — see issue #14692.
      +"""
      +
      +from cli import _strip_leaked_terminal_responses
      +
      +
      +class TestStripLeakedTerminalResponses:
      +    def test_plain_text_unchanged(self):
      +        text = "hello world"
      +        assert _strip_leaked_terminal_responses(text) == text
      +
      +    def test_empty_text(self):
      +        assert _strip_leaked_terminal_responses("") == ""
      +
      +    def test_strips_canonical_dsr_response(self):
      +        # Reports from issue #14692
      +        text = "\x1b[53;1R"
      +        assert _strip_leaked_terminal_responses(text) == ""
      +
      +    def test_strips_dsr_response_in_middle_of_text(self):
      +        text = "hello\x1b[53;1Rworld"
      +        assert _strip_leaked_terminal_responses(text) == "helloworld"
      +
      +    def test_strips_multiple_dsr_responses(self):
      +        text = "a\x1b[53;1Rb\x1b[51;1Rc\x1b[50;9Rd"
      +        assert _strip_leaked_terminal_responses(text) == "abcd"
      +
      +    def test_strips_visible_form_dsr(self):
      +        # When an upstream filter has already stripped the ESC byte and
      +        # left the caret-escape representation in place.
      +        text = "^[[53;1R"
      +        assert _strip_leaked_terminal_responses(text) == ""
      +
      +    def test_strips_visible_form_dsr_in_middle_of_text(self):
      +        text = "typed^[[53;1Rmore"
      +        assert _strip_leaked_terminal_responses(text) == "typedmore"
      +
      +    def test_does_not_strip_user_text_with_R(self):
      +        # Don't over-match; user might genuinely type text containing [N;NR patterns.
      +        # Our regex requires the leading ESC or caret-escape, so bare
      +        # "[53;1R" as user text is preserved.
      +        text = "see section [53;1R for details"
      +        assert _strip_leaked_terminal_responses(text) == text
      +
      +    def test_does_not_strip_sgr_sequences(self):
      +        # Sanity: don't wipe legitimate terminal control sequences that
      +        # aren't DSR responses.
      +        text = "\x1b[31mred\x1b[0m"
      +        assert _strip_leaked_terminal_responses(text) == text
      +
      +    def test_preserves_multiline_content(self):
      +        text = "line 1\n\x1b[53;1Rline 2"
      +        assert _strip_leaked_terminal_responses(text) == "line 1\nline 2"

1 PR closed without merging

From the close comments: merged via another change 1

  • #3181 fix(tui): status bar duplicates and degrades during long sessions · merged via another change → #3291
Closed issues · 2
  • Closed#3130TUI: Status bar duplicates and degrades during long sessions
  • Closed#12641bug(cli): Status bar duplicated as new lines when idle (not updating in-place)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#3291, #16527
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
27 Apr 2026

For your agent

The same facts, typed.