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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions src/tui/geometry/margins.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

/**
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/tui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 4 additions & 5 deletions src/tui/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions src/tui/margins.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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, {
Expand All @@ -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()
}
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading