From 3c18dbb178d4d5e9b3b970a09910d4060014a27f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 19:46:07 -0700 Subject: [PATCH 1/2] Stop painting full-width grey spacer rows on user messages Blank user-banner rows no longer fill the terminal width with background color, which could flash as a solid grey block during scroll/repaint. Closes CL-5345 --- src/tui/components/event-log-assembly.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tui/components/event-log-assembly.ts b/src/tui/components/event-log-assembly.ts index 5b2619ee3..86df7df05 100644 --- a/src/tui/components/event-log-assembly.ts +++ b/src/tui/components/event-log-assembly.ts @@ -611,19 +611,23 @@ function blockToLines( ]; } case "user": { - // A subtle box with a blank padded row above and below so the text has - // breathing room. Text starts at column 1 to line up with the assistant's - // "●" marker; a 1-col right margin keeps the fill off the edge. + // Subtle box: paint background only on content rows. Blank spacer rows + // used to be full-width bg fills and could flash as solid grey blocks + // during scroll/repaint when content was empty or mid-frame. const bg = color("userMessageBg"); const LEFT = 1; const RIGHT = 1; const innerWidth = Math.max(1, width - LEFT - RIGHT); - const blankRow = [{ text: " ".repeat(width), backgroundColor: bg }]; + const blankRow: StyledLine = [{ text: "" }]; const userLines = plainLines( compactUserCodeBlocks(block.content), { color: color("text"), backgroundColor: bg }, innerWidth, ); + if (userLines.length === 0) { + // Empty user content: skip the grey box entirely. + return []; + } const body = userLines.map((line) => { const textLen = line.reduce((n, s) => n + s.text.length, 0); const pad = Math.max(0, innerWidth - textLen + RIGHT); From bc3a3b96786e74b1a7e11bf5126b4cf956b74f05 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 22:31:30 -0700 Subject: [PATCH 2/2] Gate empty user banners on real content and cover spacer backgrounds plainLines always yields at least one row, so the length check never skipped empty user messages. Trim-gate emptiness instead, and assert spacer rows stay unpainted while body rows keep userMessageBg. --- src/tui/components/event-log-assembly.ts | 7 +++---- src/tui/components/event-log.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/tui/components/event-log-assembly.ts b/src/tui/components/event-log-assembly.ts index 86df7df05..23c95286a 100644 --- a/src/tui/components/event-log-assembly.ts +++ b/src/tui/components/event-log-assembly.ts @@ -614,6 +614,9 @@ function blockToLines( // Subtle box: paint background only on content rows. Blank spacer rows // used to be full-width bg fills and could flash as solid grey blocks // during scroll/repaint when content was empty or mid-frame. + // plainLines/wrapRanges always yield ≥1 row (even for ""), so gate on + // real emptiness rather than userLines.length. + if (!block.content.trim()) return []; const bg = color("userMessageBg"); const LEFT = 1; const RIGHT = 1; @@ -624,10 +627,6 @@ function blockToLines( { color: color("text"), backgroundColor: bg }, innerWidth, ); - if (userLines.length === 0) { - // Empty user content: skip the grey box entirely. - return []; - } const body = userLines.map((line) => { const textLen = line.reduce((n, s) => n + s.text.length, 0); const pad = Math.max(0, innerWidth - textLen + RIGHT); diff --git a/src/tui/components/event-log.test.ts b/src/tui/components/event-log.test.ts index 387ec112b..3d7f21279 100644 --- a/src/tui/components/event-log.test.ts +++ b/src/tui/components/event-log.test.ts @@ -15,6 +15,7 @@ import { } from "./event-log.js"; import { formatElapsed } from "./in-flight-indicator.js"; import type { ContentBlock, ContentBlockData } from "../use-stream.js"; +import { color } from "../theme.js"; function asBlock(data: ContentBlockData & { id: string }): ContentBlock { return data as ContentBlock; @@ -706,6 +707,25 @@ describe("flat line buffer", () => { } }); + test("user banner spacer rows have no background while body keeps userMessageBg", () => { + const block: ContentBlock = { type: "user", id: "user-bg", content: "hello" }; + const lines = buildLines([block], COLUMNS, false, isExpanded); + // blank spacer, body, blank spacer + expect(lines.length).toBe(3); + const bg = color("userMessageBg"); + expect(lines[0]!.every((seg) => seg.backgroundColor === undefined)).toBe(true); + expect(lines[2]!.every((seg) => seg.backgroundColor === undefined)).toBe(true); + expect(lines[1]!.some((seg) => seg.backgroundColor === bg)).toBe(true); + expect(lines[1]!.every((seg) => seg.backgroundColor === bg)).toBe(true); + }); + + test("empty or whitespace user content yields no grey box", () => { + for (const content of ["", " ", "\n\t"]) { + const block: ContentBlock = { type: "user", id: "empty-user", content }; + expect(buildLines([block], COLUMNS, false, isExpanded)).toEqual([]); + } + }); + test("buildResourceBanner shows brand, workspace path, and optional skills/plugins", () => { const workspace = "/home/user/project"; const banner = buildResourceBanner([{ name: "scribe" }, { name: "tdd" }], ["exa"], 80, workspace);