From cc4adf4a5f590e48de0382d2c3bad8ed4564919b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 14:51:09 -0700 Subject: [PATCH 1/3] refactor(tui): read zone rects from Yoga instead of stacking them Ordered collapse stays in the resolver: unequal flexShrink weights make this Yoga build overflow the column, and equal weights ignore COLLAPSE_ORDER. Prompt wheel forwarding uses onMouseScroll. Refs CL-10650 --- src/tui/geometry/resolve.ts | 101 +++++++++++++++++++++++++++++------- src/tui/geometry/zones.ts | 7 +-- src/tui/shell/keys.ts | 11 ++-- 3 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index 988eaf9f4..d19f23df9 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -1,6 +1,15 @@ // Pure: terminal size + zone visibility + overlay mode → rects. No I/O. -import { resolveContentWidth, resolveSideMargin } from "./zones.js"; +import { + Align, + Direction, + Display, + Edge, + FlexDirection, + Justify, + Node, + PositionType, +} from "@opentui/core/yoga"; import { COLLAPSE_ORDER, FLEET_BOARD_CAP_FRACTION, @@ -13,6 +22,8 @@ import { PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS, ZONE_REGISTRY, + resolveContentWidth, + resolveSideMargin, type ZoneId, } from "./zones.js"; @@ -300,26 +311,80 @@ function collapseOnce( return null; } -function assignRects( +function roundLayout(n: number): number { + return Math.round(n); +} + +/** + * Place already-collapsed zone heights with Yoga. The ordered collapse pass + * stays in TypeScript: unequal `flexShrink` weights (the usual priority + * trick) make this Yoga build stop shrinking and overflow the column, and + * equal weights shrink zones together instead of in `COLLAPSE_ORDER`. + * Gutters are root padding; each zone is a column item with its height + * locked (basis, min, and max) so the native layout is the rect source. + */ +function layoutRects( heights: MutableHeights, terminal: TerminalSize, ): Partial> { - const regions: Partial> = {}; - const x = resolveSideMargin(terminal.columns); - const contentWidth = resolveContentWidth(terminal.columns); - let y = 0; - for (const id of PAINT_ORDER) { - const height = heights[id]; - if (height <= 0) continue; - regions[id] = { - x, - y, - width: contentWidth, - height, - }; - y += height; + const sideMargin = resolveSideMargin(terminal.columns); + const root = Node.create(); + try { + root.setWidth(terminal.columns); + root.setHeight(terminal.rows); + root.setFlexDirection(FlexDirection.Column); + root.setJustifyContent(Justify.FlexStart); + root.setAlignItems(Align.Stretch); + root.setDisplay(Display.Flex); + root.setPositionType(PositionType.Relative); + root.setPadding(Edge.Left, sideMargin); + root.setPadding(Edge.Right, sideMargin); + root.setPadding(Edge.Top, 0); + root.setPadding(Edge.Bottom, 0); + + const placed: ZoneId[] = []; + for (const id of PAINT_ORDER) { + const height = heights[id]; + if (height <= 0) continue; + const child = Node.create(); + child.setDisplay(Display.Flex); + child.setPositionType(PositionType.Relative); + child.setFlexDirection(FlexDirection.Column); + child.setFlexGrow(0); + child.setFlexShrink(0); + child.setFlexBasis(height); + child.setHeight(height); + child.setMinHeight(height); + child.setMaxHeight(height); + child.setWidthPercent(100); + child.setMargin(Edge.Left, 0); + child.setMargin(Edge.Right, 0); + child.setMargin(Edge.Top, 0); + child.setMargin(Edge.Bottom, 0); + root.insertChild(child, root.getChildCount()); + placed.push(id); + } + + root.calculateLayout(terminal.columns, terminal.rows, Direction.LTR); + + const regions: Partial> = {}; + for (let index = 0; index < placed.length; index += 1) { + const id = placed[index]; + if (id === undefined) continue; + const child = root.getChild(index); + if (child === null) continue; + const layout = child.getComputedLayout(); + regions[id] = { + x: roundLayout(layout.left), + y: roundLayout(layout.top), + width: roundLayout(layout.width), + height: roundLayout(layout.height), + }; + } + return regions; + } finally { + root.freeRecursive(); } - return regions; } /** Resolve shell region rects from terminal size, optional chrome, and @@ -426,7 +491,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { heights.transcript += terminal.rows - assigned; } - const regions = assignRects(heights, terminal); + const regions = layoutRects(heights, terminal); return { terminal, diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index 3c079ee96..862239d8c 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -1,6 +1,7 @@ -// Chrome zone registry for the OpenTUI shell. -// Source of truth: docs/TUI.md "How it should look" (zone table + collapse order). -// Pure data — no process.stdout, no paint framework. +// Normative geometry constants for the OpenTUI shell. +// Source of truth: docs/TUI.md "How it should look" (gutter, pads, floors, +// collapse order). Rects are read back from Yoga in geometry/resolve.ts. +// No process.stdout, no paint framework. /** Constitution zone ids (snake_case matches the registry table). */ const ZONE_IDS = [ diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 0cf156169..20c3f0372 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -4,9 +4,9 @@ import { getLogger } from "@intx/log"; import { ScrollBoxRenderable, - type BaseRenderable, type KeyEvent, type MouseEvent, + type Renderable, } from "@opentui/core"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { badgeCount, pause } from "../delivery-queue.js"; @@ -291,14 +291,15 @@ export function readStopAffordance(shell: AppShell): { /** * The prompt holds focus all session and has no scrollable content, so * wheel events land on it; forward its scrolls to the transcript. + * `onMouseScroll` is the native hook. The scroll box still owns acceleration + * and the fractional accumulator on `onMouseEvent` — there is no public + * equivalent — so the forward calls that method instead of reimplementing it. */ export function routePromptWheelToTranscript( - prompt: BaseRenderable, + prompt: Renderable, transcript: ScrollBoxRenderable, ): void { - ( - prompt as unknown as { onMouseEvent: (event: MouseEvent) => void } - ).onMouseEvent = (event: MouseEvent) => { + prompt.onMouseScroll = (event: MouseEvent) => { if (event.type !== "scroll") return; ( transcript as unknown as { onMouseEvent: (event: MouseEvent) => void } From 1f7bbf6c8945956e7344ab991e18452f69b66250 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 15:02:47 -0700 Subject: [PATCH 2/3] fix(tui): keep a prompt wheel on the transcript A wheel over the focused prompt replaces onMouseEvent, so the textarea viewport does not scroll with the transcript. Refs CL-10650 --- src/tui/prompt-box.test.ts | 42 +++++++++++++++++++++++++++++++++++++- src/tui/shell/keys.ts | 17 +++++++++------ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/tui/prompt-box.test.ts b/src/tui/prompt-box.test.ts index cf3c47ba9..5069a1ae8 100644 --- a/src/tui/prompt-box.test.ts +++ b/src/tui/prompt-box.test.ts @@ -3,6 +3,7 @@ * that toggle their surface shut when pressed a second time. */ import { describe, expect, test } from "bun:test"; +import { MouseEvent } from "@opentui/core"; import { defined } from "../../testkit/defined.js"; import { PROMPT_KEY_BINDINGS } from "./prompt-input"; @@ -14,7 +15,11 @@ import { } from "./geometry/index.js"; import { focusOwner } from "./focus/index.js"; import { promptCaretRow, promptRowCount } from "./prompt-input.js"; -import { appendStreamRow, toggleShellFocus } from "./shell/chrome"; +import { + appendStreamRow, + appendTranscript, + toggleShellFocus, +} from "./shell/chrome"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; import { closeInsetOverlay } from "./shell/overlay-host"; @@ -154,6 +159,41 @@ describe("prompt box height", () => { expect(box.y + box.height).toBe(frameRows - 1); }); }); + + test("a wheel over the focused prompt scrolls the transcript only", async () => { + // The draft is taller than the capped viewport, so the textarea has its + // own scrollY. One wheel must move the transcript and leave that offset. + await withShell({ columns: 80, rows: 41 }, async (shell, h) => { + for (let i = 0; i < 40; i++) { + appendTranscript(shell, `seed-${i}`); + } + await compose(shell, h, lines(60)); + shell.prompt.focus(); + const cap = Math.floor(40 * PROMPT_CAP_FRACTION); + expect(focusOwner(shell.focus)).toBe("prompt"); + expect(shell.prompt.focused).toBe(true); + expect(promptRowCount(shell.prompt)).toBeGreaterThan(shell.prompt.height); + expect(shell.layout.heights.prompt).toBe(cap); + expect(shell.prompt.scrollY).toBeGreaterThan(0); + expect(shell.transcript.scrollTop).toBeGreaterThan(0); + + const promptScroll = shell.prompt.scrollY; + const transcriptScroll = shell.transcript.scrollTop; + shell.prompt.processMouseEvent( + new MouseEvent(shell.prompt, { + type: "scroll", + button: 4, + x: shell.prompt.x, + y: shell.prompt.y, + modifiers: { shift: false, alt: false, ctrl: false }, + scroll: { direction: "up", delta: 1 }, + }), + ); + + expect(shell.transcript.scrollTop).toBeLessThan(transcriptScroll); + expect(shell.prompt.scrollY).toBe(promptScroll); + }); + }); }); describe("prompt caret vs sent-message recall", () => { diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 20c3f0372..708cb85ee 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -289,17 +289,22 @@ export function readStopAffordance(shell: AppShell): { } /** - * The prompt holds focus all session and has no scrollable content, so - * wheel events land on it; forward its scrolls to the transcript. - * `onMouseScroll` is the native hook. The scroll box still owns acceleration - * and the fractional accumulator on `onMouseEvent` — there is no public - * equivalent — so the forward calls that method instead of reimplementing it. + * The prompt holds focus all session, so a wheel that hits it (or misses and + * falls back to focus) would otherwise scroll the textarea. Replace + * `onMouseEvent` rather than adding `onMouseScroll`: `processMouseEvent` + * runs the scroll listener and then `onMouseEvent` with no + * `defaultPrevented` check, and `TextareaRenderable` scrolls in + * `onMouseEvent`. The scroll box still owns acceleration and the fractional + * accumulator on that method — there is no public equivalent — so the + * replacement forwards to it instead of reimplementing the scroll. */ export function routePromptWheelToTranscript( prompt: Renderable, transcript: ScrollBoxRenderable, ): void { - prompt.onMouseScroll = (event: MouseEvent) => { + ( + prompt as unknown as { onMouseEvent: (event: MouseEvent) => void } + ).onMouseEvent = (event: MouseEvent) => { if (event.type !== "scroll") return; ( transcript as unknown as { onMouseEvent: (event: MouseEvent) => void } From 98208f20927daf8e7e665082c011583609cc1e0d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 15:06:20 -0700 Subject: [PATCH 3/3] refactor(tui): restore main prompt wheel routing Prompt wheel routing stays on the transcript handler already on main. This slice only reads zone rects from Yoga. Refs CL-10650 --- src/tui/prompt-box.test.ts | 42 +------------------------------------- src/tui/shell/keys.ts | 14 ++++--------- 2 files changed, 5 insertions(+), 51 deletions(-) diff --git a/src/tui/prompt-box.test.ts b/src/tui/prompt-box.test.ts index 5069a1ae8..cf3c47ba9 100644 --- a/src/tui/prompt-box.test.ts +++ b/src/tui/prompt-box.test.ts @@ -3,7 +3,6 @@ * that toggle their surface shut when pressed a second time. */ import { describe, expect, test } from "bun:test"; -import { MouseEvent } from "@opentui/core"; import { defined } from "../../testkit/defined.js"; import { PROMPT_KEY_BINDINGS } from "./prompt-input"; @@ -15,11 +14,7 @@ import { } from "./geometry/index.js"; import { focusOwner } from "./focus/index.js"; import { promptCaretRow, promptRowCount } from "./prompt-input.js"; -import { - appendStreamRow, - appendTranscript, - toggleShellFocus, -} from "./shell/chrome"; +import { appendStreamRow, toggleShellFocus } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; import { closeInsetOverlay } from "./shell/overlay-host"; @@ -159,41 +154,6 @@ describe("prompt box height", () => { expect(box.y + box.height).toBe(frameRows - 1); }); }); - - test("a wheel over the focused prompt scrolls the transcript only", async () => { - // The draft is taller than the capped viewport, so the textarea has its - // own scrollY. One wheel must move the transcript and leave that offset. - await withShell({ columns: 80, rows: 41 }, async (shell, h) => { - for (let i = 0; i < 40; i++) { - appendTranscript(shell, `seed-${i}`); - } - await compose(shell, h, lines(60)); - shell.prompt.focus(); - const cap = Math.floor(40 * PROMPT_CAP_FRACTION); - expect(focusOwner(shell.focus)).toBe("prompt"); - expect(shell.prompt.focused).toBe(true); - expect(promptRowCount(shell.prompt)).toBeGreaterThan(shell.prompt.height); - expect(shell.layout.heights.prompt).toBe(cap); - expect(shell.prompt.scrollY).toBeGreaterThan(0); - expect(shell.transcript.scrollTop).toBeGreaterThan(0); - - const promptScroll = shell.prompt.scrollY; - const transcriptScroll = shell.transcript.scrollTop; - shell.prompt.processMouseEvent( - new MouseEvent(shell.prompt, { - type: "scroll", - button: 4, - x: shell.prompt.x, - y: shell.prompt.y, - modifiers: { shift: false, alt: false, ctrl: false }, - scroll: { direction: "up", delta: 1 }, - }), - ); - - expect(shell.transcript.scrollTop).toBeLessThan(transcriptScroll); - expect(shell.prompt.scrollY).toBe(promptScroll); - }); - }); }); describe("prompt caret vs sent-message recall", () => { diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 708cb85ee..0cf156169 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -4,9 +4,9 @@ import { getLogger } from "@intx/log"; import { ScrollBoxRenderable, + type BaseRenderable, type KeyEvent, type MouseEvent, - type Renderable, } from "@opentui/core"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { badgeCount, pause } from "../delivery-queue.js"; @@ -289,17 +289,11 @@ export function readStopAffordance(shell: AppShell): { } /** - * The prompt holds focus all session, so a wheel that hits it (or misses and - * falls back to focus) would otherwise scroll the textarea. Replace - * `onMouseEvent` rather than adding `onMouseScroll`: `processMouseEvent` - * runs the scroll listener and then `onMouseEvent` with no - * `defaultPrevented` check, and `TextareaRenderable` scrolls in - * `onMouseEvent`. The scroll box still owns acceleration and the fractional - * accumulator on that method — there is no public equivalent — so the - * replacement forwards to it instead of reimplementing the scroll. + * The prompt holds focus all session and has no scrollable content, so + * wheel events land on it; forward its scrolls to the transcript. */ export function routePromptWheelToTranscript( - prompt: Renderable, + prompt: BaseRenderable, transcript: ScrollBoxRenderable, ): void { (