Skip to content

Commit f7ec8de

Browse files
committed
Pad user bubbles with a blank bar row above and below
Scrolling past dense tool/assistant rows made operator turns hard to spot. One empty bar row of air on each side of the bubble text keeps the left-edge bar continuous without changing turn-boundary gaps. CL-5603
1 parent c00a11d commit f7ec8de

4 files changed

Lines changed: 62 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions
2525
terminal's last line on terminals tall enough to spare it
2626
(`BOTTOM_MARGIN_ROWS`, collapsed below 24 rows), so the layout no longer
2727
feels flush against the frame edge.
28+
- **User-message breathing room.** Operator turns in the transcript keep a
29+
blank bar row above and below the message text, so user prompts are easier
30+
to spot while scrolling through assistant and tool rows (CL-5603).
2831
- **Drag-select auto-copy.** With mouse capture on (the default), finishing a
2932
drag selection in the transcript writes the selected text to the system
3033
clipboard on mouse-up and flashes a short status line. Alt+M still hands the

docs/TUI.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ heights, so they never change the resolver's row budget. Each collapses to
5353
zero when the terminal is too short to spare it (`TOP_PAD_MIN_TRANSCRIPT_ROWS`
5454
for the top pad, `BOTTOM_MARGIN_MIN_ROWS` for the bottom).
5555

56+
Operator turns in the transcript are left-aligned bubbles with a solid bar
57+
down the left edge (`userBubbleLines` in `src/tui/stream.ts`). Each bubble
58+
keeps one empty bar row above and below its text so the operator's voice
59+
stays easy to find while scrolling through denser assistant and tool rows —
60+
the pad is part of the bubble itself, not an extra turn-boundary gap, and
61+
assistant/tool rows are unchanged.
62+
5663
The prompt box's border carries the metadata that would otherwise cost a
5764
titlebar row: the model label sits right-aligned in the top rule; the brand
5865
lockup sits at the left of the bottom rule with the working directory and git

src/tui/stream.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,16 @@ const CREW: RowLayout = { width: 56, multiAgent: true }
2424
const lines = (row: StreamRow, layout: RowLayout = SOLO): string[] =>
2525
paintStreamRow(row, layout).content.split("\n")
2626

27+
/** Body lines of a user bubble (strip the empty pad rows above and below). */
28+
const userBody = (row: StreamRow, layout: RowLayout = SOLO): string[] => {
29+
const painted = lines(row, layout)
30+
expect(painted.length).toBeGreaterThanOrEqual(3)
31+
return painted.slice(1, -1)
32+
}
33+
2734
describe("stream paint", () => {
2835
test("one voice needs no labels: the operator is found by the bar", () => {
29-
const you = lines({ role: "user", text: "hi" })[0] as string
36+
const you = userBody({ role: "user", text: "hi" })[0] as string
3037
const agent = lines({ role: "assistant", text: "hello" })[0] as string
3138

3239
expect(you).not.toContain("you")
@@ -62,6 +69,31 @@ describe("stream paint", () => {
6269
}
6370
})
6471

72+
test("the operator's bubble has a blank bar row above and below the text", () => {
73+
const bar = "\u258d"
74+
const painted = lines({ role: "user", text: "hi" })
75+
// Shape: bare bar, body, bare bar — breathing room when scrolling (CL-5603).
76+
expect(painted).toEqual([bar, `${bar} hi`, bar])
77+
// Assistant and tool rows stay tight; the pad is user-only.
78+
expect(lines({ role: "assistant", text: "hello" })).toEqual(["hello"])
79+
expect(lines({ role: "tool", text: "ok", meta: "bash" })[0]).not.toBe(bar)
80+
// A wrapped body still sits between exactly one pad row on each side.
81+
const long = lines(
82+
{
83+
role: "user",
84+
text: "please find every call site of the legacy token helper and report which still run",
85+
},
86+
{ width: 40, multiAgent: false },
87+
)
88+
expect(long[0]).toBe(bar)
89+
expect(long[long.length - 1]).toBe(bar)
90+
expect(long.length).toBeGreaterThan(3)
91+
for (const line of long.slice(1, -1)) {
92+
expect(line.startsWith(`${bar} `)).toBe(true)
93+
expect(line.length).toBeGreaterThan(2)
94+
}
95+
})
96+
6597
test("both human voices keep the cream; nothing paints a gray", () => {
6698
const rows: readonly StreamRow[] = [
6799
{ role: "user", text: "x" },
@@ -167,8 +199,8 @@ describe("stream paint", () => {
167199
expect(crew).not.toContain("●")
168200
expect(crew.startsWith("on it")).toBe(true)
169201
// The operator stays a left-aligned bubble either way.
170-
expect(lines({ role: "user", text: "go" }, CREW)[0]).toBe(
171-
lines({ role: "user", text: "go" })[0],
202+
expect(lines({ role: "user", text: "go" }, CREW)).toEqual(
203+
lines({ role: "user", text: "go" }),
172204
)
173205
})
174206

src/tui/stream.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,18 +315,34 @@ function toolPrefix(row: StreamRow): string {
315315
/** Columns the operator's bubble may claim before it wraps. */
316316
const BUBBLE_MAX_SHARE = 0.75
317317

318+
/**
319+
* Empty bar rows painted above and below the operator's text so the turn
320+
* reads as a block when scrolling past denser assistant/tool rows (CL-5603).
321+
*/
322+
const USER_BUBBLE_PAD = 1
323+
318324
/**
319325
* The operator's turn as a block hugging the left gutter, same as an answer,
320326
* with the bar down its left edge. The bar (not alignment) is what makes a
321327
* user turn findable now that both voices share cream and the left edge; the
322328
* body sits two columns past it so the boundary reads even at a glance.
329+
* One empty bar row above and below the text gives the bubble breathing room
330+
* without changing the turn-boundary gap used by every other row.
323331
*/
324332
function userBubbleLines(text: string, width: number): string[] {
325333
const bar = `${BUBBLE_BAR} `
326334
const barWidth = stringWidth(bar)
327335
const body = Math.max(1, Math.min(width - barWidth, Math.ceil(width * BUBBLE_MAX_SHARE)))
328336
const lines = text.split("\n").flatMap((line) => wrapLines(line, body))
329-
return lines.map((line) => `${bar}${line}`)
337+
const content = lines.map((line) => `${bar}${line}`)
338+
// Bare bar (no trailing body space) so the pad reads as air, not an empty
339+
// content column — same glyph column as the body lines either way.
340+
const pad = BUBBLE_BAR
341+
return [
342+
...Array.from({ length: USER_BUBBLE_PAD }, () => pad),
343+
...content,
344+
...Array.from({ length: USER_BUBBLE_PAD }, () => pad),
345+
]
330346
}
331347

332348
/**

0 commit comments

Comments
 (0)