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' })