Skip to content

feat(cli): full-width dim separator above and below user bubble#4095

Open
CnsMaple wants to merge 2 commits into
esengine:main-v2from
CnsMaple:fix/user-bubble-scroll-anchor
Open

feat(cli): full-width dim separator above and below user bubble#4095
CnsMaple wants to merge 2 commits into
esengine:main-v2from
CnsMaple:fix/user-bubble-scroll-anchor

Conversation

@CnsMaple

Copy link
Copy Markdown
Contributor

🤖 Generated by AI (CnsMaple + Reasonix)

Problem

When mouse-scrolling back to find an older turn in the chat transcript, the
user's submitted prompt is hard to spot. The assistant's markdown reply ends
without a strong visual edge into the user bubble, so the eye has to hunt
through dense, dimmed text to find the › … line. This is a small but real
ergonomic pain on long sessions.

Fix

A full-width dim rule above and below each committed user bubble. The
rule frames the bubble and acts as a scroll anchor — when you scroll up
through an older turn, the two rules bracket the user's input cleanly.

The rule is committed as a tiny placeholder token (NUL + U+E000 + NUL)
and expanded to a real renderUserSeparator(width) call at wrap time, so
it always spans the current viewport width. Terminal resizes, replays at
a different width, and any other rewrap pick up the new width with no
re-commit.

What changed

  • internal/cli/transcript.go
    • New userSeparatorToken constant, renderUserSeparatorToken() factory,
      and expandSeparatorTokens(lines, width) expander. The expander walks
      transcript entries and replaces every token with a freshly rendered
      renderUserSeparator(width), so resize is implicit.
    • renderUserSeparator(width int) string is kept as a public helper for
      direct use; it falls back to ASCII - when colorEnabled is false and
      to "" when width <= 0.
  • internal/cli/chat_tui.go
    • wrapTranscript is now preceded by expandSeparatorTokens(cm.transcript, contentW) on every transcript change — one insertion at the call site.
    • renderUserBubbleBlock now returns the token form (no width
      parameter) and is reused by both the submit path (chat_tui.go:3180)
      and the replay path (replaySectionsFor).
    • renderUserBubble is untouched; the regression guard
      TestUserBubbleIsLightweightTranscriptLine still passes.

Tests

  • TestRenderUserSeparatorIsFullWidthAndDimmed — covers color/ASCII paths
    and width <= 0.
  • TestRenderUserBubbleBlockEmitsThreeLines — middle line is unchanged;
    separators are the token.
  • TestUserBubbleCommitPathIncludesSeparators — three transcript entries
    after a submit, top/bottom are tokens.
  • TestUserBubbleBlockScrollsAsThreeLines — full pipeline
    expandSeparatorTokens → wrapTranscript; top/bottom are exactly
    content-width cells.
  • TestSeparatorTokenAdaptsToResize — same token-form transcript rendered
    at width 60 and width 100 produces rules of the matching widths and the
    wider one contains strictly more runes (anti-cache guard).

Notes

  • The rule is a real transcript entry, not a viewport background, so it
    scrolls with the content and survives resumed sessions through the replay
    path. Historical sessions are not backfilled — only turns submitted after
    this change pick up the rule, matching the "rendering change" semantics.
  • No new state, no new key bindings, no UI surface change outside the
    transcript. The user bubble itself is unchanged.
  • AI-assisted implementation, reviewed manually by the author.

Add a full-width dim '─' rule above and below each committed user bubble
as a visual scroll anchor — when mouse-scrolling back to an older turn, the
rule frames the bubble so it stands out from the surrounding assistant
markdown.

The rule is committed as a tiny token (NUL + PUA \uE000 + NUL) and
expanded to a real renderUserSeparator(width) call at wrap time, so it
always spans the *current* viewport width — survives terminal resizes,
replays at a different width, and any other rewrap with no re-commit.

* renderUserSeparator stays as a public helper; width <= 0 collapses to ''.
* renderUserBubbleBlock now returns token form (no width parameter) and is
  reused by the submit path and the replay path.
* expandSeparatorTokens is invoked once before wrapTranscript on every
  transcript change; non-separator lines pay no per-frame cost.
* Tests cover full-width, color/ASCII fallbacks, the regression guard on
  the existing TestUserBubbleIsLightweightTranscriptLine contract, and a
  dedicated TestSeparatorTokenAdaptsToResize that proves the same token
  renders at width 60 and width 100.
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development tui Terminal UI / CLI (internal/cli, internal/control) labels Jun 12, 2026
@CnsMaple

Copy link
Copy Markdown
Contributor Author

Here is a side-by-side ASCII comparison of the transcript area before and
after this change. Terminal width is 60 columns in both renders. The
[dim] marker denotes ANSI dim SGR — in a real terminal the rule below
is rendered as a continuous dim line, not as visible [dim] text.

Before

│ The assistant's last reply ends here. Lorem ipsum dolor sit
│ amet, consectetur adipiscing elit. Sed do eiusmod tempor
│ incididunt ut labore et dolore magna aliqua. Ut enim ad minim
│ veniam, quis nostrud exercitation ullamco laboris.
  › hi
  Hi! How can I help you today?
│ Sure, here is the next reply. The text flows on for a while
│ and the eye has to hunt for the `› hi` line in the middle
│ of the previous assistant block above.

After

│ The assistant's last reply ends here. Lorem ipsum dolor sit
│ amet, consectetur adipiscing elit. Sed do eiusmod tempor
│ incididunt ut labore et dolore magna aliqua. Ut enim ad minim
│ veniam, quis nostrud exercitation ullamco laboris.
[dim]────────────────────────────────────────────────────────────
  › hi
[dim]────────────────────────────────────────────────────────────
  Hi! How can I help you today?
│ Sure, here is the next reply. The text flows on for a while
│ and the eye now lands on the dim `─` rule instantly —
│ the user turn is bracketed by two visual anchors and is
│ trivial to spot while scrolling.

Why the rule adapts to resize

The characters above are committed as a 3-byte placeholder
(\x00\uE000\x00) and replaced at wrap time by
renderUserSeparator(contentW). So the same user turn from an
earlier session renders as a 60-cell rule on a 60-col terminal and
as a 100-cell rule on a 100-col terminal — no re-commit, no stale
hard-coded width:

  60-col terminal                 100-col terminal
  ────────────────────────        ──────────────────────────────────────────────────────────────────────
   › hi                            › hi
  ────────────────────────        ──────────────────────────────────────────────────────────────────────

The placeholder is invisible in the raw transcript (a NUL-framed
Private Use Area code point), so it costs ~3 bytes per user turn and
adds no per-frame overhead for non-user-bubble entries — the expander
is a single strings.Contains short-circuit per line.

@SivanCola

Copy link
Copy Markdown
Collaborator

已处理这两个 review 点并推到 PR head:4f744d95

  • chat_tui_test.go:删除了 TestUserBubbleCommitPathIncludesSeparators 里的空 if ln == "" {} 分支,避免 SA9003: empty branch
  • chat_tui.go:native scrollback/Termux 路径现在在 tea.Println 前通过 nativeScrollbackOutput 展开 separator token,避免把 NUL/PUA placeholder 打到终端;补了 TestNativeScrollbackOutputExpandsUserSeparatorTokens 覆盖该路径。

验证:

  • git diff --check
  • GOTOOLCHAIN=auto go test ./internal/cli

@SivanCola SivanCola enabled auto-merge June 12, 2026 09:56
@SivanCola

Copy link
Copy Markdown
Collaborator

approve

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tui Terminal UI / CLI (internal/cli, internal/control) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants