diff --git a/ui-tui/src/__tests__/overlayPrimitives.test.ts b/ui-tui/src/__tests__/overlayPrimitives.test.ts new file mode 100644 index 00000000000..27cbd336a86 --- /dev/null +++ b/ui-tui/src/__tests__/overlayPrimitives.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index 34e60d7d4f4..52668c1d51f 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -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, diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index ff512f8f12b..a6a40117550 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -227,7 +227,10 @@ export function FloatingOverlays({ id: 'grid-test', render: () => ( - + {/* 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. */} + ) }) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 75bdbe7bd39..e52d1271892 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -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('model.options', { diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index 805ffdbe865..f86787f4ae4 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -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 diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx index 142139e37da..42bc91c0e02 100644 --- a/ui-tui/src/components/petPicker.tsx +++ b/ui-tui/src/components/petPicker.tsx @@ -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('pet.gallery') diff --git a/ui-tui/src/components/pluginsHub.tsx b/ui-tui/src/components/pluginsHub.tsx index 35887801053..e8578886462 100644 --- a/ui-tui/src/components/pluginsHub.tsx +++ b/ui-tui/src/components/pluginsHub.tsx @@ -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('plugins.manage', { action: 'list' }) diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index dec053db27e..87ec3339d3b 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -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 }>('skills.manage', { action: 'list' })