diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e0ace09..f3d344c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions terminal's last line on terminals tall enough to spare it (`BOTTOM_MARGIN_ROWS`, collapsed below 24 rows), so the layout no longer feels flush against the frame edge. +- **User-message breathing room.** Operator turns in the transcript keep a + blank bar row above and below the message text, so user prompts are easier + to spot while scrolling through assistant and tool rows (CL-5603). - **Drag-select auto-copy.** With mouse capture on (the default), finishing a drag selection in the transcript writes the selected text to the system clipboard on mouse-up and flashes a short status line. Alt+M still hands the diff --git a/docs/TUI.md b/docs/TUI.md index 39cd996bb..54d6a0989 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -53,6 +53,13 @@ heights, so they never change the resolver's row budget. Each collapses to zero when the terminal is too short to spare it (`TOP_PAD_MIN_TRANSCRIPT_ROWS` for the top pad, `BOTTOM_MARGIN_MIN_ROWS` for the bottom). +Operator turns in the transcript are left-aligned bubbles with a solid bar +down the left edge (`userBubbleLines` in `src/tui/stream.ts`). Each bubble +keeps one empty bar row above and below its text so the operator's voice +stays easy to find while scrolling through denser assistant and tool rows — +the pad is part of the bubble itself, not an extra turn-boundary gap, and +assistant/tool rows are unchanged. + The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule; the brand lockup sits at the left of the bottom rule with the working directory and git diff --git a/src/tui/stream.test.ts b/src/tui/stream.test.ts index c1d3054cf..57acc97d9 100644 --- a/src/tui/stream.test.ts +++ b/src/tui/stream.test.ts @@ -24,9 +24,16 @@ const CREW: RowLayout = { width: 56, multiAgent: true } const lines = (row: StreamRow, layout: RowLayout = SOLO): string[] => paintStreamRow(row, layout).content.split("\n") +/** Body lines of a user bubble (strip the empty pad rows above and below). */ +const userBody = (row: StreamRow, layout: RowLayout = SOLO): string[] => { + const painted = lines(row, layout) + expect(painted.length).toBeGreaterThanOrEqual(3) + return painted.slice(1, -1) +} + describe("stream paint", () => { test("one voice needs no labels: the operator is found by the bar", () => { - const you = lines({ role: "user", text: "hi" })[0] as string + const you = userBody({ role: "user", text: "hi" })[0] as string const agent = lines({ role: "assistant", text: "hello" })[0] as string expect(you).not.toContain("you") @@ -62,6 +69,31 @@ describe("stream paint", () => { } }) + test("the operator's bubble has a blank bar row above and below the text", () => { + const bar = "\u258d" + const painted = lines({ role: "user", text: "hi" }) + // Shape: bare bar, body, bare bar — breathing room when scrolling (CL-5603). + expect(painted).toEqual([bar, `${bar} hi`, bar]) + // Assistant and tool rows stay tight; the pad is user-only. + expect(lines({ role: "assistant", text: "hello" })).toEqual(["hello"]) + expect(lines({ role: "tool", text: "ok", meta: "bash" })[0]).not.toBe(bar) + // A wrapped body still sits between exactly one pad row on each side. + const long = lines( + { + role: "user", + text: "please find every call site of the legacy token helper and report which still run", + }, + { width: 40, multiAgent: false }, + ) + expect(long[0]).toBe(bar) + expect(long[long.length - 1]).toBe(bar) + expect(long.length).toBeGreaterThan(3) + for (const line of long.slice(1, -1)) { + expect(line.startsWith(`${bar} `)).toBe(true) + expect(line.length).toBeGreaterThan(2) + } + }) + test("both human voices keep the cream; nothing paints a gray", () => { const rows: readonly StreamRow[] = [ { role: "user", text: "x" }, @@ -167,8 +199,8 @@ describe("stream paint", () => { expect(crew).not.toContain("●") expect(crew.startsWith("on it")).toBe(true) // The operator stays a left-aligned bubble either way. - expect(lines({ role: "user", text: "go" }, CREW)[0]).toBe( - lines({ role: "user", text: "go" })[0], + expect(lines({ role: "user", text: "go" }, CREW)).toEqual( + lines({ role: "user", text: "go" }), ) }) diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 02a2866a5..106b49f34 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -315,18 +315,34 @@ function toolPrefix(row: StreamRow): string { /** Columns the operator's bubble may claim before it wraps. */ const BUBBLE_MAX_SHARE = 0.75 +/** + * Empty bar rows painted above and below the operator's text so the turn + * reads as a block when scrolling past denser assistant/tool rows (CL-5603). + */ +const USER_BUBBLE_PAD = 1 + /** * The operator's turn as a block hugging the left gutter, same as an answer, * with the bar down its left edge. The bar (not alignment) is what makes a * user turn findable now that both voices share cream and the left edge; the * body sits two columns past it so the boundary reads even at a glance. + * One empty bar row above and below the text gives the bubble breathing room + * without changing the turn-boundary gap used by every other row. */ function userBubbleLines(text: string, width: number): string[] { const bar = `${BUBBLE_BAR} ` const barWidth = stringWidth(bar) const body = Math.max(1, Math.min(width - barWidth, Math.ceil(width * BUBBLE_MAX_SHARE))) const lines = text.split("\n").flatMap((line) => wrapLines(line, body)) - return lines.map((line) => `${bar}${line}`) + const content = lines.map((line) => `${bar}${line}`) + // Bare bar (no trailing body space) so the pad reads as air, not an empty + // content column — same glyph column as the body lines either way. + const pad = BUBBLE_BAR + return [ + ...Array.from({ length: USER_BUBBLE_PAD }, () => pad), + ...content, + ...Array.from({ length: USER_BUBBLE_PAD }, () => pad), + ] } /**