From 48d0c70f616876c3027d84fca05fbb625e6ffe7f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 04:08:56 +0000 Subject: [PATCH] opentui(v5/item4): coalesce resize via a shared debounced dimensions signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui-opentui-v2/src/view/App.tsx | 3 ++ ui-tui-opentui-v2/src/view/dimensions.tsx | 50 +++++++++++++++++++++++ ui-tui-opentui-v2/src/view/homeHint.tsx | 4 +- ui-tui-opentui-v2/src/view/mdTable.tsx | 6 +-- ui-tui-opentui-v2/src/view/statusBar.tsx | 4 +- ui-tui-opentui-v2/src/view/toolPart.tsx | 4 +- 6 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 ui-tui-opentui-v2/src/view/dimensions.tsx diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 48cf8891b05..0780ab4318b 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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 ( + {/* 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) { + ) } diff --git a/ui-tui-opentui-v2/src/view/dimensions.tsx b/ui-tui-opentui-v2/src/view/dimensions.tsx new file mode 100644 index 00000000000..b8db9ec9d45 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/dimensions.tsx @@ -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>() +const COALESCE_MS = 40 + +export function DimensionsProvider(props: { children: JSX.Element }) { + const raw = useTerminalDimensions() + const [dims, setDims] = createSignal({ height: raw().height, width: raw().width }) + let timer: ReturnType | 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 {props.children} +} + +/** Coalesced dimensions; falls back to the raw hook outside a provider (e.g. headless tests). */ +export function useDimensions(): Accessor { + return useContext(DimsContext) ?? useTerminalDimensions() +} diff --git a/ui-tui-opentui-v2/src/view/homeHint.tsx b/ui-tui-opentui-v2/src/view/homeHint.tsx index dd99914dc81..755b4b1c5dd 100644 --- a/ui-tui-opentui-v2/src/view/homeHint.tsx +++ b/ui-tui-opentui-v2/src/view/homeHint.tsx @@ -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 diff --git a/ui-tui-opentui-v2/src/view/mdTable.tsx b/ui-tui-opentui-v2/src/view/mdTable.tsx index ceb8f9931dc..22cd11c51c0 100644 --- a/ui-tui-opentui-v2/src/view/mdTable.tsx +++ b/ui-tui-opentui-v2/src/view/mdTable.tsx @@ -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) => diff --git a/ui-tui-opentui-v2/src/view/statusBar.tsx b/ui-tui-opentui-v2/src/view/statusBar.tsx index 0892709def3..1cc13c6c255 100644 --- a/ui-tui-opentui-v2/src/view/statusBar.tsx +++ b/ui-tui-opentui-v2/src/view/statusBar.tsx @@ -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). diff --git a/ui-tui-opentui-v2/src/view/toolPart.tsx b/ui-tui-opentui-v2/src/view/toolPart.tsx index 31d27c0b2e4..cc0df3422b8 100644 --- a/ui-tui-opentui-v2/src/view/toolPart.tsx +++ b/ui-tui-opentui-v2/src/view/toolPart.tsx @@ -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)