From fa427ecb41d6d965a5644dc954acc3179e18578a Mon Sep 17 00:00:00 2001 From: wu-json Date: Sun, 26 Apr 2026 17:17:30 -0700 Subject: [PATCH 1/3] fix: reduce wasted space when shortcuts are collapsed or shown The ShortcutFooter always renders at least 1 row (the "? for shortcuts" text), but getShortcutFooterHeight returns 0 when shortcuts are hidden. This left 1 unaccounted row creating a gap at the bottom of the terminal. Fix by adding +1 to the footer height calculation when showShortcuts is false, in both the main page and logs page height calculations. --- src/ui/views/LogPage.tsx | 1 + src/ui/views/MainPage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/views/LogPage.tsx b/src/ui/views/LogPage.tsx index 8082091..6e248ec 100644 --- a/src/ui/views/LogPage.tsx +++ b/src/ui/views/LogPage.tsx @@ -824,6 +824,7 @@ export function LogPage() { 6 - (isSearchMode ? 1 : 0) - getShortcutFooterHeight(shortcuts.length, terminalWidth, showShortcuts) + + (showShortcuts ? 0 : 1) } isSearchMode={isSearchMode} searchQuery={searchQuery} diff --git a/src/ui/views/MainPage.tsx b/src/ui/views/MainPage.tsx index 34a155a..900f6a2 100644 --- a/src/ui/views/MainPage.tsx +++ b/src/ui/views/MainPage.tsx @@ -394,7 +394,7 @@ export function MainPage(props: { displayMode: DisplayMode }) { shortcuts.length, terminalWidth, showShortcuts, - ); + ) + (showShortcuts ? 0 : 1); const processTableHeight = processes.length + 3; From 0cca4e91b99d6a0fb715c84195d0c8fa07f27789 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sun, 26 Apr 2026 17:22:15 -0700 Subject: [PATCH 2/3] fix: correct header height and remove safety buffer that wasted bottom rows The previous fix only addressed 1 of 3 wasted rows. Two additional issues: 1. headerHeight was 4 in MainPage, but the View header only renders 2 rows (Curse v{version} and Config: {filename}). This wasted 2 rows. 2. Math.max(4, availableForLogs - 1) added an extra unused row as a buffer. Removed since the math now adds up exactly to terminalHeight. 3. LogPage used 6 as the fixed-row offset, but the actual fixed overhead is 3 (View header 2 + LogPage title 1). Also fixed sign on the showShortcuts adjustment - hidden footer takes 1 row, so LogTable should be smaller by 1, not larger. Updated normalMinHeight in View.tsx to match. --- src/ui/View.tsx | 2 +- src/ui/views/LogPage.tsx | 9 ++++++--- src/ui/views/MainPage.tsx | 5 +++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ui/View.tsx b/src/ui/View.tsx index fd73c40..04d5ac2 100644 --- a/src/ui/View.tsx +++ b/src/ui/View.tsx @@ -32,7 +32,7 @@ function View(props: { config: CurseConfig }) { const terminalHeight = stdout?.rows ?? 24; const processCount = processesRef.current.length; - const normalMinHeight = processCount + 13; // header(4) + table(N+3) + logPreview(5) + footer(1) + const normalMinHeight = processCount + 11; // header(2) + table(N+3) + logPreview(5) + footer(1) const compactMinHeight = processCount + 1; const displayMode: DisplayMode = terminalHeight < compactMinHeight diff --git a/src/ui/views/LogPage.tsx b/src/ui/views/LogPage.tsx index 6e248ec..3e6f9fe 100644 --- a/src/ui/views/LogPage.tsx +++ b/src/ui/views/LogPage.tsx @@ -820,11 +820,14 @@ export function LogPage() { )} From e86899e7f52a1b4ac3b3a2f81b74fcc56db68899 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sun, 26 Apr 2026 17:25:38 -0700 Subject: [PATCH 3/3] test: extract layout math into pure module and add regression tests The TUI height calculations were previously inline in MainPage.tsx and LogPage.tsx and mixed with ink hooks, making them hard to unit test. This extracts the math into src/ui/layout.ts as pure functions: - getShortcutFooterColumns / getShortcutFooterHeight - computeMainPageLayout - computeLogPageLayout getShortcutFooterHeight now returns 1 (instead of 0) when shortcuts are hidden, accurately reflecting the rendered "? for shortcuts" row. This removes the need for the +1 / -1 adjustments at every call site. Adds src/ui/layout.test.ts covering: - Column thresholds for the footer - Footer returns 1 row when collapsed (regression) - MainPage and LogPage heights summing to exactly terminalHeight in collapsed, expanded, and search modes (the core invariant that broke) - Min log preview height clamping in tight terminals - Expanding shortcuts shrinks the log area by the footer delta --- src/ui/View.tsx | 5 +- src/ui/components/ShortcutFooter.tsx | 29 +-- src/ui/layout.test.ts | 258 +++++++++++++++++++++++++++ src/ui/layout.ts | 130 ++++++++++++++ src/ui/views/LogPage.tsx | 18 +- src/ui/views/MainPage.tsx | 19 +- 6 files changed, 413 insertions(+), 46 deletions(-) create mode 100644 src/ui/layout.test.ts create mode 100644 src/ui/layout.ts diff --git a/src/ui/View.tsx b/src/ui/View.tsx index 04d5ac2..3b1de9a 100644 --- a/src/ui/View.tsx +++ b/src/ui/View.tsx @@ -9,6 +9,7 @@ import { useProcessManager } from "../hooks/useProcessManager"; import { ProgramStateProvider, useProgramState, ProgramStatus } from "../hooks/useProgramState"; import { Colors } from "../lib/Colors"; import type { CurseConfig } from "../parser"; +import { MIN_LOG_PREVIEW_HEIGHT, PROCESS_TABLE_OVERHEAD, VIEW_HEADER_HEIGHT } from "./layout"; import { LogPage } from "./views/LogPage"; import { MainPage, type DisplayMode } from "./views/MainPage"; @@ -32,7 +33,9 @@ function View(props: { config: CurseConfig }) { const terminalHeight = stdout?.rows ?? 24; const processCount = processesRef.current.length; - const normalMinHeight = processCount + 11; // header(2) + table(N+3) + logPreview(5) + footer(1) + // header + processTable(N + overhead) + minLogPreview + collapsed footer (1 row) + const normalMinHeight = + VIEW_HEADER_HEIGHT + processCount + PROCESS_TABLE_OVERHEAD + MIN_LOG_PREVIEW_HEIGHT + 1; const compactMinHeight = processCount + 1; const displayMode: DisplayMode = terminalHeight < compactMinHeight diff --git a/src/ui/components/ShortcutFooter.tsx b/src/ui/components/ShortcutFooter.tsx index a5a1102..ce11d57 100644 --- a/src/ui/components/ShortcutFooter.tsx +++ b/src/ui/components/ShortcutFooter.tsx @@ -1,6 +1,10 @@ import { Box, Text, useStdout } from "ink"; import { Colors } from "../../lib/Colors"; +import { getShortcutFooterColumns, getShortcutFooterHeight } from "../layout"; + +// Re-export so existing callers can keep importing it from this module. +export { getShortcutFooterHeight }; interface ShortcutFooterProps { shortcuts: string[]; @@ -15,13 +19,7 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps {showShortcuts ? ( (() => { - // Calculate number of columns based on terminal width - let numColumns; - if (terminalWidth < 80) numColumns = 1; - else if (terminalWidth < 120) numColumns = 2; - else if (terminalWidth < 160) numColumns = 3; - else numColumns = 4; - + const numColumns = getShortcutFooterColumns(terminalWidth); const itemsPerColumn = Math.ceil(shortcuts.length / numColumns); const columns = []; @@ -49,20 +47,3 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps ); } - -// Helper function to calculate the height taken by shortcuts -export function getShortcutFooterHeight( - shortcutsCount: number, - terminalWidth: number, - showShortcuts: boolean, -): number { - if (!showShortcuts) return 0; - - let numColumns; - if (terminalWidth < 80) numColumns = 1; - else if (terminalWidth < 120) numColumns = 2; - else if (terminalWidth < 160) numColumns = 3; - else numColumns = 4; - - return Math.ceil(shortcutsCount / numColumns); -} diff --git a/src/ui/layout.test.ts b/src/ui/layout.test.ts new file mode 100644 index 0000000..879bbbc --- /dev/null +++ b/src/ui/layout.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "bun:test"; + +import { + computeLogPageLayout, + computeMainPageLayout, + getShortcutFooterColumns, + getShortcutFooterHeight, + LOG_PAGE_TITLE_HEIGHT, + MIN_LOG_PREVIEW_HEIGHT, + PROCESS_TABLE_OVERHEAD, + SEARCH_BAR_HEIGHT, + VIEW_HEADER_HEIGHT, +} from "./layout"; + +describe("getShortcutFooterColumns", () => { + it("uses 1 column when terminalWidth < 80", () => { + expect(getShortcutFooterColumns(40)).toBe(1); + expect(getShortcutFooterColumns(79)).toBe(1); + }); + + it("uses 2 columns for 80 <= width < 120", () => { + expect(getShortcutFooterColumns(80)).toBe(2); + expect(getShortcutFooterColumns(119)).toBe(2); + }); + + it("uses 3 columns for 120 <= width < 160", () => { + expect(getShortcutFooterColumns(120)).toBe(3); + expect(getShortcutFooterColumns(159)).toBe(3); + }); + + it("uses 4 columns for width >= 160", () => { + expect(getShortcutFooterColumns(160)).toBe(4); + expect(getShortcutFooterColumns(400)).toBe(4); + }); +}); + +describe("getShortcutFooterHeight", () => { + // Regression: getShortcutFooterHeight previously returned 0 when shortcuts + // were collapsed, but the footer always renders the "? for shortcuts" row. + // That mismatch wasted 1 terminal row at the bottom of the screen. + it("returns 1 when shortcuts are hidden (the '? for shortcuts' row)", () => { + expect(getShortcutFooterHeight(9, 80, false)).toBe(1); + expect(getShortcutFooterHeight(0, 80, false)).toBe(1); + expect(getShortcutFooterHeight(20, 200, false)).toBe(1); + }); + + it("returns ceil(count / columns) when shortcuts are shown", () => { + // 80 width => 2 columns, 9 shortcuts => ceil(9 / 2) = 5 + expect(getShortcutFooterHeight(9, 80, true)).toBe(5); + // 120 width => 3 columns, 9 shortcuts => 3 + expect(getShortcutFooterHeight(9, 120, true)).toBe(3); + // 160 width => 4 columns, 9 shortcuts => 3 + expect(getShortcutFooterHeight(9, 160, true)).toBe(3); + // 60 width => 1 column, 12 shortcuts => 12 + expect(getShortcutFooterHeight(12, 60, true)).toBe(12); + }); +}); + +describe("computeMainPageLayout", () => { + // Regression: previously the four heights summed to terminalHeight - 3, + // leaving three wasted rows at the bottom of the screen below the + // shortcut footer. The four sources of the gap were: + // - headerHeight hardcoded to 4 (actual View header is 2 rows) + // - an extra `- 1` safety buffer in the log preview height + // - getShortcutFooterHeight returning 0 when collapsed (vs. 1 actual) + // - LogPage's separate `6` constant being similarly miscounted + it("heights sum to terminalHeight when shortcuts are collapsed", () => { + const layout = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 5, + shortcutsCount: 9, + showShortcuts: false, + }); + const total = + layout.headerHeight + + layout.processTableHeight + + layout.logPreviewHeight + + layout.shortcutFooterHeight; + expect(total).toBe(40); + }); + + it("heights sum to terminalHeight when shortcuts are expanded", () => { + const layout = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 5, + shortcutsCount: 9, + showShortcuts: true, + }); + const total = + layout.headerHeight + + layout.processTableHeight + + layout.logPreviewHeight + + layout.shortcutFooterHeight; + expect(total).toBe(40); + }); + + it("uses correct fixed heights", () => { + const layout = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 5, + shortcutsCount: 9, + showShortcuts: false, + }); + expect(layout.headerHeight).toBe(VIEW_HEADER_HEIGHT); + expect(layout.processTableHeight).toBe(5 + PROCESS_TABLE_OVERHEAD); + expect(layout.shortcutFooterHeight).toBe(1); + }); + + it("scales with process count and gives the rest to the log preview", () => { + const a = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 3, + shortcutsCount: 9, + showShortcuts: false, + }); + const b = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 7, + shortcutsCount: 9, + showShortcuts: false, + }); + expect(a.logPreviewHeight - b.logPreviewHeight).toBe(4); + }); + + it("expanding shortcuts shrinks the log preview by the same number of rows", () => { + const collapsed = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 3, + shortcutsCount: 9, + showShortcuts: false, + }); + const expanded = computeMainPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + processCount: 3, + shortcutsCount: 9, + showShortcuts: true, + }); + const footerDelta = expanded.shortcutFooterHeight - collapsed.shortcutFooterHeight; + expect(collapsed.logPreviewHeight - expanded.logPreviewHeight).toBe(footerDelta); + }); + + it("clamps the log preview to the minimum when the terminal is tight", () => { + const layout = computeMainPageLayout({ + terminalHeight: 10, // very small + terminalWidth: 120, + processCount: 5, + shortcutsCount: 9, + showShortcuts: false, + }); + expect(layout.logPreviewHeight).toBe(MIN_LOG_PREVIEW_HEIGHT); + }); +}); + +describe("computeLogPageLayout", () => { + it("heights sum to terminalHeight when shortcuts are collapsed and not searching", () => { + const layout = computeLogPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: false, + isSearchMode: false, + }); + const total = + layout.headerHeight + + layout.titleHeight + + layout.searchBarHeight + + layout.logTableHeight + + layout.shortcutFooterHeight; + expect(total).toBe(40); + }); + + it("heights sum to terminalHeight when shortcuts are expanded", () => { + const layout = computeLogPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: true, + isSearchMode: false, + }); + const total = + layout.headerHeight + + layout.titleHeight + + layout.searchBarHeight + + layout.logTableHeight + + layout.shortcutFooterHeight; + expect(total).toBe(40); + }); + + it("heights sum to terminalHeight in search mode", () => { + const layout = computeLogPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: false, + isSearchMode: true, + }); + const total = + layout.headerHeight + + layout.titleHeight + + layout.searchBarHeight + + layout.logTableHeight + + layout.shortcutFooterHeight; + expect(total).toBe(40); + expect(layout.searchBarHeight).toBe(SEARCH_BAR_HEIGHT); + }); + + it("uses correct fixed heights", () => { + const layout = computeLogPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: false, + isSearchMode: false, + }); + expect(layout.headerHeight).toBe(VIEW_HEADER_HEIGHT); + expect(layout.titleHeight).toBe(LOG_PAGE_TITLE_HEIGHT); + expect(layout.searchBarHeight).toBe(0); + expect(layout.shortcutFooterHeight).toBe(1); + }); + + it("entering search mode reduces the log table by exactly one row", () => { + const inputs = { + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: false, + }; + const noSearch = computeLogPageLayout({ ...inputs, isSearchMode: false }); + const searching = computeLogPageLayout({ ...inputs, isSearchMode: true }); + expect(noSearch.logTableHeight - searching.logTableHeight).toBe(1); + }); + + it("expanding shortcuts shrinks the log table by the same number of rows", () => { + const collapsed = computeLogPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: false, + isSearchMode: false, + }); + const expanded = computeLogPageLayout({ + terminalHeight: 40, + terminalWidth: 120, + shortcutsCount: 12, + showShortcuts: true, + isSearchMode: false, + }); + const footerDelta = expanded.shortcutFooterHeight - collapsed.shortcutFooterHeight; + expect(collapsed.logTableHeight - expanded.logTableHeight).toBe(footerDelta); + }); +}); diff --git a/src/ui/layout.ts b/src/ui/layout.ts new file mode 100644 index 0000000..6b9a806 --- /dev/null +++ b/src/ui/layout.ts @@ -0,0 +1,130 @@ +// Pure layout math for the TUI. Kept free of any ink imports so it can be +// unit-tested directly. The heights here describe the number of terminal rows +// each component renders, and the sum of all components for a given page must +// equal `terminalHeight` so no rows are wasted at the bottom. + +/** "Curse v{version}" + "Config: {filename}" — rendered by `View`. */ +export const VIEW_HEADER_HEIGHT = 2; + +/** ProcessTable border-and-header overhead: top border + header row + bottom border. */ +export const PROCESS_TABLE_OVERHEAD = 3; + +/** "Logs({name})[tail]" title bar in the log view. */ +export const LOG_PAGE_TITLE_HEIGHT = 1; + +/** Search bar row, when search mode is active. */ +export const SEARCH_BAR_HEIGHT = 1; + +/** Floor for the log preview on the main page so it stays usable. */ +export const MIN_LOG_PREVIEW_HEIGHT = 4; + +/** + * Number of columns the shortcut footer breaks into based on terminal width. + * Mirrors the layout used inside `ShortcutFooter`. + */ +export function getShortcutFooterColumns(terminalWidth: number): number { + if (terminalWidth < 80) return 1; + if (terminalWidth < 120) return 2; + if (terminalWidth < 160) return 3; + return 4; +} + +/** + * Number of rows the shortcut footer actually renders. + * + * When `showShortcuts` is false the footer still renders a single + * "? for shortcuts" row — historically this returned 0 here, which was the + * source of the wasted-row gap at the bottom of the terminal. + */ +export function getShortcutFooterHeight( + shortcutsCount: number, + terminalWidth: number, + showShortcuts: boolean, +): number { + if (!showShortcuts) return 1; + const numColumns = getShortcutFooterColumns(terminalWidth); + return Math.ceil(shortcutsCount / numColumns); +} + +export interface MainPageLayoutInput { + terminalHeight: number; + terminalWidth: number; + processCount: number; + shortcutsCount: number; + showShortcuts: boolean; +} + +export interface MainPageLayout { + headerHeight: number; + processTableHeight: number; + logPreviewHeight: number; + shortcutFooterHeight: number; +} + +/** + * Compute the row breakdown for the MainPage in normal display mode. + * + * Invariant (when terminalHeight is large enough): the four returned heights + * sum to exactly `terminalHeight`, leaving no empty rows at the bottom. + */ +export function computeMainPageLayout(input: MainPageLayoutInput): MainPageLayout { + const headerHeight = VIEW_HEADER_HEIGHT; + const processTableHeight = input.processCount + PROCESS_TABLE_OVERHEAD; + const shortcutFooterHeight = getShortcutFooterHeight( + input.shortcutsCount, + input.terminalWidth, + input.showShortcuts, + ); + const available = input.terminalHeight - headerHeight - processTableHeight - shortcutFooterHeight; + const logPreviewHeight = Math.max(MIN_LOG_PREVIEW_HEIGHT, available); + + return { + headerHeight, + processTableHeight, + logPreviewHeight, + shortcutFooterHeight, + }; +} + +export interface LogPageLayoutInput { + terminalHeight: number; + terminalWidth: number; + shortcutsCount: number; + showShortcuts: boolean; + isSearchMode: boolean; +} + +export interface LogPageLayout { + headerHeight: number; + titleHeight: number; + searchBarHeight: number; + logTableHeight: number; + shortcutFooterHeight: number; +} + +/** + * Compute the row breakdown for the LogPage. + * + * Invariant: the five returned heights sum to exactly `terminalHeight`, + * leaving no empty rows at the bottom. + */ +export function computeLogPageLayout(input: LogPageLayoutInput): LogPageLayout { + const headerHeight = VIEW_HEADER_HEIGHT; + const titleHeight = LOG_PAGE_TITLE_HEIGHT; + const searchBarHeight = input.isSearchMode ? SEARCH_BAR_HEIGHT : 0; + const shortcutFooterHeight = getShortcutFooterHeight( + input.shortcutsCount, + input.terminalWidth, + input.showShortcuts, + ); + const logTableHeight = + input.terminalHeight - headerHeight - titleHeight - searchBarHeight - shortcutFooterHeight; + + return { + headerHeight, + titleHeight, + searchBarHeight, + logTableHeight, + shortcutFooterHeight, + }; +} diff --git a/src/ui/views/LogPage.tsx b/src/ui/views/LogPage.tsx index 3e6f9fe..8cd53bd 100644 --- a/src/ui/views/LogPage.tsx +++ b/src/ui/views/LogPage.tsx @@ -9,7 +9,8 @@ import { useProgramState, ProgramStatus } from "../../hooks/useProgramState"; import { useRenderTick } from "../../hooks/useRenderTick"; import { Colors } from "../../lib/Colors"; import { preprocessLog } from "../../lib/LogProcessing"; -import { ShortcutFooter, getShortcutFooterHeight } from "../components/ShortcutFooter"; +import { ShortcutFooter } from "../components/ShortcutFooter"; +import { computeLogPageLayout } from "../layout"; function LogTable(props: { height: number; @@ -820,14 +821,13 @@ export function LogPage() { )}