From f2076c908f5cf104ac368a3d1c1ad2a7c870eacd Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 2 Nov 2026 14:14:24 +0800 Subject: [PATCH] emrg: the TUI keeps a message's clock inside the terminal width --- emrg/client/python_tui/widgets/chat_row.py | 38 ++++++- emrg/client/python_tui/widgets/markdown.py | 26 +++-- tests/test_message_time_is_the_daemons.py | 109 +++++++++++++++++++++ 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/emrg/client/python_tui/widgets/chat_row.py b/emrg/client/python_tui/widgets/chat_row.py index f4449c16..360aacbc 100644 --- a/emrg/client/python_tui/widgets/chat_row.py +++ b/emrg/client/python_tui/widgets/chat_row.py @@ -61,6 +61,42 @@ def format_message_time(timestamp) -> str: return at.strftime("%H:%M") +_CLOCK_GAP = " " + + +def clock_width(clock: str) -> int: + """Columns `clock` needs on a line, its gap included — the budget a row reserves. + + A clock is placed on a line **after** the content has been wrapped, so a row + that wraps its content to the full width leaves the clock nowhere to go: it + begins past the last column and the terminal clips it, which reads as "no + clock at all" however faithfully the value was carried (issue #2668). Every + renderer therefore subtracts this from the width it wraps to, and places the + clock ahead of any padding the renderer left on the line. + """ + return len(_CLOCK_GAP) + len(clock) if clock else 0 + + +def place_clock(line: Line, clock: str) -> None: + """Append `clock` to `line`, ahead of the padding already on it. + + The padding a markdown renderer pads a line with is part of its content span, + so appending the clock after it puts the clock at the far edge and, once the + clock's own width is in the wrap budget, still leaves the padding between the + text and the moment. Trimming it first lets the clock follow the text, and it + keeps the line within the width the row reserved (issue #2668). + """ + if not clock or not line.spans: + return + last = line.spans[-1] + stripped = last.text.rstrip() + if stripped != last.text: + last.text = stripped + if not last.text and len(line.spans) > 1: + line.spans.pop() + line.spans.append(Span(text=f"{_CLOCK_GAP}{clock}", style="dim")) + + class ChatRow(Widget): """A single chat message row. @@ -114,7 +150,7 @@ def render(self, ctx: RenderContext) -> list[Line]: # and a trailing clock reads as a margin note rather than as content. clock = format_message_time(self.timestamp) if clock and lines: - lines[0].spans.append(Span(text=f" {clock}", style="dim")) + place_clock(lines[0], clock) self._dirty = False return lines diff --git a/emrg/client/python_tui/widgets/markdown.py b/emrg/client/python_tui/widgets/markdown.py index 67dfcc25..ff03d850 100644 --- a/emrg/client/python_tui/widgets/markdown.py +++ b/emrg/client/python_tui/widgets/markdown.py @@ -15,7 +15,11 @@ from emrg.client.python_tui.theme import code_theme from emrg.client.python_tui.widgets.base import Line, RenderContext, Widget -from emrg.client.python_tui.widgets.chat_row import format_message_time +from emrg.client.python_tui.widgets.chat_row import ( + clock_width, + format_message_time, + place_clock, +) class Markdown(Widget): @@ -86,7 +90,11 @@ def render(self, ctx: RenderContext) -> list[Line]: prefix = self._ROLE_PREFIX indent = " " * len(prefix) role_style = Style.parse(self._ROLE_STYLE) - avail = max(1, ctx.width - len(prefix)) + # The clock's columns are part of this line's budget, not an addition to + # it: content wrapped to the full width leaves the clock past the last + # column, where the terminal clips it (issue #2668). + clock = format_message_time(self.timestamp) + avail = max(1, ctx.width - len(prefix) - clock_width(clock)) md = RichMarkdown(_preserve_line_breaks(self.text), code_theme=code_theme()) md_lines = rich_renderable_to_lines(md, avail) @@ -99,9 +107,8 @@ def render(self, ctx: RenderContext) -> list[Line]: # Same placement and format as `ChatRow`, from the one formatter, so the # two halves of a conversation read alike (rant 2026-10-09T09:25:00). - clock = format_message_time(self.timestamp) if clock and lines: - lines[0].spans.append(Span(text=f" {clock}", style="dim")) + place_clock(lines[0], clock) self._dirty = False return lines @@ -196,16 +203,17 @@ def render(self, ctx: RenderContext) -> list[Line]: last_fence = text.rfind("```") text = text[:last_fence] + # The clock's columns are reserved before the markdown is wrapped, so the + # settled row's moment lands inside the terminal width rather than past it + # (issue #2668). While the reply streams there is no moment and no reserve. + clock = format_message_time(self.timestamp) md = RichMarkdown(text, code_theme=self.code_theme or code_theme()) - lines = rich_renderable_to_lines(md, ctx.width) + lines = rich_renderable_to_lines(md, max(1, ctx.width - clock_width(clock))) for line in lines: line.style = ctx.style - clock = format_message_time(self.timestamp) if clock and lines: - from emrg.client.python_tui.widgets.base import Span - - lines[0].spans.append(Span(text=f" {clock}", style="dim")) + place_clock(lines[0], clock) self._last_rendered = lines self._dirty = False diff --git a/tests/test_message_time_is_the_daemons.py b/tests/test_message_time_is_the_daemons.py index f06d0a4a..8f459718 100644 --- a/tests/test_message_time_is_the_daemons.py +++ b/tests/test_message_time_is_the_daemons.py @@ -424,3 +424,112 @@ def test_clearing_drops_every_row_that_was_waiting(): assert len(parked) == 0 assert parked.fill("req-first", "2026-10-09T09:25:00") is False + + +# ── the client: the clock lands inside the width it is painted into ──────── +# +# Issue #2668. The readings above prove the clock is *carried*: they join a row's +# span text and ask whether `09:25` is in it — which an overflowing tail satisfies. +# So the suite stayed green while the host saw no time at all, on every path, for +# every message: the clock is appended after the content has already been wrapped to +# the full width, so it begins past the last column and the terminal clips it. +# +# The only reading that decides whether a person can see the clock is the rendered +# line against the terminal it is painted into. Both halves are asserted, because +# either one alone is satisfiable by a broken row: a line that fits can have padded +# the clock off the end, and a line carrying the clock can be too wide to show it. + +_GAP = " " +_WIDTHS = (60, 80, 100, 120) +_LONG = "word " * 60 + + +def _assert_clock_inside(rows, width: int, label: str) -> None: + """Every rendered line fits `width`, and the first carries the clock after the text.""" + for row in rows: + lines = row.render(_ctx(width)) + assert lines, f"{label}: rendered no lines at width {width}" + for index, line in enumerate(lines): + text = "".join(span.text for span in line.spans) + assert len(text) <= width, ( + f"{label}: line {index} is {len(text)} cells wide at terminal width {width} " + f"— its tail is clipped, so anything sitting there is invisible: {text!r}" + ) + first = "".join(span.text for span in lines[0].spans) + assert "09:25" in first, ( + f"{label}: the clock is not on the row's first line at width {width}: {first!r}" + ) + body = first[: first.rindex("09:25")] + before_clock = len(body) - len(body.rstrip(" ")) + assert before_clock == len(_GAP), ( + f"{label}: the clock sits {before_clock} cells after the text at width {width}, " + f"not the {len(_GAP)}-cell gap — the line's trailing padding is ahead of the " + f"clock, which is what pushes the moment out of view: {first!r}" + ) + + +@pytest.mark.parametrize("width", _WIDTHS) +def test_a_live_user_row_keeps_its_clock_inside_the_terminal(width): + """The row a typed message draws, wrapped to fill the line it has to share.""" + _assert_clock_inside([UserMarkdown(_LONG, timestamp="2026-10-09T09:25:00")], + width, "live user message") + + +@pytest.mark.parametrize("width", _WIDTHS) +def test_a_settled_reply_keeps_its_clock_inside_the_terminal(width): + """The live assistant row, taken from streaming to the moment the frame lands.""" + row = StreamingMarkdown() + row.feed(_LONG) + row.timestamp = "2026-10-09T09:25:00" + row.dirty = True + + _assert_clock_inside([row], width, "live assistant reply") + + +@pytest.mark.parametrize("width", _WIDTHS) +def test_a_replayed_transcript_keeps_each_clock_inside_the_terminal(width): + """`/resume` and `/rewind` rebuild the screen through the app's own replay path. + + Driven through `_replay_rows` / `_paint_replay_rows` rather than by building the + rows here: a row constructed by this test would prove the widgets, not that the + transcript a reopen paints is the same reading. + """ + from emrg.client.app import _paint_replay_rows, _replay_rows + + records = [ + {"kind": "message", "role": "user", "content": _LONG, + "timestamp": "2026-10-09T09:25:00"}, + {"kind": "message", "role": "assistant", "content": _LONG, + "timestamp": "2026-10-09T09:25:00"}, + ] + chat = ChatHistory() + _paint_replay_rows(chat, _replay_rows(records)) + + assert len(chat.rows) == 2, "the replay did not rebuild both halves of the transcript" + _assert_clock_inside(chat.rows, width, "replayed transcript") + + +@pytest.mark.parametrize("missing", [None, "", "not a timestamp"]) +@pytest.mark.parametrize("kind", ["user", "assistant"]) +def test_a_row_with_no_moment_still_fits_the_width_it_is_painted_into(kind, missing): + """A legacy record renders no clock — and must not reserve columns for one either. + + The absence is the interesting half: a renderer that reserved the clock's width + unconditionally would wrap a clockless row narrower than the terminal for a + reason the reader cannot see. + """ + if kind == "user": + row = UserMarkdown(_LONG, timestamp=missing) + else: + row = StreamingMarkdown() + row.feed(_LONG) + row.timestamp = missing or "" + + for width in _WIDTHS: + lines = row.render(_ctx(width)) + for line in lines: + text = "".join(span.text for span in line.spans) + assert len(text) <= width, f"{kind}/{missing!r}: {len(text)} cells at {width}" + assert ":" not in "".join(s.text for s in lines[0].spans).split(_LONG[:10])[-1], ( + f"{kind}/{missing!r}: a row with no readable moment rendered a clock" + )