diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8af99c0..97e0ace09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions ### TUI +- **Bottom breathing room.** The prompt box sits one blank row above the + 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. - **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 f65e28222..39cd996bb 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -45,6 +45,14 @@ middle tier: one column is already enough to keep content off the frame edge, which is the gutter's entire job, and anything wider only read as excess air on a wide pane. The gutter costs no rows. +Vertically, the same file keeps content off the top and bottom edges with one +blank row each: `TOP_PAD_ROWS` above the first transcript row, and +`BOTTOM_MARGIN_ROWS` below the prompt box. Both are carved out of the +transcript residual by the shell after the geometry resolver has assigned +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). + 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/geometry/margins.ts b/src/tui/geometry/margins.ts index 0090a571d..8ed566554 100644 --- a/src/tui/geometry/margins.ts +++ b/src/tui/geometry/margins.ts @@ -1,12 +1,12 @@ /** * Optical breathing room shared by every shell surface. * - * The margin is one number for the whole interface — transcript, prompt box, - * model bar, hint row and overlay host all sit inside it — so the shell reads - * as a single column of content rather than panes that happen to be stacked. - * - * Horizontal only. The row budget is the geometry resolver's business; nothing - * here can take a row away from it. + * The side gutter is one number for the whole interface — transcript, prompt + * box, model bar, hint row and overlay host all sit inside it — so the shell + * reads as a single column of content rather than panes that happen to be + * stacked. Top and bottom pads are carved out of the transcript residual by + * the shell after the geometry resolver has assigned heights, so they never + * change the resolver's row budget. */ /** @@ -50,14 +50,14 @@ export function resolveTopPadRows(transcriptRows: number): number { } /** - * Rows below the prompt box. Zero: the box sits on the terminal's last row. + * Rows below the prompt box once the terminal can afford them. * - * A blank row here reads as the interface floating rather than resting on the - * bottom edge — the box is the thing the operator types into, and it wants to - * be where the cursor already is. The side gutters still keep it off the left - * and right edges, which is where crowding actually shows. + * One blank row keeps the prompt off the terminal's last line the same way + * `TOP_PAD_ROWS` keeps the first transcript row off the top edge and + * `SIDE_MARGIN` keeps content off the left and right. More than one only + * reads as the interface floating, so there is no middle tier. */ -export const BOTTOM_MARGIN_ROWS = 0 +export const BOTTOM_MARGIN_ROWS = 1 /** * Below this terminal height the margin is not worth the row it costs — the diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 165f3df76..5978f3adf 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -382,9 +382,10 @@ describe("landing screen", () => { row.includes(LOCKUP_WORDMARK), ) // Session-active: the version row only reserves space on the landing - // screen (see `relayout`), so once there is real transcript content - // the box is back on the terminal's very last row. - expect(ruleRow).toBe(SIZE.height - 1) + // screen (see `relayout`). Once there is real transcript content the + // box sits one row above the terminal's last line — the optical + // bottom pad (`BOTTOM_MARGIN_ROWS`) keeps it off the frame edge. + expect(ruleRow).toBe(SIZE.height - 2) const row = painted[ruleRow]! // Left end of the rule, inside the shell gutter, costing no row. expect(row.startsWith(" ╰─ ")).toBe(true) diff --git a/src/tui/landing.ts b/src/tui/landing.ts index 206e8bf85..a6d4a5f48 100644 --- a/src/tui/landing.ts +++ b/src/tui/landing.ts @@ -62,11 +62,10 @@ export const LANDING_VERSION = `v${pkg.version}` * Minimum terminal size the version badge needs before it hides. 16 rows is * above `IDLE_TRANSCRIPT_FLOOR` (12) — the only row floor real chrome is * actually held to at rest — so the badge is gone well before the - * transcript itself would be squeezed. It is below `BOTTOM_MARGIN_MIN_ROWS` - * (24, in `geometry/margins.ts`); that constant does not currently mean - * anything in practice (`BOTTOM_MARGIN_ROWS` it gates is 0), so there is no - * real floor at 24 to be above yet, but if one is ever added there this - * threshold does not automatically clear it and should be revisited. + * transcript itself would be squeezed. It is also below + * `BOTTOM_MARGIN_MIN_ROWS` (24): the bottom pad is optical room carved from + * the transcript residual, not a second reserved chrome row, so the badge's + * own threshold does not need to clear it. */ export const VERSION_BADGE_MIN_COLUMNS = 60 export const VERSION_BADGE_MIN_ROWS = 16 diff --git a/src/tui/margins.test.ts b/src/tui/margins.test.ts index 9b909c9d7..b2a7b14e8 100644 --- a/src/tui/margins.test.ts +++ b/src/tui/margins.test.ts @@ -1,7 +1,7 @@ /** * Breathing room: one optical gutter shared by every surface, a blank row above - * the first transcript row, and a narrow-terminal floor where the gutter yields - * to content rather than squeezing it. + * the first transcript row, a blank row below the prompt box, and a + * narrow-terminal floor where each pad yields to content rather than squeezing it. */ import { describe, expect, test } from "bun:test" import { @@ -107,7 +107,15 @@ describe("top padding", () => { }) describe("bottom edge", () => { - test("the prompt box rests on the terminal's last row", async () => { + test("one blank row below the prompt once the terminal can afford it", () => { + expect(resolveBottomMarginRows(BOTTOM_MARGIN_MIN_ROWS)).toBe(1) + expect(resolveBottomMarginRows(30)).toBe(1) + expect(resolveBottomMarginRows(60)).toBe(1) + expect(resolveBottomMarginRows(BOTTOM_MARGIN_MIN_ROWS - 1)).toBe(0) + expect(resolveBottomMarginRows(12)).toBe(0) + }) + + test("the prompt box leaves a blank row above the terminal's last line", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -118,9 +126,14 @@ describe("bottom edge", () => { appendStreamRow(shell, { role: "user", text: "check the bottom" }) await settle(h) const rows = frameRows(h) - // The box is what the operator types into; it belongs where the - // cursor already is, not floating a row above it. - expect(rows[rows.length - 1]?.trim()).not.toBe("") + // One optical row under the box — same job as the top pad and the + // side gutters, just at the other edge. + expect(shell.bottomPad.visible).toBe(true) + expect(shell.bottomPad.height).toBe(1) + expect(rows[rows.length - 1]?.trim()).toBe("") + // The box itself still paints: its bottom rule is the last content + // row, not the last terminal row. + expect(rows[rows.length - 2]?.includes("╰")).toBe(true) } finally { shell.dispose() } @@ -137,7 +150,6 @@ describe("bottom edge", () => { } }) - test("collapses to zero on a short terminal so nothing else starves", async () => { const rows = BOTTOM_MARGIN_MIN_ROWS - 1 await withTestRenderer( diff --git a/src/tui/shell.ts b/src/tui/shell.ts index c92c03255..fe0ced084 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -774,8 +774,9 @@ function terminalOf( /** * The version row is real chrome, not a float — it holds its own reserved - * row at the foot of the shell rather than overlaying content that already - * fills every row (there is no other spare one; `BOTTOM_MARGIN_ROWS` is 0). + * row at the foot of the shell rather than painting into the optical bottom + * pad (`BOTTOM_MARGIN_ROWS`), which is blank breathing room, not a content + * slot. * * This genuinely costs the rest of the shell a row, not just the space it * paints in: the geometry resolver is handed `terminal.rows - 1`, so every