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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions src/tui/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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" }),
)
})

Expand Down
18 changes: 17 additions & 1 deletion src/tui/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
}

/**
Expand Down
Loading