opentui(v5/item4): coalesce resize via a shared debounced dimensions signal

Raw useTerminalDimensions fires on every SIGWINCH tick; during a drag that's a
recompute/reflow storm across every width-sensitive component (tool bodies,
tables, status bar, banner). Add a DimensionsProvider that runs the raw hook
ONCE and feeds a single leading+trailing-debounced (40ms) signal — mirroring the
gateway's 16ms event coalescing / opencode's createLeadingTrailingSignal — that
every consumer shares via useDimensions(). They now reflow together (no tearing)
and at most once per window. Falls back to the raw hook outside a provider
(headless tests). Verified: single resizes converge clean (wide banner ⇄ compact
brand at the 102-col threshold); rapid bursts coalesce. 90 pass.
This commit is contained in:
alt-glitch 2026-06-09 04:08:56 +00:00
parent 4061e635d3
commit 48d0c70f61
6 changed files with 62 additions and 9 deletions

View file

@ -18,6 +18,7 @@ import { Match, Switch } from 'solid-js'
import type { PromptHistory } from '../logic/history.ts'
import type { SessionStore } from '../logic/store.ts'
import { Composer } from './composer.tsx'
import { DimensionsProvider } from './dimensions.tsx'
import { Header } from './header.tsx'
import { AgentsDashboard } from './overlays/agentsDashboard.tsx'
import { Pager } from './overlays/pager.tsx'
@ -64,6 +65,7 @@ export function App(props: AppProps) {
}
return (
<DimensionsProvider>
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
{/* a bottom rule under the header bookends the transcript with the status
bar's top rule frames the chrome as intentional (item 8). */}
@ -132,5 +134,6 @@ export function App(props: AppProps) {
</Match>
</Switch>
</box>
</DimensionsProvider>
)
}

View file

@ -0,0 +1,50 @@
/**
* Shared, COALESCED terminal dimensions (item 4 resize hardening). Raw
* `useTerminalDimensions()` fires on every SIGWINCH tick; during a drag that's a
* recompute/reflow storm across every width-sensitive component (tool bodies,
* tables, status bar, banner). One provider runs the raw hook once and feeds a
* single leading+trailing-debounced signal (opencode's createLeadingTrailingSignal
* idiom, mirroring the gateway's 16ms event coalescing) that every consumer shares
* so they reflow together (no tearing) and at most once per COALESCE window.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { type Accessor, createContext, createEffect, createSignal, type JSX, onCleanup, useContext } from 'solid-js'
export interface Dims {
readonly width: number
readonly height: number
}
const DimsContext = createContext<Accessor<Dims>>()
const COALESCE_MS = 40
export function DimensionsProvider(props: { children: JSX.Element }) {
const raw = useTerminalDimensions()
const [dims, setDims] = createSignal<Dims>({ height: raw().height, width: raw().width })
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
createEffect(() => {
const next: Dims = { height: raw().height, width: raw().width } // track raw
const now = Date.now()
if (now - last >= COALESCE_MS) {
last = now
setDims(next) // leading edge: respond immediately to the first change
} else {
// trailing edge: coalesce the burst, land on the final size once it settles
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
last = Date.now()
setDims(next)
}, COALESCE_MS)
}
})
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return <DimsContext.Provider value={dims}>{props.children}</DimsContext.Provider>
}
/** Coalesced dimensions; falls back to the raw hook outside a provider (e.g. headless tests). */
export function useDimensions(): Accessor<Dims> {
return useContext(DimsContext) ?? useTerminalDimensions()
}

View file

@ -5,7 +5,7 @@
* panel (item 9), the common commands, and the key tips. Fully themed; decorative,
* so `selectable={false}` (item 4).
*/
import { useTerminalDimensions } from '@opentui/solid'
import { useDimensions } from './dimensions.tsx'
import { createSignal, For, Show } from 'solid-js'
import type { Catalog } from '../logic/store.ts'
@ -33,7 +33,7 @@ const BANNER_W = 102
export function HomeHint(props: { catalog: Catalog | undefined }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const dims = useDimensions()
const [open, setOpen] = createSignal(false)
const wide = () => dims().width >= BANNER_W
const cat = () => props.catalog

View file

@ -2,9 +2,9 @@
* MdTable renders a parsed GFM table as an aligned grid (item 7). The native
* markdown renderable leaves tables as raw pipes; this lays them out with
* per-column widths, alignment, a header rule, and dim `` separators. Width
* is reactive (useTerminalDimensions) so columns shrink to fit on resize.
* is reactive (useDimensions) so columns shrink to fit on resize.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { useDimensions } from './dimensions.tsx'
import { createMemo, type JSX } from 'solid-js'
import { padAligned, tableColumnWidths, type TableSegment } from '../logic/mdTable.ts'
@ -15,7 +15,7 @@ const GUTTER = 2
export function MdTable(props: { seg: TableSegment }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const dims = useDimensions()
const widths = createMemo(() => tableColumnWidths(props.seg, Math.max(24, dims().width - GUTTER - 2)))
const cellText = (raw: string, i: number) =>

View file

@ -10,7 +10,7 @@
* terminals and the cwd is left-truncated (`…/tail`) so the row NEVER wraps or
* clips. Read-only chrome no input handling here.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { useDimensions } from './dimensions.tsx'
import { createMemo, Show } from 'solid-js'
import { useTheme } from './theme.tsx'
@ -54,7 +54,7 @@ function ctxBar(pct: number, width: number): string {
export function StatusBar(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const dims = useDimensions()
const info = () => props.store.state.info
// Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical).

View file

@ -14,7 +14,7 @@
* Fully themed (no hardcoded styles); decorative glyphs are selectable={false}.
*/
import { type ToolPartState } from '../logic/store.ts'
import { useTerminalDimensions } from '@opentui/solid'
import { useDimensions } from './dimensions.tsx'
import { createMemo, createSignal, For, Show } from 'solid-js'
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
@ -36,7 +36,7 @@ function fmtDuration(s: number): string {
export function ToolPart(props: { part: ToolPartState }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const dims = useDimensions()
const [expanded, setExpanded] = createSignal(false)
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)