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
2 changes: 0 additions & 2 deletions src/tui-opentui/command-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,12 @@ describe("commandItemsFromRegistry", () => {
label: "/tasks — Show work list",
keywords: ["tasks", "slash", "command"],
dispatch: "command",
category: "command",
},
{
id: "clear",
label: "/clear — Clear screen",
keywords: ["clear", "slash", "command"],
dispatch: "command",
category: "session",
},
])
})
Expand Down
21 changes: 21 additions & 0 deletions src/tui-opentui/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,27 @@ describe("resolveGeometry — overlay modes", () => {
expect(layout.transcriptHeight).toBeGreaterThanOrEqual(OVERLAY_TRANSCRIPT_FLOOR);
});

test("a large list overlay on a short terminal never exceeds terminal rows", () => {
// A ~30-command palette asks for far more body rows than a short terminal
// has; the resolver must still sum to exactly terminal.rows rather than
// let the overlay's own border/title chrome overflow past the screen.
for (let rows = 4; rows <= 12; rows++) {
const layout = resolveGeometry({
terminal: { columns: 80, rows },
overlay: { mode: "inset", bodyRows: 48 },
});
const total = layout.chromeHeight + layout.overlayHeight + layout.transcriptHeight;
expect(total).toBe(rows);
}
});

test("overlay gets at least its border/title minimum before the transcript floor", () => {
const layout = idle80x24({
overlay: { mode: "inset", bodyRows: 48, minBodyRows: 5 },
});
expect(layout.overlayHeight).toBeGreaterThanOrEqual(5);
});

test("full_shell hides transcript and gives residual to overlay_host", () => {
const layout = idle80x24({
overlay: { mode: "full_shell", bodyRows: 20 },
Expand Down
1 change: 1 addition & 0 deletions src/tui-opentui/geometry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
COLLAPSE_ORDER,
IDLE_TRANSCRIPT_FLOOR,
OVERLAY_MAX_FRACTION,
OVERLAY_MIN_ROWS,
OVERLAY_TRANSCRIPT_FLOOR,
PAINT_ORDER,
PROMPT_BASE_ROWS,
Expand Down
21 changes: 19 additions & 2 deletions src/tui-opentui/geometry/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
COLLAPSE_ORDER,
IDLE_TRANSCRIPT_FLOOR,
OVERLAY_MAX_FRACTION,
OVERLAY_MIN_ROWS,
OVERLAY_TRANSCRIPT_FLOOR,
PAINT_ORDER,
PROMPT_BASE_ROWS,
Expand All @@ -26,6 +27,12 @@ export type OverlayInput = {
readonly mode: OverlayMode;
/** Requested overlay body rows (measured by host). Capped by fraction + floor. */
readonly bodyRows?: number;
/**
* Rows the overlay's own chrome cannot render without (border + title +
* at least one content row). Falls back to `OVERLAY_MIN_ROWS` when the
* caller has not measured its actual chrome.
*/
readonly minBodyRows?: number;
};

/**
Expand Down Expand Up @@ -315,6 +322,15 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout {
);
if (heights.prompt > promptCap) heights.prompt = promptCap;

// An open overlay with real content needs its own border/title rows or it
// renders past whatever height it was actually assigned. The transcript
// floor below cannot be satisfied at that overlay's expense.
const requestedOverlayRows = input.overlay?.bodyRows ?? 0;
const minOverlay =
mode !== "closed" && requestedOverlayRows > 0
? Math.min(input.overlay?.minBodyRows ?? OVERLAY_MIN_ROWS, requestedOverlayRows)
: 0;

// Iteratively collapse optional chrome until transcript meets floor with overlay.
// Enough steps to walk a grown prompt back to base one row at a time on top
// of dropping every optional zone.
Expand All @@ -328,15 +344,16 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout {
floor,
);
const transcript = terminal.rows - chrome - overlay;
if (transcript >= floor) {
if (transcript >= floor && overlay >= minOverlay) {
heights.transcript = Math.max(0, transcript);
heights.overlay_host = overlay;
break;
}
// Need more space: collapse one zone, then retry.
const cut = collapseOnce(heights, collapsed);
if (cut === null) {
// Nothing left — accept best effort (may be below floor on tiny terminals).
// Nothing left — relax the transcript floor rather than leave the
// overlay under its own render minimum; accept best effort past that.
heights.overlay_host = desiredOverlayHeight(
{ ...input, terminal },
mode,
Expand Down
8 changes: 8 additions & 0 deletions src/tui-opentui/geometry/zones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ export const PROMPT_CAP_FRACTION = 0.4;
/** Overlay host body may not exceed this fraction of terminal rows (proposed). */
export const OVERLAY_MAX_FRACTION = 0.7;

/**
* Smallest overlay_host an open overlay can render into: two border rows plus
* one content row. The transcript floor exists to keep conversation visible,
* but it must not starve an overlay the operator just opened below the rows
* its own border costs — that renders past its box instead of shrinking.
*/
export const OVERLAY_MIN_ROWS = 3;

/**
* Prompt floor: labelled borders + one content line. Only a terminal too short
* to seat the transcript floor alongside a composing area gets squeezed here.
Expand Down
95 changes: 79 additions & 16 deletions src/tui-opentui/palette-paint.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Frame-level checks for the command palette's three-column rows: the category
* prefix, the right-aligned chord, and how they degrade at narrow widths.
* Frame-level checks for the command palette's rows: the label, the
* right-aligned chord, and how the chord degrades at narrow widths.
*/
import { describe, expect, test } from "bun:test"

Expand Down Expand Up @@ -39,33 +39,31 @@ function rowFor(rows: readonly string[], label: string): string | undefined {
}

describe("command palette rows", () => {
test("titles the box with a broken rule and shows the filter prompt", async () => {
test("shows the filter prompt with no title rule above it", async () => {
const rows = await paletteFrame(100)
expect(rows.some((r) => r.startsWith("─ command palette ─"))).toBe(true)
expect(rows.some((r) => r.startsWith("─ command palette ─"))).toBe(false)
expect(rows.some((r) => r.trim() === ">")).toBe(true)
})

test("paints the category prefix and right-aligned chord at 100 columns", async () => {
test("has no leading selection marker or kind column", async () => {
const rows = await paletteFrame(100)
const help = rowFor(rows, "Show keymap help")
expect(help).toBeDefined()
expect(help).toMatch(/^\s+[> ] view\s+Show keymap help\s+\?$/)
expect(help).toMatch(/^\s*Show keymap help\s+\?$/)
expect(help).not.toContain(">")
expect(help).not.toContain("view")
})

test("keeps the right-aligned chord at 100 columns", async () => {
const rows = await paletteFrame(100)
const copy = rowFor(rows, "Copy active message / tool")
expect(copy?.endsWith("Alt+C")).toBe(true)
})

test("keeps both side columns at 60 columns", async () => {
const rows = await paletteFrame(60)
const help = rowFor(rows, "Show keymap help")
expect(help).toContain("view")
expect(help?.endsWith("?")).toBe(true)
})

test("drops the chord first at 48 columns, keeping the category", async () => {
const rows = await paletteFrame(48)
test("drops the chord at a narrow width, and the label always survives", async () => {
const rows = await paletteFrame(36)
const help = rowFor(rows, "Show keymap help")
expect(help).toContain("view")
expect(help).toBeDefined()
expect(help?.endsWith("?")).toBe(false)

const copy = rowFor(rows, "Copy active")
Expand Down Expand Up @@ -193,3 +191,68 @@ describe("palette filters as you type", () => {
)
})
})

describe("command palette width", () => {
// Both boxes are children of the same padded root; a width computed a
// second way for the floating palette drifts from the prompt box's "100%".
test("shares the prompt box's left/right edges while floating over landing", async () => {
const rows = await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
})
openPalette(shell)
await h.renderOnce()
return h.captureCharFrame().split("\n")
},
{ width: 80, height: 24 },
)
const overlayTop = rows.find((r) => r.includes("┌"))
const promptTop = rows.find((r) => r.includes("╭"))
expect(overlayTop).toBeDefined()
expect(promptTop).toBeDefined()
expect(overlayTop?.indexOf("┌")).toBe(promptTop?.indexOf("╭"))
expect(overlayTop?.lastIndexOf("┐")).toBe(promptTop?.lastIndexOf("╮"))
})
})

describe("command palette selection colour", () => {
test("marks the active row by text colour, not a filled background", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 100, rows: 32 },
wireKeys: false,
run: "idle",
})
openPalette(shell)
await h.renderOnce()
const frame = h.captureSpans()
const activeLine = frame.lines.find((line) =>
line.spans.some((s) => s.text.includes("Open permissions")),
)
const groundLine = frame.lines.find((line) =>
line.spans.some((s) => s.text.includes("Ask operator question")),
)
expect(activeLine).toBeDefined()
expect(groundLine).toBeDefined()
const activeBg = activeLine!.spans[0]!.bg
const groundBg = groundLine!.spans[0]!.bg
// Same background either way — selection reads through text colour
// (fg), not a filled band behind the row.
expect(activeBg).toEqual(groundBg)
const activeFg = activeLine!.spans.find((s) =>
s.text.includes("Open permissions"),
)!.fg
const groundFg = groundLine!.spans.find((s) =>
s.text.includes("Ask operator question"),
)!.fg
expect(activeFg).not.toEqual(groundFg)
},
{ width: 100, height: 32 },
)
})
})

60 changes: 10 additions & 50 deletions src/tui-opentui/palette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,27 +84,6 @@ describe("buildPaletteCatalog", () => {
})

describe("palette row columns", () => {
test("residual openers carry a category", () => {
const cols = DEFAULT_PALETTE_COMMANDS.map((c) =>
paletteRowColumns(c, shortcutForPaletteId),
)
expect(cols.every((c) => c.category.length > 0)).toBe(true)
expect(cols.find((c) => c.label === "Show keymap help")?.category).toBe(
"view",
)
})

test("registry commands get a category from their name", () => {
const catalog = buildPaletteCatalog({
commands: [
{ name: "rename", description: "Name the session" },
{ name: "wobble", description: "A plugin command" },
],
})
expect(catalog.find((c) => c.id === "rename")?.category).toBe("session")
expect(catalog.find((c) => c.id === "wobble")?.category).toBe("command")
})

test("shortcuts come from the shell keybinding table", () => {
const help = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "help")
expect(paletteRowColumns(help!, shortcutForPaletteId).shortcut).toBe("?")
Expand All @@ -115,16 +94,15 @@ describe("palette row columns", () => {

describe("formatPaletteRows", () => {
const ROWS: readonly PaletteRowColumns[] = [
{ category: "view", label: "Show keymap help", shortcut: "?" },
{ category: "edit", label: "Copy active message / tool", shortcut: "Alt+C" },
{ category: "session", label: "Resume prior session", shortcut: "" },
{ label: "Show keymap help", shortcut: "?" },
{ label: "Copy active message / tool", shortcut: "Alt+C" },
{ label: "Resume prior session", shortcut: "" },
]

test("renders category, label, and right-aligned shortcut at full width", () => {
test("renders the label and right-aligned shortcut at full width", () => {
const [help, copy] = formatPaletteRows(ROWS, 55)
expect(help).toHaveLength(55)
expect(help?.startsWith("view ")).toBe(true)
expect(help).toContain("Show keymap help")
expect(help?.startsWith("Show keymap help")).toBe(true)
expect(help?.trimEnd().endsWith("?")).toBe(true)
expect(copy?.trimEnd().endsWith("Alt+C")).toBe(true)
})
Expand All @@ -137,29 +115,11 @@ describe("formatPaletteRows", () => {
}
})

// The palette host spends the box border and the selection marker before the
// row starts, so a terminal N columns wide hands these rows N - 5.
const ROW_WIDTH_AT_60 = 55
const ROW_WIDTH_AT_48 = 43

test("the shortcut column drops first as width narrows", () => {
expect(paletteRowLayout(ROWS, ROW_WIDTH_AT_60)).toMatchObject({
showCategory: true,
showShortcut: true,
})
expect(paletteRowLayout(ROWS, ROW_WIDTH_AT_48)).toMatchObject({
showCategory: true,
showShortcut: false,
})
const rows = formatPaletteRows(ROWS, ROW_WIDTH_AT_48)
test("the shortcut column drops as width narrows, and the label always survives", () => {
expect(paletteRowLayout(ROWS, 40)).toMatchObject({ showShortcut: true })
expect(paletteRowLayout(ROWS, 30)).toMatchObject({ showShortcut: false })
const rows = formatPaletteRows(ROWS, 30)
expect(rows[0]?.includes("?")).toBe(false)
expect(rows[0]?.startsWith("view")).toBe(true)
})

test("the category drops next, and the label always survives", () => {
const layout = paletteRowLayout(ROWS, 34)
expect(layout.showCategory).toBe(false)
expect(layout.showShortcut).toBe(false)
expect(formatPaletteRows(ROWS, 34)[0]?.trimEnd()).toBe("Show keymap help")
expect(rows[0]?.trimEnd()).toBe("Show keymap help")
})
})
Loading
Loading