Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion emrg/client/python_tui/widgets/chat_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
26 changes: 17 additions & 9 deletions emrg/client/python_tui/widgets/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions tests/test_message_time_is_the_daemons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading