fix(ui-tui): overlay width caps are absolute — clampOverlayWidth (Copilot review)

Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth,
so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4
cols of chrome) overflowed and clipped at the terminal edge. One shared
clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is
ABSOLUTE (a cell knows its budget), the usability floor applies only when
the cap allows it, uncapped keeps the old floor semantics. Five call
sites (model/pet pickers, skills/plugins hubs, session switcher) route
through it; the grid-test FloatBox drops its own 24 floor. Contract-tested
including the sub-floor cap case from the review.
This commit is contained in:
Brooklyn Nicholson 2026-07-21 13:35:37 -05:00
parent 5bfffcd445
commit 11f2e54f0c
8 changed files with 47 additions and 11 deletions

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { clampOverlayWidth } from '../components/overlayPrimitives.js'
describe('clampOverlayWidth', () => {
it('prefers preferred, capped by maxWidth', () => {
expect(clampOverlayWidth(60)).toBe(60)
expect(clampOverlayWidth(60, 40)).toBe(40)
expect(clampOverlayWidth(30, 80)).toBe(30)
})
it('honors caps BELOW the usability floor instead of overflowing the cell', () => {
// Copilot review on #20379: a 20-col grid cell must get 20, not 24.
expect(clampOverlayWidth(60, 20)).toBe(20)
expect(clampOverlayWidth(60, 1)).toBe(1)
})
it('keeps the floor when the cap allows it', () => {
expect(clampOverlayWidth(10, 80)).toBe(24)
expect(clampOverlayWidth(10)).toBe(24)
})
})

View file

@ -16,7 +16,7 @@ import type { Theme } from '../theme.js'
import { ModelPicker } from './modelPicker.js'
import { windowOffset } from './overlayControls.js'
import { listRowStyle } from './overlayPrimitives.js'
import { clampOverlayWidth, listRowStyle } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const VISIBLE = 12
@ -325,7 +325,7 @@ export function ActiveSessionSwitcher({
const { stdout } = useStdout()
// Optional maxWidth lets grid layouts hand the switcher its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
const width = clampOverlayWidth(preferredWidth, maxWidth)
const promptColumns = Math.max(20, width - 11)
// Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0,

View file

@ -227,7 +227,10 @@ export function FloatingOverlays({
id: 'grid-test',
render: () => (
<FloatBox color={theme.color.border}>
<GridTestOverlay cols={Math.max(24, cols - 6)} state={gridTest} t={theme} />
{/* cols-6 = FloatBox chrome (4) + margin (2); no 24-col floor
forcing one would overflow cells narrower than 28 and clip at
the terminal edge. */}
<GridTestOverlay cols={Math.max(1, cols - 6)} state={gridTest} t={theme} />
</FloatBox>
)
})

View file

@ -10,7 +10,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js'
import { chipRowProps } from './overlayPrimitives.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 40
@ -62,7 +62,7 @@ export function ModelPicker({
// has an actual constraint to truncate against. Optional maxWidth lets
// grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', {

View file

@ -6,6 +6,17 @@ import type { UsageModelData } from '../gatewayTypes.js'
import { liftForContrast, mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
/**
* Overlay width clamp: prefer `preferred`, honor the caller's `maxWidth`
* ABSOLUTELY (a grid cell knows its budget overflowing it clips at the
* terminal edge), and keep a usability floor only when the cap allows it.
*/
export function clampOverlayWidth(preferred: number, maxWidth?: number, min = 24): number {
const cap = maxWidth === undefined ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.trunc(maxWidth))
return Math.max(Math.min(min, cap), Math.min(preferred, cap))
}
/**
* THE scrollbar treatment (transcript + overlays): thumb rides the theme
* base, accent while interacting; track recedes via an explicit blend toward

View file

@ -6,7 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, windowItems } from './overlayControls.js'
import { chipRowProps } from './overlayPrimitives.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 10
const MIN_WIDTH = 40
@ -42,7 +42,7 @@ export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) {
const { stdout } = useStdout()
// Optional maxWidth lets grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<Gallery>('pet.gallery')

View file

@ -6,7 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { chipRowProps } from './overlayPrimitives.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 44
@ -53,7 +53,7 @@ export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) {
const { stdout } = useStdout()
// Optional maxWidth lets grid layouts hand the hub its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
const width = clampOverlayWidth(preferredWidth, maxWidth)
const load = () => {
gw.request<PluginsListResponse>('plugins.manage', { action: 'list' })

View file

@ -7,6 +7,7 @@ import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { chipRowProps } from './overlayPrimitives.js'
import { clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 40
@ -26,8 +27,7 @@ export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) {
const { stdout } = useStdout()
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth))
const width = Math.max(24, Math.min(preferredWidth, widthCap))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })